mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-11 18:10:15 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636c4be9fb | ||
|
|
6bec765a9d | ||
|
|
d61d16ccc4 | ||
|
|
f2a5715b24 | ||
|
|
c064c3781f | ||
|
|
bb4dffe2a4 | ||
|
|
37cf3eeef3 | ||
|
|
40395b2999 | ||
|
|
32afe6445f | ||
|
|
793a991913 | ||
|
|
d278224ff1 | ||
|
|
9b4d0ce6a8 | ||
|
|
a1829fe590 | ||
|
|
2b2b39365c | ||
|
|
1147930f3f | ||
|
|
636f338ed7 | ||
|
|
72365d00b4 | ||
|
|
19d8086732 | ||
|
|
30488418e5 | ||
|
|
2f0badd74a | ||
|
|
6045b0579b | ||
|
|
498f1fec74 | ||
|
|
f6a541f2b9 | ||
|
|
8ce78eabca | ||
|
|
2c34c5309f | ||
|
|
77e680168a | ||
|
|
8a7e59742f | ||
|
|
42bac14770 | ||
|
|
8323834483 | ||
|
|
1751caef62 | ||
|
|
d622d1474d | ||
|
|
f28be2e7de | ||
|
|
17773913ae | ||
|
|
d469c2d3f9 | ||
|
|
4e74d32882 | ||
|
|
7b8cd37a9b | ||
|
|
eda306d726 | ||
|
|
94f3b1fe84 | ||
|
|
c50e3ba293 | ||
|
|
eff7818912 | ||
|
|
270bcff8f3 | ||
|
|
e04963c2dc | ||
|
|
f369967c91 | ||
|
|
cd982c5526 | ||
|
|
16e03c9d37 | ||
|
|
d38b1f5364 | ||
|
|
f57ba4d05e |
@@ -63,7 +63,7 @@ class SearchMediaTool(MoviePilotTool):
|
|||||||
if media_type:
|
if media_type:
|
||||||
if result.type != MediaType(media_type):
|
if result.type != MediaType(media_type):
|
||||||
continue
|
continue
|
||||||
if season and result.season != season:
|
if season is not None and result.season != season:
|
||||||
continue
|
continue
|
||||||
filtered_results.append(result)
|
filtered_results.append(result)
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class SearchTorrentsTool(MoviePilotTool):
|
|||||||
if media_type and torrent.media_info:
|
if media_type and torrent.media_info:
|
||||||
if torrent.media_info.type != MediaType(media_type):
|
if torrent.media_info.type != MediaType(media_type):
|
||||||
continue
|
continue
|
||||||
if season and torrent.meta_info and torrent.meta_info.begin_season != season:
|
if season is not None and torrent.meta_info and torrent.meta_info.begin_season != season:
|
||||||
continue
|
continue
|
||||||
# 使用正则表达式过滤标题(分辨率、质量等关键字)
|
# 使用正则表达式过滤标题(分辨率、质量等关键字)
|
||||||
if regex_pattern and torrent.torrent_info and torrent.torrent_info.title:
|
if regex_pattern and torrent.torrent_info and torrent.torrent_info.title:
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ async def seasons(mediaid: Optional[str] = None,
|
|||||||
tmdbid = int(mediaid[5:])
|
tmdbid = int(mediaid[5:])
|
||||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
|
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
|
||||||
if seasons_info:
|
if seasons_info:
|
||||||
if season:
|
if season is not None:
|
||||||
return [sea for sea in seasons_info if sea.season_number == season]
|
return [sea for sea in seasons_info if sea.season_number == season]
|
||||||
return seasons_info
|
return seasons_info
|
||||||
if title:
|
if title:
|
||||||
@@ -207,11 +207,11 @@ async def seasons(mediaid: Optional[str] = None,
|
|||||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id)
|
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id)
|
||||||
if seasons_info:
|
if seasons_info:
|
||||||
if season:
|
if season is not None:
|
||||||
return [sea for sea in seasons_info if sea.season_number == season]
|
return [sea for sea in seasons_info if sea.season_number == season]
|
||||||
return seasons_info
|
return seasons_info
|
||||||
else:
|
else:
|
||||||
sea = season or 1
|
sea = season if season is not None else 1
|
||||||
return [schemas.MediaSeason(
|
return [schemas.MediaSeason(
|
||||||
season_number=sea,
|
season_number=sea,
|
||||||
poster_path=mediainfo.poster_path,
|
poster_path=mediainfo.poster_path,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async def exists_local(title: Optional[str] = None,
|
|||||||
判断本地是否存在
|
判断本地是否存在
|
||||||
"""
|
"""
|
||||||
meta = MetaInfo(title)
|
meta = MetaInfo(title)
|
||||||
if not season:
|
if season is None:
|
||||||
season = meta.begin_season
|
season = meta.begin_season
|
||||||
# 返回对象
|
# 返回对象
|
||||||
ret_info = {}
|
ret_info = {}
|
||||||
@@ -83,7 +83,7 @@ def exists(media_in: schemas.MediaInfo,
|
|||||||
existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(mediainfo=mediainfo)
|
existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(mediainfo=mediainfo)
|
||||||
if not existsinfo:
|
if not existsinfo:
|
||||||
return {}
|
return {}
|
||||||
if media_in.season:
|
if media_in.season is not None:
|
||||||
return {
|
return {
|
||||||
media_in.season: existsinfo.seasons.get(media_in.season) or []
|
media_in.season: existsinfo.seasons.get(media_in.season) or []
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ def not_exists(media_in: schemas.MediaInfo,
|
|||||||
mtype = MediaType(media_in.type) if media_in.type else None
|
mtype = MediaType(media_in.type) if media_in.type else None
|
||||||
if mtype:
|
if mtype:
|
||||||
meta.type = mtype
|
meta.type = mtype
|
||||||
if media_in.season:
|
if media_in.season is not None:
|
||||||
meta.begin_season = media_in.season
|
meta.begin_season = media_in.season
|
||||||
meta.type = MediaType.TV
|
meta.type = MediaType.TV
|
||||||
if media_in.year:
|
if media_in.year:
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
return schemas.Response(success=False, message=errmsg)
|
return schemas.Response(success=False, message=errmsg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth_url/{name}", summary="获取 OAuth2 授权 URL", response_model=schemas.Response)
|
||||||
|
def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
"""
|
||||||
|
获取 OAuth2 授权 URL
|
||||||
|
"""
|
||||||
|
auth_data, errmsg = StorageChain().generate_auth_url(name)
|
||||||
|
if auth_data:
|
||||||
|
return schemas.Response(success=True, data=auth_data)
|
||||||
|
return schemas.Response(success=False, message=errmsg)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response)
|
@router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response)
|
||||||
def check(name: str, ck: Optional[str] = None, t: Optional[str] = None,
|
def check(name: str, ck: Optional[str] = None, t: Optional[str] = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ async def subscribe_mediaid(
|
|||||||
# 使用名称检查订阅
|
# 使用名称检查订阅
|
||||||
if title_check and title:
|
if title_check and title:
|
||||||
meta = MetaInfo(title)
|
meta = MetaInfo(title)
|
||||||
if season:
|
if season is not None:
|
||||||
meta.begin_season = season
|
meta.begin_season = season
|
||||||
result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season)
|
result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ class MediaChain(ChainBase):
|
|||||||
org_meta.year = year
|
org_meta.year = year
|
||||||
org_meta.begin_season = season_number
|
org_meta.begin_season = season_number
|
||||||
org_meta.begin_episode = episode_number
|
org_meta.begin_episode = episode_number
|
||||||
if org_meta.begin_season or org_meta.begin_episode:
|
if org_meta.begin_season is not None or org_meta.begin_episode is not None:
|
||||||
org_meta.type = MediaType.TV
|
org_meta.type = MediaType.TV
|
||||||
# 重新识别
|
# 重新识别
|
||||||
return self.recognize_media(meta=org_meta)
|
return self.recognize_media(meta=org_meta)
|
||||||
@@ -958,10 +958,10 @@ class MediaChain(ChainBase):
|
|||||||
year = None
|
year = None
|
||||||
if tmdbinfo.get('release_date'):
|
if tmdbinfo.get('release_date'):
|
||||||
year = tmdbinfo['release_date'][:4]
|
year = tmdbinfo['release_date'][:4]
|
||||||
elif tmdbinfo.get('seasons') and season:
|
elif tmdbinfo.get('seasons') and season is not None:
|
||||||
for seainfo in tmdbinfo['seasons']:
|
for seainfo in tmdbinfo['seasons']:
|
||||||
season_number = seainfo.get("season_number")
|
season_number = seainfo.get("season_number")
|
||||||
if not season_number:
|
if season_number is None:
|
||||||
continue
|
continue
|
||||||
air_date = seainfo.get("air_date")
|
air_date = seainfo.get("air_date")
|
||||||
if air_date and season_number == season:
|
if air_date and season_number == season:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class SearchChain(ChainBase):
|
|||||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||||
return []
|
return []
|
||||||
no_exists = None
|
no_exists = None
|
||||||
if season:
|
if season is not None:
|
||||||
no_exists = {
|
no_exists = {
|
||||||
tmdbid or doubanid: {
|
tmdbid or doubanid: {
|
||||||
season: NotExistMediaInfo(episodes=[])
|
season: NotExistMediaInfo(episodes=[])
|
||||||
@@ -129,7 +129,7 @@ class SearchChain(ChainBase):
|
|||||||
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
logger.error(f'{tmdbid} 媒体信息识别失败!')
|
||||||
return []
|
return []
|
||||||
no_exists = None
|
no_exists = None
|
||||||
if season:
|
if season is not None:
|
||||||
no_exists = {
|
no_exists = {
|
||||||
tmdbid or doubanid: {
|
tmdbid or doubanid: {
|
||||||
season: NotExistMediaInfo(episodes=[])
|
season: NotExistMediaInfo(episodes=[])
|
||||||
@@ -181,7 +181,7 @@ class SearchChain(ChainBase):
|
|||||||
# 过滤剧集
|
# 过滤剧集
|
||||||
season_episodes = {sea: info.episodes
|
season_episodes = {sea: info.episodes
|
||||||
for sea, info in no_exists[mediakey].items()}
|
for sea, info in no_exists[mediakey].items()}
|
||||||
elif mediainfo.season:
|
elif mediainfo.season is not None:
|
||||||
# 豆瓣只搜索当前季
|
# 豆瓣只搜索当前季
|
||||||
season_episodes = {mediainfo.season: []}
|
season_episodes = {mediainfo.season: []}
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ class StorageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
return self.run_module("generate_qrcode", storage=storage)
|
return self.run_module("generate_qrcode", storage=storage)
|
||||||
|
|
||||||
|
def generate_auth_url(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||||
|
"""
|
||||||
|
生成 OAuth2 授权 URL
|
||||||
|
"""
|
||||||
|
return self.run_module("generate_auth_url", storage=storage)
|
||||||
|
|
||||||
def check_login(self, storage: str, **kwargs) -> Optional[Tuple[dict, str]]:
|
def check_login(self, storage: str, **kwargs) -> Optional[Tuple[dict, str]]:
|
||||||
"""
|
"""
|
||||||
登录确认
|
登录确认
|
||||||
@@ -150,7 +156,7 @@ class StorageChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
判断是否包含蓝光必备的文件夹
|
判断是否包含蓝光必备的文件夹
|
||||||
"""
|
"""
|
||||||
required_files = ("BDMV", "CERTIFICATE")
|
required_files = {"BDMV", "CERTIFICATE"}
|
||||||
return any(
|
return any(
|
||||||
item.type == "dir" and item.name in required_files
|
item.type == "dir" and item.name in required_files
|
||||||
for item in fileitems or []
|
for item in fileitems or []
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class SubscribeChain(ChainBase):
|
|||||||
metainfo.year = year
|
metainfo.year = year
|
||||||
if mtype:
|
if mtype:
|
||||||
metainfo.type = mtype
|
metainfo.type = mtype
|
||||||
if season:
|
if season is not None:
|
||||||
metainfo.type = MediaType.TV
|
metainfo.type = MediaType.TV
|
||||||
metainfo.begin_season = season
|
metainfo.begin_season = season
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
@@ -174,7 +174,7 @@ class SubscribeChain(ChainBase):
|
|||||||
# 豆瓣标题处理
|
# 豆瓣标题处理
|
||||||
meta = MetaInfo(mediainfo.title)
|
meta = MetaInfo(mediainfo.title)
|
||||||
mediainfo.title = meta.name
|
mediainfo.title = meta.name
|
||||||
if not season:
|
if season is None:
|
||||||
season = meta.begin_season
|
season = meta.begin_season
|
||||||
|
|
||||||
# 使用名称识别兜底
|
# 使用名称识别兜底
|
||||||
@@ -188,7 +188,7 @@ class SubscribeChain(ChainBase):
|
|||||||
|
|
||||||
# 总集数
|
# 总集数
|
||||||
if mediainfo.type == MediaType.TV:
|
if mediainfo.type == MediaType.TV:
|
||||||
if not season:
|
if season is None:
|
||||||
season = 1
|
season = 1
|
||||||
# 总集数
|
# 总集数
|
||||||
if not kwargs.get('total_episode'):
|
if not kwargs.get('total_episode'):
|
||||||
@@ -321,7 +321,7 @@ class SubscribeChain(ChainBase):
|
|||||||
metainfo.year = year
|
metainfo.year = year
|
||||||
if mtype:
|
if mtype:
|
||||||
metainfo.type = mtype
|
metainfo.type = mtype
|
||||||
if season:
|
if season is not None:
|
||||||
metainfo.type = MediaType.TV
|
metainfo.type = MediaType.TV
|
||||||
metainfo.begin_season = season
|
metainfo.begin_season = season
|
||||||
# 识别媒体信息
|
# 识别媒体信息
|
||||||
@@ -351,7 +351,7 @@ class SubscribeChain(ChainBase):
|
|||||||
# 豆瓣标题处理
|
# 豆瓣标题处理
|
||||||
meta = MetaInfo(mediainfo.title)
|
meta = MetaInfo(mediainfo.title)
|
||||||
mediainfo.title = meta.name
|
mediainfo.title = meta.name
|
||||||
if not season:
|
if season is None:
|
||||||
season = meta.begin_season
|
season = meta.begin_season
|
||||||
|
|
||||||
# 使用名称识别兜底
|
# 使用名称识别兜底
|
||||||
@@ -365,7 +365,7 @@ class SubscribeChain(ChainBase):
|
|||||||
|
|
||||||
# 总集数
|
# 总集数
|
||||||
if mediainfo.type == MediaType.TV:
|
if mediainfo.type == MediaType.TV:
|
||||||
if not season:
|
if season is None:
|
||||||
season = 1
|
season = 1
|
||||||
# 总集数
|
# 总集数
|
||||||
if not kwargs.get('total_episode'):
|
if not kwargs.get('total_episode'):
|
||||||
@@ -530,7 +530,7 @@ class SubscribeChain(ChainBase):
|
|||||||
# 生成元数据
|
# 生成元数据
|
||||||
meta = MetaInfo(subscribe.name)
|
meta = MetaInfo(subscribe.name)
|
||||||
meta.year = subscribe.year
|
meta.year = subscribe.year
|
||||||
meta.begin_season = subscribe.season or None
|
meta.begin_season = subscribe.season if subscribe.season is not None else None
|
||||||
try:
|
try:
|
||||||
meta.type = MediaType(subscribe.type)
|
meta.type = MediaType(subscribe.type)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.log import logger
|
|||||||
from app.schemas import StorageOperSelectionEventData
|
from app.schemas import StorageOperSelectionEventData
|
||||||
from app.schemas import TransferInfo, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \
|
from app.schemas import TransferInfo, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \
|
||||||
TransferTask, TransferQueue, TransferJob, TransferJobTask
|
TransferTask, TransferQueue, TransferJob, TransferJobTask
|
||||||
|
from app.schemas.exception import OperationInterrupted
|
||||||
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
|
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
|
||||||
SystemConfigKey, ChainEventType, ContentType
|
SystemConfigKey, ChainEventType, ContentType
|
||||||
from app.utils.mixins import ConfigReloadMixin
|
from app.utils.mixins import ConfigReloadMixin
|
||||||
@@ -111,12 +112,13 @@ class JobManager:
|
|||||||
"""
|
"""
|
||||||
return schemas.MetaInfo(**task.meta.to_dict())
|
return schemas.MetaInfo(**task.meta.to_dict())
|
||||||
|
|
||||||
def add_task(self, task: TransferTask, state: Optional[str] = "waiting"):
|
def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool:
|
||||||
"""
|
"""
|
||||||
添加整理任务,自动分组到对应的作业中
|
添加整理任务,自动分组到对应的作业中
|
||||||
|
:return: True表示任务已添加,False表示任务无效或已存在(重复)
|
||||||
"""
|
"""
|
||||||
if not any([task, task.meta, task.fileitem]):
|
if not all([task, task.meta, task.fileitem]):
|
||||||
return
|
return False
|
||||||
with job_lock:
|
with job_lock:
|
||||||
__mediaid__ = self.__get_id(task)
|
__mediaid__ = self.__get_id(task)
|
||||||
if __mediaid__ not in self._job_view:
|
if __mediaid__ not in self._job_view:
|
||||||
@@ -134,7 +136,8 @@ class JobManager:
|
|||||||
else:
|
else:
|
||||||
# 不重复添加任务
|
# 不重复添加任务
|
||||||
if any([t.fileitem == task.fileitem for t in self._job_view[__mediaid__].tasks]):
|
if any([t.fileitem == task.fileitem for t in self._job_view[__mediaid__].tasks]):
|
||||||
return
|
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
|
||||||
|
return False
|
||||||
self._job_view[__mediaid__].tasks.append(
|
self._job_view[__mediaid__].tasks.append(
|
||||||
TransferJobTask(
|
TransferJobTask(
|
||||||
fileitem=task.fileitem,
|
fileitem=task.fileitem,
|
||||||
@@ -150,6 +153,7 @@ class JobManager:
|
|||||||
self._season_episodes[__mediaid__] = list(set(self._season_episodes[__mediaid__]))
|
self._season_episodes[__mediaid__] = list(set(self._season_episodes[__mediaid__]))
|
||||||
else:
|
else:
|
||||||
self._season_episodes[__mediaid__] = task.meta.episode_list
|
self._season_episodes[__mediaid__] = task.meta.episode_list
|
||||||
|
return True
|
||||||
|
|
||||||
def running_task(self, task: TransferTask):
|
def running_task(self, task: TransferTask):
|
||||||
"""
|
"""
|
||||||
@@ -221,7 +225,7 @@ class JobManager:
|
|||||||
|
|
||||||
def remove_job(self, task: TransferTask) -> Optional[TransferJob]:
|
def remove_job(self, task: TransferTask) -> Optional[TransferJob]:
|
||||||
"""
|
"""
|
||||||
移除任务对应的作业
|
移除任务对应的作业(强制,线程不安全)
|
||||||
"""
|
"""
|
||||||
with job_lock:
|
with job_lock:
|
||||||
__mediaid__ = self.__get_id(task)
|
__mediaid__ = self.__get_id(task)
|
||||||
@@ -232,68 +236,99 @@ class JobManager:
|
|||||||
return self._job_view.pop(__mediaid__)
|
return self._job_view.pop(__mediaid__)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def try_remove_job(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
尝试移除任务对应的作业(严格检查未完成作业,线程安全)
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
|
||||||
|
__mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)
|
||||||
|
|
||||||
|
meta_done = True
|
||||||
|
if __metaid__ in self._job_view:
|
||||||
|
meta_done = all(
|
||||||
|
t.state in ["completed", "failed"] for t in self._job_view[__metaid__].tasks
|
||||||
|
)
|
||||||
|
|
||||||
|
media_done = True
|
||||||
|
if __mediaid__ in self._job_view:
|
||||||
|
media_done = all(
|
||||||
|
t.state in ["completed", "failed"] for t in self._job_view[__mediaid__].tasks
|
||||||
|
)
|
||||||
|
|
||||||
|
if meta_done and media_done:
|
||||||
|
__id__ = self.__get_id(task)
|
||||||
|
if __id__ in self._job_view:
|
||||||
|
# 移除季集信息
|
||||||
|
if __id__ in self._season_episodes:
|
||||||
|
self._season_episodes.pop(__id__)
|
||||||
|
self._job_view.pop(__id__)
|
||||||
|
|
||||||
def is_done(self, task: TransferTask) -> bool:
|
def is_done(self, task: TransferTask) -> bool:
|
||||||
"""
|
"""
|
||||||
检查任务对应的作业是否整理完成(不管成功还是失败)
|
检查任务对应的作业是否整理完成(不管成功还是失败)
|
||||||
"""
|
"""
|
||||||
__metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
|
with job_lock:
|
||||||
__mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)
|
__metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
|
||||||
if __metaid__ in self._job_view:
|
__mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)
|
||||||
meta_done = all(
|
if __metaid__ in self._job_view:
|
||||||
task.state in ["completed", "failed"] for task in self._job_view[__metaid__].tasks
|
meta_done = all(
|
||||||
)
|
task.state in ["completed", "failed"] for task in self._job_view[__metaid__].tasks
|
||||||
else:
|
)
|
||||||
meta_done = True
|
else:
|
||||||
if __mediaid__ in self._job_view:
|
meta_done = True
|
||||||
media_done = all(
|
if __mediaid__ in self._job_view:
|
||||||
task.state in ["completed", "failed"] for task in self._job_view[__mediaid__].tasks
|
media_done = all(
|
||||||
)
|
task.state in ["completed", "failed"] for task in self._job_view[__mediaid__].tasks
|
||||||
else:
|
)
|
||||||
media_done = True
|
else:
|
||||||
return meta_done and media_done
|
media_done = True
|
||||||
|
return meta_done and media_done
|
||||||
|
|
||||||
def is_finished(self, task: TransferTask) -> bool:
|
def is_finished(self, task: TransferTask) -> bool:
|
||||||
"""
|
"""
|
||||||
检查任务对应的作业是否已完成且有成功的记录
|
检查任务对应的作业是否已完成且有成功的记录
|
||||||
"""
|
"""
|
||||||
__metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
|
with job_lock:
|
||||||
__mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)
|
__metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
|
||||||
if __metaid__ in self._job_view:
|
__mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)
|
||||||
meta_finished = all(
|
if __metaid__ in self._job_view:
|
||||||
task.state in ["completed", "failed"] for task in self._job_view[__metaid__].tasks
|
meta_finished = all(
|
||||||
)
|
task.state in ["completed", "failed"] for task in self._job_view[__metaid__].tasks
|
||||||
else:
|
)
|
||||||
meta_finished = True
|
else:
|
||||||
if __mediaid__ in self._job_view:
|
meta_finished = True
|
||||||
tasks = self._job_view[__mediaid__].tasks
|
if __mediaid__ in self._job_view:
|
||||||
media_finished = all(
|
tasks = self._job_view[__mediaid__].tasks
|
||||||
task.state in ["completed", "failed"] for task in tasks
|
media_finished = all(
|
||||||
) and any(
|
task.state in ["completed", "failed"] for task in tasks
|
||||||
task.state == "completed" for task in tasks
|
) and any(
|
||||||
)
|
task.state == "completed" for task in tasks
|
||||||
else:
|
)
|
||||||
media_finished = True
|
else:
|
||||||
return meta_finished and media_finished
|
media_finished = True
|
||||||
|
return meta_finished and media_finished
|
||||||
|
|
||||||
def is_success(self, task: TransferTask) -> bool:
|
def is_success(self, task: TransferTask) -> bool:
|
||||||
"""
|
"""
|
||||||
检查任务对应的作业是否全部成功
|
检查任务对应的作业是否全部成功
|
||||||
"""
|
"""
|
||||||
__metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
|
with job_lock:
|
||||||
__mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)
|
__metaid__ = self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
|
||||||
if __metaid__ in self._job_view:
|
__mediaid__ = self.__get_media_id(media=task.mediainfo, season=task.meta.begin_season)
|
||||||
meta_success = all(
|
if __metaid__ in self._job_view:
|
||||||
task.state in ["completed"] for task in self._job_view[__metaid__].tasks
|
meta_success = all(
|
||||||
)
|
task.state in ["completed"] for task in self._job_view[__metaid__].tasks
|
||||||
else:
|
)
|
||||||
meta_success = True
|
else:
|
||||||
if __mediaid__ in self._job_view:
|
meta_success = True
|
||||||
media_success = all(
|
if __mediaid__ in self._job_view:
|
||||||
task.state in ["completed"] for task in self._job_view[__mediaid__].tasks
|
media_success = all(
|
||||||
)
|
task.state in ["completed"] for task in self._job_view[__mediaid__].tasks
|
||||||
else:
|
)
|
||||||
media_success = True
|
else:
|
||||||
return meta_success and media_success
|
media_success = True
|
||||||
|
return meta_success and media_success
|
||||||
|
|
||||||
def get_all_torrent_hashes(self) -> set[str]:
|
def get_all_torrent_hashes(self) -> set[str]:
|
||||||
"""
|
"""
|
||||||
@@ -311,11 +346,13 @@ class JobManager:
|
|||||||
检查指定种子的所有任务是否都已完成
|
检查指定种子的所有任务是否都已完成
|
||||||
"""
|
"""
|
||||||
with job_lock:
|
with job_lock:
|
||||||
for job in self._job_view.values():
|
if any(
|
||||||
for task in job.tasks:
|
task.state not in {"completed", "failed"}
|
||||||
if task.download_hash == download_hash:
|
for job in self._job_view.values()
|
||||||
if task.state not in ["completed", "failed"]:
|
for task in job.tasks
|
||||||
return False
|
if task.download_hash == download_hash
|
||||||
|
):
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_torrent_success(self, download_hash: str) -> bool:
|
def is_torrent_success(self, download_hash: str) -> bool:
|
||||||
@@ -323,85 +360,95 @@ class JobManager:
|
|||||||
检查指定种子的所有任务是否都已成功
|
检查指定种子的所有任务是否都已成功
|
||||||
"""
|
"""
|
||||||
with job_lock:
|
with job_lock:
|
||||||
for job in self._job_view.values():
|
if any(
|
||||||
for task in job.tasks:
|
task.state != "completed"
|
||||||
if task.download_hash == download_hash:
|
for job in self._job_view.values()
|
||||||
if task.state not in ["completed"]:
|
for task in job.tasks
|
||||||
return False
|
if task.download_hash == download_hash
|
||||||
|
):
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def has_tasks(self, meta: MetaBase, mediainfo: Optional[MediaInfo] = None, season: Optional[int] = None) -> bool:
|
def has_tasks(self, meta: MetaBase, mediainfo: Optional[MediaInfo] = None, season: Optional[int] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
判断作业是否还有任务正在处理
|
判断作业是否还有任务正在处理
|
||||||
"""
|
"""
|
||||||
if mediainfo:
|
with job_lock:
|
||||||
__mediaid__ = self.__get_media_id(media=mediainfo, season=season)
|
if mediainfo:
|
||||||
if __mediaid__ in self._job_view:
|
__mediaid__ = self.__get_media_id(media=mediainfo, season=season)
|
||||||
return True
|
if __mediaid__ in self._job_view:
|
||||||
|
return True
|
||||||
|
|
||||||
__metaid__ = self.__get_meta_id(meta=meta, season=season)
|
__metaid__ = self.__get_meta_id(meta=meta, season=season)
|
||||||
return __metaid__ in self._job_view and len(self._job_view[__metaid__].tasks) > 0
|
return __metaid__ in self._job_view and len(self._job_view[__metaid__].tasks) > 0
|
||||||
|
|
||||||
def success_tasks(self, media: MediaInfo, season: Optional[int] = None) -> List[TransferJobTask]:
|
def success_tasks(self, media: MediaInfo, season: Optional[int] = None) -> List[TransferJobTask]:
|
||||||
"""
|
"""
|
||||||
获取作业中所有成功的任务
|
获取作业中所有成功的任务
|
||||||
"""
|
"""
|
||||||
__mediaid__ = self.__get_media_id(media=media, season=season)
|
with job_lock:
|
||||||
if __mediaid__ not in self._job_view:
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
return []
|
if __mediaid__ not in self._job_view:
|
||||||
return [task for task in self._job_view[__mediaid__].tasks if task.state == "completed"]
|
return []
|
||||||
|
return [task for task in self._job_view[__mediaid__].tasks if task.state == "completed"]
|
||||||
|
|
||||||
def all_tasks(self, media: MediaInfo, season: Optional[int] = None) -> List[TransferJobTask]:
|
def all_tasks(self, media: MediaInfo, season: Optional[int] = None) -> List[TransferJobTask]:
|
||||||
"""
|
"""
|
||||||
获取作业中全部任务
|
获取作业中全部任务
|
||||||
"""
|
"""
|
||||||
__mediaid__ = self.__get_media_id(media=media, season=season)
|
with job_lock:
|
||||||
if __mediaid__ not in self._job_view:
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
return []
|
if __mediaid__ not in self._job_view:
|
||||||
return self._job_view[__mediaid__].tasks
|
return []
|
||||||
|
return self._job_view[__mediaid__].tasks
|
||||||
|
|
||||||
def count(self, media: MediaInfo, season: Optional[int] = None) -> int:
|
def count(self, media: MediaInfo, season: Optional[int] = None) -> int:
|
||||||
"""
|
"""
|
||||||
获取作业中成功总数
|
获取作业中成功总数
|
||||||
"""
|
"""
|
||||||
__mediaid__ = self.__get_media_id(media=media, season=season)
|
with job_lock:
|
||||||
if __mediaid__ not in self._job_view:
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
return 0
|
if __mediaid__ not in self._job_view:
|
||||||
return len([task for task in self._job_view[__mediaid__].tasks if task.state == "completed"])
|
return 0
|
||||||
|
return len([task for task in self._job_view[__mediaid__].tasks if task.state == "completed"])
|
||||||
|
|
||||||
def size(self, media: MediaInfo, season: Optional[int] = None) -> int:
|
def size(self, media: MediaInfo, season: Optional[int] = None) -> int:
|
||||||
"""
|
"""
|
||||||
获取作业中所有成功文件总大小
|
获取作业中所有成功文件总大小
|
||||||
"""
|
"""
|
||||||
__mediaid__ = self.__get_media_id(media=media, season=season)
|
with job_lock:
|
||||||
if __mediaid__ not in self._job_view:
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
return 0
|
if __mediaid__ not in self._job_view:
|
||||||
return sum([
|
return 0
|
||||||
task.fileitem.size if task.fileitem.size is not None
|
return sum([
|
||||||
else (
|
task.fileitem.size if task.fileitem.size is not None
|
||||||
SystemUtils.get_directory_size(Path(task.fileitem.path)) if task.fileitem.storage == "local" else 0)
|
else (
|
||||||
for task in self._job_view[__mediaid__].tasks
|
SystemUtils.get_directory_size(Path(task.fileitem.path)) if task.fileitem.storage == "local" else 0)
|
||||||
if task.state == "completed"
|
for task in self._job_view[__mediaid__].tasks
|
||||||
])
|
if task.state == "completed"
|
||||||
|
])
|
||||||
|
|
||||||
def total(self) -> int:
|
def total(self) -> int:
|
||||||
"""
|
"""
|
||||||
获取所有任务总数
|
获取所有任务总数
|
||||||
"""
|
"""
|
||||||
return sum([len(job.tasks) for job in self._job_view.values()])
|
with job_lock:
|
||||||
|
return sum([len(job.tasks) for job in self._job_view.values()])
|
||||||
|
|
||||||
def list_jobs(self) -> List[TransferJob]:
|
def list_jobs(self) -> List[TransferJob]:
|
||||||
"""
|
"""
|
||||||
获取所有作业的任务列表
|
获取所有作业的任务列表
|
||||||
"""
|
"""
|
||||||
return list(self._job_view.values())
|
with job_lock:
|
||||||
|
return list(self._job_view.values())
|
||||||
|
|
||||||
def season_episodes(self, media: MediaInfo, season: Optional[int] = None) -> List[int]:
|
def season_episodes(self, media: MediaInfo, season: Optional[int] = None) -> List[int]:
|
||||||
"""
|
"""
|
||||||
获取作业的季集清单
|
获取作业的季集清单
|
||||||
"""
|
"""
|
||||||
__mediaid__ = self.__get_media_id(media=media, season=season)
|
with job_lock:
|
||||||
return self._season_episodes.get(__mediaid__) or []
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
|
return self._season_episodes.get(__mediaid__) or []
|
||||||
|
|
||||||
|
|
||||||
class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||||
@@ -724,26 +771,30 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
return ret_status, ret_message
|
return ret_status, ret_message
|
||||||
|
|
||||||
def put_to_queue(self, task: TransferTask):
|
def put_to_queue(self, task: TransferTask) -> bool:
|
||||||
"""
|
"""
|
||||||
添加到待整理队列
|
添加到待整理队列
|
||||||
:param task: 任务信息
|
:param task: 任务信息
|
||||||
|
:return: True表示任务已添加到队列,False表示任务无效或已存在(重复)
|
||||||
"""
|
"""
|
||||||
if not task:
|
if not task:
|
||||||
return
|
return False
|
||||||
# 维护整理任务视图
|
# 维护整理任务视图,如果任务已存在则不添加到队列
|
||||||
self.__put_to_jobview(task)
|
if not self.__put_to_jobview(task):
|
||||||
|
return False
|
||||||
# 添加到队列
|
# 添加到队列
|
||||||
self._queue.put(TransferQueue(
|
self._queue.put(TransferQueue(
|
||||||
task=task,
|
task=task,
|
||||||
callback=self.__default_callback
|
callback=self.__default_callback
|
||||||
))
|
))
|
||||||
|
return True
|
||||||
|
|
||||||
def __put_to_jobview(self, task: TransferTask):
|
def __put_to_jobview(self, task: TransferTask) -> bool:
|
||||||
"""
|
"""
|
||||||
添加到作业视图
|
添加到作业视图
|
||||||
|
:return: True表示任务已添加,False表示任务无效或已存在(重复)
|
||||||
"""
|
"""
|
||||||
self.jobview.add_task(task)
|
return self.jobview.add_task(task)
|
||||||
|
|
||||||
def remove_from_queue(self, fileitem: FileItem):
|
def remove_from_queue(self, fileitem: FileItem):
|
||||||
"""
|
"""
|
||||||
@@ -851,8 +902,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
try:
|
try:
|
||||||
# 识别
|
# 识别
|
||||||
transferhis = TransferHistoryOper()
|
transferhis = TransferHistoryOper()
|
||||||
if not task.mediainfo:
|
mediainfo = task.mediainfo
|
||||||
mediainfo = None
|
mediainfo_changed = False
|
||||||
|
if not mediainfo:
|
||||||
download_history = task.download_history
|
download_history = task.download_history
|
||||||
# 下载用户
|
# 下载用户
|
||||||
if download_history:
|
if download_history:
|
||||||
@@ -896,13 +948,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self.jobview.remove_task(task.fileitem)
|
self.jobview.remove_task(task.fileitem)
|
||||||
return False, "未识别到媒体信息"
|
return False, "未识别到媒体信息"
|
||||||
|
|
||||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
mediainfo_changed = True
|
||||||
if not settings.SCRAP_FOLLOW_TMDB:
|
|
||||||
transfer_history = transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
|
||||||
mtype=mediainfo.type.value)
|
|
||||||
if transfer_history:
|
|
||||||
mediainfo.title = transfer_history.title
|
|
||||||
|
|
||||||
|
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||||
|
if not settings.SCRAP_FOLLOW_TMDB:
|
||||||
|
transfer_history = transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||||
|
mtype=mediainfo.type.value)
|
||||||
|
if transfer_history and mediainfo.title != transfer_history.title:
|
||||||
|
mediainfo.title = transfer_history.title
|
||||||
|
mediainfo_changed = True
|
||||||
|
|
||||||
|
if mediainfo_changed:
|
||||||
# 更新任务信息
|
# 更新任务信息
|
||||||
task.mediainfo = mediainfo
|
task.mediainfo = mediainfo
|
||||||
# 更新队列任务
|
# 更新队列任务
|
||||||
@@ -994,8 +1050,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 移除已完成的任务
|
# 移除已完成的任务
|
||||||
if self.jobview.is_done(task):
|
self.jobview.try_remove_job(task)
|
||||||
self.jobview.remove_job(task)
|
|
||||||
|
|
||||||
def get_queue_tasks(self) -> List[TransferJob]:
|
def get_queue_tasks(self) -> List[TransferJob]:
|
||||||
"""
|
"""
|
||||||
@@ -1119,14 +1174,29 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def __get_trans_fileitems(
|
def __get_trans_fileitems(
|
||||||
self, fileitem: FileItem, check: bool = True
|
self,
|
||||||
|
fileitem: FileItem,
|
||||||
|
predicate: Optional[Callable[[FileItem, bool], bool]],
|
||||||
|
verify_file_exists: bool = True,
|
||||||
) -> List[Tuple[FileItem, bool]]:
|
) -> List[Tuple[FileItem, bool]]:
|
||||||
"""
|
"""
|
||||||
获取整理目录或文件列表
|
获取待整理文件项列表
|
||||||
|
|
||||||
:param fileitem: 文件项
|
:param fileitem: 源文件项
|
||||||
:param check: 检查文件是否存在,默认为True
|
:param predicate: 用于筛选目录或文件项
|
||||||
|
该函数接收两个参数:
|
||||||
|
|
||||||
|
- `file_item`: 需要判断的文件项(类型为 `FileItem`)
|
||||||
|
- `is_bluray_dir`: 表示该项是否为蓝光原盘目录(布尔值)
|
||||||
|
|
||||||
|
函数应返回 `True` 表示保留该项,`False` 表示过滤掉
|
||||||
|
|
||||||
|
若 `predicate` 为 `None`,则默认保留所有项
|
||||||
|
:param verify_file_exists: 验证目录或文件是否存在,默认值为 `True`
|
||||||
"""
|
"""
|
||||||
|
if global_vars.is_system_stopped:
|
||||||
|
raise OperationInterrupted()
|
||||||
|
|
||||||
storagechain = StorageChain()
|
storagechain = StorageChain()
|
||||||
|
|
||||||
def __is_bluray_sub(_path: str) -> bool:
|
def __is_bluray_sub(_path: str) -> bool:
|
||||||
@@ -1144,7 +1214,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return storagechain.get_file_item(storage=_storage, path=p.parent)
|
return storagechain.get_file_item(storage=_storage, path=p.parent)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if check:
|
def _apply_predicate(file_item: FileItem, is_bluray_dir: bool) -> List[Tuple[FileItem, bool]]:
|
||||||
|
if predicate is None or predicate(file_item, is_bluray_dir):
|
||||||
|
return [(file_item, is_bluray_dir)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
if verify_file_exists:
|
||||||
latest_fileitem = storagechain.get_item(fileitem)
|
latest_fileitem = storagechain.get_item(fileitem)
|
||||||
if not latest_fileitem:
|
if not latest_fileitem:
|
||||||
logger.warn(f"目录或文件不存在:{fileitem.path}")
|
logger.warn(f"目录或文件不存在:{fileitem.path}")
|
||||||
@@ -1154,28 +1229,30 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
# 是否蓝光原盘子目录或文件
|
# 是否蓝光原盘子目录或文件
|
||||||
if __is_bluray_sub(fileitem.path):
|
if __is_bluray_sub(fileitem.path):
|
||||||
if dir_item := __get_bluray_dir(fileitem.storage, Path(fileitem.path)):
|
if bluray_dir := __get_bluray_dir(fileitem.storage, Path(fileitem.path)):
|
||||||
# 返回该文件所在的原盘根目录
|
# 返回该文件所在的原盘根目录
|
||||||
return [(dir_item, True)]
|
return _apply_predicate(bluray_dir, True)
|
||||||
|
|
||||||
# 单文件
|
# 单文件
|
||||||
if fileitem.type == "file":
|
if fileitem.type == "file":
|
||||||
return [(fileitem, False)]
|
return _apply_predicate(fileitem, False)
|
||||||
|
|
||||||
# 是否蓝光原盘根目录
|
# 是否蓝光原盘根目录
|
||||||
sub_items = storagechain.list_files(fileitem, recursion=False) or []
|
sub_items = storagechain.list_files(fileitem, recursion=False) or []
|
||||||
if storagechain.contains_bluray_subdirectories(sub_items):
|
if storagechain.contains_bluray_subdirectories(sub_items):
|
||||||
# 当前目录是原盘根目录,不需要递归
|
# 当前目录是原盘根目录,不需要递归
|
||||||
return [(fileitem, True)]
|
return _apply_predicate(fileitem, True)
|
||||||
|
|
||||||
# 不是原盘根目录 递归获取目录内需要整理的文件项列表
|
# 不是原盘根目录 递归获取目录内需要整理的文件项列表
|
||||||
return [
|
return [
|
||||||
item
|
item
|
||||||
for sub_item in sub_items
|
for sub_item in sub_items
|
||||||
for item in (
|
for item in (
|
||||||
self.__get_trans_fileitems(sub_item, check=False)
|
self.__get_trans_fileitems(
|
||||||
|
sub_item, predicate, verify_file_exists=False
|
||||||
|
)
|
||||||
if sub_item.type == "dir"
|
if sub_item.type == "dir"
|
||||||
else [(sub_item, False)]
|
else _apply_predicate(sub_item, False)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1225,22 +1302,47 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
transfer_exclude_words = SystemConfigOper().get(SystemConfigKey.TransferExcludeWords)
|
transfer_exclude_words = SystemConfigOper().get(SystemConfigKey.TransferExcludeWords)
|
||||||
# 汇总错误信息
|
# 汇总错误信息
|
||||||
err_msgs: List[str] = []
|
err_msgs: List[str] = []
|
||||||
# 递归获取待整理的文件/目录列表
|
|
||||||
file_items = self.__get_trans_fileitems(fileitem)
|
|
||||||
|
|
||||||
if not file_items:
|
def _filter(file_item: FileItem, is_bluray_dir: bool) -> bool:
|
||||||
logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件")
|
"""
|
||||||
return False, f"{fileitem.name} 没有找到可整理的媒体文件"
|
过滤文件项
|
||||||
|
|
||||||
# 有集自定义格式,过滤文件
|
:return: True 表示保留,False 表示排除
|
||||||
if formaterHandler:
|
"""
|
||||||
file_items = [f for f in file_items if formaterHandler.match(f[0].name)]
|
if continue_callback and not continue_callback():
|
||||||
|
raise OperationInterrupted()
|
||||||
|
# 有集自定义格式,过滤文件
|
||||||
|
if formaterHandler and not formaterHandler.match(file_item.name):
|
||||||
|
return False
|
||||||
|
# 过滤后缀和大小(蓝光目录、附加文件不过滤)
|
||||||
|
if (
|
||||||
|
not is_bluray_dir
|
||||||
|
and not self.__is_subtitle_file(file_item)
|
||||||
|
and not self.__is_audio_file(file_item)
|
||||||
|
):
|
||||||
|
if not self.__is_media_file(file_item):
|
||||||
|
return False
|
||||||
|
if not self.__is_allow_filesize(file_item, min_filesize):
|
||||||
|
return False
|
||||||
|
# 回收站及隐藏的文件不处理
|
||||||
|
if (
|
||||||
|
file_item.path.find("/@Recycle/") != -1
|
||||||
|
or file_item.path.find("/#recycle/") != -1
|
||||||
|
or file_item.path.find("/.") != -1
|
||||||
|
or file_item.path.find("/@eaDir") != -1
|
||||||
|
):
|
||||||
|
logger.debug(f"{file_item.path} 是回收站或隐藏的文件")
|
||||||
|
return False
|
||||||
|
# 整理屏蔽词不处理
|
||||||
|
if self._is_blocked_by_exclude_words(file_item.path, transfer_exclude_words):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
# 过滤后缀和大小(蓝光目录、附加文件不过滤大小)
|
try:
|
||||||
file_items = [f for f in file_items if f[1] or
|
# 获取经过筛选后的待整理文件项列表
|
||||||
self.__is_subtitle_file(f[0]) or
|
file_items = self.__get_trans_fileitems(fileitem, predicate=_filter)
|
||||||
self.__is_audio_file(f[0]) or
|
except OperationInterrupted:
|
||||||
(self.__is_media_file(f[0]) and self.__is_allow_filesize(f[0], min_filesize))]
|
return False, f"{fileitem.name} 已取消"
|
||||||
|
|
||||||
if not file_items:
|
if not file_items:
|
||||||
logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件")
|
logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件")
|
||||||
@@ -1253,21 +1355,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
try:
|
try:
|
||||||
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
|
raise OperationInterrupted()
|
||||||
if continue_callback and not continue_callback():
|
if continue_callback and not continue_callback():
|
||||||
break
|
raise OperationInterrupted()
|
||||||
file_path = Path(file_item.path)
|
file_path = Path(file_item.path)
|
||||||
# 回收站及隐藏的文件不处理
|
|
||||||
if file_item.path.find('/@Recycle/') != -1 \
|
|
||||||
or file_item.path.find('/#recycle/') != -1 \
|
|
||||||
or file_item.path.find('/.') != -1 \
|
|
||||||
or file_item.path.find('/@eaDir') != -1:
|
|
||||||
logger.debug(f"{file_item.path} 是回收站或隐藏的文件")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 整理屏蔽词不处理
|
|
||||||
if self._is_blocked_by_exclude_words(file_item.path, transfer_exclude_words):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 整理成功的不再处理
|
# 整理成功的不再处理
|
||||||
if not force:
|
if not force:
|
||||||
@@ -1330,8 +1421,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
# 获取下载Hash
|
# 获取下载Hash
|
||||||
if download_history and (not downloader or not download_hash):
|
if download_history and (not downloader or not download_hash):
|
||||||
downloader = download_history.downloader
|
_downloader = download_history.downloader
|
||||||
download_hash = download_history.download_hash
|
_download_hash = download_history.download_hash
|
||||||
|
else:
|
||||||
|
_downloader = downloader
|
||||||
|
_download_hash = download_hash
|
||||||
|
|
||||||
# 后台整理
|
# 后台整理
|
||||||
transfer_task = TransferTask(
|
transfer_task = TransferTask(
|
||||||
@@ -1345,19 +1439,25 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
scrape=scrape,
|
scrape=scrape,
|
||||||
library_type_folder=library_type_folder,
|
library_type_folder=library_type_folder,
|
||||||
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,
|
manual=manual,
|
||||||
background=background
|
background=background
|
||||||
)
|
)
|
||||||
if background:
|
if background:
|
||||||
self.put_to_queue(task=transfer_task)
|
if self.put_to_queue(task=transfer_task):
|
||||||
logger.info(f"{file_path.name} 已添加到整理队列")
|
logger.info(f"{file_path.name} 已添加到整理队列")
|
||||||
|
else:
|
||||||
|
logger.debug(f"{file_path.name} 已在整理队列中,跳过")
|
||||||
else:
|
else:
|
||||||
# 加入列表
|
# 加入列表
|
||||||
self.__put_to_jobview(transfer_task)
|
if self.__put_to_jobview(transfer_task):
|
||||||
transfer_tasks.append(transfer_task)
|
transfer_tasks.append(transfer_task)
|
||||||
|
else:
|
||||||
|
logger.debug(f"{file_path.name} 已在整理列表中,跳过")
|
||||||
|
except OperationInterrupted:
|
||||||
|
return False, f"{fileitem.name} 已取消"
|
||||||
finally:
|
finally:
|
||||||
file_items.clear()
|
file_items.clear()
|
||||||
del file_items
|
del file_items
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ class ConfigModel(BaseModel):
|
|||||||
# ==================== 云盘配置 ====================
|
# ==================== 云盘配置 ====================
|
||||||
# 115 AppId
|
# 115 AppId
|
||||||
U115_APP_ID: str = "100196807"
|
U115_APP_ID: str = "100196807"
|
||||||
|
# 115 OAuth2 Server 地址
|
||||||
|
U115_AUTH_SERVER: str = "https://movie-pilot.org"
|
||||||
# Alipan AppId
|
# Alipan AppId
|
||||||
ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403"
|
ALIPAN_APP_ID: str = "ac1bf04dc9fd4d9aaabb65b4a668d403"
|
||||||
|
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ class MediaInfo:
|
|||||||
for seainfo in info.get('seasons'):
|
for seainfo in info.get('seasons'):
|
||||||
# 季
|
# 季
|
||||||
season = seainfo.get("season_number")
|
season = seainfo.get("season_number")
|
||||||
if not season:
|
if season is None:
|
||||||
continue
|
continue
|
||||||
# 集
|
# 集
|
||||||
episode_count = seainfo.get("episode_count")
|
episode_count = seainfo.get("episode_count")
|
||||||
@@ -545,9 +545,9 @@ class MediaInfo:
|
|||||||
# 识别标题中的季
|
# 识别标题中的季
|
||||||
meta = MetaInfo(info.get("title"))
|
meta = MetaInfo(info.get("title"))
|
||||||
# 季
|
# 季
|
||||||
if not self.season:
|
if self.season is None:
|
||||||
self.season = meta.begin_season
|
self.season = meta.begin_season
|
||||||
if self.season:
|
if self.season is not None:
|
||||||
self.type = MediaType.TV
|
self.type = MediaType.TV
|
||||||
elif not self.type:
|
elif not self.type:
|
||||||
self.type = MediaType.MOVIE
|
self.type = MediaType.MOVIE
|
||||||
@@ -607,13 +607,13 @@ class MediaInfo:
|
|||||||
# 剧集
|
# 剧集
|
||||||
if self.type == MediaType.TV and not self.seasons:
|
if self.type == MediaType.TV and not self.seasons:
|
||||||
meta = MetaInfo(info.get("title"))
|
meta = MetaInfo(info.get("title"))
|
||||||
season = meta.begin_season or 1
|
season = meta.begin_season if meta.begin_season is not None else 1
|
||||||
episodes_count = info.get("episodes_count")
|
episodes_count = info.get("episodes_count")
|
||||||
if episodes_count:
|
if episodes_count:
|
||||||
self.seasons[season] = list(range(1, episodes_count + 1))
|
self.seasons[season] = list(range(1, episodes_count + 1))
|
||||||
# 季年份
|
# 季年份
|
||||||
if self.type == MediaType.TV and not self.season_years:
|
if self.type == MediaType.TV and not self.season_years:
|
||||||
season = self.season or 1
|
season = self.season if self.season is not None else 1
|
||||||
self.season_years = {
|
self.season_years = {
|
||||||
season: self.year
|
season: self.year
|
||||||
}
|
}
|
||||||
@@ -667,7 +667,7 @@ class MediaInfo:
|
|||||||
# 识别标题中的季
|
# 识别标题中的季
|
||||||
meta = MetaInfo(self.title)
|
meta = MetaInfo(self.title)
|
||||||
# 季
|
# 季
|
||||||
if not self.season:
|
if self.season is None:
|
||||||
self.season = meta.begin_season
|
self.season = meta.begin_season
|
||||||
# 评分
|
# 评分
|
||||||
if not self.vote_average:
|
if not self.vote_average:
|
||||||
@@ -703,7 +703,7 @@ class MediaInfo:
|
|||||||
# 剧集
|
# 剧集
|
||||||
if self.type == MediaType.TV and not self.seasons:
|
if self.type == MediaType.TV and not self.seasons:
|
||||||
meta = MetaInfo(self.title)
|
meta = MetaInfo(self.title)
|
||||||
season = meta.begin_season or 1
|
season = meta.begin_season if meta.begin_season is not None else 1
|
||||||
episodes_count = info.get("total_episodes")
|
episodes_count = info.get("total_episodes")
|
||||||
if episodes_count:
|
if episodes_count:
|
||||||
self.seasons[season] = list(range(1, episodes_count + 1))
|
self.seasons[season] = list(range(1, episodes_count + 1))
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class MediaServerOper(DbOper):
|
|||||||
if not item:
|
if not item:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if kwargs.get("season"):
|
if kwargs.get("season") is not None:
|
||||||
# 判断季是否存在
|
# 判断季是否存在
|
||||||
if not item.seasoninfo:
|
if not item.seasoninfo:
|
||||||
return None
|
return None
|
||||||
@@ -75,7 +75,7 @@ class MediaServerOper(DbOper):
|
|||||||
if not item:
|
if not item:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if kwargs.get("season"):
|
if kwargs.get("season") is not None:
|
||||||
# 判断季是否存在
|
# 判断季是否存在
|
||||||
if not item.seasoninfo:
|
if not item.seasoninfo:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -104,14 +104,14 @@ class DownloadHistory(Base):
|
|||||||
# TMDBID + 类型
|
# TMDBID + 类型
|
||||||
if tmdbid and mtype:
|
if tmdbid and mtype:
|
||||||
# 电视剧某季某集
|
# 电视剧某季某集
|
||||||
if season and episode:
|
if season is not None and episode:
|
||||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||||
DownloadHistory.type == mtype,
|
DownloadHistory.type == mtype,
|
||||||
DownloadHistory.seasons == season,
|
DownloadHistory.seasons == season,
|
||||||
DownloadHistory.episodes == episode).order_by(
|
DownloadHistory.episodes == episode).order_by(
|
||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
# 电视剧某季
|
# 电视剧某季
|
||||||
elif season:
|
elif season is not None:
|
||||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||||
DownloadHistory.type == mtype,
|
DownloadHistory.type == mtype,
|
||||||
DownloadHistory.seasons == season).order_by(
|
DownloadHistory.seasons == season).order_by(
|
||||||
@@ -124,14 +124,14 @@ class DownloadHistory(Base):
|
|||||||
# 标题 + 年份
|
# 标题 + 年份
|
||||||
elif title and year:
|
elif title and year:
|
||||||
# 电视剧某季某集
|
# 电视剧某季某集
|
||||||
if season and episode:
|
if season is not None and episode:
|
||||||
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||||
DownloadHistory.year == year,
|
DownloadHistory.year == year,
|
||||||
DownloadHistory.seasons == season,
|
DownloadHistory.seasons == season,
|
||||||
DownloadHistory.episodes == episode).order_by(
|
DownloadHistory.episodes == episode).order_by(
|
||||||
DownloadHistory.id.desc()).all()
|
DownloadHistory.id.desc()).all()
|
||||||
# 电视剧某季
|
# 电视剧某季
|
||||||
elif season:
|
elif season is not None:
|
||||||
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||||
DownloadHistory.year == year,
|
DownloadHistory.year == year,
|
||||||
DownloadHistory.seasons == season).order_by(
|
DownloadHistory.seasons == season).order_by(
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class Subscribe(Base):
|
|||||||
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||||
season: Optional[int] = None):
|
season: Optional[int] = None):
|
||||||
if tmdbid:
|
if tmdbid:
|
||||||
if season:
|
if season is not None:
|
||||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||||
cls.season == season).first()
|
cls.season == season).first()
|
||||||
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
|
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
|
||||||
@@ -106,7 +106,7 @@ class Subscribe(Base):
|
|||||||
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||||
season: Optional[int] = None):
|
season: Optional[int] = None):
|
||||||
if tmdbid:
|
if tmdbid:
|
||||||
if season:
|
if season is not None:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
||||||
)
|
)
|
||||||
@@ -148,7 +148,7 @@ class Subscribe(Base):
|
|||||||
@classmethod
|
@classmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_by_title(cls, db: Session, title: str, season: Optional[int] = None):
|
def get_by_title(cls, db: Session, title: str, season: Optional[int] = None):
|
||||||
if season:
|
if season is not None:
|
||||||
return db.query(cls).filter(cls.name == title,
|
return db.query(cls).filter(cls.name == title,
|
||||||
cls.season == season).first()
|
cls.season == season).first()
|
||||||
return db.query(cls).filter(cls.name == title).first()
|
return db.query(cls).filter(cls.name == title).first()
|
||||||
@@ -156,7 +156,7 @@ class Subscribe(Base):
|
|||||||
@classmethod
|
@classmethod
|
||||||
@async_db_query
|
@async_db_query
|
||||||
async def async_get_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None):
|
async def async_get_by_title(cls, db: AsyncSession, title: str, season: Optional[int] = None):
|
||||||
if season:
|
if season is not None:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(cls).filter(cls.name == title, cls.season == season)
|
select(cls).filter(cls.name == title, cls.season == season)
|
||||||
)
|
)
|
||||||
@@ -169,7 +169,7 @@ class Subscribe(Base):
|
|||||||
@classmethod
|
@classmethod
|
||||||
@db_query
|
@db_query
|
||||||
def get_by_tmdbid(cls, db: Session, tmdbid: int, season: Optional[int] = None):
|
def get_by_tmdbid(cls, db: Session, tmdbid: int, season: Optional[int] = None):
|
||||||
if season:
|
if season is not None:
|
||||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||||
cls.season == season).all()
|
cls.season == season).all()
|
||||||
else:
|
else:
|
||||||
@@ -178,7 +178,7 @@ class Subscribe(Base):
|
|||||||
@classmethod
|
@classmethod
|
||||||
@async_db_query
|
@async_db_query
|
||||||
async def async_get_by_tmdbid(cls, db: AsyncSession, tmdbid: int, season: Optional[int] = None):
|
async def async_get_by_tmdbid(cls, db: AsyncSession, tmdbid: int, season: Optional[int] = None):
|
||||||
if season:
|
if season is not None:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class SubscribeHistory(Base):
|
|||||||
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
def exists(cls, db: Session, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||||
season: Optional[int] = None):
|
season: Optional[int] = None):
|
||||||
if tmdbid:
|
if tmdbid:
|
||||||
if season:
|
if season is not None:
|
||||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||||
cls.season == season).first()
|
cls.season == season).first()
|
||||||
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
|
return db.query(cls).filter(cls.tmdbid == tmdbid).first()
|
||||||
@@ -112,7 +112,7 @@ class SubscribeHistory(Base):
|
|||||||
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
async def async_exists(cls, db: AsyncSession, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||||
season: Optional[int] = None):
|
season: Optional[int] = None):
|
||||||
if tmdbid:
|
if tmdbid:
|
||||||
if season:
|
if season is not None:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
select(cls).filter(cls.tmdbid == tmdbid, cls.season == season)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -266,14 +266,14 @@ class TransferHistory(Base):
|
|||||||
# TMDBID + 类型
|
# TMDBID + 类型
|
||||||
if tmdbid and mtype:
|
if tmdbid and mtype:
|
||||||
# 电视剧某季某集
|
# 电视剧某季某集
|
||||||
if season and episode:
|
if season is not None and episode:
|
||||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||||
cls.type == mtype,
|
cls.type == mtype,
|
||||||
cls.seasons == season,
|
cls.seasons == season,
|
||||||
cls.episodes == episode,
|
cls.episodes == episode,
|
||||||
cls.dest == dest).all()
|
cls.dest == dest).all()
|
||||||
# 电视剧某季
|
# 电视剧某季
|
||||||
elif season:
|
elif season is not None:
|
||||||
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
return db.query(cls).filter(cls.tmdbid == tmdbid,
|
||||||
cls.type == mtype,
|
cls.type == mtype,
|
||||||
cls.seasons == season).all()
|
cls.seasons == season).all()
|
||||||
@@ -290,14 +290,14 @@ class TransferHistory(Base):
|
|||||||
# 标题 + 年份
|
# 标题 + 年份
|
||||||
elif title and year:
|
elif title and year:
|
||||||
# 电视剧某季某集
|
# 电视剧某季某集
|
||||||
if season and episode:
|
if season is not None and episode:
|
||||||
return db.query(cls).filter(cls.title == title,
|
return db.query(cls).filter(cls.title == title,
|
||||||
cls.year == year,
|
cls.year == year,
|
||||||
cls.seasons == season,
|
cls.seasons == season,
|
||||||
cls.episodes == episode,
|
cls.episodes == episode,
|
||||||
cls.dest == dest).all()
|
cls.dest == dest).all()
|
||||||
# 电视剧某季
|
# 电视剧某季
|
||||||
elif season:
|
elif season is not None:
|
||||||
return db.query(cls).filter(cls.title == title,
|
return db.query(cls).filter(cls.title == title,
|
||||||
cls.year == year,
|
cls.year == year,
|
||||||
cls.seasons == season).all()
|
cls.seasons == season).all()
|
||||||
@@ -312,7 +312,7 @@ class TransferHistory(Base):
|
|||||||
return db.query(cls).filter(cls.title == title,
|
return db.query(cls).filter(cls.title == title,
|
||||||
cls.year == year).all()
|
cls.year == year).all()
|
||||||
# 类型 + 转移路径(emby webhook season无tmdbid场景)
|
# 类型 + 转移路径(emby webhook season无tmdbid场景)
|
||||||
elif mtype and season and dest:
|
elif mtype and season is not None and dest:
|
||||||
# 电视剧某季
|
# 电视剧某季
|
||||||
return db.query(cls).filter(cls.type == mtype,
|
return db.query(cls).filter(cls.type == mtype,
|
||||||
cls.seasons == season,
|
cls.seasons == season,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class SubscribeOper(DbOper):
|
|||||||
判断是否存在
|
判断是否存在
|
||||||
"""
|
"""
|
||||||
if tmdbid:
|
if tmdbid:
|
||||||
if season:
|
if season is not None:
|
||||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
|
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
|
||||||
else:
|
else:
|
||||||
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
|
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
|
||||||
@@ -195,7 +195,7 @@ class SubscribeOper(DbOper):
|
|||||||
判断是否存在订阅历史
|
判断是否存在订阅历史
|
||||||
"""
|
"""
|
||||||
if tmdbid:
|
if tmdbid:
|
||||||
if season:
|
if season is not None:
|
||||||
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid, season=season) else False
|
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid, season=season) else False
|
||||||
else:
|
else:
|
||||||
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid) else False
|
return True if SubscribeHistory.exists(self._db, tmdbid=tmdbid) else False
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class TransferHistoryOper(DbOper):
|
|||||||
"""
|
"""
|
||||||
新增转移成功历史记录
|
新增转移成功历史记录
|
||||||
"""
|
"""
|
||||||
self.add_force(
|
return self.add_force(
|
||||||
src=fileitem.path,
|
src=fileitem.path,
|
||||||
src_storage=fileitem.storage,
|
src_storage=fileitem.storage,
|
||||||
src_fileitem=fileitem.model_dump(),
|
src_fileitem=fileitem.model_dump(),
|
||||||
|
|||||||
@@ -19,41 +19,42 @@ class CookieHelper:
|
|||||||
"username": [
|
"username": [
|
||||||
'//input[@name="username"]',
|
'//input[@name="username"]',
|
||||||
'//input[@id="form_item_username"]',
|
'//input[@id="form_item_username"]',
|
||||||
'//input[@id="username"]'
|
'//input[@id="username"]',
|
||||||
],
|
],
|
||||||
"password": [
|
"password": [
|
||||||
'//input[@name="password"]',
|
'//input[@name="password"]',
|
||||||
'//input[@id="form_item_password"]',
|
'//input[@id="form_item_password"]',
|
||||||
'//input[@id="password"]',
|
'//input[@id="password"]',
|
||||||
'//input[@type="password"]'
|
'//input[@type="password"]',
|
||||||
],
|
],
|
||||||
"captcha": [
|
"captcha": [
|
||||||
'//input[@name="imagestring"]',
|
'//input[@name="imagestring"]',
|
||||||
'//input[@name="captcha"]',
|
'//input[@name="captcha"]',
|
||||||
'//input[@id="form_item_captcha"]',
|
'//input[@id="form_item_captcha"]',
|
||||||
'//input[@placeholder="驗證碼"]'
|
'//input[@placeholder="驗證碼"]',
|
||||||
],
|
],
|
||||||
"captcha_img": [
|
"captcha_img": [
|
||||||
'//img[@alt="captcha"]/@src',
|
'//img[@alt="captcha"]/@src',
|
||||||
'//img[@alt="CAPTCHA"]/@src',
|
'//img[@alt="CAPTCHA"]/@src',
|
||||||
'//img[@alt="SECURITY CODE"]/@src',
|
'//img[@alt="SECURITY CODE"]/@src',
|
||||||
'//img[@id="LAY-user-get-vercode"]/@src',
|
'//img[@id="LAY-user-get-vercode"]/@src',
|
||||||
'//img[contains(@src,"/api/getCaptcha")]/@src'
|
'//img[contains(@src,"/api/getCaptcha")]/@src',
|
||||||
],
|
],
|
||||||
"submit": [
|
"submit": [
|
||||||
'//input[@type="submit"]',
|
'//input[@type="submit"]',
|
||||||
'//button[@type="submit"]',
|
'//button[@type="submit"]',
|
||||||
'//button[@lay-filter="login"]',
|
'//button[@lay-filter="login"]',
|
||||||
'//button[@lay-filter="formLogin"]',
|
'//button[@lay-filter="formLogin"]',
|
||||||
'//input[@type="button"][@value="登录"]'
|
'//input[@type="button"][@value="登录"]',
|
||||||
|
'//input[@id="submit-btn"]',
|
||||||
],
|
],
|
||||||
"error": [
|
"error": [
|
||||||
"//table[@class='main']//td[@class='text']/text()"
|
"//table[@class='main']//td[@class='text']/text()",
|
||||||
],
|
],
|
||||||
"twostep": [
|
"twostep": [
|
||||||
'//input[@name="two_step_code"]',
|
'//input[@name="two_step_code"]',
|
||||||
'//input[@name="2fa_secret"]',
|
'//input[@name="2fa_secret"]',
|
||||||
'//input[@name="otp"]'
|
'//input[@name="otp"]',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -382,7 +382,10 @@ class RssHelper:
|
|||||||
size = int(size_attr)
|
size = int(size_attr)
|
||||||
|
|
||||||
# 发布日期
|
# 发布日期
|
||||||
pubdate_nodes = item.xpath('.//pubDate | .//published | .//updated')
|
pubdate_nodes = item.xpath('./pubDate | ./published | ./updated')
|
||||||
|
if not pubdate_nodes:
|
||||||
|
pubdate_nodes = item.xpath('.//*[local-name()="pubDate"] | .//*[local-name()="published"] | .//*[local-name()="updated"]')
|
||||||
|
|
||||||
pubdate = ""
|
pubdate = ""
|
||||||
if pubdate_nodes and pubdate_nodes[0].text:
|
if pubdate_nodes and pubdate_nodes[0].text:
|
||||||
pubdate = StringUtils.get_time(pubdate_nodes[0].text)
|
pubdate = StringUtils.get_time(pubdate_nodes[0].text)
|
||||||
|
|||||||
@@ -139,9 +139,23 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
发送通知消息
|
发送通知消息
|
||||||
:param message: 消息通知对象
|
:param message: 消息通知对象
|
||||||
"""
|
"""
|
||||||
for conf in self.get_configs().values():
|
# DEBUG: Log entry and configs
|
||||||
|
configs = self.get_configs()
|
||||||
|
logger.debug(f"[Discord] post_message 被调用,message.source={message.source}, "
|
||||||
|
f"message.userid={message.userid}, message.channel={message.channel}")
|
||||||
|
logger.debug(f"[Discord] 当前配置数量: {len(configs)}, 配置名称: {list(configs.keys())}")
|
||||||
|
logger.debug(f"[Discord] 当前实例数量: {len(self.get_instances())}, 实例名称: {list(self.get_instances().keys())}")
|
||||||
|
|
||||||
|
if not configs:
|
||||||
|
logger.warning("[Discord] get_configs() 返回空,没有可用的 Discord 配置")
|
||||||
|
return
|
||||||
|
|
||||||
|
for conf in configs.values():
|
||||||
|
logger.debug(f"[Discord] 检查配置: name={conf.name}, type={conf.type}, enabled={conf.enabled}")
|
||||||
if not self.check_message(message, conf.name):
|
if not self.check_message(message, conf.name):
|
||||||
|
logger.debug(f"[Discord] check_message 返回 False,跳过配置: {conf.name}")
|
||||||
continue
|
continue
|
||||||
|
logger.debug(f"[Discord] check_message 通过,准备发送到: {conf.name}")
|
||||||
targets = message.targets
|
targets = message.targets
|
||||||
userid = message.userid
|
userid = message.userid
|
||||||
if not userid and targets is not None:
|
if not userid and targets is not None:
|
||||||
@@ -150,13 +164,18 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
|||||||
logger.warn("用户没有指定 Discord 用户ID,消息无法发送")
|
logger.warn("用户没有指定 Discord 用户ID,消息无法发送")
|
||||||
return
|
return
|
||||||
client: Discord = self.get_instance(conf.name)
|
client: Discord = self.get_instance(conf.name)
|
||||||
|
logger.debug(f"[Discord] get_instance('{conf.name}') 返回: {client is not None}")
|
||||||
if client:
|
if client:
|
||||||
client.send_msg(title=message.title, text=message.text,
|
logger.debug(f"[Discord] 调用 client.send_msg, userid={userid}, title={message.title[:50] if message.title else None}...")
|
||||||
|
result = client.send_msg(title=message.title, text=message.text,
|
||||||
image=message.image, userid=userid, link=message.link,
|
image=message.image, userid=userid, link=message.link,
|
||||||
buttons=message.buttons,
|
buttons=message.buttons,
|
||||||
original_message_id=message.original_message_id,
|
original_message_id=message.original_message_id,
|
||||||
original_chat_id=message.original_chat_id,
|
original_chat_id=message.original_chat_id,
|
||||||
mtype=message.mtype)
|
mtype=message.mtype)
|
||||||
|
logger.debug(f"[Discord] send_msg 返回结果: {result}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Discord] 未找到配置 '{conf.name}' 对应的 Discord 客户端实例")
|
||||||
|
|
||||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, List, Dict, Any, Tuple, Union
|
from typing import Optional, List, Dict, Any, Tuple, Union
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
@@ -33,6 +34,9 @@ class Discord:
|
|||||||
DISCORD_GUILD_ID: Optional[Union[str, int]] = None,
|
DISCORD_GUILD_ID: Optional[Union[str, int]] = None,
|
||||||
DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,
|
DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
|
logger.debug(f"[Discord] 初始化 Discord 实例: name={kwargs.get('name')}, "
|
||||||
|
f"GUILD_ID={DISCORD_GUILD_ID}, CHANNEL_ID={DISCORD_CHANNEL_ID}, "
|
||||||
|
f"TOKEN={'已配置' if DISCORD_BOT_TOKEN else '未配置'}")
|
||||||
if not DISCORD_BOT_TOKEN:
|
if not DISCORD_BOT_TOKEN:
|
||||||
logger.error("Discord Bot Token 未配置!")
|
logger.error("Discord Bot Token 未配置!")
|
||||||
return
|
return
|
||||||
@@ -40,10 +44,14 @@ class Discord:
|
|||||||
self._token = DISCORD_BOT_TOKEN
|
self._token = DISCORD_BOT_TOKEN
|
||||||
self._guild_id = self._to_int(DISCORD_GUILD_ID)
|
self._guild_id = self._to_int(DISCORD_GUILD_ID)
|
||||||
self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
|
self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
|
||||||
|
logger.debug(f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}")
|
||||||
base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
|
base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
|
||||||
self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
|
self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
|
||||||
if kwargs.get("name"):
|
if kwargs.get("name"):
|
||||||
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
|
# URL encode the source name to handle special characters in config names
|
||||||
|
encoded_name = quote(kwargs.get('name'), safe='')
|
||||||
|
self._ds_url = f"{self._ds_url}&source={encoded_name}"
|
||||||
|
logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}")
|
||||||
|
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
@@ -59,6 +67,7 @@ class Discord:
|
|||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
self._ready_event = threading.Event()
|
self._ready_event = threading.Event()
|
||||||
self._user_dm_cache: Dict[str, discord.DMChannel] = {}
|
self._user_dm_cache: Dict[str, discord.DMChannel] = {}
|
||||||
|
self._user_chat_mapping: Dict[str, str] = {} # userid -> chat_id mapping for reply targeting
|
||||||
self._broadcast_channel = None
|
self._broadcast_channel = None
|
||||||
self._bot_user_id: Optional[int] = None
|
self._bot_user_id: Optional[int] = None
|
||||||
|
|
||||||
@@ -86,6 +95,9 @@ class Discord:
|
|||||||
if not self._should_process_message(message):
|
if not self._should_process_message(message):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Update user-chat mapping for reply targeting
|
||||||
|
self._update_user_chat_mapping(str(message.author.id), str(message.channel.id))
|
||||||
|
|
||||||
cleaned_text = self._clean_bot_mention(message.content or "")
|
cleaned_text = self._clean_bot_mention(message.content or "")
|
||||||
username = message.author.display_name or message.author.global_name or message.author.name
|
username = message.author.display_name or message.author.global_name or message.author.name
|
||||||
payload = {
|
payload = {
|
||||||
@@ -112,6 +124,10 @@ class Discord:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"处理 Discord 交互响应失败:{e}")
|
logger.error(f"处理 Discord 交互响应失败:{e}")
|
||||||
|
|
||||||
|
# Update user-chat mapping for reply targeting
|
||||||
|
if interaction.user and interaction.channel:
|
||||||
|
self._update_user_chat_mapping(str(interaction.user.id), str(interaction.channel.id))
|
||||||
|
|
||||||
username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \
|
username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \
|
||||||
if interaction.user else None
|
if interaction.user else None
|
||||||
payload = {
|
payload = {
|
||||||
@@ -168,13 +184,19 @@ class Discord:
|
|||||||
original_message_id: Optional[Union[int, str]] = None,
|
original_message_id: Optional[Union[int, str]] = None,
|
||||||
original_chat_id: Optional[str] = None,
|
original_chat_id: Optional[str] = None,
|
||||||
mtype: Optional['NotificationType'] = None) -> Optional[bool]:
|
mtype: Optional['NotificationType'] = None) -> Optional[bool]:
|
||||||
|
logger.debug(f"[Discord] send_msg 被调用: userid={userid}, title={title[:50] if title else None}...")
|
||||||
|
logger.debug(f"[Discord] get_state() = {self.get_state()}, "
|
||||||
|
f"_ready_event.is_set() = {self._ready_event.is_set()}, "
|
||||||
|
f"_client = {self._client is not None}")
|
||||||
if not self.get_state():
|
if not self.get_state():
|
||||||
|
logger.warning("[Discord] get_state() 返回 False,Bot 未就绪,无法发送消息")
|
||||||
return False
|
return False
|
||||||
if not title and not text:
|
if not title and not text:
|
||||||
logger.warn("标题和内容不能同时为空")
|
logger.warn("标题和内容不能同时为空")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
logger.debug(f"[Discord] 准备异步发送消息...")
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
self._send_message(title=title, text=text, image=image, userid=userid,
|
self._send_message(title=title, text=text, image=image, userid=userid,
|
||||||
link=link, buttons=buttons,
|
link=link, buttons=buttons,
|
||||||
@@ -182,7 +204,9 @@ class Discord:
|
|||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
mtype=mtype),
|
mtype=mtype),
|
||||||
self._loop)
|
self._loop)
|
||||||
return future.result(timeout=30)
|
result = future.result(timeout=30)
|
||||||
|
logger.debug(f"[Discord] 异步发送完成,结果: {result}")
|
||||||
|
return result
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"发送 Discord 消息失败:{err}")
|
logger.error(f"发送 Discord 消息失败:{err}")
|
||||||
return False
|
return False
|
||||||
@@ -254,7 +278,9 @@ class Discord:
|
|||||||
original_message_id: Optional[Union[int, str]],
|
original_message_id: Optional[Union[int, str]],
|
||||||
original_chat_id: Optional[str],
|
original_chat_id: Optional[str],
|
||||||
mtype: Optional['NotificationType'] = None) -> bool:
|
mtype: Optional['NotificationType'] = None) -> bool:
|
||||||
|
logger.debug(f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}")
|
||||||
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
|
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
|
||||||
|
logger.debug(f"[Discord] _resolve_channel 返回: {channel}, type={type(channel)}")
|
||||||
if not channel:
|
if not channel:
|
||||||
logger.error("未找到可用的 Discord 频道或私聊")
|
logger.error("未找到可用的 Discord 频道或私聊")
|
||||||
return False
|
return False
|
||||||
@@ -264,11 +290,18 @@ class Discord:
|
|||||||
content = None
|
content = None
|
||||||
|
|
||||||
if original_message_id and original_chat_id:
|
if original_message_id and original_chat_id:
|
||||||
|
logger.debug(f"[Discord] 编辑现有消息: message_id={original_message_id}")
|
||||||
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
|
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
|
||||||
content=content, embed=embed, view=view)
|
content=content, embed=embed, view=view)
|
||||||
|
|
||||||
await channel.send(content=content, embed=embed, view=view)
|
logger.debug(f"[Discord] 发送新消息到频道: {channel}")
|
||||||
return True
|
try:
|
||||||
|
await channel.send(content=content, embed=embed, view=view)
|
||||||
|
logger.debug("[Discord] 消息发送成功")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Discord] 发送消息到频道失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def _send_list_message(self, embeds: List[discord.Embed],
|
async def _send_list_message(self, embeds: List[discord.Embed],
|
||||||
userid: Optional[str],
|
userid: Optional[str],
|
||||||
@@ -515,26 +548,54 @@ class Discord:
|
|||||||
return view
|
return view
|
||||||
|
|
||||||
async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None):
|
async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None):
|
||||||
# 优先使用明确的聊天 ID
|
"""
|
||||||
|
Resolve the channel to send messages to.
|
||||||
|
Priority order:
|
||||||
|
1. `chat_id` (original channel where user sent the message) - for contextual replies
|
||||||
|
2. `userid` mapping (channel where user last sent a message) - for contextual replies
|
||||||
|
3. Configured `_channel_id` (broadcast channel) - for system notifications
|
||||||
|
4. Any available text channel in configured guild - fallback
|
||||||
|
5. `userid` (DM) - for private conversations as a final fallback
|
||||||
|
"""
|
||||||
|
logger.debug(f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, "
|
||||||
|
f"_channel_id={self._channel_id}, _guild_id={self._guild_id}")
|
||||||
|
|
||||||
|
# Priority 1: Use explicit chat_id (reply to the same channel where user sent message)
|
||||||
if chat_id:
|
if chat_id:
|
||||||
|
logger.debug(f"[Discord] 尝试通过 chat_id={chat_id} 获取原始频道")
|
||||||
channel = self._client.get_channel(int(chat_id))
|
channel = self._client.get_channel(int(chat_id))
|
||||||
if channel:
|
if channel:
|
||||||
|
logger.debug(f"[Discord] 通过 get_channel 找到频道: {channel}")
|
||||||
return channel
|
return channel
|
||||||
try:
|
try:
|
||||||
return await self._client.fetch_channel(int(chat_id))
|
channel = await self._client.fetch_channel(int(chat_id))
|
||||||
|
logger.debug(f"[Discord] 通过 fetch_channel 找到频道: {channel}")
|
||||||
|
return channel
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.warn(f"通过 chat_id 获取 Discord 频道失败:{err}")
|
logger.warn(f"通过 chat_id 获取 Discord 频道失败:{err}")
|
||||||
|
|
||||||
# 私聊
|
# Priority 2: Use user-chat mapping (reply to where the user last sent a message)
|
||||||
if userid:
|
if userid:
|
||||||
dm = await self._get_dm_channel(str(userid))
|
mapped_chat_id = self._get_user_chat_id(str(userid))
|
||||||
if dm:
|
if mapped_chat_id:
|
||||||
return dm
|
logger.debug(f"[Discord] 从用户映射获取 chat_id={mapped_chat_id}")
|
||||||
|
channel = self._client.get_channel(int(mapped_chat_id))
|
||||||
|
if channel:
|
||||||
|
logger.debug(f"[Discord] 通过映射找到频道: {channel}")
|
||||||
|
return channel
|
||||||
|
try:
|
||||||
|
channel = await self._client.fetch_channel(int(mapped_chat_id))
|
||||||
|
logger.debug(f"[Discord] 通过 fetch_channel 找到映射频道: {channel}")
|
||||||
|
return channel
|
||||||
|
except Exception as err:
|
||||||
|
logger.warn(f"通过映射的 chat_id 获取 Discord 频道失败:{err}")
|
||||||
|
|
||||||
# 配置的广播频道
|
# Priority 3: Use configured broadcast channel (for system notifications)
|
||||||
if self._broadcast_channel:
|
if self._broadcast_channel:
|
||||||
|
logger.debug(f"[Discord] 使用缓存的广播频道: {self._broadcast_channel}")
|
||||||
return self._broadcast_channel
|
return self._broadcast_channel
|
||||||
if self._channel_id:
|
if self._channel_id:
|
||||||
|
logger.debug(f"[Discord] 尝试通过配置的 _channel_id={self._channel_id} 获取频道")
|
||||||
channel = self._client.get_channel(self._channel_id)
|
channel = self._client.get_channel(self._channel_id)
|
||||||
if not channel:
|
if not channel:
|
||||||
try:
|
try:
|
||||||
@@ -544,9 +605,11 @@ class Discord:
|
|||||||
channel = None
|
channel = None
|
||||||
self._broadcast_channel = channel
|
self._broadcast_channel = channel
|
||||||
if channel:
|
if channel:
|
||||||
|
logger.debug(f"[Discord] 通过配置的频道ID找到频道: {channel}")
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
# 按 Guild 寻找一个可用文本频道
|
# Priority 4: Find any available text channel in guild (fallback)
|
||||||
|
logger.debug(f"[Discord] 尝试在 Guild 中寻找可用频道")
|
||||||
target_guilds = []
|
target_guilds = []
|
||||||
if self._guild_id:
|
if self._guild_id:
|
||||||
guild = self._client.get_guild(self._guild_id)
|
guild = self._client.get_guild(self._guild_id)
|
||||||
@@ -554,22 +617,47 @@ class Discord:
|
|||||||
target_guilds.append(guild)
|
target_guilds.append(guild)
|
||||||
else:
|
else:
|
||||||
target_guilds = list(self._client.guilds)
|
target_guilds = list(self._client.guilds)
|
||||||
|
logger.debug(f"[Discord] 目标 Guilds 数量: {len(target_guilds)}")
|
||||||
|
|
||||||
for guild in target_guilds:
|
for guild in target_guilds:
|
||||||
for channel in guild.text_channels:
|
for channel in guild.text_channels:
|
||||||
if guild.me and channel.permissions_for(guild.me).send_messages:
|
if guild.me and channel.permissions_for(guild.me).send_messages:
|
||||||
|
logger.debug(f"[Discord] 在 Guild 中找到可用频道: {channel}")
|
||||||
self._broadcast_channel = channel
|
self._broadcast_channel = channel
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
# Priority 5: Fallback to DM (only if no channel available)
|
||||||
|
if userid:
|
||||||
|
logger.debug(f"[Discord] 回退到私聊: userid={userid}")
|
||||||
|
dm = await self._get_dm_channel(str(userid))
|
||||||
|
if dm:
|
||||||
|
logger.debug(f"[Discord] 获取到私聊频道: {dm}")
|
||||||
|
return dm
|
||||||
|
else:
|
||||||
|
logger.debug(f"[Discord] 无法获取用户 {userid} 的私聊频道")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]:
|
async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]:
|
||||||
|
logger.debug(f"[Discord] _get_dm_channel: userid={userid}")
|
||||||
if userid in self._user_dm_cache:
|
if userid in self._user_dm_cache:
|
||||||
|
logger.debug(f"[Discord] 从缓存获取私聊频道: {self._user_dm_cache.get(userid)}")
|
||||||
return self._user_dm_cache.get(userid)
|
return self._user_dm_cache.get(userid)
|
||||||
try:
|
try:
|
||||||
user_obj = self._client.get_user(int(userid)) or await self._client.fetch_user(int(userid))
|
logger.debug(f"[Discord] 尝试获取/创建用户 {userid} 的私聊频道")
|
||||||
|
user_obj = self._client.get_user(int(userid))
|
||||||
|
logger.debug(f"[Discord] get_user 结果: {user_obj}")
|
||||||
if not user_obj:
|
if not user_obj:
|
||||||
|
user_obj = await self._client.fetch_user(int(userid))
|
||||||
|
logger.debug(f"[Discord] fetch_user 结果: {user_obj}")
|
||||||
|
if not user_obj:
|
||||||
|
logger.debug(f"[Discord] 无法找到用户 {userid}")
|
||||||
return None
|
return None
|
||||||
dm = user_obj.dm_channel or await user_obj.create_dm()
|
dm = user_obj.dm_channel
|
||||||
|
logger.debug(f"[Discord] 用户现有 dm_channel: {dm}")
|
||||||
|
if not dm:
|
||||||
|
dm = await user_obj.create_dm()
|
||||||
|
logger.debug(f"[Discord] 创建新的 dm_channel: {dm}")
|
||||||
if dm:
|
if dm:
|
||||||
self._user_dm_cache[userid] = dm
|
self._user_dm_cache[userid] = dm
|
||||||
return dm
|
return dm
|
||||||
@@ -577,6 +665,25 @@ class Discord:
|
|||||||
logger.error(f"获取 Discord 私聊失败:{err}")
|
logger.error(f"获取 Discord 私聊失败:{err}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _update_user_chat_mapping(self, userid: str, chat_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Update user-chat mapping for reply targeting.
|
||||||
|
This ensures replies go to the same channel where the user sent the message.
|
||||||
|
:param userid: User ID
|
||||||
|
:param chat_id: Channel/Chat ID where the user sent the message
|
||||||
|
"""
|
||||||
|
if userid and chat_id:
|
||||||
|
self._user_chat_mapping[userid] = chat_id
|
||||||
|
logger.debug(f"[Discord] 更新用户频道映射: userid={userid} -> chat_id={chat_id}")
|
||||||
|
|
||||||
|
def _get_user_chat_id(self, userid: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the chat ID where the user last sent a message.
|
||||||
|
:param userid: User ID
|
||||||
|
:return: Chat ID or None if not found
|
||||||
|
"""
|
||||||
|
return self._user_chat_mapping.get(userid)
|
||||||
|
|
||||||
def _should_process_message(self, message: discord.Message) -> bool:
|
def _should_process_message(self, message: discord.Message) -> bool:
|
||||||
if isinstance(message.channel, discord.DMChannel):
|
if isinstance(message.channel, discord.DMChannel):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class DoubanScraper:
|
|||||||
# 电影元数据文件
|
# 电影元数据文件
|
||||||
doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)
|
doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)
|
||||||
else:
|
else:
|
||||||
if season:
|
if season is not None:
|
||||||
# 季元数据文件
|
# 季元数据文件
|
||||||
doc = self.__gen_tv_season_nfo_file(mediainfo=mediainfo, season=season)
|
doc = self.__gen_tv_season_nfo_file(mediainfo=mediainfo, season=season)
|
||||||
else:
|
else:
|
||||||
@@ -41,7 +41,7 @@ class DoubanScraper:
|
|||||||
:param episode: 集号
|
:param episode: 集号
|
||||||
"""
|
"""
|
||||||
ret_dict = {}
|
ret_dict = {}
|
||||||
if season:
|
if season is not None:
|
||||||
# 豆瓣无季图片
|
# 豆瓣无季图片
|
||||||
return {}
|
return {}
|
||||||
if episode:
|
if episode:
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ class Emby:
|
|||||||
if str(tmdb_id) != str(item_info.tmdbid):
|
if str(tmdb_id) != str(item_info.tmdbid):
|
||||||
return None, {}
|
return None, {}
|
||||||
# 查集的信息
|
# 查集的信息
|
||||||
if not season:
|
if season is None:
|
||||||
season = None
|
season = None
|
||||||
try:
|
try:
|
||||||
url = f"{self._host}emby/Shows/{item_id}/Episodes"
|
url = f"{self._host}emby/Shows/{item_id}/Episodes"
|
||||||
@@ -437,12 +437,12 @@ class Emby:
|
|||||||
season_episodes = {}
|
season_episodes = {}
|
||||||
for res_item in res_items:
|
for res_item in res_items:
|
||||||
season_index = res_item.get("ParentIndexNumber")
|
season_index = res_item.get("ParentIndexNumber")
|
||||||
if not season_index:
|
if season_index is None:
|
||||||
continue
|
continue
|
||||||
if season and season != season_index:
|
if season is not None and season != season_index:
|
||||||
continue
|
continue
|
||||||
episode_index = res_item.get("IndexNumber")
|
episode_index = res_item.get("IndexNumber")
|
||||||
if not episode_index:
|
if episode_index is None:
|
||||||
continue
|
continue
|
||||||
if season_index not in season_episodes:
|
if season_index not in season_episodes:
|
||||||
season_episodes[season_index] = []
|
season_episodes[season_index] = []
|
||||||
|
|||||||
@@ -95,12 +95,11 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return False, f"{d.name} 的下载目录 {download_path} 与媒体库目录 {library_path} 不在同一磁盘,无法硬链接"
|
return False, f"{d.name} 的下载目录 {download_path} 与媒体库目录 {library_path} 不在同一磁盘,无法硬链接"
|
||||||
# 存储
|
# 存储
|
||||||
storage_oper = self.__get_storage_oper(d.storage)
|
storage_oper = self.__get_storage_oper(d.storage)
|
||||||
if not storage_oper:
|
if storage_oper:
|
||||||
return False, f"{d.name} 的存储类型 {d.storage} 不支持"
|
if not storage_oper.check():
|
||||||
if not storage_oper.check():
|
return False, f"{d.name} 的存储测试不通过"
|
||||||
return False, f"{d.name} 的存储测试不通过"
|
if d.transfer_type and d.transfer_type not in storage_oper.support_transtype():
|
||||||
if d.transfer_type and d.transfer_type not in storage_oper.support_transtype():
|
return False, f"{d.name} 的存储不支持 {d.transfer_type} 整理方式"
|
||||||
return False, f"{d.name} 的存储不支持 {d.transfer_type} 整理方式"
|
|
||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
@@ -197,6 +196,16 @@ class FileManagerModule(_ModuleBase):
|
|||||||
return None
|
return None
|
||||||
return storage_oper.generate_qrcode()
|
return storage_oper.generate_qrcode()
|
||||||
|
|
||||||
|
def generate_auth_url(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||||
|
"""
|
||||||
|
生成 OAuth2 授权 URL
|
||||||
|
"""
|
||||||
|
storage_oper = self.__get_storage_oper(storage, "generate_auth_url")
|
||||||
|
if not storage_oper:
|
||||||
|
logger.error(f"不支持 {storage} 的 OAuth2 授权")
|
||||||
|
return {}, f"不支持 {storage} 的 OAuth2 授权"
|
||||||
|
return storage_oper.generate_auth_url()
|
||||||
|
|
||||||
def check_login(self, storage: str, **kwargs) -> Optional[Dict[str, str]]:
|
def check_login(self, storage: str, **kwargs) -> Optional[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
登录确认
|
登录确认
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class StorageBase(metaclass=ABCMeta):
|
|||||||
def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:
|
def generate_qrcode(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def generate_auth_url(self, *args, **kwargs) -> Optional[Tuple[dict, str]]:
|
||||||
|
"""
|
||||||
|
生成 OAuth2 授权 URL
|
||||||
|
"""
|
||||||
|
return {}, "此存储不支持 OAuth2 授权"
|
||||||
|
|
||||||
def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]:
|
def check_login(self, *args, **kwargs) -> Optional[Dict[str, str]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import List, Optional, Tuple, Union, Dict
|
from typing import List, Optional, Tuple, Union
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
|
||||||
import oss2
|
import oss2
|
||||||
@@ -20,7 +20,7 @@ from app.modules.filemanager.storages import transfer_process
|
|||||||
from app.schemas.types import StorageSchema
|
from app.schemas.types import StorageSchema
|
||||||
from app.utils.singleton import WeakSingleton
|
from app.utils.singleton import WeakSingleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
from app.utils.limit import QpsRateLimiter
|
from app.utils.limit import QpsRateLimiter, RateStats
|
||||||
|
|
||||||
|
|
||||||
lock = Lock()
|
lock = Lock()
|
||||||
@@ -46,22 +46,23 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
# 文件块大小,默认10MB
|
# 文件块大小,默认10MB
|
||||||
chunk_size = 10 * 1024 * 1024
|
chunk_size = 10 * 1024 * 1024
|
||||||
|
|
||||||
# 流控重试间隔时间
|
# 下载接口单独限流
|
||||||
retry_delay = 70
|
download_endpoint = "/open/ufile/downurl"
|
||||||
|
# 风控触发后休眠时间(秒)
|
||||||
|
limit_sleep_seconds = 3600
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._auth_state = {}
|
self._auth_state = {}
|
||||||
self.session = httpx.Client(follow_redirects=True, timeout=20.0)
|
self.session = httpx.Client(follow_redirects=True, timeout=20.0)
|
||||||
self._init_session()
|
self._init_session()
|
||||||
self.qps_limiter: Dict[str, QpsRateLimiter] = {
|
# 接口限流
|
||||||
"/open/ufile/files": QpsRateLimiter(4),
|
self._download_limiter = QpsRateLimiter(1)
|
||||||
"/open/folder/get_info": QpsRateLimiter(3),
|
self._api_limiter = QpsRateLimiter(3)
|
||||||
"/open/ufile/move": QpsRateLimiter(2),
|
self._limit_until = 0.0
|
||||||
"/open/ufile/copy": QpsRateLimiter(2),
|
self._limit_lock = Lock()
|
||||||
"/open/ufile/update": QpsRateLimiter(2),
|
# 总体 QPS/QPM/QPH 统计
|
||||||
"/open/ufile/delete": QpsRateLimiter(2),
|
self._rate_stats = RateStats(source="115")
|
||||||
}
|
|
||||||
|
|
||||||
def _init_session(self):
|
def _init_session(self):
|
||||||
"""
|
"""
|
||||||
@@ -105,6 +106,33 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
self.session.headers.update({"Authorization": f"Bearer {access_token}"})
|
self.session.headers.update({"Authorization": f"Bearer {access_token}"})
|
||||||
return access_token
|
return access_token
|
||||||
|
|
||||||
|
def generate_auth_url(self) -> Tuple[dict, str]:
|
||||||
|
"""
|
||||||
|
生成 OAuth2 授权 URL
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = self.session.get(f"{settings.U115_AUTH_SERVER}/u115/auth_url")
|
||||||
|
if resp is None:
|
||||||
|
return {}, "无法连接到授权服务器"
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
if not result.get("success"):
|
||||||
|
return {}, result.get("message", "获取授权URL失败")
|
||||||
|
|
||||||
|
data = result.get("data", {})
|
||||||
|
auth_url = data.get("auth_url")
|
||||||
|
state = data.get("state")
|
||||||
|
|
||||||
|
if not auth_url or not state:
|
||||||
|
return {}, "授权服务器返回数据不完整"
|
||||||
|
|
||||||
|
self._auth_state = {"state": state}
|
||||||
|
|
||||||
|
return {"authUrl": auth_url, "state": state}, ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"【115】获取授权 URL 失败: {str(e)}")
|
||||||
|
return {}, f"获取授权 URL 失败: {str(e)}"
|
||||||
|
|
||||||
def generate_qrcode(self) -> Tuple[dict, str]:
|
def generate_qrcode(self) -> Tuple[dict, str]:
|
||||||
"""
|
"""
|
||||||
实现PKCE规范的设备授权二维码生成
|
实现PKCE规范的设备授权二维码生成
|
||||||
@@ -141,8 +169,11 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
|
|
||||||
def check_login(self) -> Optional[Tuple[dict, str]]:
|
def check_login(self) -> Optional[Tuple[dict, str]]:
|
||||||
"""
|
"""
|
||||||
改进的带PKCE校验的登录状态检查
|
检查授权状态
|
||||||
"""
|
"""
|
||||||
|
if self._auth_state and self._auth_state.get("state"):
|
||||||
|
return self.__check_oauth_login()
|
||||||
|
|
||||||
if not self._auth_state:
|
if not self._auth_state:
|
||||||
return {}, "生成二维码失败"
|
return {}, "生成二维码失败"
|
||||||
try:
|
try:
|
||||||
@@ -169,6 +200,47 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {}, str(e)
|
return {}, str(e)
|
||||||
|
|
||||||
|
def __check_oauth_login(self) -> Tuple[dict, str]:
|
||||||
|
"""
|
||||||
|
检查 OAuth2 授权状态
|
||||||
|
"""
|
||||||
|
state = self._auth_state.get("state")
|
||||||
|
if not state:
|
||||||
|
return {}, "state为空"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = self.session.get(
|
||||||
|
f"{settings.U115_AUTH_SERVER}/u115/token", params={"state": state}
|
||||||
|
)
|
||||||
|
if resp is None:
|
||||||
|
return {}, "无法连接到授权服务器"
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
status = result.get("status", "pending")
|
||||||
|
|
||||||
|
if status == "completed":
|
||||||
|
data = result.get("data", {})
|
||||||
|
if data:
|
||||||
|
self.set_config(
|
||||||
|
{
|
||||||
|
"refresh_time": int(time.time()),
|
||||||
|
"access_token": data.get("access_token"),
|
||||||
|
"refresh_token": data.get("refresh_token"),
|
||||||
|
"expires_in": data.get("expires_in"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._auth_state = {}
|
||||||
|
return {"status": 2, "tip": "授权成功"}, ""
|
||||||
|
return {}, "授权服务器返回数据不完整"
|
||||||
|
elif status == "expired":
|
||||||
|
self._auth_state = {}
|
||||||
|
return {"status": -1, "tip": result.get("message", "授权已过期")}, ""
|
||||||
|
else:
|
||||||
|
return {"status": 0, "tip": "等待用户授权"}, ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"【115】检查授权状态失败: {str(e)}")
|
||||||
|
return {}, f"检查授权状态失败: {str(e)}"
|
||||||
|
|
||||||
def __get_access_token(self) -> dict:
|
def __get_access_token(self) -> dict:
|
||||||
"""
|
"""
|
||||||
确认登录后,获取相关token
|
确认登录后,获取相关token
|
||||||
@@ -222,11 +294,24 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
# 错误日志标志
|
# 错误日志标志
|
||||||
no_error_log = kwargs.pop("no_error_log", False)
|
no_error_log = kwargs.pop("no_error_log", False)
|
||||||
# 重试次数
|
# 重试次数
|
||||||
retry_times = kwargs.pop("retry_limit", 5)
|
retry_times = kwargs.pop("retry_limit", 3)
|
||||||
|
|
||||||
# qps 速率限制
|
# 按接口类型限流
|
||||||
if endpoint in self.qps_limiter:
|
if endpoint == self.download_endpoint:
|
||||||
self.qps_limiter[endpoint].acquire()
|
self._download_limiter.acquire()
|
||||||
|
else:
|
||||||
|
self._api_limiter.acquire()
|
||||||
|
self._rate_stats.record()
|
||||||
|
|
||||||
|
# 风控冷却期间阻止所有接口调用,统一等待
|
||||||
|
with self._limit_lock:
|
||||||
|
wait_until = self._limit_until
|
||||||
|
if wait_until > time.time():
|
||||||
|
wait_secs = wait_until - time.time()
|
||||||
|
logger.info(
|
||||||
|
f"【115】风控冷却中,本请求等待 {wait_secs:.0f} 秒后再调用接口..."
|
||||||
|
)
|
||||||
|
time.sleep(wait_secs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs)
|
resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs)
|
||||||
@@ -240,13 +325,24 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
|
|
||||||
kwargs["retry_limit"] = retry_times
|
kwargs["retry_limit"] = retry_times
|
||||||
|
|
||||||
# 处理速率限制
|
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
reset_time = 5 + int(resp.headers.get("X-RateLimit-Reset", 60))
|
self._rate_stats.log_stats("warning")
|
||||||
logger.debug(
|
if retry_times <= 0:
|
||||||
f"【115】{method} 请求 {endpoint} 限流,等待{reset_time}秒后重试"
|
logger.error(
|
||||||
|
f"【115】{method} 请求 {endpoint} 触发限流(429),重试次数用尽!"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
with self._limit_lock:
|
||||||
|
self._limit_until = max(
|
||||||
|
self._limit_until,
|
||||||
|
time.time() + self.limit_sleep_seconds,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"【115】触发限流(429),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..."
|
||||||
)
|
)
|
||||||
time.sleep(reset_time)
|
time.sleep(self.limit_sleep_seconds)
|
||||||
|
kwargs["retry_limit"] = retry_times - 1
|
||||||
|
kwargs["no_error_log"] = no_error_log
|
||||||
return self._request_api(method, endpoint, result_key, **kwargs)
|
return self._request_api(method, endpoint, result_key, **kwargs)
|
||||||
|
|
||||||
# 处理请求错误
|
# 处理请求错误
|
||||||
@@ -259,6 +355,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
kwargs["retry_limit"] = retry_times - 1
|
kwargs["retry_limit"] = retry_times - 1
|
||||||
|
kwargs["no_error_log"] = no_error_log
|
||||||
sleep_duration = 2 ** (5 - retry_times + 1)
|
sleep_duration = 2 ** (5 - retry_times + 1)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"【115】{method} 请求 {endpoint} 错误 {e},等待 {sleep_duration} 秒后重试..."
|
f"【115】{method} 请求 {endpoint} 错误 {e},等待 {sleep_duration} 秒后重试..."
|
||||||
@@ -269,20 +366,27 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
# 返回数据
|
# 返回数据
|
||||||
ret_data = resp.json()
|
ret_data = resp.json()
|
||||||
if ret_data.get("code") not in (0, 20004):
|
if ret_data.get("code") not in (0, 20004):
|
||||||
error_msg = ret_data.get("message")
|
error_msg = ret_data.get("message", "")
|
||||||
if not no_error_log:
|
if not no_error_log:
|
||||||
logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}")
|
logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}")
|
||||||
if "已达到当前访问上限" in error_msg:
|
if "已达到当前访问上限" in error_msg:
|
||||||
|
self._rate_stats.log_stats("warning")
|
||||||
if retry_times <= 0:
|
if retry_times <= 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"【115】{method} 请求 {endpoint} 达到访问上限,重试次数用尽!"
|
f"【115】{method} 请求 {endpoint} 触发风控(访问上限),重试次数用尽!"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
kwargs["retry_limit"] = retry_times - 1
|
with self._limit_lock:
|
||||||
logger.info(
|
self._limit_until = max(
|
||||||
f"【115】{method} 请求 {endpoint} 达到访问上限,等待 {self.retry_delay} 秒后重试..."
|
self._limit_until,
|
||||||
|
time.time() + self.limit_sleep_seconds,
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
f"【115】触发风控(访问上限),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..."
|
||||||
)
|
)
|
||||||
time.sleep(self.retry_delay)
|
time.sleep(self.limit_sleep_seconds)
|
||||||
|
kwargs["retry_limit"] = retry_times - 1
|
||||||
|
kwargs["no_error_log"] = no_error_log
|
||||||
return self._request_api(method, endpoint, result_key, **kwargs)
|
return self._request_api(method, endpoint, result_key, **kwargs)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -809,7 +913,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
|
|
||||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
企业级复制实现(支持目录递归复制)
|
复制
|
||||||
"""
|
"""
|
||||||
if fileitem.fileid is None:
|
if fileitem.fileid is None:
|
||||||
fileitem = self.get_item(Path(fileitem.path))
|
fileitem = self.get_item(Path(fileitem.path))
|
||||||
@@ -842,7 +946,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
|
|
||||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
原子性移动操作实现
|
移动
|
||||||
"""
|
"""
|
||||||
if fileitem.fileid is None:
|
if fileitem.fileid is None:
|
||||||
fileitem = self.get_item(Path(fileitem.path))
|
fileitem = self.get_item(Path(fileitem.path))
|
||||||
@@ -880,7 +984,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
|||||||
|
|
||||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||||
"""
|
"""
|
||||||
获取带有企业级配额信息的存储使用情况
|
存储使用情况
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
resp = self._request_api("GET", "/open/user/info", "data")
|
resp = self._request_api("GET", "/open/user/info", "data")
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ class TransHandler:
|
|||||||
elif overwrite_mode == 'never':
|
elif overwrite_mode == 'never':
|
||||||
# 存在不覆盖
|
# 存在不覆盖
|
||||||
self.__update_result(result=result,
|
self.__update_result(result=result,
|
||||||
|
success=False,
|
||||||
message=f"媒体库存在同名文件,当前覆盖模式为不覆盖",
|
message=f"媒体库存在同名文件,当前覆盖模式为不覆盖",
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
target_item=target_item,
|
target_item=target_item,
|
||||||
@@ -313,6 +314,9 @@ class TransHandler:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
|
f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
|
||||||
self.__delete_version_files(target_oper, new_file)
|
self.__delete_version_files(target_oper, new_file)
|
||||||
|
else:
|
||||||
|
# 附加文件 总是需要覆盖
|
||||||
|
overflag = True
|
||||||
|
|
||||||
# 整理文件
|
# 整理文件
|
||||||
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
|
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
|
||||||
@@ -497,18 +501,23 @@ class TransHandler:
|
|||||||
重命名字幕文件,补充附加信息
|
重命名字幕文件,补充附加信息
|
||||||
"""
|
"""
|
||||||
# 字幕正则式
|
# 字幕正则式
|
||||||
_zhcn_sub_re = r"([.\[(](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \
|
_zhcn_sub_re = r"([.\[(\s](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \
|
||||||
r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&]?(cn|ch[si]|sg|zho?|eng)" \
|
r"|chinese|(cn|ch[si]|sg|zho?)[-_&]?(cn|ch[si]|sg|zho?|eng|jap|ja|jpn)" \
|
||||||
r"|简[体中]?)[.\])])" \
|
r"|eng[-_&]?(cn|ch[si]|sg|zho?)|(jap|ja|jpn)[-_&]?(cn|ch[si]|sg|zho?)" \
|
||||||
|
r"|简[体中]?)[.\])\s])" \
|
||||||
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \
|
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \
|
||||||
r"|简体|简中|JPSC|sc_jp" \
|
r"|简体|简中|JPSC|sc_jp" \
|
||||||
r"|(?<![a-z0-9])gb(?![a-z0-9])"
|
r"|(?<![a-z0-9])gb(?![a-z0-9])"
|
||||||
_zhtw_sub_re = r"([.\[(](((zh[-_])?(hk|tw|cht|tc))" \
|
_zhtw_sub_re = r"([.\[(\s](((zh[-_])?(hk|tw|cht|tc))" \
|
||||||
r"|(cht|eng)[-_&]?(cht|eng)" \
|
r"|cht[-_&]?(cht|eng|jap|ja|jpn)" \
|
||||||
r"|繁[体中]?)[.\])])" \
|
r"|eng[-_&]?cht|(jap|ja|jpn)[-_&]?cht" \
|
||||||
|
r"|繁[体中]?)[.\])\s])" \
|
||||||
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp" \
|
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp" \
|
||||||
r"|(?<![a-z0-9])big5(?![a-z0-9])"
|
r"|(?<![a-z0-9])big5(?![a-z0-9])"
|
||||||
_eng_sub_re = r"[.\[(]eng[.\])]"
|
_ja_sub_re = r"([.\[(\s](ja-jp|jap|ja|jpn" \
|
||||||
|
r"|(jap|ja|jpn)[-_&]?eng|eng[-_&]?(jap|ja|jpn))[.\])\s])" \
|
||||||
|
r"|日本語|日語"
|
||||||
|
_eng_sub_re = r"[.\[(\s]eng[.\])\s]"
|
||||||
|
|
||||||
# 原文件后缀
|
# 原文件后缀
|
||||||
file_ext = f".{sub_item.extension}"
|
file_ext = f".{sub_item.extension}"
|
||||||
@@ -520,12 +529,15 @@ class TransHandler:
|
|||||||
new_file_type = ".chi.zh-cn"
|
new_file_type = ".chi.zh-cn"
|
||||||
elif re.search(_zhtw_sub_re, sub_item.name, re.I):
|
elif re.search(_zhtw_sub_re, sub_item.name, re.I):
|
||||||
new_file_type = ".zh-tw"
|
new_file_type = ".zh-tw"
|
||||||
|
elif re.search(_ja_sub_re, sub_item.name, re.I):
|
||||||
|
new_file_type = ".ja"
|
||||||
elif re.search(_eng_sub_re, sub_item.name, re.I):
|
elif re.search(_eng_sub_re, sub_item.name, re.I):
|
||||||
new_file_type = ".eng"
|
new_file_type = ".eng"
|
||||||
|
|
||||||
# 添加默认字幕标识
|
# 添加默认字幕标识
|
||||||
if ((settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn")
|
if ((settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn")
|
||||||
or (settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw")
|
or (settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw")
|
||||||
|
or (settings.DEFAULT_SUB == "ja" and new_file_type == ".ja")
|
||||||
or (settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")):
|
or (settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")):
|
||||||
new_sub_tag = ".default" + new_file_type
|
new_sub_tag = ".default" + new_file_type
|
||||||
else:
|
else:
|
||||||
@@ -789,8 +801,8 @@ class TransHandler:
|
|||||||
continue
|
continue
|
||||||
if media_file.type != "file":
|
if media_file.type != "file":
|
||||||
continue
|
continue
|
||||||
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
|
# 当前只有视频文件需要保留最新版本,其余格式无需处理,以避免误删 (issue 5449)
|
||||||
if f".{media_file.extension.lower()}" not in media_exts:
|
if f".{media_file.extension.lower()}" not in settings.RMT_MEDIAEXT:
|
||||||
continue
|
continue
|
||||||
# 识别文件中的季集信息
|
# 识别文件中的季集信息
|
||||||
filemeta = MetaInfoPath(media_path)
|
filemeta = MetaInfoPath(media_path)
|
||||||
|
|||||||
@@ -428,6 +428,12 @@ class SiteSpider:
|
|||||||
if pubdate_str:
|
if pubdate_str:
|
||||||
pubdate_str = pubdate_str.replace('\n', ' ').strip()
|
pubdate_str = pubdate_str.replace('\n', ' ').strip()
|
||||||
self.torrents_info['pubdate'] = self.__filter_text(pubdate_str, selector.get('filters'))
|
self.torrents_info['pubdate'] = self.__filter_text(pubdate_str, selector.get('filters'))
|
||||||
|
if self.torrents_info.get('pubdate'):
|
||||||
|
try:
|
||||||
|
if not isinstance(self.torrents_info['pubdate'], datetime.datetime):
|
||||||
|
datetime.datetime.strptime(str(self.torrents_info['pubdate']), '%Y-%m-%d %H:%M:%S')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.torrents_info['pubdate'] = StringUtils.unify_datetime_str(str(self.torrents_info['pubdate']))
|
||||||
|
|
||||||
def __get_date_elapsed(self, torrent: Any):
|
def __get_date_elapsed(self, torrent: Any):
|
||||||
# torrent date elapsed text
|
# torrent date elapsed text
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ class Jellyfin:
|
|||||||
if tmdb_id and item_info.tmdbid:
|
if tmdb_id and item_info.tmdbid:
|
||||||
if str(tmdb_id) != str(item_info.tmdbid):
|
if str(tmdb_id) != str(item_info.tmdbid):
|
||||||
return None, {}
|
return None, {}
|
||||||
if not season:
|
if season is None:
|
||||||
season = None
|
season = None
|
||||||
url = f"{self._host}Shows/{item_id}/Episodes"
|
url = f"{self._host}Shows/{item_id}/Episodes"
|
||||||
params = {
|
params = {
|
||||||
@@ -427,12 +427,12 @@ class Jellyfin:
|
|||||||
season_episodes = {}
|
season_episodes = {}
|
||||||
for res_item in res_items:
|
for res_item in res_items:
|
||||||
season_index = res_item.get("ParentIndexNumber")
|
season_index = res_item.get("ParentIndexNumber")
|
||||||
if not season_index:
|
if season_index is None:
|
||||||
continue
|
continue
|
||||||
if season and season != season_index:
|
if season is not None and season != season_index:
|
||||||
continue
|
continue
|
||||||
episode_index = res_item.get("IndexNumber")
|
episode_index = res_item.get("IndexNumber")
|
||||||
if not episode_index:
|
if episode_index is None:
|
||||||
continue
|
continue
|
||||||
if not season_episodes.get(season_index):
|
if not season_episodes.get(season_index):
|
||||||
season_episodes[season_index] = []
|
season_episodes[season_index] = []
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ class Plex:
|
|||||||
episodes = videos.episodes()
|
episodes = videos.episodes()
|
||||||
season_episodes = {}
|
season_episodes = {}
|
||||||
for episode in episodes:
|
for episode in episodes:
|
||||||
if season and episode.seasonNumber != int(season):
|
if season is not None and episode.seasonNumber != int(season):
|
||||||
continue
|
continue
|
||||||
if episode.seasonNumber not in season_episodes:
|
if episode.seasonNumber not in season_episodes:
|
||||||
season_episodes[episode.seasonNumber] = []
|
season_episodes[episode.seasonNumber] = []
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from slack_bolt import App
|
from slack_bolt import App
|
||||||
@@ -42,7 +43,9 @@ class Slack:
|
|||||||
|
|
||||||
# 标记消息来源
|
# 标记消息来源
|
||||||
if kwargs.get("name"):
|
if kwargs.get("name"):
|
||||||
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
|
# URL encode the source name to handle special characters
|
||||||
|
encoded_name = quote(kwargs.get('name'), safe='')
|
||||||
|
self._ds_url = f"{self._ds_url}&source={encoded_name}"
|
||||||
|
|
||||||
# 注册消息响应
|
# 注册消息响应
|
||||||
@slack_app.event("message")
|
@slack_app.event("message")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional, List, Dict, Callable
|
from typing import Optional, List, Dict, Callable
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin, quote
|
||||||
|
|
||||||
from telebot import TeleBot, apihelper
|
from telebot import TeleBot, apihelper
|
||||||
from telebot.types import BotCommand, InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto
|
from telebot.types import BotCommand, InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto
|
||||||
@@ -65,7 +65,9 @@ class Telegram:
|
|||||||
|
|
||||||
# 标记渠道来源
|
# 标记渠道来源
|
||||||
if kwargs.get("name"):
|
if kwargs.get("name"):
|
||||||
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
|
# URL encode the source name to handle special characters
|
||||||
|
encoded_name = quote(kwargs.get('name'), safe='')
|
||||||
|
self._ds_url = f"{self._ds_url}&source={encoded_name}"
|
||||||
|
|
||||||
@_bot.message_handler(commands=['start', 'help'])
|
@_bot.message_handler(commands=['start', 'help'])
|
||||||
def send_welcome(message):
|
def send_welcome(message):
|
||||||
|
|||||||
@@ -798,7 +798,7 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
if not tmdb_info:
|
if not tmdb_info:
|
||||||
return []
|
return []
|
||||||
return [schemas.TmdbSeason(**sea)
|
return [schemas.TmdbSeason(**sea)
|
||||||
for sea in tmdb_info.get("seasons", []) if sea.get("season_number")]
|
for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None]
|
||||||
|
|
||||||
def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
||||||
"""
|
"""
|
||||||
@@ -1168,7 +1168,7 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
if not tmdb_info:
|
if not tmdb_info:
|
||||||
return []
|
return []
|
||||||
return [schemas.TmdbSeason(**sea)
|
return [schemas.TmdbSeason(**sea)
|
||||||
for sea in tmdb_info.get("seasons", []) if sea.get("season_number")]
|
for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None]
|
||||||
|
|
||||||
async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class TmdbApi:
|
|||||||
"""
|
"""
|
||||||
记录匹配调试日志
|
记录匹配调试日志
|
||||||
"""
|
"""
|
||||||
if season_number and season_year:
|
if season_number is not None and season_year:
|
||||||
logger.debug(f"正在识别{mtype.value}:{name}, 季集={season_number}, 季集年份={season_year} ...")
|
logger.debug(f"正在识别{mtype.value}:{name}, 季集={season_number}, 季集年份={season_year} ...")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"正在识别{mtype.value}:{name}, 年份={year} ...")
|
logger.debug(f"正在识别{mtype.value}:{name}, 年份={year} ...")
|
||||||
@@ -473,7 +473,7 @@ class TmdbApi:
|
|||||||
info = self._set_media_type(info, MediaType.MOVIE)
|
info = self._set_media_type(info, MediaType.MOVIE)
|
||||||
else:
|
else:
|
||||||
# 有当前季和当前季集年份,使用精确匹配
|
# 有当前季和当前季集年份,使用精确匹配
|
||||||
if season_year and season_number:
|
if season_year and season_number is not None:
|
||||||
self._log_match_debug(mtype, name, season_year, season_number, season_year)
|
self._log_match_debug(mtype, name, season_year, season_number, season_year)
|
||||||
info = self.__search_tv_by_season(name,
|
info = self.__search_tv_by_season(name,
|
||||||
season_year,
|
season_year,
|
||||||
@@ -697,7 +697,7 @@ class TmdbApi:
|
|||||||
return {}
|
return {}
|
||||||
ret_seasons = {}
|
ret_seasons = {}
|
||||||
for season_info in tv_info.get("seasons") or []:
|
for season_info in tv_info.get("seasons") or []:
|
||||||
if not season_info.get("season_number"):
|
if season_info.get("season_number") is None:
|
||||||
continue
|
continue
|
||||||
ret_seasons[season_info.get("season_number")] = season_info
|
ret_seasons[season_info.get("season_number")] = season_info
|
||||||
return ret_seasons
|
return ret_seasons
|
||||||
@@ -2028,7 +2028,7 @@ class TmdbApi:
|
|||||||
info = self._set_media_type(info, MediaType.MOVIE)
|
info = self._set_media_type(info, MediaType.MOVIE)
|
||||||
else:
|
else:
|
||||||
# 有当前季和当前季集年份,使用精确匹配
|
# 有当前季和当前季集年份,使用精确匹配
|
||||||
if season_year and season_number:
|
if season_year and season_number is not None:
|
||||||
self._log_match_debug(mtype, name, season_year, season_number, season_year)
|
self._log_match_debug(mtype, name, season_year, season_number, season_year)
|
||||||
info = await self.__async_search_tv_by_season(name,
|
info = await self.__async_search_tv_by_season(name,
|
||||||
season_year,
|
season_year,
|
||||||
|
|||||||
@@ -29,3 +29,10 @@ class RateLimitExceededException(LimitException):
|
|||||||
这个异常通常用于本地限流逻辑(例如 RateLimiter),当系统检测到函数调用频率过高时,触发限流并抛出该异常。
|
这个异常通常用于本地限流逻辑(例如 RateLimiter),当系统检测到函数调用频率过高时,触发限流并抛出该异常。
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OperationInterrupted(KeyboardInterrupt):
|
||||||
|
"""
|
||||||
|
用于表示操作被中断
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|||||||
@@ -98,8 +98,14 @@ class ExponentialBackoffRateLimiter(BaseRateLimiter):
|
|||||||
每次触发限流时,等待时间会成倍增加,直到达到最大等待时间
|
每次触发限流时,等待时间会成倍增加,直到达到最大等待时间
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, base_wait: float = 60.0, max_wait: float = 600.0, backoff_factor: float = 2.0,
|
def __init__(
|
||||||
source: str = "", enable_logging: bool = True):
|
self,
|
||||||
|
base_wait: float = 60.0,
|
||||||
|
max_wait: float = 600.0,
|
||||||
|
backoff_factor: float = 2.0,
|
||||||
|
source: str = "",
|
||||||
|
enable_logging: bool = True,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
初始化 ExponentialBackoffRateLimiter 实例
|
初始化 ExponentialBackoffRateLimiter 实例
|
||||||
:param base_wait: 基础等待时间(秒),默认值为 60 秒(1 分钟)
|
:param base_wait: 基础等待时间(秒),默认值为 60 秒(1 分钟)
|
||||||
@@ -156,7 +162,9 @@ class ExponentialBackoffRateLimiter(BaseRateLimiter):
|
|||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
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
|
||||||
|
)
|
||||||
wait_time = self.next_allowed_time - current_time
|
wait_time = self.next_allowed_time - current_time
|
||||||
self.log_warning(f"触发限流,将在 {wait_time:.2f} 秒后允许继续调用")
|
self.log_warning(f"触发限流,将在 {wait_time:.2f} 秒后允许继续调用")
|
||||||
|
|
||||||
@@ -168,8 +176,13 @@ class WindowRateLimiter(BaseRateLimiter):
|
|||||||
如果超过允许的最大调用次数,则限流直到窗口期结束
|
如果超过允许的最大调用次数,则限流直到窗口期结束
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, max_calls: int, window_seconds: float,
|
def __init__(
|
||||||
source: str = "", enable_logging: bool = True):
|
self,
|
||||||
|
max_calls: int,
|
||||||
|
window_seconds: float,
|
||||||
|
source: str = "",
|
||||||
|
enable_logging: bool = True,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
初始化 WindowRateLimiter 实例
|
初始化 WindowRateLimiter 实例
|
||||||
:param max_calls: 在时间窗口内允许的最大调用次数
|
:param max_calls: 在时间窗口内允许的最大调用次数
|
||||||
@@ -190,7 +203,10 @@ class WindowRateLimiter(BaseRateLimiter):
|
|||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
# 清理超出时间窗口的调用记录
|
# 清理超出时间窗口的调用记录
|
||||||
while self.call_times and current_time - self.call_times[0] > self.window_seconds:
|
while (
|
||||||
|
self.call_times
|
||||||
|
and current_time - self.call_times[0] > self.window_seconds
|
||||||
|
):
|
||||||
self.call_times.popleft()
|
self.call_times.popleft()
|
||||||
|
|
||||||
if len(self.call_times) < self.max_calls:
|
if len(self.call_times) < self.max_calls:
|
||||||
@@ -225,8 +241,12 @@ class CompositeRateLimiter(BaseRateLimiter):
|
|||||||
当任意一个限流策略触发限流时,都会阻止调用
|
当任意一个限流策略触发限流时,都会阻止调用
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, limiters: List[BaseRateLimiter], source: str = "", enable_logging: bool = True):
|
def __init__(
|
||||||
|
self,
|
||||||
|
limiters: List[BaseRateLimiter],
|
||||||
|
source: str = "",
|
||||||
|
enable_logging: bool = True,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
初始化 CompositeRateLimiter 实例
|
初始化 CompositeRateLimiter 实例
|
||||||
:param limiters: 要组合的限流器列表
|
:param limiters: 要组合的限流器列表
|
||||||
@@ -263,7 +283,9 @@ class CompositeRateLimiter(BaseRateLimiter):
|
|||||||
|
|
||||||
|
|
||||||
# 通用装饰器:自定义限流器实例
|
# 通用装饰器:自定义限流器实例
|
||||||
def rate_limit_handler(limiter: BaseRateLimiter, raise_on_limit: bool = False) -> Callable:
|
def rate_limit_handler(
|
||||||
|
limiter: BaseRateLimiter, raise_on_limit: bool = False
|
||||||
|
) -> Callable:
|
||||||
"""
|
"""
|
||||||
通用装饰器,允许用户传递自定义的限流器实例,用于处理限流逻辑
|
通用装饰器,允许用户传递自定义的限流器实例,用于处理限流逻辑
|
||||||
该装饰器可灵活支持任意继承自 BaseRateLimiter 的限流器
|
该装饰器可灵活支持任意继承自 BaseRateLimiter 的限流器
|
||||||
@@ -344,8 +366,14 @@ def rate_limit_handler(limiter: BaseRateLimiter, raise_on_limit: bool = False) -
|
|||||||
|
|
||||||
|
|
||||||
# 装饰器:指数退避限流
|
# 装饰器:指数退避限流
|
||||||
def rate_limit_exponential(base_wait: float = 60.0, max_wait: float = 600.0, backoff_factor: float = 2.0,
|
def rate_limit_exponential(
|
||||||
raise_on_limit: bool = False, source: str = "", enable_logging: bool = True) -> Callable:
|
base_wait: float = 60.0,
|
||||||
|
max_wait: float = 600.0,
|
||||||
|
backoff_factor: float = 2.0,
|
||||||
|
raise_on_limit: bool = False,
|
||||||
|
source: str = "",
|
||||||
|
enable_logging: bool = True,
|
||||||
|
) -> Callable:
|
||||||
"""
|
"""
|
||||||
装饰器,用于应用指数退避限流策略
|
装饰器,用于应用指数退避限流策略
|
||||||
通过逐渐增加调用等待时间控制调用频率。每次触发限流时,等待时间会成倍增加,直到达到最大等待时间
|
通过逐渐增加调用等待时间控制调用频率。每次触发限流时,等待时间会成倍增加,直到达到最大等待时间
|
||||||
@@ -359,14 +387,21 @@ def rate_limit_exponential(base_wait: float = 60.0, max_wait: float = 600.0, bac
|
|||||||
:return: 装饰器函数
|
:return: 装饰器函数
|
||||||
"""
|
"""
|
||||||
# 实例化 ExponentialBackoffRateLimiter,并传入相关参数
|
# 实例化 ExponentialBackoffRateLimiter,并传入相关参数
|
||||||
limiter = ExponentialBackoffRateLimiter(base_wait, max_wait, backoff_factor, source, enable_logging)
|
limiter = ExponentialBackoffRateLimiter(
|
||||||
|
base_wait, max_wait, backoff_factor, source, enable_logging
|
||||||
|
)
|
||||||
# 使用通用装饰器逻辑包装该限流器
|
# 使用通用装饰器逻辑包装该限流器
|
||||||
return rate_limit_handler(limiter, raise_on_limit)
|
return rate_limit_handler(limiter, raise_on_limit)
|
||||||
|
|
||||||
|
|
||||||
# 装饰器:时间窗口限流
|
# 装饰器:时间窗口限流
|
||||||
def rate_limit_window(max_calls: int, window_seconds: float,
|
def rate_limit_window(
|
||||||
raise_on_limit: bool = False, source: str = "", enable_logging: bool = True) -> Callable:
|
max_calls: int,
|
||||||
|
window_seconds: float,
|
||||||
|
raise_on_limit: bool = False,
|
||||||
|
source: str = "",
|
||||||
|
enable_logging: bool = True,
|
||||||
|
) -> Callable:
|
||||||
"""
|
"""
|
||||||
装饰器,用于应用时间窗口限流策略
|
装饰器,用于应用时间窗口限流策略
|
||||||
在固定的时间窗口内限制调用次数,当调用次数超过最大值时,触发限流,直到时间窗口结束
|
在固定的时间窗口内限制调用次数,当调用次数超过最大值时,触发限流,直到时间窗口结束
|
||||||
@@ -407,3 +442,63 @@ class QpsRateLimiter:
|
|||||||
self.next_call_time = max(now, self.next_call_time) + self.interval
|
self.next_call_time = max(now, self.next_call_time) + self.interval
|
||||||
if sleep_duration > 0:
|
if sleep_duration > 0:
|
||||||
time.sleep(sleep_duration)
|
time.sleep(sleep_duration)
|
||||||
|
|
||||||
|
|
||||||
|
class RateStats:
|
||||||
|
"""
|
||||||
|
请求速率统计:记录时间戳,计算 QPS / QPM / QPH
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, window_seconds: float = 7200, source: str = ""):
|
||||||
|
"""
|
||||||
|
:param window_seconds: 统计窗口(秒),默认 2 小时,用于计算 QPH
|
||||||
|
:param source: 日志来源标识
|
||||||
|
"""
|
||||||
|
self._window = window_seconds
|
||||||
|
self._source = source
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._timestamps: deque = deque()
|
||||||
|
|
||||||
|
def record(self) -> None:
|
||||||
|
"""
|
||||||
|
记录一次请求
|
||||||
|
"""
|
||||||
|
t = time.time()
|
||||||
|
with self._lock:
|
||||||
|
self._timestamps.append(t)
|
||||||
|
while self._timestamps and t - self._timestamps[0] > self._window:
|
||||||
|
self._timestamps.popleft()
|
||||||
|
|
||||||
|
def _count_since(self, seconds: float) -> int:
|
||||||
|
t = time.time()
|
||||||
|
with self._lock:
|
||||||
|
return sum(1 for ts in self._timestamps if t - ts <= seconds)
|
||||||
|
|
||||||
|
def get_qps(self) -> float:
|
||||||
|
"""
|
||||||
|
最近 1 秒内请求数
|
||||||
|
"""
|
||||||
|
return self._count_since(1.0)
|
||||||
|
|
||||||
|
def get_qpm(self) -> float:
|
||||||
|
"""
|
||||||
|
最近 1 分钟内请求数
|
||||||
|
"""
|
||||||
|
return self._count_since(60.0)
|
||||||
|
|
||||||
|
def get_qph(self) -> float:
|
||||||
|
"""
|
||||||
|
最近 1 小时内请求数
|
||||||
|
"""
|
||||||
|
return self._count_since(3600.0)
|
||||||
|
|
||||||
|
def log_stats(self, level: str = "info") -> None:
|
||||||
|
"""
|
||||||
|
输出当前 QPS/QPM/QPH
|
||||||
|
"""
|
||||||
|
qps, qpm, qph = self.get_qps(), self.get_qpm(), self.get_qph()
|
||||||
|
msg = f"QPS={qps} QPM={qpm} QPH={qph}"
|
||||||
|
if self._source:
|
||||||
|
msg = f"[{self._source}] {msg}"
|
||||||
|
log_fn = getattr(logger, level, logger.info)
|
||||||
|
log_fn(msg)
|
||||||
|
|||||||
@@ -166,10 +166,8 @@ class SystemUtils:
|
|||||||
移动
|
移动
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 当前目录改名
|
# 直接移动到目标路径,避免中间改名步骤触发目录监控
|
||||||
temp = src.replace(src.parent / dest.name)
|
shutil.move(src, dest)
|
||||||
# 移动到目标目录
|
|
||||||
shutil.move(temp, dest)
|
|
||||||
return 0, ""
|
return 0, ""
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
return -1, str(err)
|
return -1, str(err)
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = 'v2.9.7'
|
APP_VERSION = 'v2.9.10'
|
||||||
FRONTEND_VERSION = 'v2.9.7'
|
FRONTEND_VERSION = 'v2.9.10'
|
||||||
|
|||||||
Reference in New Issue
Block a user