feat: 支持多文件手动整理与集数定位模板推荐 (#5820)

This commit is contained in:
Album
2026-05-23 09:23:50 +08:00
committed by GitHub
parent 7cbfeb2377
commit 2eb7f57a4c
6 changed files with 703 additions and 18 deletions

View File

@@ -109,6 +109,7 @@ def manual_transfer(
force = False
downloader = None
download_hash = None
src_fileitems: List[FileItem] = []
target_path = Path(transer_item.target_path) if transer_item.target_path else None
if transer_item.logid:
# 查询历史记录
@@ -123,10 +124,10 @@ def manual_transfer(
download_hash = history.download_hash
if history.status and ("move" in history.mode):
# 重新整理成功的转移,则使用成功的 dest 做 in_path
src_fileitem = FileItem(**history.dest_fileitem)
src_fileitems = [FileItem(**history.dest_fileitem)]
else:
# 源路径
src_fileitem = FileItem(**history.src_fileitem)
src_fileitems = [FileItem(**history.src_fileitem)]
# 目的路径
if history.dest_fileitem and not transer_item.preview:
# 删除旧的已整理文件
@@ -171,11 +172,29 @@ def manual_transfer(
# E01单集
transer_item.episode_detail = str(history.episodes).replace("E", "")
elif transer_item.fileitems:
src_fileitems = [fileitem for fileitem in transer_item.fileitems if fileitem]
elif transer_item.fileitem:
src_fileitem = transer_item.fileitem
src_fileitems = [transer_item.fileitem]
else:
return schemas.Response(success=False, message=f"缺少参数")
dedup_fileitems: List[FileItem] = []
seen_paths = set()
for current_fileitem in src_fileitems:
storage = current_fileitem.storage or "local"
path = current_fileitem.path
if not path:
continue
key = (storage, path)
if key in seen_paths:
continue
seen_paths.add(key)
dedup_fileitems.append(current_fileitem)
src_fileitems = dedup_fileitems
if not src_fileitems:
return schemas.Response(success=False, message="缺少参数")
# 类型(“自动/auto/none”按未指定处理
mtype = None
type_name = str(transer_item.type_name).strip() if transer_item.type_name else ""
@@ -200,6 +219,117 @@ def manual_transfer(
part=transer_item.episode_part,
offset=transer_item.episode_offset,
)
explicit_selected_files = bool(transer_item.fileitems)
def _build_failure_preview_item(file_item: FileItem, message: str) -> dict:
return {
"source": file_item.path if file_item else None,
"target": None,
"target_dir": None,
"success": False,
"message": message,
"type": None,
"title": None,
"season": None,
"episode": None,
"episode_end": None,
"part": None,
}
def _merge_messages(messages: List[str]) -> str:
valid_messages = [msg for msg in messages if msg]
if not valid_messages:
return ""
return "".join(valid_messages[:2]) + (
f",等{len(valid_messages)}条消息" if len(valid_messages) > 2 else ""
)
# 前端显式传入文件列表时,按选中的文件逐个处理,避免将目录整体展开。
if explicit_selected_files:
preview_items: List[dict] = []
error_messages: List[str] = []
all_success = True
for src_fileitem in src_fileitems:
state, errormsg = TransferChain().manual_transfer(
fileitem=src_fileitem,
target_storage=transer_item.target_storage,
target_path=target_path,
tmdbid=transer_item.tmdbid,
doubanid=transer_item.doubanid,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
transfer_type=transer_item.transfer_type,
epformat=epformat,
min_filesize=transer_item.min_filesize,
scrape=transer_item.scrape,
library_type_folder=transer_item.library_type_folder,
library_category_folder=transer_item.library_category_folder,
force=force,
background=background,
downloader=downloader,
download_hash=download_hash,
preview=transer_item.preview,
sync_extra_files=False,
)
if transer_item.preview:
if isinstance(errormsg, dict):
preview_items.extend(errormsg.get("items") or [])
if errormsg.get("message"):
error_messages.append(errormsg.get("message"))
if not state:
all_success = False
else:
if errormsg:
error_messages.append(str(errormsg))
preview_items.append(
_build_failure_preview_item(src_fileitem, str(errormsg))
)
all_success = False
elif not state:
all_success = False
if isinstance(errormsg, list):
error_messages.extend([str(msg) for msg in errormsg if msg])
elif errormsg:
error_messages.append(str(errormsg))
if transer_item.preview:
merged_preview_items: List[dict] = []
seen_sources = set()
for preview_item in preview_items:
source = preview_item.get("source")
if source in seen_sources:
continue
seen_sources.add(source)
merged_preview_items.append(preview_item)
merged_message = _merge_messages(error_messages)
preview_data = {
"summary": {
"total": len(merged_preview_items),
"success": len(
[item for item in merged_preview_items if item.get("success")]
),
"failed": len(
[item for item in merged_preview_items if not item.get("success")]
),
},
"items": merged_preview_items,
"message": merged_message,
}
return schemas.Response(
success=True,
message=merged_message or None,
data=preview_data,
)
if not all_success:
return schemas.Response(
success=False,
message=_merge_messages(error_messages),
)
return schemas.Response(success=True)
src_fileitem = src_fileitems[0]
# 开始转移
state, errormsg = TransferChain().manual_transfer(
fileitem=src_fileitem,
@@ -221,6 +351,7 @@ def manual_transfer(
downloader=downloader,
download_hash=download_hash,
preview=transer_item.preview,
sync_extra_files=True,
)
# 失败
if not state:
@@ -256,7 +387,8 @@ def recommend_episode_format(
target_path = recommend_item.fileitem.path if recommend_item.fileitem else None
logger.info(f"开始推荐集数定位模板:{target_path}")
state, errmsg, data = TransferChain().recommend_episode_format(
fileitem=recommend_item.fileitem
fileitem=recommend_item.fileitem,
fileitems=recommend_item.fileitems,
)
if not state:
logger.warn(f"推荐集数定位模板失败:{target_path} - {errmsg}")

View File

@@ -1723,33 +1723,46 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def recommend_episode_format(
self,
fileitem: FileItem,
fileitems: Optional[List[FileItem]] = None,
) -> Tuple[bool, str, Optional[dict]]:
"""
根据目录样本推荐集数定位模板
"""
if not fileitem or not fileitem.path:
if not fileitem and not fileitems:
logger.warn("推荐集数定位模板失败:缺少目录参数")
return False, "缺少目录参数", None
directory = self.__resolve_episode_format_directory(fileitem)
if not directory or directory.type != "dir":
logger.warn(f"推荐集数定位模板失败:目录不存在 - {fileitem.path}")
return False, "目录不存在", None
rules = self.__get_episode_format_rules()
sample_files = self.__get_episode_format_sample_files(directory)
if fileitems:
state, errmsg, sample_files = self.__get_selected_episode_format_sample_files(
fileitems
)
if not state:
logger.warn(f"推荐集数定位模板失败:{errmsg}")
return False, errmsg, None
target_path = sample_files[0].path if sample_files else None
else:
if not fileitem or not fileitem.path:
logger.warn("推荐集数定位模板失败:缺少目录参数")
return False, "缺少目录参数", None
directory = self.__resolve_episode_format_directory(fileitem)
if not directory or directory.type != "dir":
logger.warn(f"推荐集数定位模板失败:目录不存在 - {fileitem.path}")
return False, "目录不存在", None
sample_files = self.__get_episode_format_sample_files(directory)
target_path = directory.path
logger.info(
f"开始匹配集数定位规则:{directory.path},规则数 {len(rules)},样本数 {len(sample_files)}"
f"开始匹配集数定位规则:{target_path},规则数 {len(rules)},样本数 {len(sample_files)}"
)
state, errmsg, data = EpisodeFormatRuleHelper().recommend(
rules=rules,
sample_files=sample_files,
)
if not state:
logger.warn(f"集数定位模板推荐失败:{directory.path} - {errmsg}")
logger.warn(f"集数定位模板推荐失败:{target_path} - {errmsg}")
return state, errmsg, data
logger.info(
f"集数定位模板推荐成功:{directory.path} - 规则 {data.get('rule_name') if data else None}"
f"集数定位模板推荐成功:{target_path} - 规则 {data.get('rule_name') if data else None}"
)
return state, errmsg, data
@@ -1790,6 +1803,50 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
)
return storage_chain.get_item(parent_item)
def __get_selected_episode_format_sample_files(
self, fileitems: List[FileItem]
) -> Tuple[bool, str, List[FileItem]]:
"""
获取当前选择文件中可参与模板推荐的样本文件。
"""
if not fileitems:
return False, "没有可用于识别的样本文件", []
expected_dir_key: Optional[Tuple[str, str]] = None
selected_files: List[FileItem] = []
seen_files = set()
for item in fileitems:
if not item or not item.path or item.type != "file":
return False, "当前选择不满足智能识别条件", []
dir_key = (
item.storage or "local",
Path(item.path).parent.as_posix(),
)
if expected_dir_key is None:
expected_dir_key = dir_key
elif dir_key != expected_dir_key:
return False, "当前选择不满足智能识别条件", []
file_key = (item.storage or "local", item.path)
if file_key in seen_files:
continue
seen_files.add(file_key)
if not (
self.__is_media_file(item)
or self.__is_subtitle_file(item)
or self.__is_audio_file(item)
):
continue
if self.__is_hidden_or_recycle_path(item.path):
continue
selected_files.append(item)
if not selected_files:
return False, "没有可用于识别的样本文件", []
return True, "", selected_files
def __get_episode_format_sample_files(
self, directory: FileItem
) -> List[FileItem]:
@@ -2359,6 +2416,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if preview:
# 预览模式始终同步执行,避免进入异步队列
background = False
manual_single_file = bool(manual and fileitem and fileitem.type == "file")
# 自定义格式
formaterHandler = (
@@ -2462,8 +2520,20 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if continue_callback and not continue_callback():
raise OperationInterrupted()
is_extra_file = self.__is_subtitle_file(item) or self.__is_audio_file(item)
# 手动单文件整理时,前端可能把同目录文件拆成多个根文件提交;
# 此时应优先信任用户显式选择的根文件,并允许附加文件进入后续同媒体匹配流程,
# 避免仅因模板未覆盖字幕/音轨后缀而被提前过滤。
should_bypass_epformat_match = (
(manual_single_file and item.path == fileitem.path)
or (sync_extra_files and is_extra_file)
)
# 有集自定义格式,过滤文件
if formaterHandler and not formaterHandler.match(item.name):
if (
formaterHandler
and not should_bypass_epformat_match
and not formaterHandler.match(item.name)
):
return False
# 过滤后缀和大小(蓝光目录、附加文件不过滤)
if (
@@ -3031,6 +3101,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
downloader: Optional[str] = None,
download_hash: Optional[str] = None,
preview: Optional[bool] = False,
sync_extra_files: Optional[bool] = True,
) -> Tuple[bool, Union[str, dict]]:
"""
手动整理,支持复杂条件,带进度显示
@@ -3053,6 +3124,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param downloader: 下载器名称
:param download_hash: 下载任务哈希
:param preview: 是否仅预览
:param sync_extra_files: 是否同步整理同媒体附加文件
"""
logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid:
@@ -3092,6 +3164,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
downloader=downloader,
download_hash=download_hash,
preview=preview,
sync_extra_files=sync_extra_files,
)
if not state:
return False, errmsg
@@ -3117,6 +3190,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
downloader=downloader,
download_hash=download_hash,
preview=preview,
sync_extra_files=sync_extra_files,
)
return state, errmsg

View File

@@ -181,12 +181,15 @@ class EpisodeFormatRecommendItem(BaseModel):
"""
集数定位推荐请求
"""
fileitem: FileItem
fileitem: Optional[FileItem] = None
fileitems: Optional[List[FileItem]] = None
class ManualTransferItem(BaseModel):
# 文件项
fileitem: FileItem = None
# 文件项列表(前端多选时传入)
fileitems: Optional[List[FileItem]] = None
# 日志ID
logid: Optional[int] = None
# 目标存储