mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-10 17:42:45 +08:00
Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4666b9051d | ||
|
|
56c524a822 | ||
|
|
43e8df1b9f | ||
|
|
dbc465f6e5 | ||
|
|
bfbd3c527c | ||
|
|
412405f69b | ||
|
|
12b74eb04f | ||
|
|
2305a6287a | ||
|
|
68245be081 | ||
|
|
29e01294bd | ||
|
|
d35bee54a6 | ||
|
|
bf63be18e4 | ||
|
|
3dc7adc61a | ||
|
|
047d1e0afd | ||
|
|
7c017faf31 | ||
|
|
7a59565761 | ||
|
|
9afb904d40 | ||
|
|
8189de589a | ||
|
|
c458d7525d | ||
|
|
5c7bd95f6b | ||
|
|
70c4509682 | ||
|
|
f34e36c571 | ||
|
|
5054ffe7e4 | ||
|
|
ed30933ca2 | ||
|
|
2a4111ecce | ||
|
|
5bc8709605 | ||
|
|
efa2edf869 | ||
|
|
5c1e972feb | ||
|
|
8c23e7a7b7 | ||
|
|
57183f8cdc | ||
|
|
0481b49c04 | ||
|
|
7eb9b5e92d | ||
|
|
2a409d83d4 | ||
|
|
785a3f5de8 | ||
|
|
7c17c1c73b | ||
|
|
0ea429782c | ||
|
|
7a8f880dbe | ||
|
|
0a86b72110 | ||
|
|
cb5c06ee7e | ||
|
|
9f22ce5cc0 | ||
|
|
86e1fbc28a | ||
|
|
a5c5f7c718 | ||
|
|
ff5d94782f | ||
|
|
58a1bd2c86 | ||
|
|
f78ba6afb0 | ||
|
|
331f3455f8 | ||
|
|
ad0241b7f1 | ||
|
|
d9508533e1 | ||
|
|
6d2059447e | ||
|
|
11d4f27268 | ||
|
|
a29f987649 | ||
|
|
3e692c790e | ||
|
|
35cc214492 | ||
|
|
bae7bff70d | ||
|
|
71ef6f6a61 | ||
|
|
a8e161661c | ||
|
|
2b07766f9a | ||
|
|
adeb5361ab | ||
|
|
bd6e43c41d | ||
|
|
450289c7b7 | ||
|
|
aa93c560e5 | ||
|
|
22b1ebe1cf | ||
|
|
84bcf15e9b | ||
|
|
5b66803f6d | ||
|
|
88cbde47da | ||
|
|
03b96fa88b | ||
|
|
397a8a9536 | ||
|
|
1da0a706a3 | ||
|
|
4f2a110b5f | ||
|
|
bb356ffcee | ||
|
|
6c986416ca | ||
|
|
951ec138ef | ||
|
|
23e779ed94 | ||
|
|
29fccd3887 | ||
|
|
1bef723332 | ||
|
|
3c41fed0ef | ||
|
|
5947d0e6d0 | ||
|
|
0e4fa86372 | ||
|
|
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 |
@@ -17,7 +17,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||||
def list(
|
def current(
|
||||||
name: str = None,
|
name: str = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ def search(title: str,
|
|||||||
"""
|
"""
|
||||||
模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息
|
模糊搜索媒体/人物信息列表 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)
|
_, medias = MediaChain().search(title=title)
|
||||||
if medias:
|
if medias:
|
||||||
result = [media.to_dict() for media in medias]
|
result = [media.to_dict() for media in medias]
|
||||||
|
elif type == "collection":
|
||||||
|
result = MediaChain().search_collections(name=title)
|
||||||
else:
|
else:
|
||||||
result = MediaChain().search_persons(name=title)
|
result = MediaChain().search_persons(name=title)
|
||||||
if result:
|
if result:
|
||||||
@@ -117,7 +119,7 @@ def scrape(fileitem: schemas.FileItem,
|
|||||||
if not scrape_path.exists():
|
if not scrape_path.exists():
|
||||||
return schemas.Response(success=False, message="刮削路径不存在")
|
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} 刮削完成")
|
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ from app.db import get_db
|
|||||||
from app.db.models.subscribe import Subscribe
|
from app.db.models.subscribe import Subscribe
|
||||||
from app.db.models.subscribehistory import SubscribeHistory
|
from app.db.models.subscribehistory import SubscribeHistory
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.db.user_oper import get_current_active_user
|
from app.db.user_oper import get_current_active_user
|
||||||
from app.helper.subscribe import SubscribeHelper
|
from app.helper.subscribe import SubscribeHelper
|
||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import MediaType, EventType
|
from app.schemas.types import MediaType, EventType, SystemConfigKey
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -122,6 +123,11 @@ def update_subscribe(
|
|||||||
if subscribe_in.total_episode != subscribe.total_episode:
|
if subscribe_in.total_episode != subscribe.total_episode:
|
||||||
subscribe_dict["manual_total_episode"] = 1
|
subscribe_dict["manual_total_episode"] = 1
|
||||||
subscribe.update(db, subscribe_dict)
|
subscribe.update(db, subscribe_dict)
|
||||||
|
# 发送订阅调整事件
|
||||||
|
eventmanager.send_event(EventType.SubscribeModified, {
|
||||||
|
"subscribe_id": subscribe.id,
|
||||||
|
"subscribe_info": subscribe_dict,
|
||||||
|
})
|
||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -462,6 +468,17 @@ def subscribe_share(
|
|||||||
return schemas.Response(success=state, message=errmsg)
|
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)
|
@router.post("/fork", summary="复用订阅", response_model=schemas.Response)
|
||||||
def subscribe_fork(
|
def subscribe_fork(
|
||||||
sub: schemas.SubscribeShare,
|
sub: schemas.SubscribeShare,
|
||||||
@@ -481,6 +498,42 @@ def subscribe_fork(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str])
|
||||||
|
def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
查询已Follow的订阅分享人
|
||||||
|
"""
|
||||||
|
return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response)
|
||||||
|
def follow_subscriber(
|
||||||
|
share_uid: str = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
Follow订阅分享人
|
||||||
|
"""
|
||||||
|
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||||
|
if share_uid and share_uid not in subscribers:
|
||||||
|
subscribers.append(share_uid)
|
||||||
|
SystemConfigOper().set(SystemConfigKey.FollowSubscribers, subscribers)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/follow", summary="取消Follow订阅分享人", response_model=schemas.Response)
|
||||||
|
def unfollow_subscriber(
|
||||||
|
share_uid: str = None,
|
||||||
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
取消Follow订阅分享人
|
||||||
|
"""
|
||||||
|
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||||
|
if share_uid and share_uid in subscribers:
|
||||||
|
subscribers.remove(share_uid)
|
||||||
|
SystemConfigOper().set(SystemConfigKey.FollowSubscribers, subscribers)
|
||||||
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare])
|
@router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare])
|
||||||
def popular_subscribes(
|
def popular_subscribes(
|
||||||
name: str = None,
|
name: str = None,
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ def get_global_setting():
|
|||||||
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
|
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
|
||||||
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN"}
|
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN"}
|
||||||
)
|
)
|
||||||
|
# 追加用户唯一ID
|
||||||
|
info.update({
|
||||||
|
"USER_UNIQUE_ID": SystemUtils.generate_user_unique_id()
|
||||||
|
})
|
||||||
return schemas.Response(success=True,
|
return schemas.Response(success=True,
|
||||||
data=info)
|
data=info)
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ def tmdb_recommend(tmdbid: int,
|
|||||||
return []
|
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])
|
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson])
|
||||||
def tmdb_credits(tmdbid: int,
|
def tmdb_credits(tmdbid: int,
|
||||||
type_name: str,
|
type_name: str,
|
||||||
|
|||||||
@@ -680,6 +680,14 @@ def arr_add_series(tv: schemas.SonarrSeries,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@arr_router.put("/series", summary="更新剧集订阅")
|
||||||
|
def arr_update_series(tv: schemas.SonarrSeries) -> Any:
|
||||||
|
"""
|
||||||
|
更新Sonarr剧集订阅
|
||||||
|
"""
|
||||||
|
return arr_add_series(tv)
|
||||||
|
|
||||||
|
|
||||||
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
||||||
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class GzipRequest(Request):
|
|||||||
body = await super().body()
|
body = await super().body()
|
||||||
if "gzip" in self.headers.getlist("Content-Encoding"):
|
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||||
body = gzip.decompress(body)
|
body = gzip.decompress(body)
|
||||||
self._body = body
|
self._body = body # noqa
|
||||||
return self._body
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import copy
|
||||||
import gc
|
import gc
|
||||||
import pickle
|
import pickle
|
||||||
import traceback
|
import traceback
|
||||||
@@ -61,7 +62,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(settings.TEMP_PATH / filename, 'wb') as f:
|
with open(settings.TEMP_PATH / filename, 'wb') as f:
|
||||||
pickle.dump(cache, f)
|
pickle.dump(cache, f) # noqa
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
||||||
finally:
|
finally:
|
||||||
@@ -300,6 +301,13 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("search_persons", name=name)
|
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,
|
def search_torrents(self, site: CommentedMap,
|
||||||
keywords: List[str],
|
keywords: List[str],
|
||||||
mtype: MediaType = None,
|
mtype: MediaType = None,
|
||||||
@@ -325,19 +333,16 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
|
|
||||||
def filter_torrents(self, rule_groups: List[str],
|
def filter_torrents(self, rule_groups: List[str],
|
||||||
torrent_list: List[TorrentInfo],
|
torrent_list: List[TorrentInfo],
|
||||||
season_episodes: Dict[int, list] = None,
|
|
||||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
过滤种子资源
|
过滤种子资源
|
||||||
:param rule_groups: 过滤规则组名称列表
|
:param rule_groups: 过滤规则组名称列表
|
||||||
:param torrent_list: 资源列表
|
:param torrent_list: 资源列表
|
||||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
|
||||||
:param mediainfo: 识别的媒体信息
|
:param mediainfo: 识别的媒体信息
|
||||||
:return: 过滤后的资源列表,添加资源优先级
|
:return: 过滤后的资源列表,添加资源优先级
|
||||||
"""
|
"""
|
||||||
return self.run_module("filter_torrents", rule_groups=rule_groups,
|
return self.run_module("filter_torrents", rule_groups=rule_groups,
|
||||||
torrent_list=torrent_list, season_episodes=season_episodes,
|
torrent_list=torrent_list, mediainfo=mediainfo)
|
||||||
mediainfo=mediainfo)
|
|
||||||
|
|
||||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||||
episodes: Set[int] = None, category: str = None,
|
episodes: Set[int] = None, category: str = None,
|
||||||
@@ -488,32 +493,61 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
f"title={message.title}, "
|
f"title={message.title}, "
|
||||||
f"text={message.text},"
|
f"text={message.text},"
|
||||||
f"userid={message.userid}")
|
f"userid={message.userid}")
|
||||||
if not message.userid and message.mtype:
|
# 保存原消息
|
||||||
# 没有指定用户ID时,按规则确定发送对象
|
|
||||||
# 默认发送全体
|
|
||||||
to_targets = None
|
|
||||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
|
||||||
if notify_action == "admin":
|
|
||||||
# 仅发送管理员
|
|
||||||
logger.info(f"已设置 {message.mtype} 的消息只发送给管理员")
|
|
||||||
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
|
||||||
elif notify_action == "user":
|
|
||||||
# 发送对应用户
|
|
||||||
if message.username:
|
|
||||||
logger.info(f"已设置 {message.mtype} 的消息只发送给用户 {message.username}")
|
|
||||||
to_targets = self.useroper.get_settings(message.username)
|
|
||||||
if not message.username or to_targets is None:
|
|
||||||
if message.username:
|
|
||||||
logger.info(f"没有 {message.username} 这个用户,该消息将发送给管理员")
|
|
||||||
# 回滚发送管理员
|
|
||||||
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
|
||||||
message.targets = to_targets
|
|
||||||
# 发送事件
|
|
||||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
|
||||||
# 保存消息
|
|
||||||
self.messagehelper.put(message, role="user", title=message.title)
|
self.messagehelper.put(message, role="user", title=message.title)
|
||||||
self.messageoper.add(**message.dict())
|
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
|
||||||
|
elif send_message.username == settings.SUPERUSER:
|
||||||
|
# 管理员同名已发送
|
||||||
|
admin_sended = True
|
||||||
|
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)
|
self.run_module("post_message", message=message)
|
||||||
|
|
||||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||||
|
|||||||
@@ -527,8 +527,8 @@ class DownloadChain(ChainBase):
|
|||||||
downloaded_list.append(context)
|
downloaded_list.append(context)
|
||||||
|
|
||||||
# 电视剧整季匹配
|
# 电视剧整季匹配
|
||||||
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
|
||||||
if no_exists:
|
if no_exists:
|
||||||
|
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
||||||
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
||||||
need_seasons: Dict[int, list] = {}
|
need_seasons: Dict[int, list] = {}
|
||||||
for need_mid, need_tv in no_exists.items():
|
for need_mid, need_tv in no_exists.items():
|
||||||
@@ -631,8 +631,8 @@ class DownloadChain(ChainBase):
|
|||||||
# 全部下载完成
|
# 全部下载完成
|
||||||
break
|
break
|
||||||
# 电视剧季内的集匹配
|
# 电视剧季内的集匹配
|
||||||
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
|
||||||
if no_exists:
|
if no_exists:
|
||||||
|
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
||||||
# TMDBID列表
|
# TMDBID列表
|
||||||
need_tv_list = list(no_exists)
|
need_tv_list = list(no_exists)
|
||||||
for need_mid in need_tv_list:
|
for need_mid in need_tv_list:
|
||||||
@@ -701,8 +701,8 @@ class DownloadChain(ChainBase):
|
|||||||
logger.info(f"季 {need_season} 剩余需要集:{need_episodes}")
|
logger.info(f"季 {need_season} 剩余需要集:{need_episodes}")
|
||||||
|
|
||||||
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
|
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
|
||||||
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
|
|
||||||
if no_exists:
|
if no_exists:
|
||||||
|
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
|
||||||
# TMDBID列表
|
# TMDBID列表
|
||||||
no_exists_list = list(no_exists)
|
no_exists_list = list(no_exists)
|
||||||
for need_mid in no_exists_list:
|
for need_mid in no_exists_list:
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
fileitem: FileItem = event_data.get("fileitem")
|
fileitem: FileItem = event_data.get("fileitem")
|
||||||
meta: MetaBase = event_data.get("meta")
|
meta: MetaBase = event_data.get("meta")
|
||||||
mediainfo: MediaInfo = event_data.get("mediainfo")
|
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||||||
|
overwrite = event_data.get("overwrite", False)
|
||||||
if not fileitem:
|
if not fileitem:
|
||||||
return
|
return
|
||||||
# 刮削锁
|
# 刮削锁
|
||||||
@@ -316,7 +317,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
scraping_files.append(fileitem.path)
|
scraping_files.append(fileitem.path)
|
||||||
try:
|
try:
|
||||||
# 执行刮削
|
# 执行刮削
|
||||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=overwrite)
|
||||||
finally:
|
finally:
|
||||||
# 释放锁
|
# 释放锁
|
||||||
with scraping_lock:
|
with scraping_lock:
|
||||||
@@ -365,8 +366,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if not _fileitem or not _content or not _path:
|
if not _fileitem or not _content or not _path:
|
||||||
return
|
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)
|
tmp_file.write_bytes(_content)
|
||||||
# 获取文件的父目录
|
# 获取文件的父目录
|
||||||
try:
|
try:
|
||||||
@@ -412,31 +413,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if fileitem.type == "file":
|
if fileitem.type == "file":
|
||||||
# 是否已存在
|
# 是否已存在
|
||||||
nfo_path = filepath.with_suffix(".nfo")
|
nfo_path = filepath.with_suffix(".nfo")
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
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}")
|
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||||
return
|
|
||||||
# 电影文件
|
|
||||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
|
||||||
if not movie_nfo:
|
|
||||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
|
||||||
return
|
|
||||||
# 保存或上传nfo文件到上级目录
|
|
||||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
|
||||||
else:
|
else:
|
||||||
# 电影目录
|
# 电影目录
|
||||||
if is_bluray_folder(fileitem):
|
if is_bluray_folder(fileitem):
|
||||||
# 原盘目录
|
# 原盘目录
|
||||||
nfo_path = filepath / (filepath.name + ".nfo")
|
nfo_path = filepath / (filepath.name + ".nfo")
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
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}")
|
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||||
return
|
|
||||||
# 生成原盘nfo
|
|
||||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
|
||||||
if not movie_nfo:
|
|
||||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
|
||||||
return
|
|
||||||
# 保存或上传nfo文件到当前目录
|
|
||||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
|
|
||||||
else:
|
else:
|
||||||
# 处理目录内的文件
|
# 处理目录内的文件
|
||||||
files = __list_files(_fileitem=fileitem)
|
files = __list_files(_fileitem=fileitem)
|
||||||
@@ -455,23 +456,18 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
and attr_value.startswith("http"):
|
and attr_value.startswith("http"):
|
||||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||||
image_path = filepath / image_name
|
image_path = filepath / image_name
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||||
path=image_path):
|
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}")
|
logger.info(f"已存在图片文件:{image_path}")
|
||||||
continue
|
|
||||||
# 下载图片
|
|
||||||
content = __download_image(_url=attr_value)
|
|
||||||
# 写入图片到当前目录
|
|
||||||
if content:
|
|
||||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
|
||||||
else:
|
else:
|
||||||
# 电视剧
|
# 电视剧
|
||||||
if fileitem.type == "file":
|
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.info(f"已存在nfo文件:{nfo_path}")
|
|
||||||
return
|
|
||||||
# 重新识别季集
|
# 重新识别季集
|
||||||
file_meta = MetaInfoPath(filepath)
|
file_meta = MetaInfoPath(filepath)
|
||||||
if not file_meta.begin_episode:
|
if not file_meta.begin_episode:
|
||||||
@@ -481,33 +477,37 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if not file_mediainfo:
|
if not file_mediainfo:
|
||||||
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
|
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
|
||||||
return
|
return
|
||||||
# 获取集的nfo文件
|
# 是否已存在
|
||||||
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
nfo_path = filepath.with_suffix(".nfo")
|
||||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||||
if not episode_nfo:
|
# 获取集的nfo文件
|
||||||
logger.warn(f"{filepath.name} nfo生成失败!")
|
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||||
return
|
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||||
# 保存或上传nfo文件到上级目录
|
if episode_nfo:
|
||||||
if not parent:
|
# 保存或上传nfo文件到上级目录
|
||||||
parent = self.storagechain.get_parent_item(fileitem)
|
if not parent:
|
||||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
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,
|
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
||||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||||
if image_dict:
|
if image_dict:
|
||||||
for episode, image_url in image_dict.items():
|
for episode, image_url in image_dict.items():
|
||||||
image_path = filepath.with_suffix(Path(image_url).suffix)
|
image_path = filepath.with_suffix(Path(image_url).suffix)
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
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}")
|
logger.info(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)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# 当前为目录,处理目录内的文件
|
# 当前为目录,处理目录内的文件
|
||||||
files = __list_files(_fileitem=fileitem)
|
files = __list_files(_fileitem=fileitem)
|
||||||
@@ -526,32 +526,33 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if season_meta.begin_season is not None:
|
if season_meta.begin_season is not None:
|
||||||
# 是否已存在
|
# 是否已存在
|
||||||
nfo_path = filepath / "season.nfo"
|
nfo_path = filepath / "season.nfo"
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
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}")
|
logger.info(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)
|
|
||||||
# TMDB季poster图片
|
# TMDB季poster图片
|
||||||
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
||||||
if image_dict:
|
if image_dict:
|
||||||
for image_name, image_url in image_dict.items():
|
for image_name, image_url in image_dict.items():
|
||||||
image_path = filepath.with_name(image_name)
|
image_path = filepath.with_name(image_name)
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||||
path=image_path):
|
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}")
|
logger.info(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)
|
|
||||||
# 额外fanart季图片:poster thumb banner
|
# 额外fanart季图片:poster thumb banner
|
||||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||||
if image_dict:
|
if image_dict:
|
||||||
@@ -563,32 +564,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if image_season != str(season_meta.begin_season).rjust(2, '0'):
|
if image_season != str(season_meta.begin_season).rjust(2, '0'):
|
||||||
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||||
continue
|
continue
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||||
path=image_path):
|
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}")
|
logger.info(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 not season_meta.season:
|
if not season_meta.season:
|
||||||
# 是否已存在
|
# 是否已存在
|
||||||
nfo_path = filepath / "tvshow.nfo"
|
nfo_path = filepath / "tvshow.nfo"
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
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}")
|
logger.info(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)
|
|
||||||
# 生成目录图片
|
# 生成目录图片
|
||||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||||
if image_dict:
|
if image_dict:
|
||||||
@@ -597,14 +597,13 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
if image_name.startswith("season"):
|
if image_name.startswith("season"):
|
||||||
continue
|
continue
|
||||||
image_path = filepath / image_name
|
image_path = filepath / image_name
|
||||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||||
path=image_path):
|
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"已存在图片文件:{image_path}")
|
||||||
continue
|
|
||||||
# 下载图片
|
|
||||||
content = __download_image(image_url)
|
|
||||||
# 保存图片文件到当前目录
|
|
||||||
if content:
|
|
||||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
|
||||||
|
|
||||||
logger.info(f"{filepath.name} 刮削完成")
|
logger.info(f"{filepath.name} 刮削完成")
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import threading
|
import threading
|
||||||
from typing import List, Union, Optional, Generator
|
from typing import List, Union, Optional, Generator
|
||||||
|
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import global_vars
|
from app.core.config import global_vars
|
||||||
from app.db.mediaserver_oper import MediaServerOper
|
from app.db.mediaserver_oper import MediaServerOper
|
||||||
from app.helper.service import ServiceConfigHelper
|
from app.helper.service import ServiceConfigHelper
|
||||||
@@ -94,7 +93,7 @@ class MediaServerChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_latest_wallpapers(self, server: str = None, count: int = 10,
|
def get_latest_wallpapers(self, server: str = None, count: int = 10,
|
||||||
remote: bool = True, username: str = None) -> List[str]:
|
remote: bool = True, username: str = None) -> List[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import inspect
|
|
||||||
import io
|
import io
|
||||||
import tempfile
|
import tempfile
|
||||||
from functools import wraps
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, List
|
from typing import Any, List
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from cachetools import TTLCache
|
|
||||||
from cachetools.keys import hashkey
|
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.bangumi import BangumiChain
|
from app.chain.bangumi import BangumiChain
|
||||||
from app.chain.douban import DoubanChain
|
from app.chain.douban import DoubanChain
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.core.config import settings
|
from app.core.cache import cache_backend, cached
|
||||||
|
from app.core.config import settings, global_vars
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.common import log_execution_time
|
from app.utils.common import log_execution_time
|
||||||
@@ -23,42 +20,7 @@ from app.utils.singleton import Singleton
|
|||||||
|
|
||||||
# 推荐相关的专用缓存
|
# 推荐相关的专用缓存
|
||||||
recommend_ttl = 24 * 3600
|
recommend_ttl = 24 * 3600
|
||||||
recommend_cache = TTLCache(maxsize=256, ttl=recommend_ttl)
|
recommend_cache_region = "recommend"
|
||||||
|
|
||||||
|
|
||||||
# 推荐缓存装饰器,避免偶发网络获取数据为空时,页面由于缓存为空长时间渲染异常问题
|
|
||||||
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):
|
class RecommendChain(ChainBase, metaclass=Singleton):
|
||||||
@@ -78,7 +40,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
刷新推荐
|
刷新推荐
|
||||||
"""
|
"""
|
||||||
logger.debug("Starting to refresh Recommend data.")
|
logger.debug("Starting to refresh Recommend data.")
|
||||||
recommend_cache.clear()
|
cache_backend.clear(region=recommend_cache_region)
|
||||||
logger.debug("Recommend Cache has been cleared.")
|
logger.debug("Recommend Cache has been cleared.")
|
||||||
|
|
||||||
# 推荐来源方法
|
# 推荐来源方法
|
||||||
@@ -105,6 +67,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
|
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
|
||||||
for page in range(1, self.cache_max_pages + 1):
|
for page in range(1, self.cache_max_pages + 1):
|
||||||
for method in recommend_methods:
|
for method in recommend_methods:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
return
|
||||||
if method in methods_finished:
|
if method in methods_finished:
|
||||||
continue
|
continue
|
||||||
logger.debug(f"Fetch {method.__name__} data for page {page}.")
|
logger.debug(f"Fetch {method.__name__} data for page {page}.")
|
||||||
@@ -131,6 +95,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for data in datas:
|
for data in datas:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
return
|
||||||
poster_path = data.get("poster_path")
|
poster_path = data.get("poster_path")
|
||||||
if poster_path:
|
if poster_path:
|
||||||
poster_url = poster_path.replace("original", "w500")
|
poster_url = poster_path.replace("original", "w500")
|
||||||
@@ -190,7 +156,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
logger.debug(f"Failed to write cache file {cache_path} for URL {url}: {e}")
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def tmdb_movies(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
def tmdb_movies(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
||||||
with_original_language: str = "", page: int = 1) -> Any:
|
with_original_language: str = "", page: int = 1) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -204,7 +170,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [movie.to_dict() for movie in movies] if movies else []
|
return [movie.to_dict() for movie in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def tmdb_tvs(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
def tmdb_tvs(self, sort_by: str = "popularity.desc", with_genres: str = "",
|
||||||
with_original_language: str = "zh|en|ja|ko", page: int = 1) -> Any:
|
with_original_language: str = "zh|en|ja|ko", page: int = 1) -> Any:
|
||||||
"""
|
"""
|
||||||
@@ -218,7 +184,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def tmdb_trending(self, page: int = 1) -> Any:
|
def tmdb_trending(self, page: int = 1) -> Any:
|
||||||
"""
|
"""
|
||||||
TMDB流行趋势
|
TMDB流行趋势
|
||||||
@@ -227,7 +193,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [info.to_dict() for info in infos] if infos else []
|
return [info.to_dict() for info in infos] if infos else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Any:
|
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
Bangumi每日放送
|
Bangumi每日放送
|
||||||
@@ -236,7 +202,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_movie_showing(self, page: int = 1, count: int = 30) -> Any:
|
def douban_movie_showing(self, page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣正在热映
|
豆瓣正在热映
|
||||||
@@ -245,7 +211,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in movies] if movies else []
|
return [media.to_dict() for media in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_movies(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
def douban_movies(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣最新电影
|
豆瓣最新电影
|
||||||
@@ -255,7 +221,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in movies] if movies else []
|
return [media.to_dict() for media in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tvs(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
def douban_tvs(self, sort: str = "R", tags: str = "", page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣最新电视剧
|
豆瓣最新电视剧
|
||||||
@@ -265,7 +231,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in tvs] if tvs else []
|
return [media.to_dict() for media in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_movie_top250(self, page: int = 1, count: int = 30) -> Any:
|
def douban_movie_top250(self, page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣电影TOP250
|
豆瓣电影TOP250
|
||||||
@@ -274,7 +240,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in movies] if movies else []
|
return [media.to_dict() for media in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Any:
|
def douban_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣国产剧集榜
|
豆瓣国产剧集榜
|
||||||
@@ -283,7 +249,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in tvs] if tvs else []
|
return [media.to_dict() for media in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tv_weekly_global(self, page: int = 1, count: int = 30) -> Any:
|
def douban_tv_weekly_global(self, page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣全球剧集榜
|
豆瓣全球剧集榜
|
||||||
@@ -292,7 +258,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in tvs] if tvs else []
|
return [media.to_dict() for media in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tv_animation(self, page: int = 1, count: int = 30) -> Any:
|
def douban_tv_animation(self, page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣热门动漫
|
豆瓣热门动漫
|
||||||
@@ -301,7 +267,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in tvs] if tvs else []
|
return [media.to_dict() for media in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_movie_hot(self, page: int = 1, count: int = 30) -> Any:
|
def douban_movie_hot(self, page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣热门电影
|
豆瓣热门电影
|
||||||
@@ -310,7 +276,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [media.to_dict() for media in movies] if movies else []
|
return [media.to_dict() for media in movies] if movies else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached_with_empty_check
|
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
||||||
def douban_tv_hot(self, page: int = 1, count: int = 30) -> Any:
|
def douban_tv_hot(self, page: int = 1, count: int = 30) -> Any:
|
||||||
"""
|
"""
|
||||||
豆瓣热门电视剧
|
豆瓣热门电视剧
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ class SearchChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.filter_torrents(rule_groups=rule_groups,
|
return self.filter_torrents(rule_groups=rule_groups,
|
||||||
torrent_list=torrent_list,
|
torrent_list=torrent_list,
|
||||||
season_episodes=season_episodes,
|
|
||||||
mediainfo=mediainfo) or []
|
mediainfo=mediainfo) or []
|
||||||
|
|
||||||
# 豆瓣标题处理
|
# 豆瓣标题处理
|
||||||
@@ -185,7 +184,10 @@ class SearchChain(ChainBase):
|
|||||||
# 开始过滤
|
# 开始过滤
|
||||||
self.progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...',
|
self.progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...',
|
||||||
key=ProgressKey.Search)
|
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:
|
if rule_groups is None:
|
||||||
# 取搜索过滤规则
|
# 取搜索过滤规则
|
||||||
@@ -222,16 +224,18 @@ class SearchChain(ChainBase):
|
|||||||
if not torrent.title:
|
if not torrent.title:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 匹配订阅附加参数
|
|
||||||
if filter_params and not self.torrenthelper.filter_torrent(torrent_info=torrent,
|
|
||||||
filter_params=filter_params):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 识别元数据
|
# 识别元数据
|
||||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description,
|
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description,
|
||||||
custom_words=custom_words)
|
custom_words=custom_words)
|
||||||
if torrent.title != torrent_meta.org_string:
|
if torrent.title != torrent_meta.org_string:
|
||||||
logger.info(f"种子名称应用识别词后发生改变:{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
|
# 比对IMDBID
|
||||||
if torrent.imdbid \
|
if torrent.imdbid \
|
||||||
and mediainfo.imdb_id \
|
and mediainfo.imdb_id \
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from time import time
|
||||||
from typing import Optional, Tuple, Union, Dict
|
from typing import Optional, Tuple, Union, Dict
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ class SiteChain(ChainBase):
|
|||||||
))
|
))
|
||||||
# 低分享率警告
|
# 低分享率警告
|
||||||
if userdata.ratio and float(userdata.ratio) < 1 and not bool(
|
if userdata.ratio and float(userdata.ratio) < 1 and not bool(
|
||||||
re.search(r"(贵宾|VIP?)", userdata.user_level, re.IGNORECASE)):
|
re.search(r"(贵宾|VIP?)", userdata.user_level or "", re.IGNORECASE)):
|
||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
mtype=NotificationType.SiteMessage,
|
mtype=NotificationType.SiteMessage,
|
||||||
title=f"【站点分享率低预警】",
|
title=f"【站点分享率低预警】",
|
||||||
@@ -96,7 +97,7 @@ class SiteChain(ChainBase):
|
|||||||
))
|
))
|
||||||
return userdata
|
return userdata
|
||||||
|
|
||||||
def refresh_userdatas(self) -> Dict[str, SiteUserData]:
|
def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]:
|
||||||
"""
|
"""
|
||||||
刷新所有站点的用户数据
|
刷新所有站点的用户数据
|
||||||
"""
|
"""
|
||||||
@@ -105,7 +106,7 @@ class SiteChain(ChainBase):
|
|||||||
result = {}
|
result = {}
|
||||||
for site in sites:
|
for site in sites:
|
||||||
if global_vars.is_system_stopped:
|
if global_vars.is_system_stopped:
|
||||||
return
|
return None
|
||||||
if site.get("is_active"):
|
if site.get("is_active"):
|
||||||
userdata = self.refresh_userdata(site)
|
userdata = self.refresh_userdata(site)
|
||||||
if userdata:
|
if userdata:
|
||||||
@@ -172,27 +173,37 @@ class SiteChain(ChainBase):
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": user_agent,
|
"User-Agent": user_agent,
|
||||||
"Accept": "application/json, text/plain, */*",
|
"Accept": "application/json, text/plain, */*",
|
||||||
"Authorization": site.token
|
"Authorization": site.token,
|
||||||
|
"x-api-key": site.apikey,
|
||||||
|
"ts": str(int(time()))
|
||||||
}
|
}
|
||||||
res = RequestUtils(
|
res = RequestUtils(
|
||||||
headers=headers,
|
headers=headers,
|
||||||
proxies=settings.PROXY if site.proxy else None,
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
timeout=site.timeout or 15
|
timeout=site.timeout or 15
|
||||||
).post_res(url=url)
|
).post_res(url=url)
|
||||||
|
state = False
|
||||||
|
message = "鉴权已过期或无效"
|
||||||
if res and res.status_code == 200:
|
if res and res.status_code == 200:
|
||||||
user_info = res.json()
|
user_info = res.json() or {}
|
||||||
if user_info and user_info.get("data"):
|
if user_info.get("data"):
|
||||||
# 更新最后访问时间
|
# 更新最后访问时间
|
||||||
|
del headers["x-api-key"]
|
||||||
res = RequestUtils(headers=headers,
|
res = RequestUtils(headers=headers,
|
||||||
timeout=site.timeout or 15,
|
timeout=site.timeout or 15,
|
||||||
proxies=settings.PROXY if site.proxy else None,
|
proxies=settings.PROXY if site.proxy else None,
|
||||||
referer=f"{site.url}index"
|
referer=f"{site.url}index"
|
||||||
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
||||||
if res:
|
state = True
|
||||||
return True, "连接成功"
|
message = "连接成功,但更新状态失败"
|
||||||
else:
|
if res and res.status_code == 200:
|
||||||
return True, f"连接成功,但更新状态失败"
|
update_info = res.json() or {}
|
||||||
return False, "鉴权已过期或无效"
|
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
|
@staticmethod
|
||||||
def __yema_test(site: Site) -> Tuple[bool, str]:
|
def __yema_test(site: Site) -> Tuple[bool, str]:
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class StorageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("storage_usage", storage=storage)
|
return self.run_module("storage_usage", storage=storage)
|
||||||
|
|
||||||
def support_transtype(self, storage: str) -> Optional[str]:
|
def support_transtype(self, storage: str) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
获取支持的整理方式
|
获取支持的整理方式
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, List, Optional, Union, Tuple
|
from typing import Dict, List, Optional, Union, Tuple
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
@@ -27,8 +28,6 @@ from app.helper.message import MessageHelper
|
|||||||
from app.helper.subscribe import SubscribeHelper
|
from app.helper.subscribe import SubscribeHelper
|
||||||
from app.helper.torrent import TorrentHelper
|
from app.helper.torrent import TorrentHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import NotExistMediaInfo, Notification, SubscrbieInfo, SubscribeEpisodeInfo, SubscribeDownloadFileInfo, \
|
|
||||||
SubscribeLibraryFileInfo
|
|
||||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
|
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
|
||||||
@@ -173,14 +172,14 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.error(f'{mediainfo.title_year} {err_msg}')
|
logger.error(f'{mediainfo.title_year} {err_msg}')
|
||||||
if not exist_ok and message:
|
if not exist_ok and message:
|
||||||
# 失败发回原用户
|
# 失败发回原用户
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(schemas.Notification(channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
mtype=NotificationType.Subscribe,
|
mtype=NotificationType.Subscribe,
|
||||||
title=f"{mediainfo.title_year} {metainfo.season} "
|
title=f"{mediainfo.title_year} {metainfo.season} "
|
||||||
f"添加订阅失败!",
|
f"添加订阅失败!",
|
||||||
text=f"{err_msg}",
|
text=f"{err_msg}",
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
userid=userid))
|
userid=userid))
|
||||||
return None, err_msg
|
return None, err_msg
|
||||||
elif message:
|
elif message:
|
||||||
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
||||||
@@ -193,12 +192,12 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||||
# 订阅成功按规则发送消息
|
# 订阅成功按规则发送消息
|
||||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
||||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||||
text=text,
|
text=text,
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
link=link,
|
link=link,
|
||||||
username=username))
|
username=username))
|
||||||
# 发送事件
|
# 发送事件
|
||||||
EventManager().send_event(EventType.SubscribeAdded, {
|
EventManager().send_event(EventType.SubscribeAdded, {
|
||||||
"subscribe_id": sid,
|
"subscribe_id": sid,
|
||||||
@@ -384,7 +383,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
self.message.put('没有找到订阅!', title="订阅搜索", role="system")
|
self.message.put('没有找到订阅!', title="订阅搜索", role="system")
|
||||||
logger.debug(f"search Lock released at {datetime.now()}")
|
logger.debug(f"search Lock released at {datetime.now()}")
|
||||||
|
|
||||||
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaInfo,
|
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,
|
||||||
mediainfo: MediaInfo, downloads: List[Context]):
|
mediainfo: MediaInfo, downloads: List[Context]):
|
||||||
"""
|
"""
|
||||||
更新订阅已下载资源的优先级
|
更新订阅已下载资源的优先级
|
||||||
@@ -407,9 +406,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 正在洗版,更新资源优先级
|
# 正在洗版,更新资源优先级
|
||||||
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
|
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
|
||||||
|
|
||||||
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo, mediainfo: MediaInfo,
|
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
downloads: List[Context] = None,
|
downloads: List[Context] = None,
|
||||||
lefts: Dict[Union[int | str], Dict[int, NotExistMediaInfo]] = None,
|
lefts: Dict[Union[int | str], Dict[int, schemas.NotExistMediaInfo]] = None,
|
||||||
force: bool = False):
|
force: bool = False):
|
||||||
"""
|
"""
|
||||||
判断是否应完成订阅
|
判断是否应完成订阅
|
||||||
@@ -464,18 +463,16 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
# 从系统配置获取默认订阅站点
|
# 从系统配置获取默认订阅站点
|
||||||
default_sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
default_sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||||
# 如果订阅未指定站点信息,直接返回默认站点
|
# 如果订阅未指定站点,直接返回默认站点
|
||||||
if not subscribe.sites:
|
if not subscribe.sites:
|
||||||
return default_sites
|
return default_sites
|
||||||
|
# 如果默认订阅站点未设置,直接返回订阅指定站点
|
||||||
|
if not default_sites:
|
||||||
|
return subscribe.sites or []
|
||||||
# 尝试解析订阅中的站点数据
|
# 尝试解析订阅中的站点数据
|
||||||
user_sites = subscribe.sites
|
user_sites = subscribe.sites
|
||||||
# 计算 user_sites 和 default_sites 的交集
|
# 计算 user_sites 和 default_sites 的交集
|
||||||
intersection_sites = [site for site in user_sites if site in default_sites]
|
intersection_sites = [site for site in user_sites if site in default_sites]
|
||||||
# 如果交集与原始订阅不一致,更新数据库
|
|
||||||
if set(intersection_sites) != set(user_sites):
|
|
||||||
self.subscribeoper.update(subscribe.id, {
|
|
||||||
"sites": intersection_sites
|
|
||||||
})
|
|
||||||
# 如果交集为空,返回默认站点
|
# 如果交集为空,返回默认站点
|
||||||
return intersection_sites if intersection_sites else default_sites
|
return intersection_sites if intersection_sites else default_sites
|
||||||
|
|
||||||
@@ -507,9 +504,6 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.warn('没有缓存资源,无法匹配订阅')
|
logger.warn('没有缓存资源,无法匹配订阅')
|
||||||
return
|
return
|
||||||
|
|
||||||
# 记录重新识别过的种子
|
|
||||||
_recognize_cached = []
|
|
||||||
|
|
||||||
with self._rlock:
|
with self._rlock:
|
||||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||||
# 所有订阅
|
# 所有订阅
|
||||||
@@ -550,6 +544,12 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
if exist_flag:
|
if exist_flag:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 订阅识别词
|
||||||
|
if subscribe.custom_words:
|
||||||
|
custom_words_list = subscribe.custom_words.split("\n")
|
||||||
|
else:
|
||||||
|
custom_words_list = None
|
||||||
|
|
||||||
# 遍历缓存种子
|
# 遍历缓存种子
|
||||||
_match_context = []
|
_match_context = []
|
||||||
for domain, contexts in torrents.items():
|
for domain, contexts in torrents.items():
|
||||||
@@ -571,45 +571,43 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 有自定义识别词时,需要判断是否需要重新识别
|
# 有自定义识别词时,需要判断是否需要重新识别
|
||||||
if subscribe.custom_words:
|
if custom_words_list:
|
||||||
_, apply_words = WordsMatcher().prepare(torrent_info.title,
|
# 使用org_string,应用一次后理论上不能再次应用
|
||||||
custom_words=subscribe.custom_words.split("\n"))
|
_, apply_words = WordsMatcher().prepare(torrent_meta.org_string,
|
||||||
|
custom_words=custom_words_list)
|
||||||
if apply_words:
|
if apply_words:
|
||||||
logger.info(
|
logger.info(
|
||||||
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
|
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
|
||||||
# 重新识别元数据
|
# 重新识别元数据
|
||||||
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
||||||
custom_words=subscribe.custom_words)
|
custom_words=custom_words_list)
|
||||||
|
# 更新元数据缓存
|
||||||
|
context.meta_info = torrent_meta
|
||||||
# 媒体信息需要重新识别
|
# 媒体信息需要重新识别
|
||||||
torrent_mediainfo = None
|
torrent_mediainfo = None
|
||||||
|
|
||||||
# 先判断是否有没识别的种子,否则重新识别
|
# 先判断是否有没识别的种子,否则重新识别
|
||||||
if not torrent_mediainfo \
|
if not torrent_mediainfo \
|
||||||
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||||
# 避免重复处理
|
# 重新识别媒体信息
|
||||||
_cache_key = f"{torrent_meta.org_string}_{torrent_info.description}"
|
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
||||||
if _cache_key not in _recognize_cached:
|
if torrent_mediainfo:
|
||||||
_recognize_cached.append(_cache_key)
|
# 更新种子缓存
|
||||||
# 重新识别媒体信息
|
context.media_info = torrent_mediainfo
|
||||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
else:
|
||||||
if torrent_mediainfo:
|
# 通过标题匹配兜底
|
||||||
# 更新种子缓存
|
logger.warn(
|
||||||
|
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||||
|
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||||
|
torrent_meta=torrent_meta,
|
||||||
|
torrent=torrent_info):
|
||||||
|
# 匹配成功
|
||||||
|
logger.info(
|
||||||
|
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||||
|
torrent_mediainfo = mediainfo
|
||||||
context.media_info = torrent_mediainfo
|
context.media_info = torrent_mediainfo
|
||||||
if not torrent_mediainfo:
|
else:
|
||||||
# 通过标题匹配兜底
|
continue
|
||||||
logger.warn(
|
|
||||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
|
||||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
|
||||||
torrent_meta=torrent_meta,
|
|
||||||
torrent=torrent_info):
|
|
||||||
# 匹配成功
|
|
||||||
logger.info(
|
|
||||||
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
|
||||||
# 更新种子缓存
|
|
||||||
torrent_mediainfo = mediainfo
|
|
||||||
context.media_info = mediainfo
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 直接比对媒体信息
|
# 直接比对媒体信息
|
||||||
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
|
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
|
||||||
@@ -782,6 +780,63 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
})
|
})
|
||||||
logger.info(f'{subscribe.name} 订阅元数据更新完成')
|
logger.info(f'{subscribe.name} 订阅元数据更新完成')
|
||||||
|
|
||||||
|
def follow(self):
|
||||||
|
"""
|
||||||
|
刷新follow的用户分享,并自动添加订阅
|
||||||
|
"""
|
||||||
|
follow_users: List[str] = self.systemconfig.get(SystemConfigKey.FollowSubscribers)
|
||||||
|
if not follow_users:
|
||||||
|
return
|
||||||
|
share_subs = self.subscribehelper.get_shares()
|
||||||
|
logger.info(f'开始刷新follow用户分享订阅 ...')
|
||||||
|
success_count = 0
|
||||||
|
for share_sub in share_subs:
|
||||||
|
uid = share_sub.get("share_uid")
|
||||||
|
if uid and uid in follow_users:
|
||||||
|
# 订阅已存在则跳过
|
||||||
|
if self.subscribeoper.exists(tmdbid=share_sub.get("tmdbid"),
|
||||||
|
doubanid=share_sub.get("doubanid"),
|
||||||
|
season=share_sub.get("season")):
|
||||||
|
continue
|
||||||
|
# 去除无效属性
|
||||||
|
for key in list(share_sub.keys()):
|
||||||
|
if not hasattr(schemas.Subscribe(), key):
|
||||||
|
share_sub.pop(key)
|
||||||
|
# 类型转换
|
||||||
|
subscribe_in = schemas.Subscribe(**share_sub)
|
||||||
|
mtype = MediaType(subscribe_in.type)
|
||||||
|
# 豆瓣标题处理
|
||||||
|
if subscribe_in.doubanid or subscribe_in.bangumiid:
|
||||||
|
meta = MetaInfo(subscribe_in.name)
|
||||||
|
subscribe_in.name = meta.name
|
||||||
|
subscribe_in.season = meta.begin_season
|
||||||
|
# 标题转换
|
||||||
|
if subscribe_in.name:
|
||||||
|
title = subscribe_in.name
|
||||||
|
else:
|
||||||
|
title = None
|
||||||
|
sid, message = SubscribeChain().add(mtype=mtype,
|
||||||
|
title=title,
|
||||||
|
year=subscribe_in.year,
|
||||||
|
tmdbid=subscribe_in.tmdbid,
|
||||||
|
season=subscribe_in.season,
|
||||||
|
doubanid=subscribe_in.doubanid,
|
||||||
|
bangumiid=subscribe_in.bangumiid,
|
||||||
|
username="订阅分享",
|
||||||
|
best_version=subscribe_in.best_version,
|
||||||
|
save_path=subscribe_in.save_path,
|
||||||
|
search_imdbid=subscribe_in.search_imdbid,
|
||||||
|
custom_words=subscribe_in.custom_words,
|
||||||
|
media_category=subscribe_in.media_category,
|
||||||
|
filter_groups=subscribe_in.filter_groups,
|
||||||
|
exist_ok=True)
|
||||||
|
if sid:
|
||||||
|
success_count += 1
|
||||||
|
logger.info(f'follow用户分享订阅 {title} 添加成功')
|
||||||
|
else:
|
||||||
|
logger.error(f'follow用户分享订阅 {title} 添加失败:{message}')
|
||||||
|
logger.info(f'follow用户分享订阅刷新完成,共添加 {success_count} 个订阅')
|
||||||
|
|
||||||
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
||||||
"""
|
"""
|
||||||
更新已下载信息到note字段
|
更新已下载信息到note字段
|
||||||
@@ -838,7 +893,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
return note
|
return note
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
|
||||||
subscribe: Subscribe,
|
subscribe: Subscribe,
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo,
|
||||||
update_date: bool = False):
|
update_date: bool = False):
|
||||||
@@ -893,11 +948,11 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||||
# 完成订阅按规则发送消息
|
# 完成订阅按规则发送消息
|
||||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
||||||
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
||||||
image=mediainfo.get_message_image(),
|
image=mediainfo.get_message_image(),
|
||||||
link=link,
|
link=link,
|
||||||
username=subscribe.username))
|
username=subscribe.username))
|
||||||
# 发送事件
|
# 发送事件
|
||||||
EventManager().send_event(EventType.SubscribeComplete, {
|
EventManager().send_event(EventType.SubscribeComplete, {
|
||||||
"subscribe_id": subscribe.id,
|
"subscribe_id": subscribe.id,
|
||||||
@@ -917,9 +972,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
subscribes = self.subscribeoper.list()
|
subscribes = self.subscribeoper.list()
|
||||||
if not subscribes:
|
if not subscribes:
|
||||||
self.post_message(Notification(channel=channel,
|
self.post_message(schemas.Notification(channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
title='没有任何订阅!', userid=userid))
|
title='没有任何订阅!', userid=userid))
|
||||||
return
|
return
|
||||||
title = f"共有 {len(subscribes)} 个订阅,回复对应指令操作: " \
|
title = f"共有 {len(subscribes)} 个订阅,回复对应指令操作: " \
|
||||||
f"\n- 删除订阅:/subscribe_delete [id]" \
|
f"\n- 删除订阅:/subscribe_delete [id]" \
|
||||||
@@ -935,8 +990,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
||||||
f"/{subscribe.total_episode}]")
|
f"/{subscribe.total_episode}]")
|
||||||
# 发送列表
|
# 发送列表
|
||||||
self.post_message(Notification(channel=channel, source=source,
|
self.post_message(schemas.Notification(channel=channel, source=source,
|
||||||
title=title, text='\n'.join(messages), userid=userid))
|
title=title, text='\n'.join(messages), userid=userid))
|
||||||
|
|
||||||
def remote_delete(self, arg_str: str, channel: MessageChannel,
|
def remote_delete(self, arg_str: str, channel: MessageChannel,
|
||||||
userid: Union[str, int] = None, source: str = None):
|
userid: Union[str, int] = None, source: str = None):
|
||||||
@@ -944,9 +999,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
删除订阅
|
删除订阅
|
||||||
"""
|
"""
|
||||||
if not arg_str:
|
if not arg_str:
|
||||||
self.post_message(Notification(channel=channel, source=source,
|
self.post_message(schemas.Notification(channel=channel, source=source,
|
||||||
title="请输入正确的命令格式:/subscribe_delete [id],"
|
title="请输入正确的命令格式:/subscribe_delete [id],"
|
||||||
"[id]为订阅编号", userid=userid))
|
"[id]为订阅编号", userid=userid))
|
||||||
return
|
return
|
||||||
arg_strs = str(arg_str).split()
|
arg_strs = str(arg_str).split()
|
||||||
for arg_str in arg_strs:
|
for arg_str in arg_strs:
|
||||||
@@ -956,8 +1011,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
subscribe_id = int(arg_str)
|
subscribe_id = int(arg_str)
|
||||||
subscribe = self.subscribeoper.get(subscribe_id)
|
subscribe = self.subscribeoper.get(subscribe_id)
|
||||||
if not subscribe:
|
if not subscribe:
|
||||||
self.post_message(Notification(channel=channel, source=source,
|
self.post_message(schemas.Notification(channel=channel, source=source,
|
||||||
title=f"订阅编号 {subscribe_id} 不存在!", userid=userid))
|
title=f"订阅编号 {subscribe_id} 不存在!", userid=userid))
|
||||||
return
|
return
|
||||||
# 删除订阅
|
# 删除订阅
|
||||||
self.subscribeoper.delete(subscribe_id)
|
self.subscribeoper.delete(subscribe_id)
|
||||||
@@ -971,13 +1026,13 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_subscribe_no_exits(subscribe_name: str,
|
def __get_subscribe_no_exits(subscribe_name: str,
|
||||||
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
no_exists: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
|
||||||
mediakey: Union[str, int],
|
mediakey: Union[str, int],
|
||||||
begin_season: int,
|
begin_season: int,
|
||||||
total_episode: int,
|
total_episode: int,
|
||||||
start_episode: int,
|
start_episode: int,
|
||||||
downloaded_episodes: List[int] = None
|
downloaded_episodes: List[int] = None
|
||||||
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
) -> Tuple[bool, Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]]]:
|
||||||
"""
|
"""
|
||||||
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
根据订阅开始集数和总集数,结合TMDB信息计算当前订阅的缺失集数
|
||||||
:param subscribe_name: 订阅名称
|
:param subscribe_name: 订阅名称
|
||||||
@@ -1027,7 +1082,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 与原集列表取交集
|
# 与原集列表取交集
|
||||||
episodes = list(set(episode_list).intersection(set(new_episodes)))
|
episodes = list(set(episode_list).intersection(set(new_episodes)))
|
||||||
# 更新集合
|
# 更新集合
|
||||||
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
|
||||||
season=begin_season,
|
season=begin_season,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total_episode,
|
total_episode=total_episode,
|
||||||
@@ -1054,7 +1109,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
if not episodes:
|
if not episodes:
|
||||||
return True, {}
|
return True, {}
|
||||||
# 更新集合
|
# 更新集合
|
||||||
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
|
||||||
season=begin_season,
|
season=begin_season,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total,
|
total_episode=total,
|
||||||
@@ -1068,7 +1123,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 如果存在已下载剧集,则差集为空时,说明所有均已存在
|
# 如果存在已下载剧集,则差集为空时,说明所有均已存在
|
||||||
if not episodes:
|
if not episodes:
|
||||||
return True, {}
|
return True, {}
|
||||||
no_exists[mediakey][begin_season] = NotExistMediaInfo(
|
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
|
||||||
season=begin_season,
|
season=begin_season,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total_episode,
|
total_episode=total_episode,
|
||||||
@@ -1143,18 +1198,19 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 默认过滤规则
|
# 默认过滤规则
|
||||||
default_rule = self.systemconfig.get(SystemConfigKey.SubscribeDefaultParams) or {}
|
default_rule = self.systemconfig.get(SystemConfigKey.SubscribeDefaultParams) or {}
|
||||||
return {
|
return {
|
||||||
"include": subscribe.include or default_rule.get("include"),
|
key: value for key, value in {
|
||||||
"exclude": subscribe.exclude or default_rule.get("exclude"),
|
"include": subscribe.include or default_rule.get("include"),
|
||||||
"quality": subscribe.quality or default_rule.get("quality"),
|
"exclude": subscribe.exclude or default_rule.get("exclude"),
|
||||||
"resolution": subscribe.resolution or default_rule.get("resolution"),
|
"quality": subscribe.quality or default_rule.get("quality"),
|
||||||
"effect": subscribe.effect or default_rule.get("effect"),
|
"resolution": subscribe.resolution or default_rule.get("resolution"),
|
||||||
"tv_size": default_rule.get("tv_size"),
|
"effect": subscribe.effect or default_rule.get("effect"),
|
||||||
"movie_size": default_rule.get("movie_size"),
|
"tv_size": default_rule.get("tv_size"),
|
||||||
"min_seeders": default_rule.get("min_seeders"),
|
"movie_size": default_rule.get("movie_size"),
|
||||||
"min_seeders_time": default_rule.get("min_seeders_time"),
|
"min_seeders": default_rule.get("min_seeders"),
|
||||||
}
|
"min_seeders_time": default_rule.get("min_seeders_time"),
|
||||||
|
}.items() if value is not None}
|
||||||
|
|
||||||
def subscribe_files_info(self, subscribe: Subscribe) -> Optional[SubscrbieInfo]:
|
def subscribe_files_info(self, subscribe: Subscribe) -> Optional[schemas.SubscrbieInfo]:
|
||||||
"""
|
"""
|
||||||
订阅相关的下载和文件信息
|
订阅相关的下载和文件信息
|
||||||
"""
|
"""
|
||||||
@@ -1162,10 +1218,10 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 返回订阅数据
|
# 返回订阅数据
|
||||||
subscribe_info = SubscrbieInfo()
|
subscribe_info = schemas.SubscrbieInfo()
|
||||||
|
|
||||||
# 所有集的数据
|
# 所有集的数据
|
||||||
episodes: Dict[int, SubscribeEpisodeInfo] = {}
|
episodes: Dict[int, schemas.SubscribeEpisodeInfo] = {}
|
||||||
if subscribe.tmdbid and subscribe.type == MediaType.TV.value:
|
if subscribe.tmdbid and subscribe.type == MediaType.TV.value:
|
||||||
# 查询TMDB中的集信息
|
# 查询TMDB中的集信息
|
||||||
tmdb_episodes = self.tmdbchain.tmdb_episodes(
|
tmdb_episodes = self.tmdbchain.tmdb_episodes(
|
||||||
@@ -1174,7 +1230,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
if tmdb_episodes:
|
if tmdb_episodes:
|
||||||
for episode in tmdb_episodes:
|
for episode in tmdb_episodes:
|
||||||
info = SubscribeEpisodeInfo()
|
info = schemas.SubscribeEpisodeInfo()
|
||||||
info.title = episode.name
|
info.title = episode.name
|
||||||
info.description = episode.overview
|
info.description = episode.overview
|
||||||
info.backdrop = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/w500${episode.still_path}"
|
info.backdrop = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/w500${episode.still_path}"
|
||||||
@@ -1182,12 +1238,12 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
elif subscribe.type == MediaType.TV.value:
|
elif subscribe.type == MediaType.TV.value:
|
||||||
# 根据开始结束集计算集信息
|
# 根据开始结束集计算集信息
|
||||||
for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1):
|
for i in range(subscribe.start_episode or 1, subscribe.total_episode + 1):
|
||||||
info = SubscribeEpisodeInfo()
|
info = schemas.SubscribeEpisodeInfo()
|
||||||
info.title = f'第 {i} 集'
|
info.title = f'第 {i} 集'
|
||||||
episodes[i] = info
|
episodes[i] = info
|
||||||
else:
|
else:
|
||||||
# 电影
|
# 电影
|
||||||
info = SubscribeEpisodeInfo()
|
info = schemas.SubscribeEpisodeInfo()
|
||||||
info.title = subscribe.name
|
info.title = subscribe.name
|
||||||
episodes[0] = info
|
episodes[0] = info
|
||||||
|
|
||||||
@@ -1202,7 +1258,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 识别文件名
|
# 识别文件名
|
||||||
file_meta = MetaInfo(file.filepath)
|
file_meta = MetaInfo(file.filepath)
|
||||||
# 下载文件信息
|
# 下载文件信息
|
||||||
file_info = SubscribeDownloadFileInfo(
|
file_info = schemas.SubscribeDownloadFileInfo(
|
||||||
torrent_title=his.torrent_name,
|
torrent_title=his.torrent_name,
|
||||||
site_name=his.torrent_site,
|
site_name=his.torrent_site,
|
||||||
downloader=file.downloader,
|
downloader=file.downloader,
|
||||||
@@ -1245,7 +1301,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 识别文件名
|
# 识别文件名
|
||||||
file_meta = MetaInfo(fileitem.path)
|
file_meta = MetaInfo(fileitem.path)
|
||||||
# 媒体库文件信息
|
# 媒体库文件信息
|
||||||
file_info = SubscribeLibraryFileInfo(
|
file_info = schemas.SubscribeLibraryFileInfo(
|
||||||
storage=fileitem.storage,
|
storage=fileitem.storage,
|
||||||
file_path=fileitem.path,
|
file_path=fileitem.path,
|
||||||
)
|
)
|
||||||
@@ -1264,7 +1320,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
subscribe_info.episodes = episodes
|
subscribe_info.episodes = episodes
|
||||||
return subscribe_info
|
return subscribe_info
|
||||||
|
|
||||||
def check_and_handle_existing_media(self, subscribe: Subscribe, meta: MetaInfo,
|
def check_and_handle_existing_media(self, subscribe: Subscribe, meta: MetaBase,
|
||||||
mediainfo: MediaInfo, mediakey: str):
|
mediainfo: MediaInfo, mediakey: str):
|
||||||
"""
|
"""
|
||||||
检查媒体是否已经存在,并根据情况执行相应的操作
|
检查媒体是否已经存在,并根据情况执行相应的操作
|
||||||
@@ -1305,7 +1361,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
|||||||
# 对于电视剧,构造缺失的媒体信息
|
# 对于电视剧,构造缺失的媒体信息
|
||||||
no_exists = {
|
no_exists = {
|
||||||
mediakey: {
|
mediakey: {
|
||||||
subscribe.season: NotExistMediaInfo(
|
subscribe.season: schemas.NotExistMediaInfo(
|
||||||
season=subscribe.season,
|
season=subscribe.season,
|
||||||
episodes=[],
|
episodes=[],
|
||||||
total_episode=subscribe.total_episode,
|
total_episode=subscribe.total_episode,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
@@ -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
|
return FRONTEND_VERSION
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import random
|
import random
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
@@ -38,6 +37,13 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_trending", page=page)
|
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]:
|
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||||
"""
|
"""
|
||||||
根据TMDBID查询themoviedb所有季信息
|
根据TMDBID查询themoviedb所有季信息
|
||||||
@@ -112,7 +118,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_random_wallpager(self) -> Optional[str]:
|
def get_random_wallpager(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
获取随机壁纸,缓存1个小时
|
获取随机壁纸,缓存1个小时
|
||||||
@@ -126,7 +132,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
|||||||
return info.backdrop_path
|
return info.backdrop_path
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_trending_wallpapers(self, num: int = 10) -> List[str]:
|
def get_trending_wallpapers(self, num: int = 10) -> List[str]:
|
||||||
"""
|
"""
|
||||||
获取所有流行壁纸
|
获取所有流行壁纸
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
title=f"{task.mediainfo.title_year} {task.meta.season_episode} 入库失败!",
|
title=f"{task.mediainfo.title_year} {task.meta.season_episode} 入库失败!",
|
||||||
text=f"原因:{transferinfo.message or '未知'}",
|
text=f"原因:{transferinfo.message or '未知'}",
|
||||||
image=task.mediainfo.get_message_image(),
|
image=task.mediainfo.get_message_image(),
|
||||||
|
username=task.username,
|
||||||
link=settings.MP_DOMAIN('#/history')
|
link=settings.MP_DOMAIN('#/history')
|
||||||
))
|
))
|
||||||
# 整理失败
|
# 整理失败
|
||||||
@@ -452,9 +453,12 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
if transferinfo.transfer_type in ["move"]:
|
if transferinfo.transfer_type in ["move"]:
|
||||||
# 所有成功的业务
|
# 所有成功的业务
|
||||||
tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season)
|
tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season)
|
||||||
|
# 记录已处理的种子hash
|
||||||
|
processed_hashes = set()
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
# 下载器hash
|
# 下载器hash
|
||||||
if t.download_hash:
|
if t.download_hash and t.download_hash not in processed_hashes:
|
||||||
|
processed_hashes.add(t.download_hash)
|
||||||
if self.remove_torrents(t.download_hash, downloader=t.downloader):
|
if self.remove_torrents(t.download_hash, downloader=t.downloader):
|
||||||
logger.info(f"移动模式删除种子成功:{t.download_hash} ")
|
logger.info(f"移动模式删除种子成功:{t.download_hash} ")
|
||||||
# 删除残留目录
|
# 删除残留目录
|
||||||
@@ -462,8 +466,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
self.storagechain.delete_media_file(t.fileitem, delete_self=False)
|
self.storagechain.delete_media_file(t.fileitem, delete_self=False)
|
||||||
# 整理完成且有成功的任务时
|
# 整理完成且有成功的任务时
|
||||||
if self.jobview.is_finished(task):
|
if self.jobview.is_finished(task):
|
||||||
# 发送通知
|
# 发送通知,实时手动整理时不发
|
||||||
if transferinfo.need_notify:
|
if transferinfo.need_notify and (task.background or not task.manual):
|
||||||
se_str = None
|
se_str = None
|
||||||
if task.mediainfo.type == MediaType.TV:
|
if task.mediainfo.type == MediaType.TV:
|
||||||
season_episodes = self.jobview.season_episodes(task.mediainfo, task.meta.begin_season)
|
season_episodes = self.jobview.season_episodes(task.mediainfo, task.meta.begin_season)
|
||||||
@@ -479,7 +483,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
self.send_transfer_message(meta=task.meta,
|
self.send_transfer_message(meta=task.meta,
|
||||||
mediainfo=task.mediainfo,
|
mediainfo=task.mediainfo,
|
||||||
transferinfo=transferinfo,
|
transferinfo=transferinfo,
|
||||||
season_episode=se_str)
|
season_episode=se_str,
|
||||||
|
username=task.username)
|
||||||
# 刮削事件
|
# 刮削事件
|
||||||
if transferinfo.need_scrape:
|
if transferinfo.need_scrape:
|
||||||
self.eventmanager.send_event(EventType.MetadataScrape, {
|
self.eventmanager.send_event(EventType.MetadataScrape, {
|
||||||
@@ -493,23 +498,28 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def put_to_queue(self, task: TransferTask, callback: Optional[Callable] = None):
|
def put_to_queue(self, task: TransferTask):
|
||||||
"""
|
"""
|
||||||
添加到待整理队列
|
添加到待整理队列
|
||||||
:param task: 任务信息
|
:param task: 任务信息
|
||||||
:param callback: 回调函数
|
|
||||||
"""
|
"""
|
||||||
if not task:
|
if not task:
|
||||||
return
|
return
|
||||||
# 维护整理任务视图
|
# 维护整理任务视图
|
||||||
with task_lock:
|
self.__put_to_jobview(task)
|
||||||
self.jobview.add_task(task)
|
|
||||||
# 添加到队列
|
# 添加到队列
|
||||||
self._queue.put(TransferQueue(
|
self._queue.put(TransferQueue(
|
||||||
task=task,
|
task=task,
|
||||||
callback=callback or self.__default_callback
|
callback=self.__default_callback
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def __put_to_jobview(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
添加到作业视图
|
||||||
|
"""
|
||||||
|
with task_lock:
|
||||||
|
self.jobview.add_task(task)
|
||||||
|
|
||||||
def remove_from_queue(self, fileitem: FileItem):
|
def remove_from_queue(self, fileitem: FileItem):
|
||||||
"""
|
"""
|
||||||
从待整理队列移除
|
从待整理队列移除
|
||||||
@@ -538,8 +548,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
task = item.task
|
task = item.task
|
||||||
if not task:
|
if not task:
|
||||||
continue
|
continue
|
||||||
# 正在处理
|
|
||||||
self.jobview.running_task(task)
|
|
||||||
# 文件信息
|
# 文件信息
|
||||||
fileitem = task.fileitem
|
fileitem = task.fileitem
|
||||||
# 开始新队列
|
# 开始新队列
|
||||||
@@ -576,10 +584,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
self.progress.update(value=processed_num / total_num * 100,
|
self.progress.update(value=processed_num / total_num * 100,
|
||||||
text=__process_msg,
|
text=__process_msg,
|
||||||
key=ProgressKey.FileTransfer)
|
key=ProgressKey.FileTransfer)
|
||||||
# 移除已完成的任务
|
|
||||||
with task_lock:
|
|
||||||
if self.jobview.is_done(task):
|
|
||||||
self.jobview.remove_job(task)
|
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
if not __queue_start:
|
if not __queue_start:
|
||||||
# 结束进度
|
# 结束进度
|
||||||
@@ -606,102 +610,119 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
处理整理任务
|
处理整理任务
|
||||||
"""
|
"""
|
||||||
# 识别
|
try:
|
||||||
if not task.mediainfo:
|
# 识别
|
||||||
download_history = task.download_history
|
if not task.mediainfo:
|
||||||
# 识别媒体信息
|
mediainfo = None
|
||||||
if download_history and (download_history.tmdbid or download_history.doubanid):
|
download_history = task.download_history
|
||||||
# 下载记录中已存在识别信息
|
# 下载用户
|
||||||
mediainfo: MediaInfo = self.recognize_media(mtype=MediaType(download_history.type),
|
if download_history:
|
||||||
tmdbid=download_history.tmdbid,
|
task.username = download_history.username
|
||||||
doubanid=download_history.doubanid)
|
# 识别媒体信息
|
||||||
|
if download_history.tmdbid or download_history.doubanid:
|
||||||
|
# 下载记录中已存在识别信息
|
||||||
|
mediainfo: Optional[MediaInfo] = self.recognize_media(mtype=MediaType(download_history.type),
|
||||||
|
tmdbid=download_history.tmdbid,
|
||||||
|
doubanid=download_history.doubanid)
|
||||||
|
if mediainfo:
|
||||||
|
# 更新自定义媒体类别
|
||||||
|
if download_history.media_category:
|
||||||
|
mediainfo.category = download_history.media_category
|
||||||
|
else:
|
||||||
|
# 识别媒体信息
|
||||||
|
mediainfo = self.mediachain.recognize_by_meta(task.meta)
|
||||||
|
|
||||||
|
# 更新媒体图片
|
||||||
if mediainfo:
|
if mediainfo:
|
||||||
# 更新自定义媒体类别
|
self.obtain_images(mediainfo=mediainfo)
|
||||||
if download_history.media_category:
|
|
||||||
mediainfo.category = download_history.media_category
|
|
||||||
else:
|
|
||||||
# 识别媒体信息
|
|
||||||
mediainfo = self.mediachain.recognize_by_meta(task.meta)
|
|
||||||
# 更新媒体图片
|
|
||||||
if mediainfo:
|
|
||||||
self.obtain_images(mediainfo=mediainfo)
|
|
||||||
if not mediainfo:
|
|
||||||
# 新增整理失败历史记录
|
|
||||||
his = self.transferhis.add_fail(
|
|
||||||
fileitem=task.fileitem,
|
|
||||||
mode=task.transfer_type,
|
|
||||||
meta=task.meta,
|
|
||||||
downloader=task.downloader,
|
|
||||||
download_hash=task.download_hash
|
|
||||||
)
|
|
||||||
self.post_message(Notification(
|
|
||||||
mtype=NotificationType.Manual,
|
|
||||||
title=f"{task.fileitem.name} 未识别到媒体信息,无法入库!",
|
|
||||||
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别整理。",
|
|
||||||
link=settings.MP_DOMAIN('#/history')
|
|
||||||
))
|
|
||||||
# 任务失败,直接移除task
|
|
||||||
self.jobview.remove_task(task.fileitem)
|
|
||||||
return False, "未识别到媒体信息"
|
|
||||||
|
|
||||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
if not mediainfo:
|
||||||
if not settings.SCRAP_FOLLOW_TMDB:
|
# 新增整理失败历史记录
|
||||||
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
his = self.transferhis.add_fail(
|
||||||
mtype=mediainfo.type.value)
|
fileitem=task.fileitem,
|
||||||
if transfer_history:
|
mode=task.transfer_type,
|
||||||
mediainfo.title = transfer_history.title
|
meta=task.meta,
|
||||||
|
downloader=task.downloader,
|
||||||
|
download_hash=task.download_hash
|
||||||
|
)
|
||||||
|
self.post_message(Notification(
|
||||||
|
mtype=NotificationType.Manual,
|
||||||
|
title=f"{task.fileitem.name} 未识别到媒体信息,无法入库!",
|
||||||
|
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别整理。",
|
||||||
|
username=task.username,
|
||||||
|
link=settings.MP_DOMAIN('#/history')
|
||||||
|
))
|
||||||
|
# 任务失败,直接移除task
|
||||||
|
self.jobview.remove_task(task.fileitem)
|
||||||
|
return False, "未识别到媒体信息"
|
||||||
|
|
||||||
# 获取集数据
|
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||||
if not task.episodes_info and mediainfo.type == MediaType.TV:
|
if not settings.SCRAP_FOLLOW_TMDB:
|
||||||
if task.meta.begin_season is None:
|
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||||
task.meta.begin_season = 1
|
mtype=mediainfo.type.value)
|
||||||
mediainfo.season = mediainfo.season or task.meta.begin_season
|
if transfer_history:
|
||||||
task.episodes_info = self.tmdbchain.tmdb_episodes(
|
mediainfo.title = transfer_history.title
|
||||||
tmdbid=mediainfo.tmdb_id,
|
|
||||||
season=mediainfo.season
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新任务信息
|
# 获取集数据
|
||||||
task.mediainfo = mediainfo
|
if not task.episodes_info and mediainfo.type == MediaType.TV:
|
||||||
# 更新队列任务
|
if task.meta.begin_season is None:
|
||||||
curr_task = self.jobview.remove_task(task.fileitem)
|
task.meta.begin_season = 1
|
||||||
self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting")
|
mediainfo.season = mediainfo.season or task.meta.begin_season
|
||||||
|
task.episodes_info = self.tmdbchain.tmdb_episodes(
|
||||||
|
tmdbid=mediainfo.tmdb_id,
|
||||||
|
season=mediainfo.season
|
||||||
|
)
|
||||||
|
|
||||||
# 查询整理目标目录
|
# 更新任务信息
|
||||||
if not task.target_directory:
|
task.mediainfo = mediainfo
|
||||||
if task.target_path:
|
# 更新队列任务
|
||||||
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
|
curr_task = self.jobview.remove_task(task.fileitem)
|
||||||
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting")
|
||||||
dest_path=task.target_path,
|
|
||||||
target_storage=task.target_storage)
|
|
||||||
else:
|
|
||||||
# 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录
|
|
||||||
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
|
||||||
storage=task.fileitem.storage,
|
|
||||||
src_path=Path(task.fileitem.path),
|
|
||||||
target_storage=task.target_storage)
|
|
||||||
|
|
||||||
# 执行整理
|
# 查询整理目标目录
|
||||||
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
|
if not task.target_directory:
|
||||||
meta=task.meta,
|
if task.target_path:
|
||||||
mediainfo=task.mediainfo,
|
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
|
||||||
target_directory=task.target_directory,
|
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
||||||
target_storage=task.target_storage,
|
dest_path=task.target_path,
|
||||||
target_path=task.target_path,
|
target_storage=task.target_storage)
|
||||||
transfer_type=task.transfer_type,
|
else:
|
||||||
episodes_info=task.episodes_info,
|
# 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录
|
||||||
scrape=task.scrape,
|
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
||||||
library_type_folder=task.library_type_folder,
|
storage=task.fileitem.storage,
|
||||||
library_category_folder=task.library_category_folder)
|
src_path=Path(task.fileitem.path),
|
||||||
if not transferinfo:
|
target_storage=task.target_storage)
|
||||||
logger.error("文件整理模块运行失败")
|
|
||||||
return False, "文件整理模块运行失败"
|
|
||||||
|
|
||||||
# 回调,位置传参:任务、整理结果
|
# 正在处理
|
||||||
if callback:
|
self.jobview.running_task(task)
|
||||||
return callback(task, transferinfo)
|
|
||||||
|
|
||||||
return transferinfo.success, transferinfo.message
|
# 执行整理
|
||||||
|
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
|
||||||
|
meta=task.meta,
|
||||||
|
mediainfo=task.mediainfo,
|
||||||
|
target_directory=task.target_directory,
|
||||||
|
target_storage=task.target_storage,
|
||||||
|
target_path=task.target_path,
|
||||||
|
transfer_type=task.transfer_type,
|
||||||
|
episodes_info=task.episodes_info,
|
||||||
|
scrape=task.scrape,
|
||||||
|
library_type_folder=task.library_type_folder,
|
||||||
|
library_category_folder=task.library_category_folder)
|
||||||
|
if not transferinfo:
|
||||||
|
logger.error("文件整理模块运行失败")
|
||||||
|
return False, "文件整理模块运行失败"
|
||||||
|
|
||||||
|
# 回调,位置传参:任务、整理结果
|
||||||
|
if callback:
|
||||||
|
return callback(task, transferinfo)
|
||||||
|
|
||||||
|
return transferinfo.success, transferinfo.message
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# 移除已完成的任务
|
||||||
|
with task_lock:
|
||||||
|
if self.jobview.is_done(task):
|
||||||
|
self.jobview.remove_job(task)
|
||||||
|
|
||||||
def get_queue_tasks(self) -> List[TransferJob]:
|
def get_queue_tasks(self) -> List[TransferJob]:
|
||||||
"""
|
"""
|
||||||
@@ -783,11 +804,11 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
# 非MoviePilot下载的任务,按文件识别
|
# 非MoviePilot下载的任务,按文件识别
|
||||||
mediainfo = None
|
mediainfo = None
|
||||||
|
|
||||||
# 执行整理,匹配源目录
|
# 执行实时整理,匹配源目录
|
||||||
state, errmsg = self.do_transfer(
|
state, errmsg = self.do_transfer(
|
||||||
fileitem=FileItem(
|
fileitem=FileItem(
|
||||||
storage="local",
|
storage="local",
|
||||||
path=str(file_path),
|
path=str(file_path).replace("\\", "/"),
|
||||||
type="dir" if not file_path.is_file() else "file",
|
type="dir" if not file_path.is_file() else "file",
|
||||||
name=file_path.name,
|
name=file_path.name,
|
||||||
size=file_path.stat().st_size,
|
size=file_path.stat().st_size,
|
||||||
@@ -795,7 +816,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
),
|
),
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
downloader=torrent.downloader,
|
downloader=torrent.downloader,
|
||||||
download_hash=torrent.hash
|
download_hash=torrent.hash,
|
||||||
|
background=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 设置下载任务状态
|
# 设置下载任务状态
|
||||||
@@ -885,7 +907,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
library_type_folder: bool = None, library_category_folder: bool = None,
|
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||||
season: int = None, epformat: EpisodeFormat = None, min_filesize: int = 0,
|
season: int = None, epformat: EpisodeFormat = None, min_filesize: int = 0,
|
||||||
downloader: str = None, download_hash: str = None,
|
downloader: str = None, download_hash: str = None,
|
||||||
force: bool = False, background: bool = True) -> Tuple[bool, str]:
|
force: bool = False, background: bool = True,
|
||||||
|
manual: bool = False) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
执行一个复杂目录的整理操作
|
执行一个复杂目录的整理操作
|
||||||
:param fileitem: 文件项
|
:param fileitem: 文件项
|
||||||
@@ -905,6 +928,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
:param download_hash: 下载记录hash
|
:param download_hash: 下载记录hash
|
||||||
:param force: 是否强制整理
|
:param force: 是否强制整理
|
||||||
:param background: 是否后台运行
|
:param background: 是否后台运行
|
||||||
|
:param manual: 是否手动整理
|
||||||
返回:成功标识,错误信息
|
返回:成功标识,错误信息
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -963,23 +987,10 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件")
|
logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件")
|
||||||
return False, f"{fileitem.name} 没有找到可整理的媒体文件"
|
return False, f"{fileitem.name} 没有找到可整理的媒体文件"
|
||||||
|
|
||||||
# 总数量
|
logger.info(f"正在计划整理 {len(file_items)} 个文件...")
|
||||||
total_num = len(file_items)
|
|
||||||
# 已处理数量
|
|
||||||
processed_num = 0
|
|
||||||
# 失败数量
|
|
||||||
fail_num = 0
|
|
||||||
logger.info(f"正在计划整理 {total_num} 个文件...")
|
|
||||||
if not background:
|
|
||||||
# 启动进度
|
|
||||||
self.progress.start(ProgressKey.FileTransfer)
|
|
||||||
__process_msg = f"开始整理,共 {total_num} 个文件 ..."
|
|
||||||
logger.info(__process_msg)
|
|
||||||
self.progress.update(value=0,
|
|
||||||
text=__process_msg,
|
|
||||||
key=ProgressKey.FileTransfer)
|
|
||||||
|
|
||||||
# 整理所有文件
|
# 整理所有文件
|
||||||
|
transfer_tasks: List[TransferTask] = []
|
||||||
for file_item, bluray_dir in file_items:
|
for file_item, bluray_dir in file_items:
|
||||||
if global_vars.is_system_stopped:
|
if global_vars.is_system_stopped:
|
||||||
break
|
break
|
||||||
@@ -1003,27 +1014,18 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
is_blocked = True
|
is_blocked = True
|
||||||
break
|
break
|
||||||
if is_blocked:
|
if is_blocked:
|
||||||
fail_num += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 整理成功的不再处理
|
# 整理成功的不再处理
|
||||||
if not force:
|
if not force:
|
||||||
transferd = self.transferhis.get_by_src(file_item.path, storage=file_item.storage)
|
transferd = self.transferhis.get_by_src(file_item.path, storage=file_item.storage)
|
||||||
if transferd:
|
if transferd:
|
||||||
all_success = False
|
if not transferd.status:
|
||||||
|
all_success = False
|
||||||
logger.info(f"{file_item.path} 已整理过,如需重新处理,请删除整理记录。")
|
logger.info(f"{file_item.path} 已整理过,如需重新处理,请删除整理记录。")
|
||||||
err_msgs.append(f"{file_item.name} 已整理过")
|
err_msgs.append(f"{file_item.name} 已整理过")
|
||||||
fail_num += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 更新进度
|
|
||||||
if not background:
|
|
||||||
__process_msg = f"正在整理 ({processed_num + 1}/{total_num}){file_item.name} ..."
|
|
||||||
logger.info(__process_msg)
|
|
||||||
self.progress.update(value=processed_num / total_num * 100,
|
|
||||||
text=__process_msg,
|
|
||||||
key=ProgressKey.FileTransfer)
|
|
||||||
|
|
||||||
if not meta:
|
if not meta:
|
||||||
# 文件元数据
|
# 文件元数据
|
||||||
file_meta = MetaInfoPath(file_path)
|
file_meta = MetaInfoPath(file_path)
|
||||||
@@ -1038,7 +1040,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
all_success = False
|
all_success = False
|
||||||
logger.error(f"{file_path.name} 无法识别有效信息")
|
logger.error(f"{file_path.name} 无法识别有效信息")
|
||||||
err_msgs.append(f"{file_path.name} 无法识别有效信息")
|
err_msgs.append(f"{file_path.name} 无法识别有效信息")
|
||||||
fail_num += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 自定义识别
|
# 自定义识别
|
||||||
@@ -1081,28 +1082,57 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
library_category_folder=library_category_folder,
|
library_category_folder=library_category_folder,
|
||||||
downloader=downloader,
|
downloader=downloader,
|
||||||
download_hash=download_hash,
|
download_hash=download_hash,
|
||||||
download_history=download_history
|
download_history=download_history,
|
||||||
|
manual=manual,
|
||||||
|
background=background
|
||||||
)
|
)
|
||||||
if background:
|
if background:
|
||||||
self.put_to_queue(
|
self.put_to_queue(task=transfer_task)
|
||||||
task=transfer_task
|
|
||||||
)
|
|
||||||
logger.info(f"{file_path.name} 已添加到整理队列")
|
logger.info(f"{file_path.name} 已添加到整理队列")
|
||||||
else:
|
else:
|
||||||
|
# 加入列表
|
||||||
|
self.__put_to_jobview(transfer_task)
|
||||||
|
transfer_tasks.append(transfer_task)
|
||||||
|
|
||||||
|
# 实时整理
|
||||||
|
if transfer_tasks:
|
||||||
|
# 总数量
|
||||||
|
total_num = len(transfer_tasks)
|
||||||
|
# 已处理数量
|
||||||
|
processed_num = 0
|
||||||
|
# 失败数量
|
||||||
|
fail_num = 0
|
||||||
|
|
||||||
|
# 启动进度
|
||||||
|
self.progress.start(ProgressKey.FileTransfer)
|
||||||
|
__process_msg = f"开始整理,共 {total_num} 个文件 ..."
|
||||||
|
logger.info(__process_msg)
|
||||||
|
self.progress.update(value=0,
|
||||||
|
text=__process_msg,
|
||||||
|
key=ProgressKey.FileTransfer)
|
||||||
|
|
||||||
|
for transfer_task in transfer_tasks:
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
break
|
||||||
|
# 更新进度
|
||||||
|
__process_msg = f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..."
|
||||||
|
logger.info(__process_msg)
|
||||||
|
self.progress.update(value=(processed_num + fail_num) / total_num * 100,
|
||||||
|
text=__process_msg,
|
||||||
|
key=ProgressKey.FileTransfer)
|
||||||
state, err_msg = self.__handle_transfer(
|
state, err_msg = self.__handle_transfer(
|
||||||
task=transfer_task,
|
task=transfer_task,
|
||||||
callback=self.__default_callback
|
callback=self.__default_callback
|
||||||
)
|
)
|
||||||
if not state:
|
if not state:
|
||||||
all_success = False
|
all_success = False
|
||||||
logger.warn(f"{file_path.name} {err_msg}")
|
logger.warn(f"{transfer_task.fileitem.name} {err_msg}")
|
||||||
err_msgs.append(f"{file_path.name} {err_msg}")
|
err_msgs.append(f"{transfer_task.fileitem.name} {err_msg}")
|
||||||
fail_num += 1
|
fail_num += 1
|
||||||
# 完成计数
|
else:
|
||||||
processed_num += 1
|
processed_num += 1
|
||||||
|
|
||||||
# 整理结束
|
# 整理结束
|
||||||
if not background:
|
|
||||||
__end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个"
|
__end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个"
|
||||||
logger.info(__end_msg)
|
logger.info(__end_msg)
|
||||||
self.progress.update(value=100,
|
self.progress.update(value=100,
|
||||||
@@ -1197,7 +1227,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
download_hash=history.download_hash,
|
download_hash=history.download_hash,
|
||||||
force=True,
|
force=True,
|
||||||
background=False)
|
background=False,
|
||||||
|
manual=True)
|
||||||
if not state:
|
if not state:
|
||||||
return False, errmsg
|
return False, errmsg
|
||||||
|
|
||||||
@@ -1266,7 +1297,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
library_type_folder=library_type_folder,
|
library_type_folder=library_type_folder,
|
||||||
library_category_folder=library_category_folder,
|
library_category_folder=library_category_folder,
|
||||||
force=force,
|
force=force,
|
||||||
background=background
|
background=background,
|
||||||
|
manual=True
|
||||||
)
|
)
|
||||||
if not state:
|
if not state:
|
||||||
return False, errmsg
|
return False, errmsg
|
||||||
@@ -1287,11 +1319,12 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
library_type_folder=library_type_folder,
|
library_type_folder=library_type_folder,
|
||||||
library_category_folder=library_category_folder,
|
library_category_folder=library_category_folder,
|
||||||
force=force,
|
force=force,
|
||||||
background=background)
|
background=background,
|
||||||
|
manual=True)
|
||||||
return state, errmsg
|
return state, errmsg
|
||||||
|
|
||||||
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
|
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
transferinfo: TransferInfo, season_episode: str = None):
|
transferinfo: TransferInfo, season_episode: str = None, username: str = None):
|
||||||
"""
|
"""
|
||||||
发送入库成功的消息
|
发送入库成功的消息
|
||||||
"""
|
"""
|
||||||
@@ -1312,4 +1345,5 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
|||||||
self.post_message(Notification(
|
self.post_message(Notification(
|
||||||
mtype=NotificationType.Organize,
|
mtype=NotificationType.Organize,
|
||||||
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
|
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
|
||||||
|
username=username,
|
||||||
link=settings.MP_DOMAIN('#/history')))
|
link=settings.MP_DOMAIN('#/history')))
|
||||||
|
|||||||
552
app/core/cache.py
Normal file
552
app/core/cache.py
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import pickle
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import redis
|
||||||
|
from cachetools import TTLCache
|
||||||
|
from cachetools.keys import hashkey
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.log import logger
|
||||||
|
|
||||||
|
# 默认缓存区
|
||||||
|
DEFAULT_CACHE_REGION = "DEFAULT"
|
||||||
|
|
||||||
|
|
||||||
|
class CacheBackend(ABC):
|
||||||
|
"""
|
||||||
|
缓存后端基类,定义通用的缓存接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set(self, key: str, value: Any, ttl: int, region: str = DEFAULT_CACHE_REGION, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
设置缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param value: 缓存的值
|
||||||
|
:param ttl: 缓存的存活时间,单位秒
|
||||||
|
:param region: 缓存的区
|
||||||
|
:param kwargs: 其他参数
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def exists(self, key: str, region: str = DEFAULT_CACHE_REGION) -> bool:
|
||||||
|
"""
|
||||||
|
判断缓存键是否存在
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 存在返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, key: str, region: str = DEFAULT_CACHE_REGION) -> Any:
|
||||||
|
"""
|
||||||
|
获取缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 返回缓存的值,如果缓存不存在返回 None
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(self, key: str, region: str = DEFAULT_CACHE_REGION) -> None:
|
||||||
|
"""
|
||||||
|
删除缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def clear(self, region: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
清除指定区域的缓存或全部缓存
|
||||||
|
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
关闭缓存连接
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_region(region: str = DEFAULT_CACHE_REGION):
|
||||||
|
"""
|
||||||
|
获取缓存的区
|
||||||
|
"""
|
||||||
|
return f"region:{region}" if region else "region:default"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_cache_key(func, args, kwargs):
|
||||||
|
"""
|
||||||
|
获取缓存的键,通过哈希函数对函数的参数进行处理
|
||||||
|
:param func: 被装饰的函数
|
||||||
|
:param args: 位置参数
|
||||||
|
:param kwargs: 关键字参数
|
||||||
|
:return: 缓存键
|
||||||
|
"""
|
||||||
|
signature = inspect.signature(func)
|
||||||
|
# 绑定传入的参数并应用默认值
|
||||||
|
bound = signature.bind(*args, **kwargs)
|
||||||
|
bound.apply_defaults()
|
||||||
|
# 忽略第一个参数,如果它是实例(self)或类(cls)
|
||||||
|
parameters = list(signature.parameters.keys())
|
||||||
|
if parameters and parameters[0] in ("self", "cls"):
|
||||||
|
bound.arguments.pop(parameters[0], None)
|
||||||
|
# 按照函数签名顺序提取参数值列表
|
||||||
|
keys = [
|
||||||
|
bound.arguments[param] for param in signature.parameters if param in bound.arguments
|
||||||
|
]
|
||||||
|
# 使用有序参数生成缓存键
|
||||||
|
return f"{func.__name__}_{hashkey(*keys)}"
|
||||||
|
|
||||||
|
|
||||||
|
class CacheToolsBackend(CacheBackend):
|
||||||
|
"""
|
||||||
|
基于 `cachetools.TTLCache` 实现的缓存后端
|
||||||
|
|
||||||
|
特性:
|
||||||
|
- 支持动态设置缓存的 TTL(Time To Live,存活时间)和最大条目数(Maxsize)
|
||||||
|
- 缓存实例按区域(region)划分,不同 region 拥有独立的缓存实例
|
||||||
|
- 同一 region 共享相同的 TTL 和 Maxsize,设置时只能作用于整个 region
|
||||||
|
|
||||||
|
限制:
|
||||||
|
- 不支持按 `key` 独立隔离 TTL 和 Maxsize,仅支持作用于 region 级别
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, maxsize: int = 1000, ttl: int = 1800):
|
||||||
|
"""
|
||||||
|
初始化缓存实例
|
||||||
|
|
||||||
|
:param maxsize: 缓存的最大条目数
|
||||||
|
:param ttl: 默认缓存存活时间,单位秒
|
||||||
|
"""
|
||||||
|
self.maxsize = maxsize
|
||||||
|
self.ttl = ttl
|
||||||
|
# 存储各个 region 的缓存实例,region -> TTLCache
|
||||||
|
self._region_caches: Dict[str, TTLCache] = {}
|
||||||
|
|
||||||
|
def __get_region_cache(self, region: str) -> Optional[TTLCache]:
|
||||||
|
"""
|
||||||
|
获取指定区域的缓存实例,如果不存在则返回 None
|
||||||
|
"""
|
||||||
|
region = self.get_region(region)
|
||||||
|
return self._region_caches.get(region)
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl: int = None, region: str = DEFAULT_CACHE_REGION, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
设置缓存值支持每个 key 独立配置 TTL 和 Maxsize
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param value: 缓存的值
|
||||||
|
:param ttl: 缓存的存活时间,单位秒如果未传入则使用默认值
|
||||||
|
:param region: 缓存的区
|
||||||
|
:param kwargs: maxsize: 缓存的最大条目数如果未传入则使用默认值
|
||||||
|
"""
|
||||||
|
ttl = ttl or self.ttl
|
||||||
|
maxsize = kwargs.get("maxsize", self.maxsize)
|
||||||
|
region = self.get_region(region)
|
||||||
|
# 如果该 key 尚未有缓存实例,则创建一个新的 TTLCache 实例
|
||||||
|
region_cache = self._region_caches.setdefault(region, TTLCache(maxsize=maxsize, ttl=ttl))
|
||||||
|
# 设置缓存值
|
||||||
|
region_cache[key] = value
|
||||||
|
|
||||||
|
def exists(self, key: str, region: str = DEFAULT_CACHE_REGION) -> bool:
|
||||||
|
"""
|
||||||
|
判断缓存键是否存在
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 存在返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
region_cache = self.__get_region_cache(region)
|
||||||
|
if region_cache is None:
|
||||||
|
return False
|
||||||
|
return key in region_cache
|
||||||
|
|
||||||
|
def get(self, key: str, region: str = DEFAULT_CACHE_REGION) -> Any:
|
||||||
|
"""
|
||||||
|
获取缓存的值
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 返回缓存的值,如果缓存不存在返回 None
|
||||||
|
"""
|
||||||
|
region_cache = self.__get_region_cache(region)
|
||||||
|
if region_cache is None:
|
||||||
|
return None
|
||||||
|
return region_cache.get(key)
|
||||||
|
|
||||||
|
def delete(self, key: str, region: str = DEFAULT_CACHE_REGION) -> None:
|
||||||
|
"""
|
||||||
|
删除缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
region_cache = self.__get_region_cache(region)
|
||||||
|
if region_cache is None:
|
||||||
|
return None
|
||||||
|
del region_cache[key]
|
||||||
|
|
||||||
|
def clear(self, region: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
清除指定区域的缓存或全部缓存
|
||||||
|
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
if region:
|
||||||
|
# 清理指定缓存区
|
||||||
|
region_cache = self.__get_region_cache(region)
|
||||||
|
if region_cache:
|
||||||
|
region_cache.clear()
|
||||||
|
logger.info(f"Cleared cache for region: {region}")
|
||||||
|
else:
|
||||||
|
# 清除所有区域的缓存
|
||||||
|
for region_cache in self._region_caches.values():
|
||||||
|
region_cache.clear()
|
||||||
|
logger.info("Cleared all cache")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
内存缓存不需要关闭资源
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RedisBackend(CacheBackend):
|
||||||
|
"""
|
||||||
|
基于 Redis 实现的缓存后端,支持通过 Redis 存储缓存
|
||||||
|
|
||||||
|
特性:
|
||||||
|
- 支持动态设置缓存的 TTL(Time To Live,存活时间)
|
||||||
|
- 支持分区域(region)管理缓存,不同的 region 采用独立的命名空间
|
||||||
|
- 支持自定义最大内存限制(maxmemory)和内存淘汰策略(如 allkeys-lru)
|
||||||
|
|
||||||
|
限制:
|
||||||
|
- 由于 Redis 的分布式特性,写入和读取可能受到网络延迟的影响
|
||||||
|
- Pickle 反序列化可能存在安全风险,需进一步重构调用来源,避免复杂对象缓存
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 类型缓存集合,针对非容器简单类型
|
||||||
|
_complex_serializable_types = set()
|
||||||
|
_simple_serializable_types = set()
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str = "redis://localhost", ttl: int = 1800):
|
||||||
|
"""
|
||||||
|
初始化 Redis 缓存实例
|
||||||
|
|
||||||
|
:param redis_url: Redis 服务的 URL
|
||||||
|
:param ttl: 缓存的存活时间,单位秒
|
||||||
|
"""
|
||||||
|
self.redis_url = redis_url
|
||||||
|
self.ttl = ttl
|
||||||
|
try:
|
||||||
|
self.client = redis.Redis.from_url(
|
||||||
|
redis_url,
|
||||||
|
decode_responses=False,
|
||||||
|
socket_timeout=30,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
health_check_interval=60,
|
||||||
|
)
|
||||||
|
# 测试连接,确保 Redis 可用
|
||||||
|
self.client.ping()
|
||||||
|
logger.debug(f"Successfully connected to Redis")
|
||||||
|
self.set_memory_limit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to Redis: {e}")
|
||||||
|
raise RuntimeError("Redis connection failed") from e
|
||||||
|
|
||||||
|
def set_memory_limit(self, policy: str = "allkeys-lru"):
|
||||||
|
"""
|
||||||
|
动态设置 Redis 最大内存和内存淘汰策略
|
||||||
|
:param policy: 淘汰策略(如 'allkeys-lru')
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 如果有显式值,则直接使用,为 0 时说明不限制,如果未配置,开启 BIG_MEMORY_MODE 时为 "1024mb",未开启时为 "256mb"
|
||||||
|
maxmemory = settings.CACHE_REDIS_MAXMEMORY or ("1024mb" if settings.BIG_MEMORY_MODE else "256mb")
|
||||||
|
self.client.config_set("maxmemory", maxmemory)
|
||||||
|
self.client.config_set("maxmemory-policy", policy)
|
||||||
|
logger.debug(f"Redis maxmemory set to {maxmemory}, policy: {policy}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set Redis maxmemory or policy: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_container_type(t):
|
||||||
|
return t in (list, dict, tuple, set)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def serialize(cls, value: Any) -> bytes:
|
||||||
|
"""
|
||||||
|
将值序列化为二进制数据,根据序列化方式标识格式
|
||||||
|
"""
|
||||||
|
vt = type(value)
|
||||||
|
# 针对非容器类型使用缓存策略
|
||||||
|
if not cls.is_container_type(vt):
|
||||||
|
# 如果已知需要复杂序列化
|
||||||
|
if vt in cls._complex_serializable_types:
|
||||||
|
return b"PICKLE" + b"\x00" + pickle.dumps(value)
|
||||||
|
# 如果已知可以简单序列化
|
||||||
|
if vt in cls._simple_serializable_types:
|
||||||
|
json_data = json.dumps(value).encode("utf-8")
|
||||||
|
return b"JSON" + b"\x00" + json_data
|
||||||
|
# 对于未知的非容器类型,尝试简单序列化,如抛出异常,再使用复杂序列化
|
||||||
|
try:
|
||||||
|
json_data = json.dumps(value).encode("utf-8")
|
||||||
|
cls._simple_serializable_types.add(vt)
|
||||||
|
return b"JSON" + b"\x00" + json_data
|
||||||
|
except TypeError:
|
||||||
|
cls._complex_serializable_types.add(vt)
|
||||||
|
return b"PICKLE" + b"\x00" + pickle.dumps(value)
|
||||||
|
# 针对容器类型,每次尝试简单序列化,不使用缓存
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
json_data = json.dumps(value).encode("utf-8")
|
||||||
|
return b"JSON" + b"\x00" + json_data
|
||||||
|
except TypeError:
|
||||||
|
return b"PICKLE" + b"\x00" + pickle.dumps(value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deserialize(cls, value: bytes) -> Any:
|
||||||
|
"""
|
||||||
|
将二进制数据反序列化为原始值,根据格式标识区分序列化方式
|
||||||
|
"""
|
||||||
|
format_marker, data = value.split(b"\x00", 1)
|
||||||
|
if format_marker == b"JSON":
|
||||||
|
return json.loads(data.decode("utf-8"))
|
||||||
|
elif format_marker == b"PICKLE":
|
||||||
|
return pickle.loads(data)
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown serialization format")
|
||||||
|
|
||||||
|
# @staticmethod
|
||||||
|
# def serialize(value: Any) -> bytes:
|
||||||
|
# return msgpack.packb(value, use_bin_type=True)
|
||||||
|
#
|
||||||
|
# @staticmethod
|
||||||
|
# def deserialize(value: bytes) -> Any:
|
||||||
|
# return msgpack.unpackb(value, raw=False)
|
||||||
|
|
||||||
|
def get_redis_key(self, region: str, key: str) -> str:
|
||||||
|
"""
|
||||||
|
获取缓存 Key
|
||||||
|
"""
|
||||||
|
# 使用 region 作为缓存键的一部分
|
||||||
|
region = self.get_region(quote(region))
|
||||||
|
return f"{region}:key:{quote(key)}"
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl: int = None, region: str = DEFAULT_CACHE_REGION, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
设置缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param value: 缓存的值
|
||||||
|
:param ttl: 缓存的存活时间,单位秒如果未传入则使用默认值
|
||||||
|
:param region: 缓存的区
|
||||||
|
:param kwargs: kwargs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ttl = ttl or self.ttl
|
||||||
|
redis_key = self.get_redis_key(region, key)
|
||||||
|
# 对值进行序列化
|
||||||
|
serialized_value = self.serialize(value)
|
||||||
|
kwargs.pop("maxsize", None)
|
||||||
|
self.client.set(redis_key, serialized_value, ex=ttl, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set key: {key} in region: {region}, error: {e}")
|
||||||
|
|
||||||
|
def exists(self, key: str, region: str = DEFAULT_CACHE_REGION) -> bool:
|
||||||
|
"""
|
||||||
|
判断缓存键是否存在
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 存在返回 True,否则返回 False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
redis_key = self.get_redis_key(region, key)
|
||||||
|
return self.client.exists(redis_key) == 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to exists key: {key} region: {region}, error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get(self, key: str, region: str = DEFAULT_CACHE_REGION) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
获取缓存的值
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
:return: 返回缓存的值,如果缓存不存在返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
redis_key = self.get_redis_key(region, key)
|
||||||
|
value = self.client.get(redis_key)
|
||||||
|
if value is not None:
|
||||||
|
return self.deserialize(value) # noqa
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get key: {key} in region: {region}, error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete(self, key: str, region: str = DEFAULT_CACHE_REGION) -> None:
|
||||||
|
"""
|
||||||
|
删除缓存
|
||||||
|
|
||||||
|
:param key: 缓存的键
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
redis_key = self.get_redis_key(region, key)
|
||||||
|
self.client.delete(redis_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete key: {key} in region: {region}, error: {e}")
|
||||||
|
|
||||||
|
def clear(self, region: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
清除指定区域的缓存或全部缓存
|
||||||
|
|
||||||
|
:param region: 缓存的区
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if region:
|
||||||
|
cache_region = self.get_region(quote(region))
|
||||||
|
redis_key = f"{cache_region}:key:*"
|
||||||
|
# self.client.delete(*self.client.keys(redis_key))
|
||||||
|
with self.client.pipeline() as pipe:
|
||||||
|
for key in self.client.scan_iter(redis_key):
|
||||||
|
pipe.delete(key)
|
||||||
|
pipe.execute()
|
||||||
|
logger.info(f"Cleared Redis cache for region: {region}")
|
||||||
|
else:
|
||||||
|
self.client.flushdb()
|
||||||
|
logger.info("Cleared all Redis cache")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to clear cache, region: {region}, error: {e}")
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""
|
||||||
|
关闭 Redis 客户端的连接池
|
||||||
|
"""
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache_backend(maxsize: int = 1000, ttl: int = 1800) -> CacheBackend:
|
||||||
|
"""
|
||||||
|
根据配置获取缓存后端实例
|
||||||
|
|
||||||
|
:param maxsize: 缓存的最大条目数
|
||||||
|
:param ttl: 缓存的默认存活时间,单位秒
|
||||||
|
:return: 返回缓存后端实例
|
||||||
|
"""
|
||||||
|
cache_type = settings.CACHE_BACKEND_TYPE
|
||||||
|
logger.debug(f"Cache backend type from settings: {cache_type}")
|
||||||
|
|
||||||
|
if cache_type == "redis":
|
||||||
|
redis_url = settings.CACHE_BACKEND_URL
|
||||||
|
if redis_url:
|
||||||
|
try:
|
||||||
|
logger.debug(f"Attempting to use RedisBackend with URL: {redis_url}, TTL: {ttl}")
|
||||||
|
return RedisBackend(redis_url=redis_url, ttl=ttl)
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("Falling back to CacheToolsBackend due to Redis connection failure.")
|
||||||
|
else:
|
||||||
|
logger.debug("Cache backend type is redis, but no valid REDIS_URL found. "
|
||||||
|
"Falling back to CacheToolsBackend.")
|
||||||
|
|
||||||
|
# 如果不是 Redis,回退到内存缓存
|
||||||
|
logger.debug(f"Using CacheToolsBackend with default maxsize: {maxsize}, TTL: {ttl}")
|
||||||
|
return CacheToolsBackend(maxsize=maxsize, ttl=ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def cached(region: Optional[str] = None, maxsize: int = 1000, ttl: int = 1800,
|
||||||
|
skip_none: bool = True, skip_empty: bool = False):
|
||||||
|
"""
|
||||||
|
自定义缓存装饰器,支持为每个 key 动态传递 maxsize 和 ttl
|
||||||
|
|
||||||
|
:param region: 缓存的区
|
||||||
|
:param maxsize: 缓存的最大条目数,默认值为 1000
|
||||||
|
:param ttl: 缓存的存活时间,单位秒,默认值为 1800
|
||||||
|
:param skip_none: 跳过 None 缓存,默认为 True
|
||||||
|
:param skip_empty: 跳过空值缓存(如 None, [], {}, "", set()),默认为 False
|
||||||
|
:return: 装饰器函数
|
||||||
|
"""
|
||||||
|
|
||||||
|
def should_cache(value: Any) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否应该缓存结果,如果返回值是 None 或空值则不缓存
|
||||||
|
|
||||||
|
:param value: 要判断的缓存值
|
||||||
|
:return: 是否缓存结果
|
||||||
|
"""
|
||||||
|
if skip_none and value is None:
|
||||||
|
return False
|
||||||
|
# if skip_empty and value in [None, [], {}, "", set()]:
|
||||||
|
if skip_empty and not value:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
|
||||||
|
# 获取缓存区
|
||||||
|
cache_region = region if region is not None else f"{func.__module__}.{func.__name__}"
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# 获取缓存键
|
||||||
|
cache_key = cache_backend.get_cache_key(func, args, kwargs)
|
||||||
|
# 尝试获取缓存
|
||||||
|
cached_value = cache_backend.get(cache_key, region=cache_region)
|
||||||
|
if should_cache(cached_value):
|
||||||
|
return cached_value
|
||||||
|
# 执行函数并缓存结果
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
# 判断是否需要缓存
|
||||||
|
if not should_cache(result):
|
||||||
|
return result
|
||||||
|
# 设置缓存(如果有传入的 maxsize 和 ttl,则覆盖默认值)
|
||||||
|
cache_backend.set(cache_key, result, ttl=ttl, maxsize=maxsize, region=cache_region)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cache_clear():
|
||||||
|
"""
|
||||||
|
清理缓存区
|
||||||
|
"""
|
||||||
|
# 清理缓存区
|
||||||
|
cache_backend.clear(region=cache_region)
|
||||||
|
|
||||||
|
wrapper.cache_region = cache_region
|
||||||
|
wrapper.cache_clear = cache_clear
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# 缓存后端实例
|
||||||
|
cache_backend = get_cache_backend()
|
||||||
|
|
||||||
|
|
||||||
|
def close_cache() -> None:
|
||||||
|
"""
|
||||||
|
关闭缓存后端连接并清理资源
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if cache_backend:
|
||||||
|
cache_backend.close()
|
||||||
|
logger.info("Cache backend closed successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"Error while closing cache backend: {e}")
|
||||||
@@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional, Tuple, Type
|
|||||||
from dotenv import set_key
|
from dotenv import set_key
|
||||||
from pydantic import BaseModel, BaseSettings, validator, Field
|
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.system import SystemUtils
|
||||||
from app.utils.url import UrlUtils
|
from app.utils.url import UrlUtils
|
||||||
|
|
||||||
@@ -71,6 +71,12 @@ class ConfigModel(BaseModel):
|
|||||||
DB_TIMEOUT: int = 60
|
DB_TIMEOUT: int = 60
|
||||||
# SQLite 是否启用 WAL 模式,默认关闭
|
# SQLite 是否启用 WAL 模式,默认关闭
|
||||||
DB_WAL_ENABLE: bool = False
|
DB_WAL_ENABLE: bool = False
|
||||||
|
# 缓存类型,支持 cachetools 和 redis,默认使用 cachetools
|
||||||
|
CACHE_BACKEND_TYPE: str = "cachetools"
|
||||||
|
# 缓存连接字符串,仅外部缓存(如 Redis、Memcached)需要
|
||||||
|
CACHE_BACKEND_URL: Optional[str] = None
|
||||||
|
# Redis 缓存最大内存限制,未配置时,如开启大内存模式时为 "1024mb",未开启时为 "256mb"
|
||||||
|
CACHE_REDIS_MAXMEMORY: Optional[str] = None
|
||||||
# 配置文件目录
|
# 配置文件目录
|
||||||
CONFIG_DIR: Optional[str] = None
|
CONFIG_DIR: Optional[str] = None
|
||||||
# 超级管理员
|
# 超级管理员
|
||||||
@@ -244,7 +250,7 @@ class ConfigModel(BaseModel):
|
|||||||
TOKENIZED_SEARCH: bool = False
|
TOKENIZED_SEARCH: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings, ConfigModel):
|
class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||||
"""
|
"""
|
||||||
系统配置类
|
系统配置类
|
||||||
"""
|
"""
|
||||||
@@ -351,7 +357,7 @@ class Settings(BaseSettings, ConfigModel):
|
|||||||
return default, True
|
return default, True
|
||||||
|
|
||||||
@validator('*', pre=True, always=True)
|
@validator('*', pre=True, always=True)
|
||||||
def generic_type_validator(cls, value: Any, field):
|
def generic_type_validator(cls, value: Any, field): # noqa
|
||||||
"""
|
"""
|
||||||
通用校验器,尝试将配置值转换为期望的类型
|
通用校验器,尝试将配置值转换为期望的类型
|
||||||
"""
|
"""
|
||||||
@@ -406,6 +412,8 @@ class Settings(BaseSettings, ConfigModel):
|
|||||||
# 仅成功更新配置时,才更新内存
|
# 仅成功更新配置时,才更新内存
|
||||||
if success:
|
if success:
|
||||||
setattr(self, key, converted_value)
|
setattr(self, key, converted_value)
|
||||||
|
if hasattr(log_settings, key):
|
||||||
|
setattr(log_settings, key, converted_value)
|
||||||
return success, message
|
return success, message
|
||||||
return True, ""
|
return True, ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -416,8 +424,21 @@ class Settings(BaseSettings, ConfigModel):
|
|||||||
更新多个配置项
|
更新多个配置项
|
||||||
"""
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
|
log_updated, plugin_monitor_updated = False, False
|
||||||
for k, v in env.items():
|
for k, v in env.items():
|
||||||
results[k] = self.update_setting(k, v)
|
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
|
return results
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ class MediaInfo:
|
|||||||
douban_id: str = None
|
douban_id: str = None
|
||||||
# Bangumi ID
|
# Bangumi ID
|
||||||
bangumi_id: int = None
|
bangumi_id: int = None
|
||||||
|
# 合集ID
|
||||||
|
collection_id: int = None
|
||||||
# 媒体原语种
|
# 媒体原语种
|
||||||
original_language: str = None
|
original_language: str = None
|
||||||
# 媒体原发行标题
|
# 媒体原发行标题
|
||||||
@@ -397,6 +399,8 @@ class MediaInfo:
|
|||||||
if info.get("external_ids"):
|
if info.get("external_ids"):
|
||||||
self.tvdb_id = info.get("external_ids", {}).get("tvdb_id")
|
self.tvdb_id = info.get("external_ids", {}).get("tvdb_id")
|
||||||
self.imdb_id = info.get("external_ids", {}).get("imdb_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
|
self.vote_average = round(float(info.get('vote_average')), 1) if info.get('vote_average') else 0
|
||||||
# 描述
|
# 描述
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ class EventManager(metaclass=Singleton):
|
|||||||
|
|
||||||
# 对于类实例(实现了 __call__ 方法)
|
# 对于类实例(实现了 __call__ 方法)
|
||||||
if not inspect.isfunction(handler) and hasattr(handler, "__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)
|
return cls.__get_handler_identifier(handler_cls)
|
||||||
|
|
||||||
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
|
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class MetaBase(object):
|
|||||||
_subtitle_flag = False
|
_subtitle_flag = False
|
||||||
_title_episodel_re = r"Episode\s+(\d{1,4})"
|
_title_episodel_re = r"Episode\s+(\d{1,4})"
|
||||||
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
|
||||||
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
|
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季"
|
||||||
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])"
|
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])"
|
||||||
_subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
_subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
||||||
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
|
||||||
@@ -247,7 +247,7 @@ class MetaBase(object):
|
|||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
return
|
return
|
||||||
# x集全
|
# x集全/全x集
|
||||||
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
|
||||||
if episode_all_str:
|
if episode_all_str:
|
||||||
episode_all = episode_all_str.group(1)
|
episode_all = episode_all_str.group(1)
|
||||||
@@ -259,8 +259,6 @@ class MetaBase(object):
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||||
return
|
return
|
||||||
self.begin_episode = None
|
|
||||||
self.end_episode = None
|
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
self._subtitle_flag = True
|
self._subtitle_flag = True
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -220,11 +220,23 @@ class PluginManager(metaclass=Singleton):
|
|||||||
self._running_plugins = {}
|
self._running_plugins = {}
|
||||||
logger.info("插件停止完成")
|
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):
|
def __start_monitor(self):
|
||||||
"""
|
"""
|
||||||
开发者模式下监测插件文件修改
|
启用监测插件文件修改监测
|
||||||
"""
|
"""
|
||||||
logger.info("开发者模式下开始监测插件文件修改...")
|
logger.info("开始监测插件文件修改...")
|
||||||
monitor_handler = PluginMonitorHandler()
|
monitor_handler = PluginMonitorHandler()
|
||||||
self._observer = Observer()
|
self._observer = Observer()
|
||||||
self._observer.schedule(monitor_handler, str(settings.ROOT_PATH / "app" / "plugins"), recursive=True)
|
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):
|
def stop_monitor(self):
|
||||||
"""
|
"""
|
||||||
停止监测插件修改
|
停止监测插件文件修改监测
|
||||||
"""
|
"""
|
||||||
# 停止监测
|
# 停止监测
|
||||||
if self._observer:
|
if self._observer and self._observer.is_alive():
|
||||||
logger.info("正在停止插件文件修改监测...")
|
logger.info("正在停止插件文件修改监测...")
|
||||||
self._observer.stop()
|
self._observer.stop()
|
||||||
self._observer.join()
|
self._observer.join()
|
||||||
logger.info("插件文件修改监测停止完成")
|
logger.info("插件文件修改监测停止完成")
|
||||||
|
else:
|
||||||
|
logger.info("未启用插件文件修改监测,无需停止")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __stop_plugin(plugin: Any):
|
def __stop_plugin(plugin: Any):
|
||||||
@@ -668,7 +682,7 @@ class PluginManager(metaclass=Singleton):
|
|||||||
# 相同 ID 的插件保留版本号最大的版本
|
# 相同 ID 的插件保留版本号最大的版本
|
||||||
max_versions = {}
|
max_versions = {}
|
||||||
for p in all_plugins:
|
for p in all_plugins:
|
||||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
|
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]):
|
||||||
max_versions[p.id] = p.plugin_version
|
max_versions[p.id] = p.plugin_version
|
||||||
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
|
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
|
||||||
logger.info(f"共获取到 {len(result)} 个线上插件")
|
logger.info(f"共获取到 {len(result)} 个线上插件")
|
||||||
@@ -809,7 +823,7 @@ class PluginManager(metaclass=Singleton):
|
|||||||
plugin.has_update = False
|
plugin.has_update = False
|
||||||
if plugin_static:
|
if plugin_static:
|
||||||
installed_version = getattr(plugin_static, "plugin_version")
|
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
|
plugin.has_update = True
|
||||||
# 运行状态
|
# 运行状态
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class Base:
|
|||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
def to_dict(self):
|
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
|
@declared_attr
|
||||||
def __tablename__(self) -> str:
|
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():
|
def update_db():
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class MessageOper(DbOper):
|
|||||||
|
|
||||||
# 从kwargs中去掉Message中没有的字段
|
# 从kwargs中去掉Message中没有的字段
|
||||||
for k in list(kwargs.keys()):
|
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)
|
kwargs.pop(k)
|
||||||
|
|
||||||
Message(**kwargs).create(self._db)
|
Message(**kwargs).create(self._db)
|
||||||
|
|||||||
@@ -67,27 +67,6 @@ class UserOper(DbOper):
|
|||||||
def get_permissions(self, name: str) -> dict:
|
def get_permissions(self, name: str) -> dict:
|
||||||
"""
|
"""
|
||||||
获取用户权限
|
获取用户权限
|
||||||
{
|
|
||||||
"admin": "管理员",
|
|
||||||
"usermanage": "用户管理",
|
|
||||||
"dashboard": "仪表板",
|
|
||||||
"ranking": "推荐榜单",
|
|
||||||
"resource": {
|
|
||||||
"search": "搜索站点资源",
|
|
||||||
"download": "下载站点资源",
|
|
||||||
},
|
|
||||||
"subscribe": {
|
|
||||||
"request": "提交订阅请求",
|
|
||||||
"autopass": "订阅请求自动批准"
|
|
||||||
"approve": "审批订阅请求",
|
|
||||||
"calendar": "查看订阅日历",
|
|
||||||
"manage": "管理所有订阅"
|
|
||||||
},
|
|
||||||
"downloading": {
|
|
||||||
"view": "查看正在下载任务",
|
|
||||||
"manager": "管理正在下载任务"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
user = User.get_by_name(self._db, name)
|
user = User.get_by_name(self._db, name)
|
||||||
if user:
|
if user:
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class FormatParser(object):
|
|||||||
# `details` 格式为 `X`
|
# `details` 格式为 `X`
|
||||||
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||||
return int(eval(start_ep)), None, self.part
|
return int(eval(start_ep)), None, self.part
|
||||||
else:
|
elif not self._format:
|
||||||
# `details` 格式为 `X,X`
|
# `details` 格式为 `X,X`
|
||||||
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
start_ep = self.__offset.replace("EP", str(self._start_ep))
|
||||||
end_ep = self.__offset.replace("EP", str(self._end_ep))
|
end_ep = self.__offset.replace("EP", str(self._end_ep))
|
||||||
|
|||||||
@@ -64,13 +64,12 @@ class ModuleHelper:
|
|||||||
|
|
||||||
def reload_sub_modules(parent_module, parent_module_name):
|
def reload_sub_modules(parent_module, parent_module_name):
|
||||||
"""重新加载一级子模块"""
|
"""重新加载一级子模块"""
|
||||||
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__):
|
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__, parent_module_name+'.'):
|
||||||
full_sub_module_name = f'{parent_module_name}.{sub_module_name}'
|
|
||||||
try:
|
try:
|
||||||
full_sub_module = importlib.import_module(full_sub_module_name)
|
full_sub_module = importlib.import_module(sub_module_name)
|
||||||
importlib.reload(full_sub_module)
|
importlib.reload(full_sub_module)
|
||||||
except Exception as sub_err:
|
except Exception as sub_err:
|
||||||
logger.debug(f'加载子模块 {full_sub_module_name} 失败:{str(sub_err)} - {traceback.format_exc()}')
|
logger.debug(f'加载子模块 {sub_module_name} 失败:{str(sub_err)} - {traceback.format_exc()}')
|
||||||
|
|
||||||
# 遍历包中的所有子模块
|
# 遍历包中的所有子模块
|
||||||
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
|
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import traceback
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Set
|
from typing import Any, Dict, List, Optional, Tuple, Set
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||||
from packaging.version import Version, InvalidVersion
|
from packaging.version import Version, InvalidVersion
|
||||||
from pkg_resources import Requirement, working_set
|
from pkg_resources import Requirement, working_set
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
@@ -38,24 +38,26 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
if self.install_report():
|
if self.install_report():
|
||||||
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
|
@cached(maxsize=1000, ttl=1800)
|
||||||
def get_plugins(self, repo_url: str, package_version: str = None) -> Dict[str, dict]:
|
def get_plugins(self, repo_url: str, package_version: str = None) -> Optional[Dict[str, dict]]:
|
||||||
"""
|
"""
|
||||||
获取Github所有最新插件列表
|
获取Github所有最新插件列表
|
||||||
:param repo_url: Github仓库地址
|
:param repo_url: Github仓库地址
|
||||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||||
"""
|
"""
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
return {}
|
return None
|
||||||
|
|
||||||
user, repo = self.get_repo_info(repo_url)
|
user, repo = self.get_repo_info(repo_url)
|
||||||
if not user or not repo:
|
if not user or not repo:
|
||||||
return {}
|
return None
|
||||||
|
|
||||||
raw_url = self._base_url.format(user=user, repo=repo)
|
raw_url = self._base_url.format(user=user, repo=repo)
|
||||||
package_url = f"{raw_url}package.{package_version}.json" if package_version else f"{raw_url}package.json"
|
package_url = f"{raw_url}package.{package_version}.json" if package_version else f"{raw_url}package.json"
|
||||||
|
|
||||||
res = self.__request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"))
|
res = self.__request_with_fallback(package_url, headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"))
|
||||||
|
if res is None:
|
||||||
|
return None
|
||||||
if res:
|
if res:
|
||||||
try:
|
try:
|
||||||
return json.loads(res.text)
|
return json.loads(res.text)
|
||||||
@@ -113,7 +115,7 @@ class PluginHelper(metaclass=Singleton):
|
|||||||
return None, None
|
return None, None
|
||||||
return user, repo
|
return user, repo
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=1800))
|
@cached(maxsize=1, ttl=1800)
|
||||||
def get_statistic(self) -> Dict:
|
def get_statistic(self) -> Dict:
|
||||||
"""
|
"""
|
||||||
获取插件安装统计
|
获取插件安装统计
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class ResourceHelper(metaclass=Singleton):
|
|||||||
local_version = self.siteshelper.indexer_version
|
local_version = self.siteshelper.indexer_version
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
if StringUtils.compare_version(version, local_version) > 0:
|
if StringUtils.compare_version(version, ">", local_version):
|
||||||
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from app.core.cache import cached, cache_backend
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.subscribe_oper import SubscribeOper
|
from app.db.subscribe_oper import SubscribeOper
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
|
|
||||||
class SubscribeHelper(metaclass=Singleton):
|
class SubscribeHelper(metaclass=Singleton):
|
||||||
@@ -30,14 +30,17 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
|
|
||||||
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||||
|
|
||||||
|
_shares_cache_region = "subscribe_share"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.systemconfig = SystemConfigOper()
|
self.systemconfig = SystemConfigOper()
|
||||||
|
self.share_user_id = SystemUtils.generate_user_unique_id()
|
||||||
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||||
if not self.systemconfig.get(SystemConfigKey.SubscribeReport):
|
if not self.systemconfig.get(SystemConfigKey.SubscribeReport):
|
||||||
if self.sub_report():
|
if self.sub_report():
|
||||||
self.systemconfig.set(SystemConfigKey.SubscribeReport, "1")
|
self.systemconfig.set(SystemConfigKey.SubscribeReport, "1")
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=20, ttl=1800))
|
@cached(maxsize=20, ttl=1800)
|
||||||
def get_statistic(self, stype: str, page: int = 1, count: int = 30) -> List[dict]:
|
def get_statistic(self, stype: str, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取订阅统计数据
|
获取订阅统计数据
|
||||||
@@ -125,17 +128,39 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
return False, "订阅不存在"
|
return False, "订阅不存在"
|
||||||
subscribe_dict = subscribe.to_dict()
|
subscribe_dict = subscribe.to_dict()
|
||||||
subscribe_dict.pop("id")
|
subscribe_dict.pop("id")
|
||||||
|
cache_backend.clear(region=self._shares_cache_region)
|
||||||
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
res = RequestUtils(proxies=settings.PROXY, content_type="application/json",
|
||||||
timeout=10).post(self._sub_share,
|
timeout=10).post(self._sub_share,
|
||||||
json={
|
json={
|
||||||
"share_title": share_title,
|
"share_title": share_title,
|
||||||
"share_comment": share_comment,
|
"share_comment": share_comment,
|
||||||
"share_user": share_user,
|
"share_user": share_user,
|
||||||
|
"share_uid": self.share_user_id,
|
||||||
**subscribe_dict
|
**subscribe_dict
|
||||||
})
|
})
|
||||||
if res is None:
|
if res is None:
|
||||||
return False, "连接MoviePilot服务器失败"
|
return False, "连接MoviePilot服务器失败"
|
||||||
if res.ok:
|
if res.ok:
|
||||||
|
# 清除 get_shares 的缓存,以便实时看到结果
|
||||||
|
cache_backend.clear(region=self._shares_cache_region)
|
||||||
|
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 的缓存,以便实时看到结果
|
||||||
|
cache_backend.clear(region=self._shares_cache_region)
|
||||||
return True, ""
|
return True, ""
|
||||||
else:
|
else:
|
||||||
return False, res.json().get("message")
|
return False, res.json().get("message")
|
||||||
@@ -156,8 +181,8 @@ class SubscribeHelper(metaclass=Singleton):
|
|||||||
else:
|
else:
|
||||||
return False, res.json().get("message")
|
return False, res.json().get("message")
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=20, ttl=1800))
|
@cached(region=_shares_cache_region)
|
||||||
def get_shares(self, name: str, page: int = 1, count: int = 30) -> List[dict]:
|
def get_shares(self, name: str = None, page: int = 1, count: int = 30) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
获取订阅分享数据
|
获取订阅分享数据
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from torrentool.api import Torrent
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import Context, TorrentInfo, MediaInfo
|
from app.core.context import Context, TorrentInfo, MediaInfo
|
||||||
|
from app.core.meta import MetaBase
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.db.site_oper import SiteOper
|
from app.db.site_oper import SiteOper
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
@@ -445,3 +446,38 @@ class TorrentHelper(metaclass=Singleton):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
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
|
||||||
|
|||||||
115
app/log.py
115
app/log.py
@@ -1,19 +1,24 @@
|
|||||||
import inspect
|
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from pydantic import BaseSettings
|
from pydantic import BaseSettings, BaseModel
|
||||||
|
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
|
|
||||||
class LogSettings(BaseSettings):
|
class LogConfigModel(BaseModel):
|
||||||
"""
|
"""
|
||||||
日志设置
|
Pydantic 配置模型,描述所有配置项及其类型和默认值
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "ignore" # 忽略未定义的配置项
|
||||||
|
|
||||||
# 配置文件目录
|
# 配置文件目录
|
||||||
CONFIG_DIR: Optional[str] = None
|
CONFIG_DIR: Optional[str] = None
|
||||||
# 是否为调试模式
|
# 是否为调试模式
|
||||||
@@ -29,6 +34,12 @@ class LogSettings(BaseSettings):
|
|||||||
# 文件日志格式
|
# 文件日志格式
|
||||||
LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s"
|
LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
class LogSettings(BaseSettings, LogConfigModel):
|
||||||
|
"""
|
||||||
|
日志设置类
|
||||||
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def CONFIG_PATH(self):
|
def CONFIG_PATH(self):
|
||||||
return SystemUtils.get_config_path(self.CONFIG_DIR)
|
return SystemUtils.get_config_path(self.CONFIG_DIR)
|
||||||
@@ -85,6 +96,8 @@ class LoggerManager:
|
|||||||
_loggers: Dict[str, Any] = {}
|
_loggers: Dict[str, Any] = {}
|
||||||
# 默认日志文件名称
|
# 默认日志文件名称
|
||||||
_default_log_file = "moviepilot.log"
|
_default_log_file = "moviepilot.log"
|
||||||
|
# 线程锁
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_caller():
|
def __get_caller():
|
||||||
@@ -96,35 +109,54 @@ class LoggerManager:
|
|||||||
caller_name = None
|
caller_name = None
|
||||||
# 调用者插件名称
|
# 调用者插件名称
|
||||||
plugin_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
|
parts = filepath.parts
|
||||||
|
# 设定调用者文件名称
|
||||||
if not caller_name:
|
if not caller_name:
|
||||||
# 设定调用者文件名称
|
if parts[-1] == "__init__.py" and len(parts) >= 2:
|
||||||
if parts[-1] == "__init__.py":
|
|
||||||
caller_name = parts[-2]
|
caller_name = parts[-2]
|
||||||
else:
|
else:
|
||||||
caller_name = parts[-1]
|
caller_name = parts[-1]
|
||||||
|
# 设定调用者插件名称
|
||||||
if "app" in parts:
|
if "app" in parts:
|
||||||
if not plugin_name and "plugins" in parts:
|
if not plugin_name and "plugins" in parts:
|
||||||
# 设定调用者插件名称
|
try:
|
||||||
plugin_name = parts[parts.index("plugins") + 1]
|
plugins_index = parts.index("plugins")
|
||||||
if plugin_name == "__init__.py":
|
if plugins_index + 1 < len(parts):
|
||||||
plugin_name = "plugin"
|
plugin_candidate = parts[plugins_index + 1]
|
||||||
break
|
if plugin_candidate == "__init__.py":
|
||||||
|
plugin_name = "plugin"
|
||||||
|
else:
|
||||||
|
plugin_name = plugin_candidate
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
if "main.py" in parts:
|
if "main.py" in parts:
|
||||||
# 已经到达程序的入口
|
# 已经到达程序的入口,停止遍历
|
||||||
break
|
break
|
||||||
elif len(parts) != 1:
|
elif len(parts) != 1:
|
||||||
# 已经超出程序范围
|
# 已经超出程序范围,停止遍历
|
||||||
|
break
|
||||||
|
# 获取上一个帧
|
||||||
|
try:
|
||||||
|
frame = frame.f_back
|
||||||
|
except AttributeError:
|
||||||
break
|
break
|
||||||
return caller_name or "log.py", plugin_name
|
return caller_name or "log.py", plugin_name
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __setup_logger(log_file: str):
|
def __setup_logger(log_file: str):
|
||||||
"""
|
"""
|
||||||
设置日志
|
初始化日志实例
|
||||||
log_file:日志文件相对路径
|
:param log_file:日志文件相对路径
|
||||||
"""
|
"""
|
||||||
log_file_path = log_settings.LOG_PATH / log_file
|
log_file_path = log_settings.LOG_PATH / log_file
|
||||||
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -132,11 +164,8 @@ class LoggerManager:
|
|||||||
# 创建新实例
|
# 创建新实例
|
||||||
_logger = logging.getLogger(log_file_path.stem)
|
_logger = logging.getLogger(log_file_path.stem)
|
||||||
|
|
||||||
if log_settings.DEBUG:
|
# 设置日志级别
|
||||||
_logger.setLevel(logging.DEBUG)
|
_logger.setLevel(LoggerManager.__get_log_level())
|
||||||
else:
|
|
||||||
loglevel = getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
|
|
||||||
_logger.setLevel(loglevel)
|
|
||||||
|
|
||||||
# 移除已有的 handler,避免重复添加
|
# 移除已有的 handler,避免重复添加
|
||||||
for handler in _logger.handlers:
|
for handler in _logger.handlers:
|
||||||
@@ -162,6 +191,46 @@ class LoggerManager:
|
|||||||
|
|
||||||
return _logger
|
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):
|
def logger(self, method: str, msg: str, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
获取模块的logger
|
获取模块的logger
|
||||||
@@ -181,7 +250,7 @@ class LoggerManager:
|
|||||||
# 获取调用者的模块的logger
|
# 获取调用者的模块的logger
|
||||||
_logger = self._loggers.get(logfile)
|
_logger = self._loggers.get(logfile)
|
||||||
if not _logger:
|
if not _logger:
|
||||||
_logger = self.__setup_logger(logfile)
|
_logger = self.__setup_logger(log_file=logfile)
|
||||||
self._loggers[logfile] = _logger
|
self._loggers[logfile] = _logger
|
||||||
# 调用logger的方法打印日志
|
# 调用logger的方法打印日志
|
||||||
if hasattr(_logger, method):
|
if hasattr(_logger, method):
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ class BangumiApi(object):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["bangumi"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["bangumi"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def __invoke(cls, url, **kwargs):
|
def __invoke(cls, url, **kwargs):
|
||||||
req_url = cls._base_url + url
|
req_url = cls._base_url + url
|
||||||
params = {}
|
params = {}
|
||||||
@@ -188,7 +188,8 @@ class BangumiApi(object):
|
|||||||
获取人物参演作品
|
获取人物参演作品
|
||||||
"""
|
"""
|
||||||
ret_list = []
|
ret_list = []
|
||||||
result = self.__invoke(self._urls["person_credits"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
result = self.__invoke(self._urls["person_credits"] % person_id,
|
||||||
|
_ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||||
if result:
|
if result:
|
||||||
for item in result:
|
for item in result:
|
||||||
ret_list.append(item)
|
ret_list.append(item)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from random import choice
|
|||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
@@ -174,14 +174,14 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
).digest()
|
).digest()
|
||||||
).decode()
|
).decode()
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def __invoke_recommend(self, url: str, **kwargs) -> dict:
|
def __invoke_recommend(self, url: str, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
推荐/发现类API
|
推荐/发现类API
|
||||||
"""
|
"""
|
||||||
return self.__invoke(url, **kwargs)
|
return self.__invoke(url, **kwargs)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def __invoke_search(self, url: str, **kwargs) -> dict:
|
def __invoke_search(self, url: str, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
搜索类API
|
搜索类API
|
||||||
@@ -216,7 +216,7 @@ class DoubanApi(metaclass=Singleton):
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
return resp.json() if resp else {}
|
return resp.json() if resp else {}
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def __post(self, url: str, **kwargs) -> dict:
|
def __post(self, url: str, **kwargs) -> dict:
|
||||||
"""
|
"""
|
||||||
POST请求
|
POST请求
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ class DoubanCache(metaclass=Singleton):
|
|||||||
return
|
return
|
||||||
|
|
||||||
with open(self._meta_path, 'wb') as f:
|
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:
|
def _random_sample(self, new_meta_data: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class DoubanScraper:
|
|||||||
# 电视剧元数据文件
|
# 电视剧元数据文件
|
||||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||||
if doc:
|
if doc:
|
||||||
return doc.toprettyxml(indent=" ", encoding="utf-8")
|
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ class Emby:
|
|||||||
year: str = None,
|
year: str = None,
|
||||||
tmdb_id: int = None,
|
tmdb_id: int = None,
|
||||||
season: int = None
|
season: int = None
|
||||||
) -> Tuple[Optional[str], Optional[Dict[int, List[Dict[int, list]]]]]:
|
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||||||
"""
|
"""
|
||||||
根据标题和年份和季,返回Emby中的剧集列表
|
根据标题和年份和季,返回Emby中的剧集列表
|
||||||
:param item_id: Emby中的ID
|
:param item_id: Emby中的ID
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Optional, Tuple, Union
|
from typing import Optional, Tuple, Union
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from app.core.cache import cached
|
||||||
|
|
||||||
from app.core.context import MediaInfo, settings
|
from app.core.context import MediaInfo, settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules import _ModuleBase
|
from app.modules import _ModuleBase
|
||||||
@@ -11,7 +10,6 @@ from app.utils.http import RequestUtils
|
|||||||
|
|
||||||
|
|
||||||
class FanartModule(_ModuleBase):
|
class FanartModule(_ModuleBase):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"name": "The Wheel of Time",
|
"name": "The Wheel of Time",
|
||||||
@@ -384,7 +382,7 @@ class FanartModule(_ModuleBase):
|
|||||||
continue
|
continue
|
||||||
if not isinstance(images, list):
|
if not isinstance(images, list):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 图片属性xx_path
|
# 图片属性xx_path
|
||||||
image_name = self.__name(name)
|
image_name = self.__name(name)
|
||||||
if image_name.startswith("season"):
|
if image_name.startswith("season"):
|
||||||
@@ -422,7 +420,7 @@ class FanartModule(_ModuleBase):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["fanart"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["fanart"], ttl=settings.CACHE_CONF["meta"], skip_none=False)
|
||||||
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
||||||
if media_type == MediaType.MOVIE:
|
if media_type == MediaType.MOVIE:
|
||||||
image_url = cls._movie_url % queryid
|
image_url = cls._movie_url % queryid
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def support_transtype(self, storage: str) -> Optional[Dict[str, str]]:
|
def support_transtype(self, storage: str) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
支持的整理方式
|
支持的整理方式
|
||||||
"""
|
"""
|
||||||
@@ -368,10 +368,7 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 覆盖模式
|
# 覆盖模式
|
||||||
overwrite_mode = target_directory.overwrite_mode
|
overwrite_mode = target_directory.overwrite_mode
|
||||||
# 是否需要刮削
|
# 是否需要刮削
|
||||||
if scrape is None:
|
need_scrape = target_directory.scraping if scrape is None else scrape
|
||||||
need_scrape = target_directory.scraping
|
|
||||||
else:
|
|
||||||
need_scrape = scrape
|
|
||||||
# 目标存储类型
|
# 目标存储类型
|
||||||
if not target_storage:
|
if not target_storage:
|
||||||
target_storage = target_directory.library_storage
|
target_storage = target_directory.library_storage
|
||||||
@@ -923,18 +920,6 @@ class FileManagerModule(_ModuleBase):
|
|||||||
rename_format = settings.TV_RENAME_FORMAT \
|
rename_format = settings.TV_RENAME_FORMAT \
|
||||||
if mediainfo.type == MediaType.TV else settings.MOVIE_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":
|
if fileitem.type == "dir":
|
||||||
# 整理整个目录,一般为蓝光原盘
|
# 整理整个目录,一般为蓝光原盘
|
||||||
@@ -1014,12 +999,15 @@ class FileManagerModule(_ModuleBase):
|
|||||||
overflag = False
|
overflag = False
|
||||||
# 目的操作对象
|
# 目的操作对象
|
||||||
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
target_oper: StorageBase = self.__get_storage_oper(target_storage)
|
||||||
|
# 计算重命名中的文件夹层级
|
||||||
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
|
folder_path = new_file.parents[rename_format_level - 1]
|
||||||
# 目标目录
|
# 目标目录
|
||||||
target_diritem = target_oper.get_folder(new_file.parents[rename_format_level - 1])
|
target_diritem = target_oper.get_folder(folder_path)
|
||||||
if not target_diritem:
|
if not target_diritem:
|
||||||
logger.error(f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败")
|
logger.error(f"目标目录 {folder_path} 获取失败")
|
||||||
return TransferInfo(success=False,
|
return TransferInfo(success=False,
|
||||||
message=f"目标目录 {new_file.parents[rename_format_level - 1]} 获取失败",
|
message=f"目标目录 {folder_path} 获取失败",
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
fail_list=[fileitem.path],
|
fail_list=[fileitem.path],
|
||||||
transfer_type=transfer_type,
|
transfer_type=transfer_type,
|
||||||
@@ -1259,10 +1247,6 @@ class FileManagerModule(_ModuleBase):
|
|||||||
# 重命名格式
|
# 重命名格式
|
||||||
rename_format = settings.TV_RENAME_FORMAT \
|
rename_format = settings.TV_RENAME_FORMAT \
|
||||||
if mediainfo.type == MediaType.TV else settings.MOVIE_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:
|
|
||||||
continue
|
|
||||||
# 获取路径(重命名路径)
|
# 获取路径(重命名路径)
|
||||||
target_path = self.get_rename_path(
|
target_path = self.get_rename_path(
|
||||||
path=dir_path,
|
path=dir_path,
|
||||||
@@ -1270,13 +1254,19 @@ class FileManagerModule(_ModuleBase):
|
|||||||
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
||||||
mediainfo=mediainfo)
|
mediainfo=mediainfo)
|
||||||
)
|
)
|
||||||
|
# 计算重命名中的文件夹层数
|
||||||
|
rename_format_level = len(rename_format.split("/")) - 1
|
||||||
# 取相对路径的第1层目录
|
# 取相对路径的第1层目录
|
||||||
media_path = target_path.parents[rename_format_level - 1]
|
media_path = target_path.parents[rename_format_level - 1]
|
||||||
# 检索媒体文件
|
# 检索媒体文件
|
||||||
fileitem = storage_oper.get_item(media_path)
|
fileitem = storage_oper.get_item(media_path)
|
||||||
if not fileitem:
|
if not fileitem:
|
||||||
continue
|
continue
|
||||||
media_files = self.list_files(fileitem, True)
|
try:
|
||||||
|
media_files = self.list_files(fileitem, True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"获取媒体文件列表失败:{str(e)}")
|
||||||
|
continue
|
||||||
if media_files:
|
if media_files:
|
||||||
for media_file in media_files:
|
for media_file in media_files:
|
||||||
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
|
if f".{media_file.extension.lower()}" in settings.RMT_MEDIAEXT:
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
获取父目录
|
获取父目录
|
||||||
"""
|
"""
|
||||||
return self.get_folder(Path(fileitem.path).parent)
|
return self.get_item(Path(fileitem.path).parent)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
|
|
||||||
# 支持的整理方式
|
# 支持的整理方式
|
||||||
transtype = {
|
transtype = {
|
||||||
|
"copy": "复制",
|
||||||
"move": "移动",
|
"move": "移动",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
refresh_token = self.__auth_params.get("refreshToken")
|
refresh_token = self.__auth_params.get("refreshToken")
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
try:
|
try:
|
||||||
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c,
|
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)
|
name="MoviePilot V2", level=logging.ERROR, re_login=False)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"初始化阿里云盘失败:{str(err)}")
|
logger.error(f"初始化阿里云盘失败:{str(err)}")
|
||||||
@@ -327,7 +328,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
item = self.aligo.get_file_by_path(path=str(path))
|
item = self.aligo.get_file_by_path(path=str(path))
|
||||||
if item:
|
if item:
|
||||||
return self.__get_fileitem(item, parent=path.parent)
|
return self.__get_fileitem(item, parent=str(path.parent))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
from requests import Response
|
from requests import Response
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.modules.filemanager.storages import StorageBase
|
from app.modules.filemanager.storages import StorageBase
|
||||||
@@ -67,7 +67,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
|||||||
return self.__generate_token
|
return self.__generate_token
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=60 * 60 * 24 * 2 - 60 * 5))
|
@cached(maxsize=1, ttl=60 * 60 * 24 * 2 - 60 * 5)
|
||||||
def __generate_token(self) -> str:
|
def __generate_token(self) -> str:
|
||||||
"""
|
"""
|
||||||
使用账号密码生成一个临时token
|
使用账号密码生成一个临时token
|
||||||
@@ -553,7 +553,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
|||||||
:param new_name: 上传后文件名
|
:param new_name: 上传后文件名
|
||||||
:param task: 是否为任务,默认为False避免未完成上传时对文件进行操作
|
:param task: 是否为任务,默认为False避免未完成上传时对文件进行操作
|
||||||
"""
|
"""
|
||||||
encoded_path = UrlUtils.quote(fileitem.path + path.name)
|
encoded_path = UrlUtils.quote((Path(fileitem.path) / path.name).as_posix())
|
||||||
headers = self.__get_header_with_token()
|
headers = self.__get_header_with_token()
|
||||||
headers.setdefault("Content-Type", "application/octet-stream")
|
headers.setdefault("Content-Type", "application/octet-stream")
|
||||||
headers.setdefault("As-Task", str(task).lower())
|
headers.setdefault("As-Task", str(task).lower())
|
||||||
@@ -569,7 +569,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
|||||||
return
|
return
|
||||||
|
|
||||||
new_item = self.get_item(Path(fileitem.path) / path.name)
|
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):
|
if self.rename(new_item, new_name):
|
||||||
return self.get_item(Path(new_item.path).with_name(new_name))
|
return self.get_item(Path(new_item.path).with_name(new_name))
|
||||||
|
|
||||||
|
|||||||
@@ -192,13 +192,11 @@ class FilterModule(_ModuleBase):
|
|||||||
|
|
||||||
def filter_torrents(self, rule_groups: List[str],
|
def filter_torrents(self, rule_groups: List[str],
|
||||||
torrent_list: List[TorrentInfo],
|
torrent_list: List[TorrentInfo],
|
||||||
season_episodes: Dict[int, list] = None,
|
|
||||||
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
mediainfo: MediaInfo = None) -> List[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
过滤种子资源
|
过滤种子资源
|
||||||
:param rule_groups: 过滤规则组名称列表
|
:param rule_groups: 过滤规则组名称列表
|
||||||
:param torrent_list: 资源列表
|
:param torrent_list: 资源列表
|
||||||
:param season_episodes: 季集数过滤 {season:[episodes]}
|
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
:return: 过滤后的资源列表,添加资源优先级
|
:return: 过滤后的资源列表,添加资源优先级
|
||||||
"""
|
"""
|
||||||
@@ -215,24 +213,18 @@ class FilterModule(_ModuleBase):
|
|||||||
torrent_list = self.__filter_torrents(
|
torrent_list = self.__filter_torrents(
|
||||||
rule_string=group.rule_string,
|
rule_string=group.rule_string,
|
||||||
rule_name=group.name,
|
rule_name=group.name,
|
||||||
torrent_list=torrent_list,
|
torrent_list=torrent_list
|
||||||
season_episodes=season_episodes
|
)
|
||||||
)
|
|
||||||
return torrent_list
|
return torrent_list
|
||||||
|
|
||||||
def __filter_torrents(self, rule_string: str, rule_name: str,
|
def __filter_torrents(self, rule_string: str, rule_name: str,
|
||||||
torrent_list: List[TorrentInfo],
|
torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
||||||
season_episodes: Dict[int, list]) -> List[TorrentInfo]:
|
|
||||||
"""
|
"""
|
||||||
过滤种子
|
过滤种子
|
||||||
"""
|
"""
|
||||||
# 返回种子列表
|
# 返回种子列表
|
||||||
ret_torrents = []
|
ret_torrents = []
|
||||||
for torrent in torrent_list:
|
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):
|
if not self.__get_order(torrent, rule_string):
|
||||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} {torrent.description} "
|
logger.debug(f"种子 {torrent.site_name} - {torrent.title} {torrent.description} "
|
||||||
@@ -242,39 +234,6 @@ class FilterModule(_ModuleBase):
|
|||||||
|
|
||||||
return ret_torrents
|
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]:
|
def __get_order(self, torrent: TorrentInfo, rule_str: str) -> Optional[TorrentInfo]:
|
||||||
"""
|
"""
|
||||||
获取种子匹配的规则优先级,值越大越优先,未匹配时返回None
|
获取种子匹配的规则优先级,值越大越优先,未匹配时返回None
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
|||||||
message_text = message_labels[0].xpath("string(.)")
|
message_text = message_labels[0].xpath("string(.)")
|
||||||
|
|
||||||
logger.debug(f"{self._site_name} 消息原始信息 {message_text}")
|
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:
|
if message_unread_match and len(message_unread_match[-1]) == 2:
|
||||||
self.message_unread = StringUtils.str_int(message_unread_match[-1][1])
|
self.message_unread = StringUtils.str_int(message_unread_match[-1][1])
|
||||||
@@ -208,9 +208,16 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
|||||||
# 是否存在下页数据
|
# 是否存在下页数据
|
||||||
next_page = None
|
next_page = None
|
||||||
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href')
|
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href')
|
||||||
if next_page_text:
|
|
||||||
next_page = next_page_text[-1].strip()
|
#防止识别到详情页
|
||||||
# fix up page url
|
while next_page_text:
|
||||||
|
next_page = next_page_text.pop().strip()
|
||||||
|
if not next_page.startswith('details.php'):
|
||||||
|
break;
|
||||||
|
next_page = None
|
||||||
|
|
||||||
|
# fix up page url
|
||||||
|
if next_page:
|
||||||
if self.userid not in next_page:
|
if self.userid not in next_page:
|
||||||
next_page = f'{next_page}&userid={self.userid}&type=seeding'
|
next_page = f'{next_page}&userid={self.userid}&type=seeding'
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ from pathlib import Path
|
|||||||
from typing import List, Optional, Dict, Tuple, Generator, Any, Union
|
from typing import List, Optional, Dict, Tuple, Generator, Any, Union
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
from plexapi import media
|
from plexapi import media
|
||||||
from plexapi.myplex import MyPlexAccount
|
from plexapi.myplex import MyPlexAccount
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
from requests import Response, Session
|
from requests import Response, Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.core.cache import cached
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
@@ -83,7 +83,7 @@ class Plex:
|
|||||||
logger.error(f"Authentication failed: {e}")
|
logger.error(f"Authentication failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=100, ttl=86400))
|
@cached(maxsize=100, ttl=86400)
|
||||||
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
|
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
|
||||||
"""
|
"""
|
||||||
获取媒体服务器最近添加的媒体的图片列表
|
获取媒体服务器最近添加的媒体的图片列表
|
||||||
@@ -293,7 +293,7 @@ class Plex:
|
|||||||
season_episodes[episode.seasonNumber].append(episode.index)
|
season_episodes[episode.seasonNumber].append(episode.index)
|
||||||
return videos.key, season_episodes
|
return videos.key, season_episodes
|
||||||
|
|
||||||
def get_remote_image_by_id(self,
|
def get_remote_image_by_id(self,
|
||||||
item_id: str,
|
item_id: str,
|
||||||
image_type: str,
|
image_type: str,
|
||||||
depth: int = 0,
|
depth: int = 0,
|
||||||
|
|||||||
@@ -134,10 +134,9 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
|||||||
category=category,
|
category=category,
|
||||||
ignore_category_check=False
|
ignore_category_check=False
|
||||||
)
|
)
|
||||||
# 获取下载器全局设置
|
|
||||||
application = server.qbc.application.preferences
|
|
||||||
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
||||||
torrent_layout = application.get("torrent_content_layout", "Original")
|
torrent_layout = server.get_content_layout()
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
# 读取种子的名称
|
# 读取种子的名称
|
||||||
|
|||||||
@@ -448,3 +448,14 @@ class Qbittorrent:
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"修改tracker出错:{str(err)}")
|
logger.error(f"修改tracker出错:{str(err)}")
|
||||||
return False
|
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")
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ class Slack:
|
|||||||
return ""
|
return ""
|
||||||
conversation_id = ""
|
conversation_id = ""
|
||||||
try:
|
try:
|
||||||
for result in self._client.conversations_list():
|
for result in self._client.conversations_list(types="public_channel,private_channel"):
|
||||||
if conversation_id:
|
if conversation_id:
|
||||||
break
|
break
|
||||||
for channel in result["channels"]:
|
for channel in result["channels"]:
|
||||||
|
|||||||
@@ -311,6 +311,27 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
return [MediaPerson(source='themoviedb', **person) for person in results]
|
return [MediaPerson(source='themoviedb', **person) for person in results]
|
||||||
return []
|
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,
|
def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||||
season: int = None, episode: int = None) -> Optional[str]:
|
season: int = None, episode: int = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class TmdbScraper:
|
|||||||
# 电视剧元数据文件
|
# 电视剧元数据文件
|
||||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||||
if doc:
|
if doc:
|
||||||
return doc.toprettyxml(indent=" ", encoding="utf-8")
|
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ from typing import Optional, List
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import zhconv
|
import zhconv
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
from app.schemas import APIRateLimitException
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
|
from app.utils.limit import rate_limit_exponential
|
||||||
from app.utils.string import StringUtils
|
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
|
from .tmdbv3api.exceptions import TMDbException
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ class TmdbApi:
|
|||||||
self.discover = Discover()
|
self.discover = Discover()
|
||||||
self.trending = Trending()
|
self.trending = Trending()
|
||||||
self.person = Person()
|
self.person = Person()
|
||||||
|
self.collection = Collection()
|
||||||
|
|
||||||
def search_multiis(self, title: str) -> List[dict]:
|
def search_multiis(self, title: str) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -101,6 +104,32 @@ class TmdbApi:
|
|||||||
return []
|
return []
|
||||||
return self.search.people(term=name) or []
|
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
|
@staticmethod
|
||||||
def __compare_names(file_name: str, tmdb_names: list) -> bool:
|
def __compare_names(file_name: str, tmdb_names: list) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -464,7 +493,8 @@ class TmdbApi:
|
|||||||
|
|
||||||
return ret_info
|
return ret_info
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"])
|
||||||
|
@rate_limit_exponential(source="match_tmdb_web", base_wait=5, max_wait=1800, enable_logging=True)
|
||||||
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
|
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
|
||||||
"""
|
"""
|
||||||
搜索TMDB网站,直接抓取结果,结果只有一条时才返回
|
搜索TMDB网站,直接抓取结果,结果只有一条时才返回
|
||||||
@@ -477,51 +507,56 @@ class TmdbApi:
|
|||||||
return {}
|
return {}
|
||||||
logger.info("正在从TheDbMovie网站查询:%s ..." % name)
|
logger.info("正在从TheDbMovie网站查询:%s ..." % name)
|
||||||
tmdb_url = "https://www.themoviedb.org/search?query=%s" % quote(name)
|
tmdb_url = "https://www.themoviedb.org/search?query=%s" % quote(name)
|
||||||
res = RequestUtils(timeout=5, ua=settings.USER_AGENT).get_res(url=tmdb_url)
|
res = RequestUtils(timeout=5, ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=tmdb_url)
|
||||||
if res and res.status_code == 200:
|
if res is None:
|
||||||
html_text = res.text
|
return None
|
||||||
if not html_text:
|
if res.status_code == 429:
|
||||||
return None
|
raise APIRateLimitException("触发TheDbMovie网站限流,获取媒体信息失败")
|
||||||
try:
|
if res.status_code != 200:
|
||||||
tmdb_links = []
|
return {}
|
||||||
html = etree.HTML(html_text)
|
html_text = res.text
|
||||||
if mtype == MediaType.TV:
|
if not html_text:
|
||||||
links = html.xpath("//a[@data-id and @data-media-type='tv']/@href")
|
return {}
|
||||||
else:
|
try:
|
||||||
links = html.xpath("//a[@data-id]/@href")
|
tmdb_links = []
|
||||||
for link in links:
|
html = etree.HTML(html_text)
|
||||||
if not link or (not link.startswith("/tv") and not link.startswith("/movie")):
|
if mtype == MediaType.TV:
|
||||||
continue
|
links = html.xpath("//a[@data-id and @data-media-type='tv']/@href")
|
||||||
if link not in tmdb_links:
|
else:
|
||||||
tmdb_links.append(link)
|
links = html.xpath("//a[@data-id]/@href")
|
||||||
if len(tmdb_links) == 1:
|
for link in links:
|
||||||
tmdbinfo = self.get_info(
|
if not link or (not link.startswith("/tv") and not link.startswith("/movie")):
|
||||||
mtype=MediaType.TV if tmdb_links[0].startswith("/tv") else MediaType.MOVIE,
|
continue
|
||||||
tmdbid=tmdb_links[0].split("/")[-1])
|
if link not in tmdb_links:
|
||||||
if tmdbinfo:
|
tmdb_links.append(link)
|
||||||
if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV:
|
if len(tmdb_links) == 1:
|
||||||
return {}
|
tmdbinfo = self.get_info(
|
||||||
if tmdbinfo.get('media_type') == MediaType.MOVIE:
|
mtype=MediaType.TV if tmdb_links[0].startswith("/tv") else MediaType.MOVIE,
|
||||||
logger.info("%s 从WEB识别到 电影:TMDBID=%s, 名称=%s, 上映日期=%s" % (
|
tmdbid=tmdb_links[0].split("/")[-1])
|
||||||
name,
|
if tmdbinfo:
|
||||||
tmdbinfo.get('id'),
|
if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV:
|
||||||
tmdbinfo.get('title'),
|
return {}
|
||||||
tmdbinfo.get('release_date')))
|
if tmdbinfo.get('media_type') == MediaType.MOVIE:
|
||||||
else:
|
logger.info("%s 从WEB识别到 电影:TMDBID=%s, 名称=%s, 上映日期=%s" % (
|
||||||
logger.info("%s 从WEB识别到 电视剧:TMDBID=%s, 名称=%s, 首播日期=%s" % (
|
name,
|
||||||
name,
|
tmdbinfo.get('id'),
|
||||||
tmdbinfo.get('id'),
|
tmdbinfo.get('title'),
|
||||||
tmdbinfo.get('name'),
|
tmdbinfo.get('release_date')))
|
||||||
tmdbinfo.get('first_air_date')))
|
else:
|
||||||
return tmdbinfo
|
logger.info("%s 从WEB识别到 电视剧:TMDBID=%s, 名称=%s, 首播日期=%s" % (
|
||||||
elif len(tmdb_links) > 1:
|
name,
|
||||||
logger.info("%s TMDB网站返回数据过多:%s" % (name, len(tmdb_links)))
|
tmdbinfo.get('id'),
|
||||||
else:
|
tmdbinfo.get('name'),
|
||||||
logger.info("%s TMDB网站未查询到媒体信息!" % name)
|
tmdbinfo.get('first_air_date')))
|
||||||
except Exception as err:
|
return tmdbinfo
|
||||||
logger.error(f"从TheDbMovie网站查询出错:{str(err)}")
|
elif len(tmdb_links) > 1:
|
||||||
return None
|
logger.info("%s TMDB网站返回数据过多:%s" % (name, len(tmdb_links)))
|
||||||
return None
|
else:
|
||||||
|
logger.info("%s TMDB网站未查询到媒体信息!" % name)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"从TheDbMovie网站查询出错:{str(err)}")
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
def get_info(self,
|
def get_info(self,
|
||||||
mtype: MediaType,
|
mtype: MediaType,
|
||||||
@@ -651,14 +686,14 @@ class TmdbApi:
|
|||||||
else:
|
else:
|
||||||
en_title = __get_tmdb_lang_title(tmdb_info, "US")
|
en_title = __get_tmdb_lang_title(tmdb_info, "US")
|
||||||
tmdb_info['en_title'] = en_title or org_title
|
tmdb_info['en_title'] = en_title or org_title
|
||||||
|
|
||||||
# 查找香港台湾译名
|
# 查找香港台湾译名
|
||||||
tmdb_info['hk_title'] = __get_tmdb_lang_title(tmdb_info, "HK")
|
tmdb_info['hk_title'] = __get_tmdb_lang_title(tmdb_info, "HK")
|
||||||
tmdb_info['tw_title'] = __get_tmdb_lang_title(tmdb_info, "TW")
|
tmdb_info['tw_title'] = __get_tmdb_lang_title(tmdb_info, "TW")
|
||||||
|
|
||||||
# 查找新加坡名(用于替代中文名)
|
# 查找新加坡名(用于替代中文名)
|
||||||
tmdb_info['sg_title'] = __get_tmdb_lang_title(tmdb_info, "SG") or org_title
|
tmdb_info['sg_title'] = __get_tmdb_lang_title(tmdb_info, "SG") or org_title
|
||||||
|
|
||||||
def __get_movie_detail(self,
|
def __get_movie_detail(self,
|
||||||
tmdbid: int,
|
tmdbid: int,
|
||||||
append_to_response: str = "images,"
|
append_to_response: str = "images,"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
from app.core.cache import cached
|
||||||
from ..tmdb import TMDb
|
from ..tmdb import TMDb
|
||||||
from cachetools import cached, TTLCache
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
@@ -13,7 +13,7 @@ class Discover(TMDb):
|
|||||||
"tv": "/discover/tv"
|
"tv": "/discover/tv"
|
||||||
}
|
}
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
@cached(maxsize=1, ttl=43200)
|
||||||
def discover_movies(self, params_tuple):
|
def discover_movies(self, params_tuple):
|
||||||
"""
|
"""
|
||||||
Discover movies by different types of data like average rating, number of votes, genres and certifications.
|
Discover movies by different types of data like average rating, number of votes, genres and certifications.
|
||||||
@@ -23,7 +23,7 @@ class Discover(TMDb):
|
|||||||
params = dict(params_tuple)
|
params = dict(params_tuple)
|
||||||
return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False)
|
return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
@cached(maxsize=1, ttl=43200)
|
||||||
def discover_tv_shows(self, params_tuple):
|
def discover_tv_shows(self, params_tuple):
|
||||||
"""
|
"""
|
||||||
Discover TV shows by different types of data like average rating, number of votes, genres,
|
Discover TV shows by different types of data like average rating, number of votes, genres,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from cachetools import cached, TTLCache
|
from app.core.cache import cached
|
||||||
|
|
||||||
from ..tmdb import TMDb
|
from ..tmdb import TMDb
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ from ..tmdb import TMDb
|
|||||||
class Trending(TMDb):
|
class Trending(TMDb):
|
||||||
_urls = {"trending": "/trending/%s/%s"}
|
_urls = {"trending": "/trending/%s/%s"}
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
@cached(maxsize=1024, ttl=43200)
|
||||||
def _trending(self, media_type="all", time_window="day", page=1):
|
def _trending(self, media_type="all", time_window="day", page=1):
|
||||||
"""
|
"""
|
||||||
Get trending, TTLCache 12 hours
|
Get trending, TTLCache 12 hours
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from datetime import datetime
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests.exceptions
|
import requests.exceptions
|
||||||
from cachetools import TTLCache, cached
|
|
||||||
|
|
||||||
|
from app.core.cache import cached
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
from .exceptions import TMDbException
|
from .exceptions import TMDbException
|
||||||
@@ -137,7 +137,7 @@ class TMDb(object):
|
|||||||
def cache(self, cache):
|
def cache(self, cache):
|
||||||
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
||||||
|
|
||||||
@cached(cache=TTLCache(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"]))
|
@cached(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"])
|
||||||
def cached_request(self, method, url, data, json,
|
def cached_request(self, method, url, data, json,
|
||||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
Simple-to-use Python interface to The TVDB's API (thetvdb.com)
|
Simple-to-use Python interface to The TVDB's API (thetvdb.com)
|
||||||
"""
|
"""
|
||||||
@@ -6,19 +5,20 @@
|
|||||||
__author__ = "dbr/Ben"
|
__author__ = "dbr/Ben"
|
||||||
__version__ = "3.1.0"
|
__version__ = "3.1.0"
|
||||||
|
|
||||||
import sys
|
import getpass
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import types
|
import types
|
||||||
import getpass
|
|
||||||
import tempfile
|
|
||||||
import warnings
|
import warnings
|
||||||
import logging
|
from typing import Optional, Union
|
||||||
import hashlib
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests_cache
|
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
|
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
|
"""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
|
"""Helper function, lists series with corresponding ID
|
||||||
"""
|
"""
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
@@ -267,6 +268,7 @@ class ShowContainer(dict):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
self._stack = []
|
self._stack = []
|
||||||
self._lastgc = time.time()
|
self._lastgc = time.time()
|
||||||
|
|
||||||
@@ -336,42 +338,6 @@ class Show(dict):
|
|||||||
|
|
||||||
Search terms are converted to lower case (unicode) strings.
|
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 = []
|
results = []
|
||||||
for cur_season in self.values():
|
for cur_season in self.values():
|
||||||
@@ -386,6 +352,7 @@ class Season(dict):
|
|||||||
def __init__(self, show=None):
|
def __init__(self, show=None):
|
||||||
"""The show attribute points to the parent show
|
"""The show attribute points to the parent show
|
||||||
"""
|
"""
|
||||||
|
super().__init__()
|
||||||
self.show = show
|
self.show = show
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -420,6 +387,7 @@ class Episode(dict):
|
|||||||
def __init__(self, season=None):
|
def __init__(self, season=None):
|
||||||
"""The season attribute points to the parent season
|
"""The season attribute points to the parent season
|
||||||
"""
|
"""
|
||||||
|
super().__init__()
|
||||||
self.season = season
|
self.season = season
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -540,7 +508,7 @@ class Tvdb:
|
|||||||
self,
|
self,
|
||||||
interactive=False,
|
interactive=False,
|
||||||
select_first=False,
|
select_first=False,
|
||||||
cache=True,
|
cache: Union[str, bool, requests.Session] = True,
|
||||||
banners=False,
|
banners=False,
|
||||||
actors=False,
|
actors=False,
|
||||||
custom_ui=None,
|
custom_ui=None,
|
||||||
@@ -690,7 +658,7 @@ class Tvdb:
|
|||||||
LOG.debug("Using specified requests.Session")
|
LOG.debug("Using specified requests.Session")
|
||||||
self.session = cache
|
self.session = cache
|
||||||
try:
|
try:
|
||||||
self.session.get
|
self.session.get # noqa
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
(
|
(
|
||||||
@@ -776,7 +744,7 @@ class Tvdb:
|
|||||||
cache_key = self.session.cache.create_key(
|
cache_key = self.session.cache.create_key(
|
||||||
fake_session_for_key.prepare_request(requests.Request('GET', url))
|
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?
|
# FIXME: Can this just check for hasattr(self.session, "cache") instead?
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -956,6 +924,7 @@ class Tvdb:
|
|||||||
banners_resp = self._getetsrc(self.config['url_seriesBanner'] % sid)
|
banners_resp = self._getetsrc(self.config['url_seriesBanner'] % sid)
|
||||||
banners = {}
|
banners = {}
|
||||||
for cur_banner in banners_resp.keys():
|
for cur_banner in banners_resp.keys():
|
||||||
|
btype = None
|
||||||
banners_info = self._getetsrc(self.config['url_seriesBannerInfo'] % (sid, cur_banner))
|
banners_info = self._getetsrc(self.config['url_seriesBannerInfo'] % (sid, cur_banner))
|
||||||
for banner_info in banners_info:
|
for banner_info in banners_info:
|
||||||
bid = banner_info.get('id')
|
bid = banner_info.get('id')
|
||||||
@@ -981,32 +950,14 @@ class Tvdb:
|
|||||||
LOG.debug("Transforming %s to %s" % (k, new_key))
|
LOG.debug("Transforming %s to %s" % (k, new_key))
|
||||||
new_url = self.config['url_artworkPrefix'] % v
|
new_url = self.config['url_artworkPrefix'] % v
|
||||||
banners[btype][btype2][bid][new_key] = new_url
|
banners[btype][btype2][bid][new_key] = new_url
|
||||||
|
if btype:
|
||||||
banners[btype]['raw'] = banners_info
|
banners[btype]['raw'] = banners_info
|
||||||
self._setShowData(sid, "_banners", banners)
|
self._setShowData(sid, "_banners", banners)
|
||||||
|
|
||||||
def _parseActors(self, sid):
|
def _parseActors(self, sid):
|
||||||
"""Parsers actors XML, from
|
"""Parsers actors XML, from
|
||||||
http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/actors.xml
|
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
|
Any key starting with an underscore has been processed (not the raw
|
||||||
data from the XML)
|
data from the XML)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from typing import Optional, Union, Tuple, List
|
from typing import Optional, Union, Tuple, List, Literal
|
||||||
|
|
||||||
import transmission_rpc
|
import transmission_rpc
|
||||||
from transmission_rpc import Client, Torrent, File
|
from transmission_rpc import Client, Torrent, File
|
||||||
from transmission_rpc.session import SessionStats, Session
|
from transmission_rpc.session import SessionStats, Session
|
||||||
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.string import StringUtils
|
from app.utils.url import UrlUtils
|
||||||
|
|
||||||
|
|
||||||
class Transmission:
|
class Transmission:
|
||||||
|
_protocol: Literal["http", "https"] = "http"
|
||||||
_host: str = None
|
_host: str = None
|
||||||
_port: int = None
|
_port: int = None
|
||||||
_username: str = None
|
_username: str = None
|
||||||
@@ -28,9 +29,14 @@ class Transmission:
|
|||||||
若不设置参数,则创建配置文件设置的下载器
|
若不设置参数,则创建配置文件设置的下载器
|
||||||
"""
|
"""
|
||||||
if host and port:
|
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:
|
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:
|
else:
|
||||||
logger.error("Transmission配置不完整!")
|
logger.error("Transmission配置不完整!")
|
||||||
return
|
return
|
||||||
@@ -46,8 +52,9 @@ class Transmission:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 登录
|
# 登录
|
||||||
logger.info(f"正在连接 transmission:{self._host}:{self._port}")
|
logger.info(f"正在连接 transmission:{self._protocol}://{self._host}:{self._port}")
|
||||||
trt = transmission_rpc.Client(host=self._host,
|
trt = transmission_rpc.Client(protocol=self._protocol,
|
||||||
|
host=self._host,
|
||||||
port=self._port,
|
port=self._port,
|
||||||
username=self._username,
|
username=self._username,
|
||||||
password=self._password,
|
password=self._password,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class WebPushModule(_ModuleBase, _MessageBase):
|
|||||||
webpush_users = conf.config.get("WEBPUSH_USERNAME") or ""
|
webpush_users = conf.config.get("WEBPUSH_USERNAME") or ""
|
||||||
if webpush_users:
|
if webpush_users:
|
||||||
# 设定了接收用户时,非该用户的消息不接收
|
# 设定了接收用户时,非该用户的消息不接收
|
||||||
if not message.userid or message.userid not in webpush_users.split(","):
|
if not message.username or message.username not in webpush_users.split(","):
|
||||||
continue
|
continue
|
||||||
if not message.title and not message.text:
|
if not message.title and not message.text:
|
||||||
logger.warn("标题和内容不能同时为空")
|
logger.warn("标题和内容不能同时为空")
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from cachetools import TTLCache
|
from cachetools import TTLCache
|
||||||
@@ -217,18 +218,41 @@ class Monitor(metaclass=Singleton):
|
|||||||
:param event_path: 事件文件路径
|
:param event_path: 事件文件路径
|
||||||
:param file_size: 文件大小
|
:param file_size: 文件大小
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __is_bluray_sub(_path: Path) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否蓝光原盘目录内的子目录或文件
|
||||||
|
"""
|
||||||
|
return True if re.search(r"BDMV[/\\]STREAM", str(_path), re.IGNORECASE) else False
|
||||||
|
|
||||||
|
def __get_bluray_dir(_path: Path) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
获取蓝光原盘BDMV目录的上级目录
|
||||||
|
"""
|
||||||
|
for p in _path.parents:
|
||||||
|
if p.name == "BDMV":
|
||||||
|
return p.parent
|
||||||
|
return None
|
||||||
|
|
||||||
# 全程加锁
|
# 全程加锁
|
||||||
with lock:
|
with lock:
|
||||||
|
# 蓝光原盘文件处理
|
||||||
|
if __is_bluray_sub(event_path):
|
||||||
|
event_path = __get_bluray_dir(event_path)
|
||||||
|
if not event_path:
|
||||||
|
return
|
||||||
|
|
||||||
# TTL缓存控重
|
# TTL缓存控重
|
||||||
if self._cache.get(str(event_path)):
|
if self._cache.get(str(event_path)):
|
||||||
return
|
return
|
||||||
self._cache[str(event_path)] = True
|
self._cache[str(event_path)] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 开始整理
|
# 开始整理
|
||||||
self.transferchain.do_transfer(
|
self.transferchain.do_transfer(
|
||||||
fileitem=FileItem(
|
fileitem=FileItem(
|
||||||
storage=storage,
|
storage=storage,
|
||||||
path=str(event_path),
|
path=str(event_path).replace("\\", "/"),
|
||||||
type="file",
|
type="file",
|
||||||
name=event_path.name,
|
name=event_path.name,
|
||||||
basename=event_path.stem,
|
basename=event_path.stem,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from app.chain.recommend import RecommendChain
|
|||||||
from app.chain.site import SiteChain
|
from app.chain.site import SiteChain
|
||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.chain.torrents import TorrentsChain
|
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.event import EventManager
|
from app.core.event import EventManager
|
||||||
@@ -93,6 +92,11 @@ class Scheduler(metaclass=Singleton):
|
|||||||
"func": SubscribeChain().refresh,
|
"func": SubscribeChain().refresh,
|
||||||
"running": False,
|
"running": False,
|
||||||
},
|
},
|
||||||
|
"subscribe_follow": {
|
||||||
|
"name": "关注的订阅分享",
|
||||||
|
"func": SubscribeChain().follow,
|
||||||
|
"running": False,
|
||||||
|
},
|
||||||
"transfer": {
|
"transfer": {
|
||||||
"name": "下载文件整理",
|
"name": "下载文件整理",
|
||||||
"func": TransferChain().process,
|
"func": TransferChain().process,
|
||||||
@@ -242,6 +246,18 @@ class Scheduler(metaclass=Singleton):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 关注订阅分享(每1小时)
|
||||||
|
self._scheduler.add_job(
|
||||||
|
self.start,
|
||||||
|
"interval",
|
||||||
|
id="subscribe_follow",
|
||||||
|
name="关注的订阅分享",
|
||||||
|
hours=1,
|
||||||
|
kwargs={
|
||||||
|
'job_id': 'subscribe_follow'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 下载器文件转移(每5分钟)
|
# 下载器文件转移(每5分钟)
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self.start,
|
self.start,
|
||||||
@@ -549,7 +565,6 @@ class Scheduler(metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
清理缓存
|
清理缓存
|
||||||
"""
|
"""
|
||||||
TorrentsChain().clear_cache()
|
|
||||||
SchedulerChain().clear_cache()
|
SchedulerChain().clear_cache()
|
||||||
|
|
||||||
def user_auth(self):
|
def user_auth(self):
|
||||||
@@ -587,6 +602,6 @@ class Scheduler(metaclass=Singleton):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
self._auth_count += 1
|
self._auth_count += 1
|
||||||
logger.error(f"用户认证失败:{msg},共失败 {self._auth_count} 次")
|
logger.error(f"用户认证失败,{msg},共失败 {self._auth_count} 次")
|
||||||
if self._auth_count >= __max_try__:
|
if self._auth_count >= __max_try__:
|
||||||
logger.error("用户认证失败次数过多,将不再尝试认证!")
|
logger.error("用户认证失败次数过多,将不再尝试认证!")
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class MediaInfo(BaseModel):
|
|||||||
"""
|
"""
|
||||||
# 来源:themoviedb、douban、bangumi
|
# 来源:themoviedb、douban、bangumi
|
||||||
source: Optional[str] = None
|
source: Optional[str] = None
|
||||||
# 类型 电影、电视剧
|
# 类型 电影、电视剧、合集
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
# 媒体标题
|
# 媒体标题
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
@@ -79,6 +79,8 @@ class MediaInfo(BaseModel):
|
|||||||
title_year: Optional[str] = None
|
title_year: Optional[str] = None
|
||||||
# 当前指定季,如有
|
# 当前指定季,如有
|
||||||
season: Optional[int] = None
|
season: Optional[int] = None
|
||||||
|
# 合集等id
|
||||||
|
collection_id: Optional[int] = None
|
||||||
# TMDB ID
|
# TMDB ID
|
||||||
tmdb_id: Optional[int] = None
|
tmdb_id: Optional[int] = None
|
||||||
# IMDB ID
|
# IMDB ID
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class AuthCredentials(ChainEventData):
|
|||||||
service: Optional[str] = Field(default=None, description="服务名称")
|
service: Optional[str] = Field(default=None, description="服务名称")
|
||||||
|
|
||||||
@root_validator(pre=True)
|
@root_validator(pre=True)
|
||||||
def check_fields_based_on_grant_type(cls, values):
|
def check_fields_based_on_grant_type(cls, values): # noqa
|
||||||
grant_type = values.get("grant_type")
|
grant_type = values.get("grant_type")
|
||||||
if not grant_type:
|
if not grant_type:
|
||||||
values["grant_type"] = "password"
|
values["grant_type"] = "password"
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ class SubscribeShare(BaseModel):
|
|||||||
share_comment: Optional[str] = None
|
share_comment: Optional[str] = None
|
||||||
# 分享人
|
# 分享人
|
||||||
share_user: Optional[str] = None
|
share_user: Optional[str] = None
|
||||||
|
# 分享人唯一ID
|
||||||
|
share_uid: Optional[str] = None
|
||||||
# 订阅名称
|
# 订阅名称
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
# 订阅年份
|
# 订阅年份
|
||||||
@@ -127,6 +129,8 @@ class SubscribeShare(BaseModel):
|
|||||||
custom_words: Optional[str] = None
|
custom_words: Optional[str] = None
|
||||||
# 自定义媒体类别
|
# 自定义媒体类别
|
||||||
media_category: Optional[str] = None
|
media_category: Optional[str] = None
|
||||||
|
# 复用人次
|
||||||
|
count: Optional[int] = 0
|
||||||
|
|
||||||
|
|
||||||
class SubscribeDownloadFileInfo(BaseModel):
|
class SubscribeDownloadFileInfo(BaseModel):
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ from typing import Optional, List, Any, Callable
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.schemas import TmdbEpisode, DownloadHistory
|
from app.schemas.tmdb import TmdbEpisode
|
||||||
|
from app.schemas.history import DownloadHistory
|
||||||
|
from app.schemas.context import MetaInfo, MediaInfo
|
||||||
from app.schemas.file import FileItem
|
from app.schemas.file import FileItem
|
||||||
from app.schemas.system import TransferDirectoryConf
|
from app.schemas.system import TransferDirectoryConf
|
||||||
from schemas import MediaInfo, MetaInfo
|
|
||||||
|
|
||||||
|
|
||||||
class TransferTorrent(BaseModel):
|
class TransferTorrent(BaseModel):
|
||||||
@@ -47,8 +48,8 @@ class TransferTask(BaseModel):
|
|||||||
"""
|
"""
|
||||||
文件整理任务
|
文件整理任务
|
||||||
"""
|
"""
|
||||||
fileitem: FileItem = None
|
fileitem: FileItem
|
||||||
meta: Any = None
|
meta: Optional[Any] = None
|
||||||
mediainfo: Optional[Any] = None
|
mediainfo: Optional[Any] = None
|
||||||
target_directory: Optional[TransferDirectoryConf] = None
|
target_directory: Optional[TransferDirectoryConf] = None
|
||||||
target_storage: Optional[str] = None
|
target_storage: Optional[str] = None
|
||||||
@@ -58,9 +59,12 @@ class TransferTask(BaseModel):
|
|||||||
library_type_folder: Optional[bool] = False
|
library_type_folder: Optional[bool] = False
|
||||||
library_category_folder: Optional[bool] = False
|
library_category_folder: Optional[bool] = False
|
||||||
episodes_info: Optional[List[TmdbEpisode]] = None
|
episodes_info: Optional[List[TmdbEpisode]] = None
|
||||||
|
username: Optional[str] = None
|
||||||
downloader: Optional[str] = None
|
downloader: Optional[str] = None
|
||||||
download_hash: Optional[str] = None
|
download_hash: Optional[str] = None
|
||||||
download_history: Optional[DownloadHistory] = None
|
download_history: Optional[DownloadHistory] = None
|
||||||
|
manual: Optional[bool] = False
|
||||||
|
background: Optional[bool] = True
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from enum import Enum
|
|||||||
class MediaType(Enum):
|
class MediaType(Enum):
|
||||||
MOVIE = '电影'
|
MOVIE = '电影'
|
||||||
TV = '电视剧'
|
TV = '电视剧'
|
||||||
|
COLLECTION = '系列'
|
||||||
UNKNOWN = '未知'
|
UNKNOWN = '未知'
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ class EventType(Enum):
|
|||||||
NoticeMessage = "notice.message"
|
NoticeMessage = "notice.message"
|
||||||
# 订阅已添加
|
# 订阅已添加
|
||||||
SubscribeAdded = "subscribe.added"
|
SubscribeAdded = "subscribe.added"
|
||||||
|
# 订阅已调整
|
||||||
|
SubscribeModified = "subscribe.modified"
|
||||||
# 订阅已删除
|
# 订阅已删除
|
||||||
SubscribeDeleted = "subscribe.deleted"
|
SubscribeDeleted = "subscribe.deleted"
|
||||||
# 订阅已完成
|
# 订阅已完成
|
||||||
@@ -132,6 +135,8 @@ class SystemConfigKey(Enum):
|
|||||||
DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig"
|
DefaultTvSubscribeConfig = "DefaultTvSubscribeConfig"
|
||||||
# 用户站点认证参数
|
# 用户站点认证参数
|
||||||
UserSiteAuthParams = "UserSiteAuthParams"
|
UserSiteAuthParams = "UserSiteAuthParams"
|
||||||
|
# Follow订阅分享者
|
||||||
|
FollowSubscribers = "FollowSubscribers"
|
||||||
|
|
||||||
|
|
||||||
# 处理进度Key字典
|
# 处理进度Key字典
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import sys
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.core.cache import close_cache
|
||||||
from app.core.config import global_vars, settings
|
from app.core.config import global_vars, settings
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
@@ -88,7 +89,7 @@ def user_auth():
|
|||||||
if status:
|
if status:
|
||||||
logger.info(f"{msg} 用户认证成功")
|
logger.info(f"{msg} 用户认证成功")
|
||||||
else:
|
else:
|
||||||
logger.info(f"用户认证失败:{msg}")
|
logger.info(f"用户认证失败,{msg}")
|
||||||
|
|
||||||
|
|
||||||
def check_auth():
|
def check_auth():
|
||||||
@@ -129,6 +130,8 @@ def shutdown_modules(_: FastAPI):
|
|||||||
Monitor().stop()
|
Monitor().stop()
|
||||||
# 停止线程池
|
# 停止线程池
|
||||||
ThreadHelper().shutdown()
|
ThreadHelper().shutdown()
|
||||||
|
# 停止缓存连接
|
||||||
|
close_cache()
|
||||||
# 停止数据库连接
|
# 停止数据库连接
|
||||||
close_database()
|
close_database()
|
||||||
# 停止前端服务
|
# 停止前端服务
|
||||||
|
|||||||
@@ -207,6 +207,32 @@ class RequestUtils:
|
|||||||
raise_exception=raise_exception,
|
raise_exception=raise_exception,
|
||||||
**kwargs)
|
**kwargs)
|
||||||
|
|
||||||
|
def delete_res(self,
|
||||||
|
url: str,
|
||||||
|
data: Any = None,
|
||||||
|
params: dict = None,
|
||||||
|
allow_redirects: bool = True,
|
||||||
|
raise_exception: bool = False,
|
||||||
|
**kwargs) -> Optional[Response]:
|
||||||
|
"""
|
||||||
|
发送DELETE请求并返回响应对象
|
||||||
|
:param url: 请求的URL
|
||||||
|
:param data: 请求的数据
|
||||||
|
:param params: 请求的参数
|
||||||
|
:param allow_redirects: 是否允许重定向
|
||||||
|
:param raise_exception: 是否在发生异常时抛出异常,否则默认拦截异常返回None
|
||||||
|
:param kwargs: 其他请求参数,如headers, cookies, proxies等
|
||||||
|
:return: HTTP响应对象,若发生RequestException则返回None
|
||||||
|
:raises: requests.exceptions.RequestException 仅raise_exception为True时会抛出
|
||||||
|
"""
|
||||||
|
return self.request(method="delete",
|
||||||
|
url=url,
|
||||||
|
data=data,
|
||||||
|
params=params,
|
||||||
|
allow_redirects=allow_redirects,
|
||||||
|
raise_exception=raise_exception,
|
||||||
|
**kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def cookie_parse(cookies_str: str, array: bool = False) -> Union[list, dict]:
|
def cookie_parse(cookies_str: str, array: bool = False) -> Union[list, dict]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ class ExponentialBackoffRateLimiter(BaseRateLimiter):
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
self.next_allowed_time = current_time + self.current_wait
|
self.next_allowed_time = current_time + self.current_wait
|
||||||
self.current_wait = min(self.current_wait * self.backoff_factor, self.max_wait)
|
self.current_wait = min(self.current_wait * self.backoff_factor, self.max_wait)
|
||||||
self.log_warning(f"触发限流,将在 {self.current_wait} 秒后允许继续调用")
|
wait_time = self.next_allowed_time - current_time
|
||||||
|
self.log_warning(f"触发限流,将在 {wait_time:.2f} 秒后允许继续调用")
|
||||||
|
|
||||||
|
|
||||||
# 时间窗口限流器
|
# 时间窗口限流器
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ _special_domains = [
|
|||||||
'pt.ecust.pp.ua',
|
'pt.ecust.pp.ua',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 内置版本号转换字典
|
||||||
|
_version_map = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
|
||||||
|
# 不符合的版本号
|
||||||
|
_other_version = -5
|
||||||
|
|
||||||
|
|
||||||
class StringUtils:
|
class StringUtils:
|
||||||
|
|
||||||
@@ -222,7 +227,7 @@ class StringUtils:
|
|||||||
size = float(size)
|
size = float(size)
|
||||||
d = [(1024 - 1, 'K'), (1024 ** 2 - 1, 'M'), (1024 ** 3 - 1, 'G'), (1024 ** 4 - 1, 'T')]
|
d = [(1024 - 1, 'K'), (1024 ** 2 - 1, 'M'), (1024 ** 3 - 1, 'G'), (1024 ** 4 - 1, 'T')]
|
||||||
s = [x[0] for x in d]
|
s = [x[0] for x in d]
|
||||||
index = bisect.bisect_left(s, size) - 1
|
index = bisect.bisect_left(s, size) - 1 # noqa
|
||||||
if index == -1:
|
if index == -1:
|
||||||
return str(size) + "B"
|
return str(size) + "B"
|
||||||
else:
|
else:
|
||||||
@@ -740,27 +745,122 @@ class StringUtils:
|
|||||||
return ''.join(common_prefix)
|
return ''.join(common_prefix)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compare_version(v1: str, v2: str) -> int:
|
def compare_version(v1: str, compare_type: str, v2: str, verbose: bool = False) \
|
||||||
|
-> Tuple[Optional[bool], str | Exception] | Optional[bool]:
|
||||||
"""
|
"""
|
||||||
比较两个版本号的大小,v1 > v2时返回1,v1 < v2时返回-1,v1 = v2时返回0
|
比较两个版本号的大小
|
||||||
|
|
||||||
|
:param v1: 比对的来源版本号
|
||||||
|
:param v2: 比对的目标版本号
|
||||||
|
:param verbose: 是否输出比对结果的时候输出详细消息,默认 False 不输出
|
||||||
|
:param compare_type: 识别模式。支持直接使用符号进行比对
|
||||||
|
'ge' or '>=' :来源 >= 目标
|
||||||
|
'le' or '<=' :来源 <= 目标
|
||||||
|
'eq' or '==' :来源 == 目标
|
||||||
|
'gt' or '>' :来源 > 目标
|
||||||
|
'lt' or '<' :来源 < 目标
|
||||||
|
:return
|
||||||
"""
|
"""
|
||||||
if not v1 or not v2:
|
|
||||||
return 0
|
def __preprocess_version(version: str) -> list:
|
||||||
v1 = v1.replace('v', '')
|
"""
|
||||||
v2 = v2.replace('v', '')
|
预处理版本号,去除首尾空字符串与换行符,去除开头大小写v,并拆分版本号
|
||||||
v1 = [int(x) for x in v1.split('.')]
|
"""
|
||||||
v2 = [int(x) for x in v2.split('.')]
|
return re.split(r'[.-]', version.strip().lstrip('vV'))
|
||||||
for i in range(min(len(v1), len(v2))):
|
|
||||||
if v1[i] > v2[i]:
|
def __conversion_version(version_list) -> list:
|
||||||
return 1
|
"""
|
||||||
elif v1[i] < v2[i]:
|
英文字符转换为数字
|
||||||
return -1
|
:param version_list : 版本号列表,格式:['1', '2', '3', 'beta']
|
||||||
if len(v1) > len(v2):
|
"""
|
||||||
return 1
|
result = []
|
||||||
elif len(v1) < len(v2):
|
for item in version_list:
|
||||||
return -1
|
# stable = -1,rc = -2,beta = -3,alpha = -4
|
||||||
else:
|
if item.isdigit():
|
||||||
return 0
|
result.append(int(item))
|
||||||
|
# 其余不符合的,都为-5
|
||||||
|
else:
|
||||||
|
value = _version_map.get(item, _other_version)
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not v1 or not v2:
|
||||||
|
raise ValueError("要比较的版本号不全")
|
||||||
|
if not compare_type:
|
||||||
|
raise ValueError("缺少比对模式,无法比对")
|
||||||
|
if compare_type not in {"ge", "gt", "le", "lt", "eq", "==", ">=", ">", "<=", "<"}:
|
||||||
|
raise ValueError(f"设置的版本比对模式 {compare_type} 不是有效的模式!")
|
||||||
|
|
||||||
|
# 拆分获取版本号各个分段值做成列表
|
||||||
|
v1_list = __conversion_version(__preprocess_version(version=v1))
|
||||||
|
v2_list = __conversion_version(__preprocess_version(version=v2))
|
||||||
|
|
||||||
|
# 补全版本号位置,保持长度一致
|
||||||
|
max_length = max(len(v1_list), len(v2_list))
|
||||||
|
v1_list += [0] * (max_length - len(v1_list))
|
||||||
|
v2_list += [0] * (max_length - len(v2_list))
|
||||||
|
|
||||||
|
ver_comparison, ver_comparison_err = None, None
|
||||||
|
for v1_value, v2_value in zip(v1_list, v2_list):
|
||||||
|
# 来源==目标
|
||||||
|
if compare_type in {"eq", "=="}:
|
||||||
|
if v1_value != v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = None, "不等于"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
ver_comparison, ver_comparison_err = "等于", None
|
||||||
|
|
||||||
|
# 来源>=目标
|
||||||
|
elif compare_type in {"ge", ">="}:
|
||||||
|
if v1_value > v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = "大于", None
|
||||||
|
break
|
||||||
|
elif v1_value < v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = None, "小于"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
ver_comparison, ver_comparison_err = "等于", None
|
||||||
|
|
||||||
|
# 来源>目标
|
||||||
|
elif compare_type in {"gt", ">"}:
|
||||||
|
if v1_value > v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = "大于", None
|
||||||
|
break
|
||||||
|
elif v1_value < v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = None, "小于"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
ver_comparison, ver_comparison_err = None, "等于"
|
||||||
|
|
||||||
|
# 来源<=目标
|
||||||
|
elif compare_type in {"le", "<="}:
|
||||||
|
if v1_value > v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = None, "大于"
|
||||||
|
break
|
||||||
|
elif v1_value < v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = "小于", None
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
ver_comparison, ver_comparison_err = "等于", None
|
||||||
|
|
||||||
|
# 来源<目标
|
||||||
|
elif compare_type in {"lt", "<"}:
|
||||||
|
if v1_value > v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = None, "大于"
|
||||||
|
break
|
||||||
|
elif v1_value < v2_value:
|
||||||
|
ver_comparison, ver_comparison_err = "小于", None
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
ver_comparison, ver_comparison_err = None, "等于"
|
||||||
|
|
||||||
|
msg = f"版本号 {v1} {ver_comparison if ver_comparison else ver_comparison_err} 目标版本号 {v2} !"
|
||||||
|
|
||||||
|
return (True if ver_comparison else False, msg) if verbose else True if ver_comparison else False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return (None, e) if verbose else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def diff_time_str(time_str: str):
|
def diff_time_str(time_str: str):
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import uuid
|
||||||
from glob import glob
|
from glob import glob
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Tuple, Union
|
from typing import List, Optional, Tuple, Union
|
||||||
@@ -556,3 +558,45 @@ class SystemUtils:
|
|||||||
# 确保是空文件夹
|
# 确保是空文件夹
|
||||||
if folder.is_dir() and not any(folder.iterdir()):
|
if folder.is_dir() and not any(folder.iterdir()):
|
||||||
folder.rmdir()
|
folder.rmdir()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_user_unique_id():
|
||||||
|
"""
|
||||||
|
根据优先级依次尝试生成稳定唯一ID:
|
||||||
|
1. 文件系统唯一标识符。
|
||||||
|
2. MAC 地址。
|
||||||
|
3. 主机名。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_filesystem_unique_id():
|
||||||
|
"""
|
||||||
|
获取文件系统的唯一标识符。
|
||||||
|
使用根目录的设备号和 inode。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stat_info = os.stat("/")
|
||||||
|
fs_id = f"{stat_info.st_dev}-{stat_info.st_ino}"
|
||||||
|
return hashlib.sha256(fs_id.encode("utf-8")).hexdigest()
|
||||||
|
except Exception as e:
|
||||||
|
print(str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_mac_address_id():
|
||||||
|
"""
|
||||||
|
获取设备的 MAC 地址并生成唯一标识符。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mac_address = uuid.getnode()
|
||||||
|
if (mac_address >> 40) % 2: # 检查是否是虚拟MAC地址
|
||||||
|
raise ValueError("MAC地址可能是虚拟地址")
|
||||||
|
mac_str = f"{mac_address:012x}"
|
||||||
|
return hashlib.sha256(mac_str.encode("utf-8")).hexdigest()
|
||||||
|
except Exception as e:
|
||||||
|
print(str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
for method in [get_filesystem_unique_id, get_mac_address_id]:
|
||||||
|
unique_id = method()
|
||||||
|
if unique_id:
|
||||||
|
return unique_id
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union, Tuple
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ class UrlUtils:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def adapt_request_url(host: str, endpoint: str) -> Optional[str]:
|
def adapt_request_url(host: str, endpoint: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL。
|
基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL
|
||||||
:param host: 主机头
|
:param host: 主机头
|
||||||
:param endpoint: 端点
|
:param endpoint: 端点
|
||||||
:return: 完整的请求URL字符串
|
:return: 完整的请求URL字符串
|
||||||
@@ -42,7 +42,7 @@ class UrlUtils:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:
|
def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
使用给定的主机头、路径和查询参数组合生成完整的URL。
|
使用给定的主机头、路径和查询参数组合生成完整的URL
|
||||||
:param host: str, 主机头,例如 https://example.com
|
:param host: str, 主机头,例如 https://example.com
|
||||||
:param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1
|
:param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1
|
||||||
:param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"}
|
:param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"}
|
||||||
@@ -101,9 +101,42 @@ class UrlUtils:
|
|||||||
def quote(s: str) -> str:
|
def quote(s: str) -> str:
|
||||||
"""
|
"""
|
||||||
将字符串编码为 URL 安全的格式
|
将字符串编码为 URL 安全的格式
|
||||||
这将确保路径中的特殊字符(如空格、中文字符等)被正确编码,以便在 URL 中传输
|
|
||||||
|
|
||||||
:param s: 要编码的字符串
|
:param s: 要编码的字符串
|
||||||
:return: 编码后的字符串
|
:return: 编码后的字符串
|
||||||
"""
|
"""
|
||||||
return parse.quote(s)
|
return parse.quote(s)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_url_params(url: str) -> Optional[Tuple[str, str, int, str]]:
|
||||||
|
"""
|
||||||
|
解析给定的 URL,并提取协议、主机名、端口和路径信息
|
||||||
|
|
||||||
|
:param url: str
|
||||||
|
需要解析的 URL 字符串
|
||||||
|
可以是完整的 URL(例如:"http://example.com:8080/path")或不带协议的地址(例如:"example.com:1234")
|
||||||
|
:return: Optional[Tuple[str, str, int, str]]
|
||||||
|
- str: 协议(例如:"http", "https")
|
||||||
|
- str: 主机名或 IP 地址(例如:"example.com", "192.168.1.1")
|
||||||
|
- int: 端口号(例如:80, 443)
|
||||||
|
- str: URL 的路径部分(例如:"/", "/path")
|
||||||
|
如果输入地址无效或无法解析,则返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = UrlUtils.standardize_base_url(host=url)
|
||||||
|
parsed = urlparse(url)
|
||||||
|
|
||||||
|
if not parsed.hostname:
|
||||||
|
return None
|
||||||
|
protocol = parsed.scheme
|
||||||
|
hostname = parsed.hostname
|
||||||
|
port = parsed.port or (443 if protocol == "https" else 80)
|
||||||
|
path = parsed.path or "/"
|
||||||
|
|
||||||
|
return protocol, hostname, port, path
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parse_url_params: {e}")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from cachetools import TTLCache, cached
|
from app.core.cache import cached
|
||||||
|
|
||||||
from app.utils.http import RequestUtils
|
from app.utils.http import RequestUtils
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ class WebUtils:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_bing_wallpaper() -> Optional[str]:
|
def get_bing_wallpaper() -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
获取Bing每日壁纸
|
获取Bing每日壁纸
|
||||||
@@ -93,7 +93,7 @@ class WebUtils:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
@cached(maxsize=1, ttl=3600)
|
||||||
def get_bing_wallpapers(num: int = 7) -> List[str]:
|
def get_bing_wallpapers(num: int = 7) -> List[str]:
|
||||||
"""
|
"""
|
||||||
获取7天的Bing每日壁纸
|
获取7天的Bing每日壁纸
|
||||||
|
|||||||
@@ -63,4 +63,6 @@ p115client==0.0.3.8.3.3
|
|||||||
python-cookietools==0.0.2.1
|
python-cookietools==0.0.2.1
|
||||||
aligo~=6.2.4
|
aligo~=6.2.4
|
||||||
aiofiles~=24.1.0
|
aiofiles~=24.1.0
|
||||||
jieba~=0.42.1
|
jieba~=0.42.1
|
||||||
|
rsa~=4.9
|
||||||
|
redis~=5.2.1
|
||||||
@@ -968,7 +968,7 @@ meta_cases = [{
|
|||||||
"year": "2023",
|
"year": "2023",
|
||||||
"part": "",
|
"part": "",
|
||||||
"season": "S02",
|
"season": "S02",
|
||||||
"episode": "",
|
"episode": "E01-E08",
|
||||||
"restype": "WEB-DL",
|
"restype": "WEB-DL",
|
||||||
"pix": "2160p",
|
"pix": "2160p",
|
||||||
"video_codec": "H265",
|
"video_codec": "H265",
|
||||||
@@ -1016,7 +1016,7 @@ meta_cases = [{
|
|||||||
"year": "2019",
|
"year": "2019",
|
||||||
"part": "",
|
"part": "",
|
||||||
"season": "S01",
|
"season": "S01",
|
||||||
"episode": "",
|
"episode": "E01-E36",
|
||||||
"restype": "WEB-DL",
|
"restype": "WEB-DL",
|
||||||
"pix": "2160p",
|
"pix": "2160p",
|
||||||
"video_codec": "H265",
|
"video_codec": "H265",
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = 'v2.1.7'
|
APP_VERSION = 'v2.2.4'
|
||||||
FRONTEND_VERSION = 'v2.1.7'
|
FRONTEND_VERSION = 'v2.2.4'
|
||||||
|
|||||||
Reference in New Issue
Block a user