mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 01:03:08 +08:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f32405b646 | ||
|
|
13955dafe3 | ||
|
|
eaca396a9f | ||
|
|
fabd9f2f75 | ||
|
|
0d8480769f | ||
|
|
dc850f1c48 | ||
|
|
fb311f3d8a | ||
|
|
293d89510a | ||
|
|
9446e88012 | ||
|
|
6f593beeed | ||
|
|
0dc20cd9b4 | ||
|
|
a0543e914e | ||
|
|
1435cd6526 | ||
|
|
7e24181c37 | ||
|
|
922c391ffc | ||
|
|
39169e8faa | ||
|
|
433712aa80 | ||
|
|
23650657cd | ||
|
|
b5d58b8a9e | ||
|
|
0514ff0189 | ||
|
|
9a15e3f9b3 | ||
|
|
104113852a | ||
|
|
430702abd3 | ||
|
|
d7300777cb | ||
|
|
4fd61a9c8d | ||
|
|
af2b4aa867 | ||
|
|
7e252f1692 | ||
|
|
a7e7174cb2 | ||
|
|
6e2d0c2aad | ||
|
|
aeb65d7cac | ||
|
|
e7c580d375 | ||
|
|
90fedade76 | ||
|
|
49d9715106 | ||
|
|
c194e8c59a | ||
|
|
b6f9315e2b | ||
|
|
f91f99de52 | ||
|
|
3ad3a769ab | ||
|
|
261bb5fa81 | ||
|
|
704dcf46d3 | ||
|
|
9fab50edb0 | ||
|
|
5d2a911849 | ||
|
|
89e96ee27a | ||
|
|
41636395ff | ||
|
|
6f1f89ac26 | ||
|
|
3078c076dc | ||
|
|
a7794fa2ad |
@@ -17,7 +17,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
|
||||
def list(
|
||||
def current(
|
||||
name: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
|
||||
@@ -117,7 +117,7 @@ def scrape(fileitem: schemas.FileItem,
|
||||
if not scrape_path.exists():
|
||||
return schemas.Response(success=False, message="刮削路径不存在")
|
||||
# 手动刮削
|
||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)
|
||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class GzipRequest(Request):
|
||||
body = await super().body()
|
||||
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||
body = gzip.decompress(body)
|
||||
self._body = body
|
||||
self._body = body # noqa
|
||||
return self._body
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import gc
|
||||
import pickle
|
||||
import traceback
|
||||
@@ -61,7 +62,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
try:
|
||||
with open(settings.TEMP_PATH / filename, 'wb') as f:
|
||||
pickle.dump(cache, f)
|
||||
pickle.dump(cache, f) # noqa
|
||||
except Exception as err:
|
||||
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
||||
finally:
|
||||
@@ -488,32 +489,58 @@ class ChainBase(metaclass=ABCMeta):
|
||||
f"title={message.title}, "
|
||||
f"text={message.text},"
|
||||
f"userid={message.userid}")
|
||||
if not message.userid and message.mtype:
|
||||
# 没有指定用户ID时,按规则确定发送对象
|
||||
# 默认发送全体
|
||||
to_targets = None
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
||||
if notify_action == "admin":
|
||||
# 仅发送管理员
|
||||
logger.info(f"已设置 {message.mtype} 的消息只发送给管理员")
|
||||
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
elif notify_action == "user":
|
||||
# 发送对应用户
|
||||
if message.username:
|
||||
logger.info(f"已设置 {message.mtype} 的消息只发送给用户 {message.username}")
|
||||
to_targets = self.useroper.get_settings(message.username)
|
||||
if not message.username or to_targets is None:
|
||||
if message.username:
|
||||
logger.info(f"没有 {message.username} 这个用户,该消息将发送给管理员")
|
||||
# 回滚发送管理员
|
||||
to_targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
message.targets = to_targets
|
||||
# 发送事件
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 保存消息
|
||||
# 保存原消息
|
||||
self.messagehelper.put(message, role="user", title=message.title)
|
||||
self.messageoper.add(**message.dict())
|
||||
# 发送
|
||||
# 发送消息按设置隔离
|
||||
if not message.userid and message.mtype:
|
||||
# 消息隔离设置
|
||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
||||
if notify_action:
|
||||
# 'admin' 'user,admin' 'user' 'all'
|
||||
actions = notify_action.split(",")
|
||||
# 是否已发送管理员标志
|
||||
admin_sended = False
|
||||
send_orignal = False
|
||||
for action in actions:
|
||||
send_message = copy.deepcopy(message)
|
||||
if action == "admin" and not admin_sended:
|
||||
# 仅发送管理员
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
elif action == "user" and send_message.username:
|
||||
# 发送对应用户
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}")
|
||||
# 读取用户消息IDS
|
||||
send_message.targets = self.useroper.get_settings(send_message.username)
|
||||
if send_message.targets is None:
|
||||
# 没有找到用户
|
||||
if not admin_sended:
|
||||
# 回滚发送管理员
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息将发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
else:
|
||||
# 管理员发过了,此消息不发了
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息无法发送到对应用户")
|
||||
continue
|
||||
else:
|
||||
# 按原消息发送全体
|
||||
if not admin_sended:
|
||||
send_orignal = True
|
||||
break
|
||||
# 按设定发送
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage,
|
||||
data={**send_message.dict(), "type": send_message.mtype})
|
||||
self.run_module("post_message", message=send_message)
|
||||
if not send_orignal:
|
||||
return
|
||||
# 发送消息事件
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 按原消息发送
|
||||
self.run_module("post_message", message=message)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
|
||||
@@ -307,6 +307,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
fileitem: FileItem = event_data.get("fileitem")
|
||||
meta: MetaBase = event_data.get("meta")
|
||||
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||||
overwrite = event_data.get("overwrite", False)
|
||||
if not fileitem:
|
||||
return
|
||||
# 刮削锁
|
||||
@@ -316,7 +317,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
scraping_files.append(fileitem.path)
|
||||
try:
|
||||
# 执行刮削
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=overwrite)
|
||||
finally:
|
||||
# 释放锁
|
||||
with scraping_lock:
|
||||
@@ -365,8 +366,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
if not _fileitem or not _content or not _path:
|
||||
return
|
||||
# 保存文件到临时目录
|
||||
tmp_file = settings.TEMP_PATH / _path.name
|
||||
# 保存文件到临时目录,文件名随机
|
||||
tmp_file = settings.TEMP_PATH / f"{_path.name}.{StringUtils.generate_random_str(10)}"
|
||||
tmp_file.write_bytes(_content)
|
||||
# 获取文件的父目录
|
||||
try:
|
||||
@@ -412,31 +413,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if fileitem.type == "file":
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 电影文件
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 电影文件
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if not movie_nfo:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
return
|
||||
# 保存或上传nfo文件到上级目录
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
# 电影目录
|
||||
if is_bluray_folder(fileitem):
|
||||
# 原盘目录
|
||||
nfo_path = filepath / (filepath.name + ".nfo")
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 生成原盘nfo
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到当前目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 生成原盘nfo
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if not movie_nfo:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
return
|
||||
# 保存或上传nfo文件到当前目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
# 处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
@@ -455,23 +456,18 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
image_path = filepath / image_name
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(_url=attr_value)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(_url=attr_value)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
# 电视剧
|
||||
if fileitem.type == "file":
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 重新识别季集
|
||||
file_meta = MetaInfoPath(filepath)
|
||||
if not file_meta.begin_episode:
|
||||
@@ -481,33 +477,37 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if not file_mediainfo:
|
||||
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
|
||||
return
|
||||
# 获取集的nfo文件
|
||||
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if not episode_nfo:
|
||||
logger.warn(f"{filepath.name} nfo生成失败!")
|
||||
return
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 获取集的nfo文件
|
||||
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if episode_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
# 获取集的图片
|
||||
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if image_dict:
|
||||
for episode, image_url in image_dict.items():
|
||||
image_path = filepath.with_suffix(Path(image_url).suffix)
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
|
||||
else:
|
||||
# 当前为目录,处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
@@ -526,32 +526,33 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if season_meta.begin_season is not None:
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "season.nfo"
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有季号,生成季nfo
|
||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo,
|
||||
season=season_meta.begin_season)
|
||||
if season_nfo:
|
||||
# 写入nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 当前目录有季号,生成季nfo
|
||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo, season=season_meta.begin_season)
|
||||
if not season_nfo:
|
||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||
return
|
||||
# 写入nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||
# TMDB季poster图片
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
image_path = filepath.with_name(image_name)
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到剧集目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到剧集目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
# 额外fanart季图片:poster thumb banner
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
@@ -563,32 +564,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if image_season != str(season_meta.begin_season).rjust(2, '0'):
|
||||
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||
continue
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
|
||||
# 判断当前目录是不是剧集根目录
|
||||
if not season_meta.season:
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "tvshow.nfo"
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if tv_nfo:
|
||||
# 写入tvshow nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
return
|
||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if not tv_nfo:
|
||||
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||
return
|
||||
# 写入tvshow nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||
# 生成目录图片
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
@@ -597,14 +597,13 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if image_name.startswith("season"):
|
||||
continue
|
||||
image_path = filepath / image_name
|
||||
if not overwrite and self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
continue
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
|
||||
logger.info(f"{filepath.name} 刮削完成")
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.chain import ChainBase
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.config import settings, global_vars
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.common import log_execution_time
|
||||
@@ -105,6 +105,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
|
||||
for page in range(1, self.cache_max_pages + 1):
|
||||
for method in recommend_methods:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
if method in methods_finished:
|
||||
continue
|
||||
logger.debug(f"Fetch {method.__name__} data for page {page}.")
|
||||
@@ -131,6 +133,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
return
|
||||
|
||||
for data in datas:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
poster_path = data.get("poster_path")
|
||||
if poster_path:
|
||||
poster_url = poster_path.replace("original", "w500")
|
||||
|
||||
@@ -96,7 +96,7 @@ class SiteChain(ChainBase):
|
||||
))
|
||||
return userdata
|
||||
|
||||
def refresh_userdatas(self) -> Dict[str, SiteUserData]:
|
||||
def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]:
|
||||
"""
|
||||
刷新所有站点的用户数据
|
||||
"""
|
||||
@@ -105,7 +105,7 @@ class SiteChain(ChainBase):
|
||||
result = {}
|
||||
for site in sites:
|
||||
if global_vars.is_system_stopped:
|
||||
return
|
||||
return None
|
||||
if site.get("is_active"):
|
||||
userdata = self.refresh_userdata(site)
|
||||
if userdata:
|
||||
|
||||
@@ -114,7 +114,7 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("storage_usage", storage=storage)
|
||||
|
||||
def support_transtype(self, storage: str) -> Optional[str]:
|
||||
def support_transtype(self, storage: str) -> Optional[dict]:
|
||||
"""
|
||||
获取支持的整理方式
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,8 @@ import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union, Tuple
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
@@ -508,7 +510,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
return
|
||||
|
||||
# 记录重新识别过的种子
|
||||
_recognize_cached = []
|
||||
_recognize_cached = TTLCache(maxsize=1024, ttl=6 * 3600)
|
||||
|
||||
with self._rlock:
|
||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||
@@ -572,14 +574,15 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 有自定义识别词时,需要判断是否需要重新识别
|
||||
if subscribe.custom_words:
|
||||
custom_words_list = subscribe.custom_words.split("\n")
|
||||
_, apply_words = WordsMatcher().prepare(torrent_info.title,
|
||||
custom_words=subscribe.custom_words.split("\n"))
|
||||
custom_words=custom_words_list)
|
||||
if apply_words:
|
||||
logger.info(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
|
||||
# 重新识别元数据
|
||||
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
||||
custom_words=subscribe.custom_words)
|
||||
custom_words=custom_words_list)
|
||||
# 媒体信息需要重新识别
|
||||
torrent_mediainfo = None
|
||||
|
||||
@@ -588,8 +591,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
# 避免重复处理
|
||||
_cache_key = f"{torrent_meta.org_string}_{torrent_info.description}"
|
||||
if _cache_key not in _recognize_cached:
|
||||
_recognize_cached.append(_cache_key)
|
||||
if not _recognize_cached.get(_cache_key):
|
||||
_recognize_cached[_cache_key] = True
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta)
|
||||
if torrent_mediainfo:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
@@ -161,4 +162,15 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
获取前端版本
|
||||
"""
|
||||
if SystemUtils.is_frozen() and SystemUtils.is_windows():
|
||||
version_file = settings.CONFIG_PATH.parent / "nginx" / "html" / "version.txt"
|
||||
else:
|
||||
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
|
||||
if version_file.exists():
|
||||
try:
|
||||
with open(version_file, 'r') as f:
|
||||
version = str(f.read()).strip()
|
||||
return version
|
||||
except Exception as err:
|
||||
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
return FRONTEND_VERSION
|
||||
|
||||
@@ -414,6 +414,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
title=f"{task.mediainfo.title_year} {task.meta.season_episode} 入库失败!",
|
||||
text=f"原因:{transferinfo.message or '未知'}",
|
||||
image=task.mediainfo.get_message_image(),
|
||||
username=task.username,
|
||||
link=settings.MP_DOMAIN('#/history')
|
||||
))
|
||||
# 整理失败
|
||||
@@ -462,8 +463,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
self.storagechain.delete_media_file(t.fileitem, delete_self=False)
|
||||
# 整理完成且有成功的任务时
|
||||
if self.jobview.is_finished(task):
|
||||
# 发送通知
|
||||
if transferinfo.need_notify:
|
||||
# 发送通知,实时手动整理时不发
|
||||
if transferinfo.need_notify and (task.background or not task.manual):
|
||||
se_str = None
|
||||
if task.mediainfo.type == MediaType.TV:
|
||||
season_episodes = self.jobview.season_episodes(task.mediainfo, task.meta.begin_season)
|
||||
@@ -479,7 +480,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
self.send_transfer_message(meta=task.meta,
|
||||
mediainfo=task.mediainfo,
|
||||
transferinfo=transferinfo,
|
||||
season_episode=se_str)
|
||||
season_episode=se_str,
|
||||
username=task.username)
|
||||
# 刮削事件
|
||||
if transferinfo.need_scrape:
|
||||
self.eventmanager.send_event(EventType.MetadataScrape, {
|
||||
@@ -493,23 +495,28 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
return True, ""
|
||||
|
||||
def put_to_queue(self, task: TransferTask, callback: Optional[Callable] = None):
|
||||
def put_to_queue(self, task: TransferTask):
|
||||
"""
|
||||
添加到待整理队列
|
||||
:param task: 任务信息
|
||||
:param callback: 回调函数
|
||||
"""
|
||||
if not task:
|
||||
return
|
||||
# 维护整理任务视图
|
||||
with task_lock:
|
||||
self.jobview.add_task(task)
|
||||
self.__put_to_jobview(task)
|
||||
# 添加到队列
|
||||
self._queue.put(TransferQueue(
|
||||
task=task,
|
||||
callback=callback or self.__default_callback
|
||||
callback=self.__default_callback
|
||||
))
|
||||
|
||||
def __put_to_jobview(self, task: TransferTask):
|
||||
"""
|
||||
添加到作业视图
|
||||
"""
|
||||
with task_lock:
|
||||
self.jobview.add_task(task)
|
||||
|
||||
def remove_from_queue(self, fileitem: FileItem):
|
||||
"""
|
||||
从待整理队列移除
|
||||
@@ -576,10 +583,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
# 移除已完成的任务
|
||||
with task_lock:
|
||||
if self.jobview.is_done(task):
|
||||
self.jobview.remove_job(task)
|
||||
except queue.Empty:
|
||||
if not __queue_start:
|
||||
# 结束进度
|
||||
@@ -606,102 +609,116 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
处理整理任务
|
||||
"""
|
||||
# 识别
|
||||
if not task.mediainfo:
|
||||
download_history = task.download_history
|
||||
# 识别媒体信息
|
||||
if download_history and (download_history.tmdbid or download_history.doubanid):
|
||||
# 下载记录中已存在识别信息
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=MediaType(download_history.type),
|
||||
tmdbid=download_history.tmdbid,
|
||||
doubanid=download_history.doubanid)
|
||||
try:
|
||||
# 识别
|
||||
if not task.mediainfo:
|
||||
mediainfo = None
|
||||
download_history = task.download_history
|
||||
# 下载用户
|
||||
if download_history:
|
||||
task.username = download_history.username
|
||||
# 识别媒体信息
|
||||
if download_history.tmdbid or download_history.doubanid:
|
||||
# 下载记录中已存在识别信息
|
||||
mediainfo: Optional[MediaInfo] = self.recognize_media(mtype=MediaType(download_history.type),
|
||||
tmdbid=download_history.tmdbid,
|
||||
doubanid=download_history.doubanid)
|
||||
if mediainfo:
|
||||
# 更新自定义媒体类别
|
||||
if download_history.media_category:
|
||||
mediainfo.category = download_history.media_category
|
||||
else:
|
||||
# 识别媒体信息
|
||||
mediainfo = self.mediachain.recognize_by_meta(task.meta)
|
||||
|
||||
# 更新媒体图片
|
||||
if mediainfo:
|
||||
# 更新自定义媒体类别
|
||||
if download_history.media_category:
|
||||
mediainfo.category = download_history.media_category
|
||||
else:
|
||||
# 识别媒体信息
|
||||
mediainfo = self.mediachain.recognize_by_meta(task.meta)
|
||||
# 更新媒体图片
|
||||
if mediainfo:
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
if not mediainfo:
|
||||
# 新增整理失败历史记录
|
||||
his = self.transferhis.add_fail(
|
||||
fileitem=task.fileitem,
|
||||
mode=task.transfer_type,
|
||||
meta=task.meta,
|
||||
downloader=task.downloader,
|
||||
download_hash=task.download_hash
|
||||
)
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{task.fileitem.name} 未识别到媒体信息,无法入库!",
|
||||
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别整理。",
|
||||
link=settings.MP_DOMAIN('#/history')
|
||||
))
|
||||
# 任务失败,直接移除task
|
||||
self.jobview.remove_task(task.fileitem)
|
||||
return False, "未识别到媒体信息"
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
if not settings.SCRAP_FOLLOW_TMDB:
|
||||
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||
mtype=mediainfo.type.value)
|
||||
if transfer_history:
|
||||
mediainfo.title = transfer_history.title
|
||||
if not mediainfo:
|
||||
# 新增整理失败历史记录
|
||||
his = self.transferhis.add_fail(
|
||||
fileitem=task.fileitem,
|
||||
mode=task.transfer_type,
|
||||
meta=task.meta,
|
||||
downloader=task.downloader,
|
||||
download_hash=task.download_hash
|
||||
)
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{task.fileitem.name} 未识别到媒体信息,无法入库!",
|
||||
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别整理。",
|
||||
username=task.username,
|
||||
link=settings.MP_DOMAIN('#/history')
|
||||
))
|
||||
# 任务失败,直接移除task
|
||||
self.jobview.remove_task(task.fileitem)
|
||||
return False, "未识别到媒体信息"
|
||||
|
||||
# 获取集数据
|
||||
if not task.episodes_info and mediainfo.type == MediaType.TV:
|
||||
if task.meta.begin_season is None:
|
||||
task.meta.begin_season = 1
|
||||
mediainfo.season = mediainfo.season or task.meta.begin_season
|
||||
task.episodes_info = self.tmdbchain.tmdb_episodes(
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season
|
||||
)
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
if not settings.SCRAP_FOLLOW_TMDB:
|
||||
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||
mtype=mediainfo.type.value)
|
||||
if transfer_history:
|
||||
mediainfo.title = transfer_history.title
|
||||
|
||||
# 更新任务信息
|
||||
task.mediainfo = mediainfo
|
||||
# 更新队列任务
|
||||
curr_task = self.jobview.remove_task(task.fileitem)
|
||||
self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting")
|
||||
# 获取集数据
|
||||
if not task.episodes_info and mediainfo.type == MediaType.TV:
|
||||
if task.meta.begin_season is None:
|
||||
task.meta.begin_season = 1
|
||||
mediainfo.season = mediainfo.season or task.meta.begin_season
|
||||
task.episodes_info = self.tmdbchain.tmdb_episodes(
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season
|
||||
)
|
||||
|
||||
# 查询整理目标目录
|
||||
if not task.target_directory:
|
||||
if task.target_path:
|
||||
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
|
||||
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
||||
dest_path=task.target_path,
|
||||
target_storage=task.target_storage)
|
||||
else:
|
||||
# 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录
|
||||
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
||||
storage=task.fileitem.storage,
|
||||
src_path=Path(task.fileitem.path),
|
||||
target_storage=task.target_storage)
|
||||
# 更新任务信息
|
||||
task.mediainfo = mediainfo
|
||||
# 更新队列任务
|
||||
curr_task = self.jobview.remove_task(task.fileitem)
|
||||
self.jobview.add_task(task, state=curr_task.state if curr_task else "waiting")
|
||||
|
||||
# 执行整理
|
||||
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
|
||||
meta=task.meta,
|
||||
mediainfo=task.mediainfo,
|
||||
target_directory=task.target_directory,
|
||||
target_storage=task.target_storage,
|
||||
target_path=task.target_path,
|
||||
transfer_type=task.transfer_type,
|
||||
episodes_info=task.episodes_info,
|
||||
scrape=task.scrape,
|
||||
library_type_folder=task.library_type_folder,
|
||||
library_category_folder=task.library_category_folder)
|
||||
if not transferinfo:
|
||||
logger.error("文件整理模块运行失败")
|
||||
return False, "文件整理模块运行失败"
|
||||
# 查询整理目标目录
|
||||
if not task.target_directory:
|
||||
if task.target_path:
|
||||
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
|
||||
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
||||
dest_path=task.target_path,
|
||||
target_storage=task.target_storage)
|
||||
else:
|
||||
# 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录
|
||||
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
||||
storage=task.fileitem.storage,
|
||||
src_path=Path(task.fileitem.path),
|
||||
target_storage=task.target_storage)
|
||||
|
||||
# 回调,位置传参:任务、整理结果
|
||||
if callback:
|
||||
return callback(task, transferinfo)
|
||||
# 执行整理
|
||||
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
|
||||
meta=task.meta,
|
||||
mediainfo=task.mediainfo,
|
||||
target_directory=task.target_directory,
|
||||
target_storage=task.target_storage,
|
||||
target_path=task.target_path,
|
||||
transfer_type=task.transfer_type,
|
||||
episodes_info=task.episodes_info,
|
||||
scrape=task.scrape,
|
||||
library_type_folder=task.library_type_folder,
|
||||
library_category_folder=task.library_category_folder)
|
||||
if not transferinfo:
|
||||
logger.error("文件整理模块运行失败")
|
||||
return False, "文件整理模块运行失败"
|
||||
|
||||
return transferinfo.success, transferinfo.message
|
||||
# 回调,位置传参:任务、整理结果
|
||||
if callback:
|
||||
return callback(task, transferinfo)
|
||||
|
||||
return transferinfo.success, transferinfo.message
|
||||
|
||||
finally:
|
||||
# 移除已完成的任务
|
||||
with task_lock:
|
||||
if self.jobview.is_done(task):
|
||||
self.jobview.remove_job(task)
|
||||
|
||||
def get_queue_tasks(self) -> List[TransferJob]:
|
||||
"""
|
||||
@@ -783,11 +800,11 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 非MoviePilot下载的任务,按文件识别
|
||||
mediainfo = None
|
||||
|
||||
# 执行整理,匹配源目录
|
||||
# 执行实时整理,匹配源目录
|
||||
state, errmsg = self.do_transfer(
|
||||
fileitem=FileItem(
|
||||
storage="local",
|
||||
path=str(file_path),
|
||||
path=str(file_path).replace("\\", "/"),
|
||||
type="dir" if not file_path.is_file() else "file",
|
||||
name=file_path.name,
|
||||
size=file_path.stat().st_size,
|
||||
@@ -795,7 +812,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
),
|
||||
mediainfo=mediainfo,
|
||||
downloader=torrent.downloader,
|
||||
download_hash=torrent.hash
|
||||
download_hash=torrent.hash,
|
||||
background=False,
|
||||
)
|
||||
|
||||
# 设置下载任务状态
|
||||
@@ -885,7 +903,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||
season: int = None, epformat: EpisodeFormat = None, min_filesize: int = 0,
|
||||
downloader: str = None, download_hash: str = None,
|
||||
force: bool = False, background: bool = True) -> Tuple[bool, str]:
|
||||
force: bool = False, background: bool = True,
|
||||
manual: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
执行一个复杂目录的整理操作
|
||||
:param fileitem: 文件项
|
||||
@@ -905,6 +924,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
:param download_hash: 下载记录hash
|
||||
:param force: 是否强制整理
|
||||
:param background: 是否后台运行
|
||||
:param manual: 是否手动整理
|
||||
返回:成功标识,错误信息
|
||||
"""
|
||||
|
||||
@@ -969,9 +989,11 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
processed_num = 0
|
||||
# 失败数量
|
||||
fail_num = 0
|
||||
|
||||
logger.info(f"正在计划整理 {total_num} 个文件...")
|
||||
|
||||
# 启动进度
|
||||
if not background:
|
||||
# 启动进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
__process_msg = f"开始整理,共 {total_num} 个文件 ..."
|
||||
logger.info(__process_msg)
|
||||
@@ -980,6 +1002,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 整理所有文件
|
||||
transfer_tasks: List[TransferTask] = []
|
||||
for file_item, bluray_dir in file_items:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
@@ -990,6 +1013,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
or file_item.path.find('/.') != -1 \
|
||||
or file_item.path.find('/@eaDir') != -1:
|
||||
logger.debug(f"{file_item.path} 是回收站或隐藏的文件")
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
@@ -1017,14 +1041,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 更新进度
|
||||
if not background:
|
||||
__process_msg = f"正在整理 ({processed_num + 1}/{total_num}){file_item.name} ..."
|
||||
logger.info(__process_msg)
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
if not meta:
|
||||
# 文件元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
@@ -1082,28 +1098,43 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
library_category_folder=library_category_folder,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
download_history=download_history
|
||||
download_history=download_history,
|
||||
manual=manual,
|
||||
background=background
|
||||
)
|
||||
if background:
|
||||
self.put_to_queue(
|
||||
task=transfer_task
|
||||
)
|
||||
self.put_to_queue(task=transfer_task)
|
||||
logger.info(f"{file_path.name} 已添加到整理队列")
|
||||
processed_num += 1
|
||||
else:
|
||||
# 加入列表
|
||||
self.__put_to_jobview(transfer_task)
|
||||
transfer_tasks.append(transfer_task)
|
||||
|
||||
# 实时整理
|
||||
if not background:
|
||||
for transfer_task in transfer_tasks:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 更新进度
|
||||
__process_msg = f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..."
|
||||
logger.info(__process_msg)
|
||||
self.progress.update(value=(processed_num + fail_num) / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
state, err_msg = self.__handle_transfer(
|
||||
task=transfer_task,
|
||||
callback=self.__default_callback
|
||||
)
|
||||
if not state:
|
||||
all_success = False
|
||||
logger.warn(f"{file_path.name} {err_msg}")
|
||||
err_msgs.append(f"{file_path.name} {err_msg}")
|
||||
logger.warn(f"{transfer_task.fileitem.name} {err_msg}")
|
||||
err_msgs.append(f"{transfer_task.fileitem.name} {err_msg}")
|
||||
fail_num += 1
|
||||
# 完成计数
|
||||
processed_num += 1
|
||||
else:
|
||||
processed_num += 1
|
||||
|
||||
# 整理结束
|
||||
if not background:
|
||||
# 整理结束
|
||||
__end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个"
|
||||
logger.info(__end_msg)
|
||||
self.progress.update(value=100,
|
||||
@@ -1198,7 +1229,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
mediainfo=mediainfo,
|
||||
download_hash=history.download_hash,
|
||||
force=True,
|
||||
background=False)
|
||||
background=False,
|
||||
manual=True)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
|
||||
@@ -1267,7 +1299,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
library_type_folder=library_type_folder,
|
||||
library_category_folder=library_category_folder,
|
||||
force=force,
|
||||
background=background
|
||||
background=background,
|
||||
manual=True
|
||||
)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
@@ -1288,11 +1321,12 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
library_type_folder=library_type_folder,
|
||||
library_category_folder=library_category_folder,
|
||||
force=force,
|
||||
background=background)
|
||||
background=background,
|
||||
manual=True)
|
||||
return state, errmsg
|
||||
|
||||
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transferinfo: TransferInfo, season_episode: str = None):
|
||||
transferinfo: TransferInfo, season_episode: str = None, username: str = None):
|
||||
"""
|
||||
发送入库成功的消息
|
||||
"""
|
||||
@@ -1313,4 +1347,5 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Organize,
|
||||
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
|
||||
username=username,
|
||||
link=settings.MP_DOMAIN('#/history')))
|
||||
|
||||
@@ -351,7 +351,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return default, True
|
||||
|
||||
@validator('*', pre=True, always=True)
|
||||
def generic_type_validator(cls, value: Any, field):
|
||||
def generic_type_validator(cls, value: Any, field): # noqa
|
||||
"""
|
||||
通用校验器,尝试将配置值转换为期望的类型
|
||||
"""
|
||||
@@ -418,14 +418,21 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
更新多个配置项
|
||||
"""
|
||||
results = {}
|
||||
log_updated = False
|
||||
log_updated, plugin_monitor_updated = False, False
|
||||
for k, v in env.items():
|
||||
results[k] = self.update_setting(k, v)
|
||||
if hasattr(log_settings, k):
|
||||
log_updated = True
|
||||
if k in ["PLUGIN_AUTO_RELOAD", "DEV"]:
|
||||
plugin_monitor_updated = True
|
||||
# 本次更新存在日志配置项更新,需要重新加载日志配置
|
||||
if log_updated:
|
||||
logger.update_loggers()
|
||||
# 本次更新存在插件监控配置项更新,需要重新加载插件监控
|
||||
if plugin_monitor_updated:
|
||||
# 解决顶层循环导入问题
|
||||
from app.core.plugin import PluginManager
|
||||
PluginManager().reload_monitor()
|
||||
return results
|
||||
|
||||
@property
|
||||
|
||||
@@ -293,7 +293,7 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 对于类实例(实现了 __call__ 方法)
|
||||
if not inspect.isfunction(handler) and hasattr(handler, "__call__"):
|
||||
handler_cls = handler.__class__
|
||||
handler_cls = handler.__class__ # noqa
|
||||
return cls.__get_handler_identifier(handler_cls)
|
||||
|
||||
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
|
||||
|
||||
@@ -220,11 +220,23 @@ class PluginManager(metaclass=Singleton):
|
||||
self._running_plugins = {}
|
||||
logger.info("插件停止完成")
|
||||
|
||||
def reload_monitor(self):
|
||||
"""
|
||||
重新加载插件文件修改监测
|
||||
"""
|
||||
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||
if self._observer and self._observer.is_alive():
|
||||
logger.info("插件文件修改监测已经在运行中...")
|
||||
else:
|
||||
self.__start_monitor()
|
||||
else:
|
||||
self.stop_monitor()
|
||||
|
||||
def __start_monitor(self):
|
||||
"""
|
||||
开发者模式下监测插件文件修改
|
||||
启用监测插件文件修改监测
|
||||
"""
|
||||
logger.info("开发者模式下开始监测插件文件修改...")
|
||||
logger.info("开始监测插件文件修改...")
|
||||
monitor_handler = PluginMonitorHandler()
|
||||
self._observer = Observer()
|
||||
self._observer.schedule(monitor_handler, str(settings.ROOT_PATH / "app" / "plugins"), recursive=True)
|
||||
@@ -232,14 +244,16 @@ class PluginManager(metaclass=Singleton):
|
||||
|
||||
def stop_monitor(self):
|
||||
"""
|
||||
停止监测插件修改
|
||||
停止监测插件文件修改监测
|
||||
"""
|
||||
# 停止监测
|
||||
if self._observer:
|
||||
if self._observer and self._observer.is_alive():
|
||||
logger.info("正在停止插件文件修改监测...")
|
||||
self._observer.stop()
|
||||
self._observer.join()
|
||||
logger.info("插件文件修改监测停止完成")
|
||||
else:
|
||||
logger.info("未启用插件文件修改监测,无需停止")
|
||||
|
||||
@staticmethod
|
||||
def __stop_plugin(plugin: Any):
|
||||
@@ -668,7 +682,7 @@ class PluginManager(metaclass=Singleton):
|
||||
# 相同 ID 的插件保留版本号最大的版本
|
||||
max_versions = {}
|
||||
for p in all_plugins:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
|
||||
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, ">", max_versions[p.id]):
|
||||
max_versions[p.id] = p.plugin_version
|
||||
result = [p for p in all_plugins if p.plugin_version == max_versions[p.id]]
|
||||
logger.info(f"共获取到 {len(result)} 个线上插件")
|
||||
@@ -809,7 +823,7 @@ class PluginManager(metaclass=Singleton):
|
||||
plugin.has_update = False
|
||||
if plugin_static:
|
||||
installed_version = getattr(plugin_static, "plugin_version")
|
||||
if StringUtils.compare_version(installed_version, plugin_info.get("version")) < 0:
|
||||
if StringUtils.compare_version(installed_version, "<", plugin_info.get("version")):
|
||||
# 需要更新
|
||||
plugin.has_update = True
|
||||
# 运行状态
|
||||
|
||||
@@ -225,7 +225,7 @@ class Base:
|
||||
return list(result)
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
|
||||
@@ -11,7 +11,7 @@ def init_db():
|
||||
初始化数据库
|
||||
"""
|
||||
# 全量建表
|
||||
Base.metadata.create_all(bind=Engine)
|
||||
Base.metadata.create_all(bind=Engine) # noqa
|
||||
|
||||
|
||||
def update_db():
|
||||
|
||||
@@ -57,7 +57,7 @@ class MessageOper(DbOper):
|
||||
|
||||
# 从kwargs中去掉Message中没有的字段
|
||||
for k in list(kwargs.keys()):
|
||||
if k not in Message.__table__.columns.keys():
|
||||
if k not in Message.__table__.columns.keys(): # noqa
|
||||
kwargs.pop(k)
|
||||
|
||||
Message(**kwargs).create(self._db)
|
||||
|
||||
@@ -70,7 +70,7 @@ class ResourceHelper(metaclass=Singleton):
|
||||
local_version = self.siteshelper.indexer_version
|
||||
else:
|
||||
continue
|
||||
if StringUtils.compare_version(version, local_version) > 0:
|
||||
if StringUtils.compare_version(version, ">", local_version):
|
||||
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
||||
else:
|
||||
continue
|
||||
|
||||
@@ -30,6 +30,8 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
|
||||
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||
|
||||
_shares_cache = TTLCache(maxsize=20, ttl=1800)
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
@@ -136,6 +138,8 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
if res is None:
|
||||
return False, "连接MoviePilot服务器失败"
|
||||
if res.ok:
|
||||
# 清除 get_shares 的缓存,以便实时看到结果
|
||||
self._shares_cache.clear()
|
||||
return True, ""
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
@@ -156,7 +160,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
@cached(cache=TTLCache(maxsize=20, ttl=1800))
|
||||
@cached(cache=_shares_cache)
|
||||
def get_shares(self, name: str, page: int = 1, count: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取订阅分享数据
|
||||
|
||||
101
app/log.py
101
app/log.py
@@ -1,5 +1,6 @@
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
@@ -95,6 +96,8 @@ class LoggerManager:
|
||||
_loggers: Dict[str, Any] = {}
|
||||
# 默认日志文件名称
|
||||
_default_log_file = "moviepilot.log"
|
||||
# 线程锁
|
||||
_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def __get_caller():
|
||||
@@ -106,35 +109,53 @@ class LoggerManager:
|
||||
caller_name = None
|
||||
# 调用者插件名称
|
||||
plugin_name = None
|
||||
for i in inspect.stack()[3:]:
|
||||
filepath = Path(i.filename)
|
||||
|
||||
try:
|
||||
frame = sys._getframe(3) # noqa
|
||||
except (AttributeError, ValueError):
|
||||
# 如果无法获取帧,返回默认值
|
||||
return "log.py", None
|
||||
|
||||
while frame:
|
||||
filepath = Path(frame.f_code.co_filename)
|
||||
parts = filepath.parts
|
||||
# 设定调用者文件名称
|
||||
if not caller_name:
|
||||
# 设定调用者文件名称
|
||||
if parts[-1] == "__init__.py":
|
||||
if parts[-1] == "__init__.py" and len(parts) >= 2:
|
||||
caller_name = parts[-2]
|
||||
else:
|
||||
caller_name = parts[-1]
|
||||
# 设定调用者插件名称
|
||||
if "app" in parts:
|
||||
if not plugin_name and "plugins" in parts:
|
||||
# 设定调用者插件名称
|
||||
plugin_name = parts[parts.index("plugins") + 1]
|
||||
if plugin_name == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
break
|
||||
try:
|
||||
plugins_index = parts.index("plugins")
|
||||
if plugins_index + 1 < len(parts):
|
||||
plugin_candidate = parts[plugins_index + 1]
|
||||
if plugin_candidate == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
else:
|
||||
plugin_name = plugin_candidate
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if "main.py" in parts:
|
||||
# 已经到达程序的入口
|
||||
# 已经到达程序的入口,停止遍历
|
||||
break
|
||||
elif len(parts) != 1:
|
||||
# 已经超出程序范围
|
||||
# 已经超出程序范围,停止遍历
|
||||
break
|
||||
# 获取上一个帧
|
||||
try:
|
||||
frame = frame.f_back
|
||||
except AttributeError:
|
||||
break
|
||||
return caller_name or "log.py", plugin_name
|
||||
|
||||
@staticmethod
|
||||
def __setup_logger(log_file: str):
|
||||
"""
|
||||
设置日志
|
||||
|
||||
初始化日志实例
|
||||
:param log_file:日志文件相对路径
|
||||
"""
|
||||
log_file_path = log_settings.LOG_PATH / log_file
|
||||
@@ -143,13 +164,8 @@ class LoggerManager:
|
||||
# 创建新实例
|
||||
_logger = logging.getLogger(log_file_path.stem)
|
||||
|
||||
if log_settings.DEBUG:
|
||||
_logger.setLevel(logging.DEBUG)
|
||||
|
||||
# 全局日志等级
|
||||
else:
|
||||
loglevel = getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
_logger.setLevel(loglevel)
|
||||
# 设置日志级别
|
||||
_logger.setLevel(LoggerManager.__get_log_level())
|
||||
|
||||
# 移除已有的 handler,避免重复添加
|
||||
for handler in _logger.handlers:
|
||||
@@ -179,16 +195,41 @@ class LoggerManager:
|
||||
"""
|
||||
更新日志实例
|
||||
"""
|
||||
_new_loggers: Dict[str, Any] = {}
|
||||
for log_file, _logger in self._loggers.items():
|
||||
# 移除已有的 handler,避免重复添加
|
||||
for handler in _logger.handlers:
|
||||
_logger.removeHandler(handler)
|
||||
# 重新设置日志实例
|
||||
_new_logger = self.__setup_logger(log_file=log_file)
|
||||
_new_loggers[log_file] = _new_logger
|
||||
with LoggerManager._lock:
|
||||
for _logger in self._loggers.values():
|
||||
self.__update_logger_handlers(_logger)
|
||||
|
||||
self._loggers = _new_loggers
|
||||
@staticmethod
|
||||
def __update_logger_handlers(_logger: logging.Logger):
|
||||
"""
|
||||
更新 Logger 的 handler 配置
|
||||
:param _logger: 需要更新的 Logger 实例
|
||||
"""
|
||||
# 更新现有 handler
|
||||
for handler in _logger.handlers:
|
||||
try:
|
||||
if isinstance(handler, RotatingFileHandler):
|
||||
# 更新最大文件大小和备份数量
|
||||
handler.maxBytes = log_settings.LOG_MAX_FILE_SIZE_BYTES
|
||||
handler.backupCount = log_settings.LOG_BACKUP_COUNT
|
||||
# 更新日志文件输出格式
|
||||
file_formatter = CustomFormatter(log_settings.LOG_FILE_FORMAT)
|
||||
handler.setFormatter(file_formatter)
|
||||
elif isinstance(handler, logging.StreamHandler):
|
||||
# 更新控制台输出格式
|
||||
console_formatter = CustomFormatter(log_settings.LOG_CONSOLE_FORMAT)
|
||||
handler.setFormatter(console_formatter)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update handler: {handler}. Error: {e}")
|
||||
# 更新日志级别
|
||||
_logger.setLevel(LoggerManager.__get_log_level())
|
||||
|
||||
@staticmethod
|
||||
def __get_log_level():
|
||||
"""
|
||||
获取当前日志级别
|
||||
"""
|
||||
return logging.DEBUG if log_settings.DEBUG else getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
|
||||
def logger(self, method: str, msg: str, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -179,7 +179,7 @@ class DoubanCache(metaclass=Singleton):
|
||||
return
|
||||
|
||||
with open(self._meta_path, 'wb') as f:
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
|
||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
|
||||
|
||||
def _random_sample(self, new_meta_data: dict) -> bool:
|
||||
"""
|
||||
|
||||
@@ -28,7 +28,7 @@ class DoubanScraper:
|
||||
# 电视剧元数据文件
|
||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||
if doc:
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ class Emby:
|
||||
year: str = None,
|
||||
tmdb_id: int = None,
|
||||
season: int = None
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[Dict[int, list]]]]]:
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
|
||||
"""
|
||||
根据标题和年份和季,返回Emby中的剧集列表
|
||||
:param item_id: Emby中的ID
|
||||
|
||||
@@ -109,7 +109,7 @@ class FileManagerModule(_ModuleBase):
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
def support_transtype(self, storage: str) -> Optional[Dict[str, str]]:
|
||||
def support_transtype(self, storage: str) -> Optional[dict]:
|
||||
"""
|
||||
支持的整理方式
|
||||
"""
|
||||
@@ -368,10 +368,7 @@ class FileManagerModule(_ModuleBase):
|
||||
# 覆盖模式
|
||||
overwrite_mode = target_directory.overwrite_mode
|
||||
# 是否需要刮削
|
||||
if scrape is None:
|
||||
need_scrape = target_directory.scraping
|
||||
else:
|
||||
need_scrape = scrape
|
||||
need_scrape = scrape or target_directory.scraping
|
||||
# 目标存储类型
|
||||
if not target_storage:
|
||||
target_storage = target_directory.library_storage
|
||||
|
||||
@@ -30,6 +30,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
|
||||
# 支持的整理方式
|
||||
transtype = {
|
||||
"copy": "复制",
|
||||
"move": "移动",
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
refresh_token = self.__auth_params.get("refreshToken")
|
||||
if refresh_token:
|
||||
try:
|
||||
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c,
|
||||
self.aligo = Aligo(refresh_token=refresh_token, show=show_qrcode, use_aria2=self._has_aria2c, # noqa
|
||||
name="MoviePilot V2", level=logging.ERROR, re_login=False)
|
||||
except Exception as err:
|
||||
logger.error(f"初始化阿里云盘失败:{str(err)}")
|
||||
@@ -327,7 +328,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
return None
|
||||
item = self.aligo.get_file_by_path(path=str(path))
|
||||
if item:
|
||||
return self.__get_fileitem(item, parent=path.parent)
|
||||
return self.__get_fileitem(item, parent=str(path.parent))
|
||||
return None
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
|
||||
@@ -553,7 +553,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
:param new_name: 上传后文件名
|
||||
:param task: 是否为任务,默认为False避免未完成上传时对文件进行操作
|
||||
"""
|
||||
encoded_path = UrlUtils.quote(fileitem.path + path.name)
|
||||
encoded_path = UrlUtils.quote((Path(fileitem.path) / path.name).as_posix())
|
||||
headers = self.__get_header_with_token()
|
||||
headers.setdefault("Content-Type", "application/octet-stream")
|
||||
headers.setdefault("As-Task", str(task).lower())
|
||||
@@ -569,7 +569,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
return
|
||||
|
||||
new_item = self.get_item(Path(fileitem.path) / path.name)
|
||||
if new_name and new_name != path.name:
|
||||
if new_item and new_name and new_name != path.name:
|
||||
if self.rename(new_item, new_name):
|
||||
return self.get_item(Path(new_item.path).with_name(new_name))
|
||||
|
||||
|
||||
@@ -134,10 +134,9 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
category=category,
|
||||
ignore_category_check=False
|
||||
)
|
||||
# 获取下载器全局设置
|
||||
application = server.qbc.application.preferences
|
||||
|
||||
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
||||
torrent_layout = application.get("torrent_content_layout", "Original")
|
||||
torrent_layout = server.get_content_layout()
|
||||
|
||||
if not state:
|
||||
# 读取种子的名称
|
||||
|
||||
@@ -448,3 +448,14 @@ class Qbittorrent:
|
||||
except Exception as err:
|
||||
logger.error(f"修改tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def get_content_layout(self) -> Optional[str]:
|
||||
"""
|
||||
获取内容布局
|
||||
"""
|
||||
if not self.qbc:
|
||||
return None
|
||||
# 获取下载器全局设置
|
||||
application = self.qbc.application.preferences
|
||||
# 获取种子内容布局: `Original: 原始, Subfolder: 创建子文件夹, NoSubfolder: 不创建子文件夹`
|
||||
return application.get("torrent_content_layout", "Original")
|
||||
|
||||
@@ -45,7 +45,7 @@ class TmdbScraper:
|
||||
# 电视剧元数据文件
|
||||
doc = self.__gen_tv_nfo_file(mediainfo=mediainfo)
|
||||
if doc:
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
return doc.toprettyxml(indent=" ", encoding="utf-8") # noqa
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
"""
|
||||
Simple-to-use Python interface to The TVDB's API (thetvdb.com)
|
||||
"""
|
||||
@@ -6,19 +5,20 @@
|
||||
__author__ = "dbr/Ben"
|
||||
__version__ = "3.1.0"
|
||||
|
||||
import sys
|
||||
import getpass
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import getpass
|
||||
import tempfile
|
||||
import warnings
|
||||
import logging
|
||||
import hashlib
|
||||
from typing import Optional, Union
|
||||
|
||||
import requests
|
||||
import requests_cache
|
||||
from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS
|
||||
from requests_cache.backends.base import _to_bytes, _DEFAULT_HEADERS # noqa
|
||||
|
||||
IS_PY2 = sys.version_info[0] == 2
|
||||
|
||||
@@ -176,7 +176,8 @@ class ConsoleUI(BaseUI):
|
||||
"""Interactively allows the user to select a show from a console based UI
|
||||
"""
|
||||
|
||||
def _displaySeries(self, allSeries, limit=6):
|
||||
@staticmethod
|
||||
def _displaySeries(allSeries, limit: Optional[int] = 6):
|
||||
"""Helper function, lists series with corresponding ID
|
||||
"""
|
||||
if limit is not None:
|
||||
@@ -267,6 +268,7 @@ class ShowContainer(dict):
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._stack = []
|
||||
self._lastgc = time.time()
|
||||
|
||||
@@ -336,42 +338,6 @@ class Show(dict):
|
||||
|
||||
Search terms are converted to lower case (unicode) strings.
|
||||
|
||||
# Examples
|
||||
|
||||
These examples assume t is an instance of Tvdb():
|
||||
|
||||
>>> t = Tvdb()
|
||||
>>>
|
||||
|
||||
To search for all episodes of Scrubs with a bit of data
|
||||
containing "my first day":
|
||||
|
||||
>>> t['Scrubs'].search("my first day")
|
||||
[<Episode 01x01 - u'My First Day'>]
|
||||
>>>
|
||||
|
||||
Search for "My Name Is Earl" episode named "Faked His Own Death":
|
||||
|
||||
>>> t['My Name Is Earl'].search('Faked My Own Death', key='episodeName')
|
||||
[<Episode 01x04 - u'Faked My Own Death'>]
|
||||
>>>
|
||||
|
||||
To search Scrubs for all episodes with "mentor" in the episode name:
|
||||
|
||||
>>> t['scrubs'].search('mentor', key='episodeName')
|
||||
[<Episode 01x02 - u'My Mentor'>, <Episode 03x15 - u'My Tormented Mentor'>]
|
||||
>>>
|
||||
|
||||
# Using search results
|
||||
|
||||
>>> results = t['Scrubs'].search("my first")
|
||||
>>> print results[0]['episodeName']
|
||||
My First Day
|
||||
>>> for x in results: print x['episodeName']
|
||||
My First Day
|
||||
My First Step
|
||||
My First Kill
|
||||
>>>
|
||||
"""
|
||||
results = []
|
||||
for cur_season in self.values():
|
||||
@@ -386,6 +352,7 @@ class Season(dict):
|
||||
def __init__(self, show=None):
|
||||
"""The show attribute points to the parent show
|
||||
"""
|
||||
super().__init__()
|
||||
self.show = show
|
||||
|
||||
def __repr__(self):
|
||||
@@ -420,6 +387,7 @@ class Episode(dict):
|
||||
def __init__(self, season=None):
|
||||
"""The season attribute points to the parent season
|
||||
"""
|
||||
super().__init__()
|
||||
self.season = season
|
||||
|
||||
def __repr__(self):
|
||||
@@ -540,7 +508,7 @@ class Tvdb:
|
||||
self,
|
||||
interactive=False,
|
||||
select_first=False,
|
||||
cache=True,
|
||||
cache: Union[str, bool, requests.Session] = True,
|
||||
banners=False,
|
||||
actors=False,
|
||||
custom_ui=None,
|
||||
@@ -690,7 +658,7 @@ class Tvdb:
|
||||
LOG.debug("Using specified requests.Session")
|
||||
self.session = cache
|
||||
try:
|
||||
self.session.get
|
||||
self.session.get # noqa
|
||||
except AttributeError:
|
||||
raise ValueError(
|
||||
(
|
||||
@@ -776,7 +744,7 @@ class Tvdb:
|
||||
cache_key = self.session.cache.create_key(
|
||||
fake_session_for_key.prepare_request(requests.Request('GET', url))
|
||||
)
|
||||
except Exception:
|
||||
except Exception: # noqa
|
||||
# FIXME: Can this just check for hasattr(self.session, "cache") instead?
|
||||
pass
|
||||
|
||||
@@ -956,6 +924,7 @@ class Tvdb:
|
||||
banners_resp = self._getetsrc(self.config['url_seriesBanner'] % sid)
|
||||
banners = {}
|
||||
for cur_banner in banners_resp.keys():
|
||||
btype = None
|
||||
banners_info = self._getetsrc(self.config['url_seriesBannerInfo'] % (sid, cur_banner))
|
||||
for banner_info in banners_info:
|
||||
bid = banner_info.get('id')
|
||||
@@ -981,32 +950,14 @@ class Tvdb:
|
||||
LOG.debug("Transforming %s to %s" % (k, new_key))
|
||||
new_url = self.config['url_artworkPrefix'] % v
|
||||
banners[btype][btype2][bid][new_key] = new_url
|
||||
|
||||
banners[btype]['raw'] = banners_info
|
||||
self._setShowData(sid, "_banners", banners)
|
||||
if btype:
|
||||
banners[btype]['raw'] = banners_info
|
||||
self._setShowData(sid, "_banners", banners)
|
||||
|
||||
def _parseActors(self, sid):
|
||||
"""Parsers actors XML, from
|
||||
http://thetvdb.com/api/[APIKEY]/series/[SERIES ID]/actors.xml
|
||||
|
||||
Actors are retrieved using t['show name]['_actors'], for example:
|
||||
|
||||
>>> t = Tvdb(actors = True)
|
||||
>>> actors = t['scrubs']['_actors']
|
||||
>>> type(actors)
|
||||
<class 'tvdb_api.Actors'>
|
||||
>>> type(actors[0])
|
||||
<class 'tvdb_api.Actor'>
|
||||
>>> actors[0]
|
||||
<Actor u'John C. McGinley'>
|
||||
>>> sorted(actors[0].keys())
|
||||
[u'id', u'image', u'imageAdded', u'imageAuthor', u'lastUpdated', u'name', u'role',
|
||||
u'seriesId', u'sortOrder']
|
||||
>>> actors[0]['name']
|
||||
u'John C. McGinley'
|
||||
>>> actors[0]['image']
|
||||
u'http://thetvdb.com/banners/actors/43638.jpg'
|
||||
|
||||
Any key starting with an underscore has been processed (not the raw
|
||||
data from the XML)
|
||||
"""
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from typing import Optional, Union, Tuple, List
|
||||
from typing import Optional, Union, Tuple, List, Literal
|
||||
|
||||
import transmission_rpc
|
||||
from transmission_rpc import Client, Torrent, File
|
||||
from transmission_rpc.session import SessionStats, Session
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
|
||||
class Transmission:
|
||||
_protocol: Literal["http", "https"] = "http"
|
||||
_host: str = None
|
||||
_port: int = None
|
||||
_username: str = None
|
||||
@@ -28,9 +29,14 @@ class Transmission:
|
||||
若不设置参数,则创建配置文件设置的下载器
|
||||
"""
|
||||
if host and port:
|
||||
self._host, self._port = host, port
|
||||
self._protocol, self._host, self._port = kwargs.get("protocol", self._protocol), host, port
|
||||
elif host:
|
||||
self._host, self._port = StringUtils.get_domain_address(address=host, prefix=False)
|
||||
result = UrlUtils.parse_url_params(url=host)
|
||||
if result:
|
||||
self._protocol, self._host, self._port, path = result
|
||||
else:
|
||||
logger.error("Transmission配置不正确!")
|
||||
return
|
||||
else:
|
||||
logger.error("Transmission配置不完整!")
|
||||
return
|
||||
@@ -46,8 +52,9 @@ class Transmission:
|
||||
"""
|
||||
try:
|
||||
# 登录
|
||||
logger.info(f"正在连接 transmission:{self._host}:{self._port}")
|
||||
trt = transmission_rpc.Client(host=self._host,
|
||||
logger.info(f"正在连接 transmission:{self._protocol}://{self._host}:{self._port}")
|
||||
trt = transmission_rpc.Client(protocol=self._protocol,
|
||||
host=self._host,
|
||||
port=self._port,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
|
||||
@@ -252,7 +252,7 @@ class Monitor(metaclass=Singleton):
|
||||
self.transferchain.do_transfer(
|
||||
fileitem=FileItem(
|
||||
storage=storage,
|
||||
path=str(event_path),
|
||||
path=str(event_path).replace("\\", "/"),
|
||||
type="file",
|
||||
name=event_path.name,
|
||||
basename=event_path.stem,
|
||||
|
||||
@@ -587,6 +587,6 @@ class Scheduler(metaclass=Singleton):
|
||||
|
||||
else:
|
||||
self._auth_count += 1
|
||||
logger.error(f"用户认证失败:{msg},共失败 {self._auth_count} 次")
|
||||
logger.error(f"用户认证失败,{msg},共失败 {self._auth_count} 次")
|
||||
if self._auth_count >= __max_try__:
|
||||
logger.error("用户认证失败次数过多,将不再尝试认证!")
|
||||
|
||||
@@ -50,7 +50,7 @@ class AuthCredentials(ChainEventData):
|
||||
service: Optional[str] = Field(default=None, description="服务名称")
|
||||
|
||||
@root_validator(pre=True)
|
||||
def check_fields_based_on_grant_type(cls, values):
|
||||
def check_fields_based_on_grant_type(cls, values): # noqa
|
||||
grant_type = values.get("grant_type")
|
||||
if not grant_type:
|
||||
values["grant_type"] = "password"
|
||||
|
||||
@@ -3,10 +3,11 @@ from typing import Optional, List, Any, Callable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas import TmdbEpisode, DownloadHistory
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.context import MetaInfo, MediaInfo
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from schemas import MediaInfo, MetaInfo
|
||||
|
||||
|
||||
class TransferTorrent(BaseModel):
|
||||
@@ -58,9 +59,12 @@ class TransferTask(BaseModel):
|
||||
library_type_folder: Optional[bool] = False
|
||||
library_category_folder: Optional[bool] = False
|
||||
episodes_info: Optional[List[TmdbEpisode]] = None
|
||||
username: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
download_history: Optional[DownloadHistory] = None
|
||||
manual: Optional[bool] = False
|
||||
background: Optional[bool] = True
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
|
||||
@@ -88,7 +88,7 @@ def user_auth():
|
||||
if status:
|
||||
logger.info(f"{msg} 用户认证成功")
|
||||
else:
|
||||
logger.info(f"用户认证失败:{msg}")
|
||||
logger.info(f"用户认证失败,{msg}")
|
||||
|
||||
|
||||
def check_auth():
|
||||
|
||||
@@ -17,6 +17,11 @@ _special_domains = [
|
||||
'pt.ecust.pp.ua',
|
||||
]
|
||||
|
||||
# 内置版本号转换字典
|
||||
_version_map = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
|
||||
# 不符合的版本号
|
||||
_other_version = -5
|
||||
|
||||
|
||||
class StringUtils:
|
||||
|
||||
@@ -222,7 +227,7 @@ class StringUtils:
|
||||
size = float(size)
|
||||
d = [(1024 - 1, 'K'), (1024 ** 2 - 1, 'M'), (1024 ** 3 - 1, 'G'), (1024 ** 4 - 1, 'T')]
|
||||
s = [x[0] for x in d]
|
||||
index = bisect.bisect_left(s, size) - 1
|
||||
index = bisect.bisect_left(s, size) - 1 # noqa
|
||||
if index == -1:
|
||||
return str(size) + "B"
|
||||
else:
|
||||
@@ -740,27 +745,122 @@ class StringUtils:
|
||||
return ''.join(common_prefix)
|
||||
|
||||
@staticmethod
|
||||
def compare_version(v1: str, v2: str) -> int:
|
||||
def compare_version(v1: str, compare_type: str, v2: str, verbose: bool = False) \
|
||||
-> Tuple[Optional[bool], str | Exception] | Optional[bool]:
|
||||
"""
|
||||
比较两个版本号的大小,v1 > v2时返回1,v1 < v2时返回-1,v1 = v2时返回0
|
||||
比较两个版本号的大小
|
||||
|
||||
:param v1: 比对的来源版本号
|
||||
:param v2: 比对的目标版本号
|
||||
:param verbose: 是否输出比对结果的时候输出详细消息,默认 False 不输出
|
||||
:param compare_type: 识别模式。支持直接使用符号进行比对
|
||||
'ge' or '>=' :来源 >= 目标
|
||||
'le' or '<=' :来源 <= 目标
|
||||
'eq' or '==' :来源 == 目标
|
||||
'gt' or '>' :来源 > 目标
|
||||
'lt' or '<' :来源 < 目标
|
||||
:return
|
||||
"""
|
||||
if not v1 or not v2:
|
||||
return 0
|
||||
v1 = v1.replace('v', '')
|
||||
v2 = v2.replace('v', '')
|
||||
v1 = [int(x) for x in v1.split('.')]
|
||||
v2 = [int(x) for x in v2.split('.')]
|
||||
for i in range(min(len(v1), len(v2))):
|
||||
if v1[i] > v2[i]:
|
||||
return 1
|
||||
elif v1[i] < v2[i]:
|
||||
return -1
|
||||
if len(v1) > len(v2):
|
||||
return 1
|
||||
elif len(v1) < len(v2):
|
||||
return -1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def __preprocess_version(version: str) -> list:
|
||||
"""
|
||||
预处理版本号,去除首尾空字符串与换行符,去除开头大小写v,并拆分版本号
|
||||
"""
|
||||
return re.split(r'[.-]', version.strip().lstrip('vV'))
|
||||
|
||||
def __conversion_version(version_list) -> list:
|
||||
"""
|
||||
英文字符转换为数字
|
||||
:param version_list : 版本号列表,格式:['1', '2', '3', 'beta']
|
||||
"""
|
||||
result = []
|
||||
for item in version_list:
|
||||
# stable = -1,rc = -2,beta = -3,alpha = -4
|
||||
if item.isdigit():
|
||||
result.append(int(item))
|
||||
# 其余不符合的,都为-5
|
||||
else:
|
||||
value = _version_map.get(item, _other_version)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
try:
|
||||
if not v1 or not v2:
|
||||
raise ValueError("要比较的版本号不全")
|
||||
if not compare_type:
|
||||
raise ValueError("缺少比对模式,无法比对")
|
||||
if compare_type not in {"ge", "gt", "le", "lt", "eq", "==", ">=", ">", "<=", "<"}:
|
||||
raise ValueError(f"设置的版本比对模式 {compare_type} 不是有效的模式!")
|
||||
|
||||
# 拆分获取版本号各个分段值做成列表
|
||||
v1_list = __conversion_version(__preprocess_version(version=v1))
|
||||
v2_list = __conversion_version(__preprocess_version(version=v2))
|
||||
|
||||
# 补全版本号位置,保持长度一致
|
||||
max_length = max(len(v1_list), len(v2_list))
|
||||
v1_list += [0] * (max_length - len(v1_list))
|
||||
v2_list += [0] * (max_length - len(v2_list))
|
||||
|
||||
ver_comparison, ver_comparison_err = None, None
|
||||
for v1_value, v2_value in zip(v1_list, v2_list):
|
||||
# 来源==目标
|
||||
if compare_type in {"eq", "=="}:
|
||||
if v1_value != v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "不等于"
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = "等于", None
|
||||
|
||||
# 来源>=目标
|
||||
elif compare_type in {"ge", ">="}:
|
||||
if v1_value > v2_value:
|
||||
ver_comparison, ver_comparison_err = "大于", None
|
||||
break
|
||||
elif v1_value < v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "小于"
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = "等于", None
|
||||
|
||||
# 来源>目标
|
||||
elif compare_type in {"gt", ">"}:
|
||||
if v1_value > v2_value:
|
||||
ver_comparison, ver_comparison_err = "大于", None
|
||||
break
|
||||
elif v1_value < v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "小于"
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = None, "等于"
|
||||
|
||||
# 来源<=目标
|
||||
elif compare_type in {"le", "<="}:
|
||||
if v1_value > v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "大于"
|
||||
break
|
||||
elif v1_value < v2_value:
|
||||
ver_comparison, ver_comparison_err = "小于", None
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = "等于", None
|
||||
|
||||
# 来源<目标
|
||||
elif compare_type in {"lt", "<"}:
|
||||
if v1_value > v2_value:
|
||||
ver_comparison, ver_comparison_err = None, "大于"
|
||||
break
|
||||
elif v1_value < v2_value:
|
||||
ver_comparison, ver_comparison_err = "小于", None
|
||||
break
|
||||
else:
|
||||
ver_comparison, ver_comparison_err = None, "等于"
|
||||
|
||||
msg = f"版本号 {v1} {ver_comparison if ver_comparison else ver_comparison_err} 目标版本号 {v2} !"
|
||||
|
||||
return (True if ver_comparison else False, msg) if verbose else True if ver_comparison else False
|
||||
|
||||
except Exception as e:
|
||||
return (None, e) if verbose else None
|
||||
|
||||
@staticmethod
|
||||
def diff_time_str(time_str: str):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, Tuple
|
||||
from urllib import parse
|
||||
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
@@ -27,7 +27,7 @@ class UrlUtils:
|
||||
@staticmethod
|
||||
def adapt_request_url(host: str, endpoint: str) -> Optional[str]:
|
||||
"""
|
||||
基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL。
|
||||
基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL
|
||||
:param host: 主机头
|
||||
:param endpoint: 端点
|
||||
:return: 完整的请求URL字符串
|
||||
@@ -42,7 +42,7 @@ class UrlUtils:
|
||||
@staticmethod
|
||||
def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:
|
||||
"""
|
||||
使用给定的主机头、路径和查询参数组合生成完整的URL。
|
||||
使用给定的主机头、路径和查询参数组合生成完整的URL
|
||||
:param host: str, 主机头,例如 https://example.com
|
||||
:param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1
|
||||
:param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"}
|
||||
@@ -101,9 +101,42 @@ class UrlUtils:
|
||||
def quote(s: str) -> str:
|
||||
"""
|
||||
将字符串编码为 URL 安全的格式
|
||||
这将确保路径中的特殊字符(如空格、中文字符等)被正确编码,以便在 URL 中传输
|
||||
|
||||
:param s: 要编码的字符串
|
||||
:return: 编码后的字符串
|
||||
"""
|
||||
return parse.quote(s)
|
||||
|
||||
@staticmethod
|
||||
def parse_url_params(url: str) -> Optional[Tuple[str, str, int, str]]:
|
||||
"""
|
||||
解析给定的 URL,并提取协议、主机名、端口和路径信息
|
||||
|
||||
:param url: str
|
||||
需要解析的 URL 字符串
|
||||
可以是完整的 URL(例如:"http://example.com:8080/path")或不带协议的地址(例如:"example.com:1234")
|
||||
:return: Optional[Tuple[str, str, int, str]]
|
||||
- str: 协议(例如:"http", "https")
|
||||
- str: 主机名或 IP 地址(例如:"example.com", "192.168.1.1")
|
||||
- int: 端口号(例如:80, 443)
|
||||
- str: URL 的路径部分(例如:"/", "/path")
|
||||
如果输入地址无效或无法解析,则返回 None
|
||||
"""
|
||||
try:
|
||||
if not url:
|
||||
return None
|
||||
|
||||
url = UrlUtils.standardize_base_url(host=url)
|
||||
parsed = urlparse(url)
|
||||
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
protocol = parsed.scheme
|
||||
hostname = parsed.hostname
|
||||
port = parsed.port or (443 if protocol == "https" else 80)
|
||||
path = parsed.path or "/"
|
||||
|
||||
return protocol, hostname, port, path
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parse_url_params: {e}")
|
||||
return None
|
||||
|
||||
@@ -63,4 +63,5 @@ p115client==0.0.3.8.3.3
|
||||
python-cookietools==0.0.2.1
|
||||
aligo~=6.2.4
|
||||
aiofiles~=24.1.0
|
||||
jieba~=0.42.1
|
||||
jieba~=0.42.1
|
||||
rsa~=4.9
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.1.8'
|
||||
FRONTEND_VERSION = 'v2.1.8'
|
||||
APP_VERSION = 'v2.2.0'
|
||||
FRONTEND_VERSION = 'v2.2.0'
|
||||
|
||||
Reference in New Issue
Block a user