mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: 支持多文件手动整理与集数定位模板推荐 (#5820)
This commit is contained in:
@@ -109,6 +109,7 @@ def manual_transfer(
|
|||||||
force = False
|
force = False
|
||||||
downloader = None
|
downloader = None
|
||||||
download_hash = None
|
download_hash = None
|
||||||
|
src_fileitems: List[FileItem] = []
|
||||||
target_path = Path(transer_item.target_path) if transer_item.target_path else None
|
target_path = Path(transer_item.target_path) if transer_item.target_path else None
|
||||||
if transer_item.logid:
|
if transer_item.logid:
|
||||||
# 查询历史记录
|
# 查询历史记录
|
||||||
@@ -123,10 +124,10 @@ def manual_transfer(
|
|||||||
download_hash = history.download_hash
|
download_hash = history.download_hash
|
||||||
if history.status and ("move" in history.mode):
|
if history.status and ("move" in history.mode):
|
||||||
# 重新整理成功的转移,则使用成功的 dest 做 in_path
|
# 重新整理成功的转移,则使用成功的 dest 做 in_path
|
||||||
src_fileitem = FileItem(**history.dest_fileitem)
|
src_fileitems = [FileItem(**history.dest_fileitem)]
|
||||||
else:
|
else:
|
||||||
# 源路径
|
# 源路径
|
||||||
src_fileitem = FileItem(**history.src_fileitem)
|
src_fileitems = [FileItem(**history.src_fileitem)]
|
||||||
# 目的路径
|
# 目的路径
|
||||||
if history.dest_fileitem and not transer_item.preview:
|
if history.dest_fileitem and not transer_item.preview:
|
||||||
# 删除旧的已整理文件
|
# 删除旧的已整理文件
|
||||||
@@ -171,11 +172,29 @@ def manual_transfer(
|
|||||||
# E01单集
|
# E01单集
|
||||||
transer_item.episode_detail = str(history.episodes).replace("E", "")
|
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:
|
elif transer_item.fileitem:
|
||||||
src_fileitem = transer_item.fileitem
|
src_fileitems = [transer_item.fileitem]
|
||||||
else:
|
else:
|
||||||
return schemas.Response(success=False, message=f"缺少参数")
|
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”按未指定处理)
|
# 类型(“自动/auto/none”按未指定处理)
|
||||||
mtype = None
|
mtype = None
|
||||||
type_name = str(transer_item.type_name).strip() if transer_item.type_name else ""
|
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,
|
part=transer_item.episode_part,
|
||||||
offset=transer_item.episode_offset,
|
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(
|
state, errormsg = TransferChain().manual_transfer(
|
||||||
fileitem=src_fileitem,
|
fileitem=src_fileitem,
|
||||||
@@ -221,6 +351,7 @@ def manual_transfer(
|
|||||||
downloader=downloader,
|
downloader=downloader,
|
||||||
download_hash=download_hash,
|
download_hash=download_hash,
|
||||||
preview=transer_item.preview,
|
preview=transer_item.preview,
|
||||||
|
sync_extra_files=True,
|
||||||
)
|
)
|
||||||
# 失败
|
# 失败
|
||||||
if not state:
|
if not state:
|
||||||
@@ -256,7 +387,8 @@ def recommend_episode_format(
|
|||||||
target_path = recommend_item.fileitem.path if recommend_item.fileitem else None
|
target_path = recommend_item.fileitem.path if recommend_item.fileitem else None
|
||||||
logger.info(f"开始推荐集数定位模板:{target_path}")
|
logger.info(f"开始推荐集数定位模板:{target_path}")
|
||||||
state, errmsg, data = TransferChain().recommend_episode_format(
|
state, errmsg, data = TransferChain().recommend_episode_format(
|
||||||
fileitem=recommend_item.fileitem
|
fileitem=recommend_item.fileitem,
|
||||||
|
fileitems=recommend_item.fileitems,
|
||||||
)
|
)
|
||||||
if not state:
|
if not state:
|
||||||
logger.warn(f"推荐集数定位模板失败:{target_path} - {errmsg}")
|
logger.warn(f"推荐集数定位模板失败:{target_path} - {errmsg}")
|
||||||
|
|||||||
+85
-11
@@ -1723,33 +1723,46 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
def recommend_episode_format(
|
def recommend_episode_format(
|
||||||
self,
|
self,
|
||||||
fileitem: FileItem,
|
fileitem: FileItem,
|
||||||
|
fileitems: Optional[List[FileItem]] = None,
|
||||||
) -> Tuple[bool, str, Optional[dict]]:
|
) -> Tuple[bool, str, Optional[dict]]:
|
||||||
"""
|
"""
|
||||||
根据目录样本推荐集数定位模板
|
根据目录样本推荐集数定位模板
|
||||||
"""
|
"""
|
||||||
if not fileitem or not fileitem.path:
|
if not fileitem and not fileitems:
|
||||||
logger.warn("推荐集数定位模板失败:缺少目录参数")
|
logger.warn("推荐集数定位模板失败:缺少目录参数")
|
||||||
return False, "缺少目录参数", None
|
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()
|
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(
|
logger.info(
|
||||||
f"开始匹配集数定位规则:{directory.path},规则数 {len(rules)},样本数 {len(sample_files)}"
|
f"开始匹配集数定位规则:{target_path},规则数 {len(rules)},样本数 {len(sample_files)}"
|
||||||
)
|
)
|
||||||
state, errmsg, data = EpisodeFormatRuleHelper().recommend(
|
state, errmsg, data = EpisodeFormatRuleHelper().recommend(
|
||||||
rules=rules,
|
rules=rules,
|
||||||
sample_files=sample_files,
|
sample_files=sample_files,
|
||||||
)
|
)
|
||||||
if not state:
|
if not state:
|
||||||
logger.warn(f"集数定位模板推荐失败:{directory.path} - {errmsg}")
|
logger.warn(f"集数定位模板推荐失败:{target_path} - {errmsg}")
|
||||||
return state, errmsg, data
|
return state, errmsg, data
|
||||||
logger.info(
|
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
|
return state, errmsg, data
|
||||||
|
|
||||||
@@ -1790,6 +1803,50 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
return storage_chain.get_item(parent_item)
|
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(
|
def __get_episode_format_sample_files(
|
||||||
self, directory: FileItem
|
self, directory: FileItem
|
||||||
) -> List[FileItem]:
|
) -> List[FileItem]:
|
||||||
@@ -2359,6 +2416,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
if preview:
|
if preview:
|
||||||
# 预览模式始终同步执行,避免进入异步队列
|
# 预览模式始终同步执行,避免进入异步队列
|
||||||
background = False
|
background = False
|
||||||
|
manual_single_file = bool(manual and fileitem and fileitem.type == "file")
|
||||||
|
|
||||||
# 自定义格式
|
# 自定义格式
|
||||||
formaterHandler = (
|
formaterHandler = (
|
||||||
@@ -2462,8 +2520,20 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
if continue_callback and not continue_callback():
|
if continue_callback and not continue_callback():
|
||||||
raise OperationInterrupted()
|
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
|
return False
|
||||||
# 过滤后缀和大小(蓝光目录、附加文件不过滤)
|
# 过滤后缀和大小(蓝光目录、附加文件不过滤)
|
||||||
if (
|
if (
|
||||||
@@ -3031,6 +3101,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
downloader: Optional[str] = None,
|
downloader: Optional[str] = None,
|
||||||
download_hash: Optional[str] = None,
|
download_hash: Optional[str] = None,
|
||||||
preview: Optional[bool] = False,
|
preview: Optional[bool] = False,
|
||||||
|
sync_extra_files: Optional[bool] = True,
|
||||||
) -> Tuple[bool, Union[str, dict]]:
|
) -> Tuple[bool, Union[str, dict]]:
|
||||||
"""
|
"""
|
||||||
手动整理,支持复杂条件,带进度显示
|
手动整理,支持复杂条件,带进度显示
|
||||||
@@ -3053,6 +3124,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
:param downloader: 下载器名称
|
:param downloader: 下载器名称
|
||||||
:param download_hash: 下载任务哈希
|
:param download_hash: 下载任务哈希
|
||||||
:param preview: 是否仅预览
|
:param preview: 是否仅预览
|
||||||
|
:param sync_extra_files: 是否同步整理同媒体附加文件
|
||||||
"""
|
"""
|
||||||
logger.info(f"手动整理:{fileitem.path} ...")
|
logger.info(f"手动整理:{fileitem.path} ...")
|
||||||
if tmdbid or doubanid:
|
if tmdbid or doubanid:
|
||||||
@@ -3092,6 +3164,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
downloader=downloader,
|
downloader=downloader,
|
||||||
download_hash=download_hash,
|
download_hash=download_hash,
|
||||||
preview=preview,
|
preview=preview,
|
||||||
|
sync_extra_files=sync_extra_files,
|
||||||
)
|
)
|
||||||
if not state:
|
if not state:
|
||||||
return False, errmsg
|
return False, errmsg
|
||||||
@@ -3117,6 +3190,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
downloader=downloader,
|
downloader=downloader,
|
||||||
download_hash=download_hash,
|
download_hash=download_hash,
|
||||||
preview=preview,
|
preview=preview,
|
||||||
|
sync_extra_files=sync_extra_files,
|
||||||
)
|
)
|
||||||
return state, errmsg
|
return state, errmsg
|
||||||
|
|
||||||
|
|||||||
@@ -181,12 +181,15 @@ class EpisodeFormatRecommendItem(BaseModel):
|
|||||||
"""
|
"""
|
||||||
集数定位推荐请求
|
集数定位推荐请求
|
||||||
"""
|
"""
|
||||||
fileitem: FileItem
|
fileitem: Optional[FileItem] = None
|
||||||
|
fileitems: Optional[List[FileItem]] = None
|
||||||
|
|
||||||
|
|
||||||
class ManualTransferItem(BaseModel):
|
class ManualTransferItem(BaseModel):
|
||||||
# 文件项
|
# 文件项
|
||||||
fileitem: FileItem = None
|
fileitem: FileItem = None
|
||||||
|
# 文件项列表(前端多选时传入)
|
||||||
|
fileitems: Optional[List[FileItem]] = None
|
||||||
# 日志ID
|
# 日志ID
|
||||||
logid: Optional[int] = None
|
logid: Optional[int] = None
|
||||||
# 目标存储
|
# 目标存储
|
||||||
|
|||||||
@@ -485,6 +485,101 @@ def test_transfer_chain_recommend_episode_format_passes_helper_data(monkeypatch)
|
|||||||
assert data == helper_data
|
assert data == helper_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_chain_recommend_episode_format_uses_selected_fileitems(monkeypatch):
|
||||||
|
chain = object.__new__(TransferChain)
|
||||||
|
monkeypatch.setattr(chain, "_media_exts", [".mkv", ".mp4"], raising=False)
|
||||||
|
monkeypatch.setattr(chain, "_subtitle_exts", [".ass", ".ssa"], raising=False)
|
||||||
|
monkeypatch.setattr(chain, "_audio_exts", [".mka", ".aac"], raising=False)
|
||||||
|
selected_fileitems = [
|
||||||
|
FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/downloads/Show/Show - 01.mkv",
|
||||||
|
type="file",
|
||||||
|
name="Show - 01.mkv",
|
||||||
|
extension="mkv",
|
||||||
|
),
|
||||||
|
FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/downloads/Show/Show - 01.ass",
|
||||||
|
type="file",
|
||||||
|
name="Show - 01.ass",
|
||||||
|
extension="ass",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
helper_data = {
|
||||||
|
"rule_name": "智能分析",
|
||||||
|
"episode_format": "Show - {ep}.{a}",
|
||||||
|
"sample_file": "Show - 01.mkv",
|
||||||
|
"pattern": None,
|
||||||
|
"sample_count": 2,
|
||||||
|
"majority_count": 2,
|
||||||
|
"confidence": "high",
|
||||||
|
"size_filter_relaxed": False,
|
||||||
|
"native_verified_count": 0,
|
||||||
|
"native_fallback_count": 0,
|
||||||
|
"native_conflict_count": 0,
|
||||||
|
"reason": "selected_samples",
|
||||||
|
"reasons": ["selected_samples"],
|
||||||
|
"message": "已基于选中文件生成集数定位模板",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chain,
|
||||||
|
"_TransferChain__get_episode_format_rules",
|
||||||
|
lambda: [],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.chain.transfer.EpisodeFormatRuleHelper.recommend",
|
||||||
|
lambda self, rules, sample_files: (True, "", {
|
||||||
|
**helper_data,
|
||||||
|
"received_samples": [item.name for item in sample_files],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
state, errmsg, data = TransferChain.recommend_episode_format(
|
||||||
|
chain,
|
||||||
|
fileitem=None,
|
||||||
|
fileitems=selected_fileitems,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state is True
|
||||||
|
assert errmsg == ""
|
||||||
|
assert data["received_samples"] == [item.name for item in selected_fileitems]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_chain_recommend_episode_format_rejects_invalid_selected_fileitems():
|
||||||
|
chain = object.__new__(TransferChain)
|
||||||
|
chain._media_exts = [".mkv", ".mp4"]
|
||||||
|
chain._subtitle_exts = [".ass", ".ssa"]
|
||||||
|
chain._audio_exts = [".mka", ".aac"]
|
||||||
|
selected_fileitems = [
|
||||||
|
FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/downloads/Show/Show - 01.mkv",
|
||||||
|
type="file",
|
||||||
|
name="Show - 01.mkv",
|
||||||
|
extension="mkv",
|
||||||
|
),
|
||||||
|
FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/downloads/Other/Show - 02.mkv",
|
||||||
|
type="file",
|
||||||
|
name="Show - 02.mkv",
|
||||||
|
extension="mkv",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
state, errmsg, data = TransferChain.recommend_episode_format(
|
||||||
|
chain,
|
||||||
|
fileitem=None,
|
||||||
|
fileitems=selected_fileitems,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state is False
|
||||||
|
assert errmsg == "当前选择不满足智能识别条件"
|
||||||
|
assert data is None
|
||||||
|
|
||||||
|
|
||||||
def test_transfer_chain_episode_format_samples_include_extra_files(monkeypatch):
|
def test_transfer_chain_episode_format_samples_include_extra_files(monkeypatch):
|
||||||
chain = object.__new__(TransferChain)
|
chain = object.__new__(TransferChain)
|
||||||
directory = FileItem(
|
directory = FileItem(
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import sys
|
|||||||
sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites"))
|
sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites"))
|
||||||
setattr(sys.modules["app.helper.sites"], "SitesHelper", object)
|
setattr(sys.modules["app.helper.sites"], "SitesHelper", object)
|
||||||
|
|
||||||
from app.api.endpoints.transfer import manual_transfer
|
from app.api.endpoints.transfer import manual_transfer, recommend_episode_format
|
||||||
from app.schemas import ManualTransferItem
|
from app.schemas import ManualTransferItem, EpisodeFormatRecommendItem
|
||||||
|
|
||||||
|
|
||||||
def test_manual_transfer_from_history_preserves_download_context(monkeypatch):
|
def test_manual_transfer_from_history_preserves_download_context(monkeypatch):
|
||||||
@@ -52,3 +52,206 @@ def test_manual_transfer_from_history_preserves_download_context(monkeypatch):
|
|||||||
assert captured["download_hash"] == "abc123"
|
assert captured["download_hash"] == "abc123"
|
||||||
assert captured["episode_group"] == "WEB-DL"
|
assert captured["episode_group"] == "WEB-DL"
|
||||||
assert captured["season"] == 1
|
assert captured["season"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_transfer_preview_uses_explicit_fileitems_instead_of_directory(monkeypatch):
|
||||||
|
dir_item = {
|
||||||
|
"storage": "local",
|
||||||
|
"path": "/downloads/Test Show/",
|
||||||
|
"name": "Test Show",
|
||||||
|
"type": "dir",
|
||||||
|
}
|
||||||
|
file_paths = [
|
||||||
|
"/downloads/Test Show/Test.Show.S01E01.mkv",
|
||||||
|
"/downloads/Test Show/Test.Show.S01E02.mkv",
|
||||||
|
"/downloads/Test Show/Test.Show.S01E03.mkv",
|
||||||
|
]
|
||||||
|
selected_fileitems = [
|
||||||
|
{
|
||||||
|
"storage": "local",
|
||||||
|
"path": file_path,
|
||||||
|
"name": file_path.rsplit("/", 1)[-1],
|
||||||
|
"type": "file",
|
||||||
|
}
|
||||||
|
for file_path in file_paths
|
||||||
|
]
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
class FakeTransferChain:
|
||||||
|
def manual_transfer(self, **kwargs):
|
||||||
|
captured.append(kwargs)
|
||||||
|
fileitem = kwargs["fileitem"]
|
||||||
|
return True, {
|
||||||
|
"summary": {"total": 1, "success": 1, "failed": 0},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"source": fileitem.path,
|
||||||
|
"target": f"/library/{fileitem.name}",
|
||||||
|
"target_dir": "/library",
|
||||||
|
"success": True,
|
||||||
|
"message": "",
|
||||||
|
"type": "电视剧",
|
||||||
|
"title": "Test Show (2026)",
|
||||||
|
"season": 1,
|
||||||
|
"episode": 1,
|
||||||
|
"episode_end": None,
|
||||||
|
"part": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
|
||||||
|
|
||||||
|
resp = manual_transfer(
|
||||||
|
transer_item=ManualTransferItem(
|
||||||
|
fileitem=dir_item,
|
||||||
|
fileitems=selected_fileitems,
|
||||||
|
preview=True,
|
||||||
|
),
|
||||||
|
background=False,
|
||||||
|
db=object(),
|
||||||
|
_="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.success is True
|
||||||
|
assert len(captured) == 3
|
||||||
|
assert [item["fileitem"].path for item in captured] == file_paths
|
||||||
|
assert all(item["sync_extra_files"] is False for item in captured)
|
||||||
|
assert resp.data["summary"] == {"total": 3, "success": 3, "failed": 0}
|
||||||
|
assert [item["source"] for item in resp.data["items"]] == file_paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_transfer_preview_multi_select_collects_failures(monkeypatch):
|
||||||
|
file_paths = [
|
||||||
|
"/downloads/Test Show/Test.Show.S01E01.mkv",
|
||||||
|
"/downloads/Test Show/Test.Show.S01E02.mkv",
|
||||||
|
]
|
||||||
|
selected_fileitems = [
|
||||||
|
{
|
||||||
|
"storage": "local",
|
||||||
|
"path": file_path,
|
||||||
|
"name": file_path.rsplit("/", 1)[-1],
|
||||||
|
"type": "file",
|
||||||
|
}
|
||||||
|
for file_path in file_paths
|
||||||
|
]
|
||||||
|
|
||||||
|
class FakeTransferChain:
|
||||||
|
def manual_transfer(self, **kwargs):
|
||||||
|
fileitem = kwargs["fileitem"]
|
||||||
|
if fileitem.path.endswith("E02.mkv"):
|
||||||
|
return False, f"{fileitem.name} 没有找到可整理的媒体文件"
|
||||||
|
return True, {
|
||||||
|
"summary": {"total": 1, "success": 1, "failed": 0},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"source": fileitem.path,
|
||||||
|
"target": f"/library/{fileitem.name}",
|
||||||
|
"target_dir": "/library",
|
||||||
|
"success": True,
|
||||||
|
"message": "",
|
||||||
|
"type": "电视剧",
|
||||||
|
"title": "Test Show (2026)",
|
||||||
|
"season": 1,
|
||||||
|
"episode": 1,
|
||||||
|
"episode_end": None,
|
||||||
|
"part": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
|
||||||
|
|
||||||
|
resp = manual_transfer(
|
||||||
|
transer_item=ManualTransferItem(
|
||||||
|
fileitems=selected_fileitems,
|
||||||
|
preview=True,
|
||||||
|
),
|
||||||
|
background=False,
|
||||||
|
db=object(),
|
||||||
|
_="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.success is True
|
||||||
|
assert resp.data["summary"] == {"total": 2, "success": 1, "failed": 1}
|
||||||
|
assert [item["source"] for item in resp.data["items"]] == file_paths
|
||||||
|
assert resp.data["items"][1]["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommend_episode_format_passes_selected_fileitems(monkeypatch):
|
||||||
|
selected_fileitems = [
|
||||||
|
{
|
||||||
|
"storage": "local",
|
||||||
|
"path": "/downloads/Test Show/Test.Show.S01E01.mkv",
|
||||||
|
"name": "Test.Show.S01E01.mkv",
|
||||||
|
"type": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"storage": "local",
|
||||||
|
"path": "/downloads/Test Show/Test.Show.S01E02.mkv",
|
||||||
|
"name": "Test.Show.S01E02.mkv",
|
||||||
|
"type": "file",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeTransferChain:
|
||||||
|
def recommend_episode_format(self, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return True, "", {"episode_format": "Show.S01E{ep}.mkv"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
|
||||||
|
|
||||||
|
resp = recommend_episode_format(
|
||||||
|
recommend_item=EpisodeFormatRecommendItem(
|
||||||
|
fileitem=selected_fileitems[0],
|
||||||
|
fileitems=selected_fileitems,
|
||||||
|
),
|
||||||
|
_="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.success is True
|
||||||
|
assert captured["fileitem"].path == selected_fileitems[0]["path"]
|
||||||
|
assert [item.path for item in captured["fileitems"]] == [
|
||||||
|
item["path"] for item in selected_fileitems
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommend_episode_format_accepts_fileitems_without_fileitem(monkeypatch):
|
||||||
|
selected_fileitems = [
|
||||||
|
{
|
||||||
|
"storage": "local",
|
||||||
|
"path": "/downloads/Test Show/Test.Show.S01E01.mkv",
|
||||||
|
"name": "Test.Show.S01E01.mkv",
|
||||||
|
"type": "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"storage": "local",
|
||||||
|
"path": "/downloads/Test Show/Test.Show.S01E02.mkv",
|
||||||
|
"name": "Test.Show.S01E02.mkv",
|
||||||
|
"type": "file",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeTransferChain:
|
||||||
|
def recommend_episode_format(self, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return True, "", {"episode_format": "Show.S01E{ep}.mkv"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
|
||||||
|
|
||||||
|
resp = recommend_episode_format(
|
||||||
|
recommend_item=EpisodeFormatRecommendItem(
|
||||||
|
fileitems=selected_fileitems,
|
||||||
|
),
|
||||||
|
_="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.success is True
|
||||||
|
assert captured["fileitem"] is None
|
||||||
|
assert [item.path for item in captured["fileitems"]] == [
|
||||||
|
item["path"] for item in selected_fileitems
|
||||||
|
]
|
||||||
|
|||||||
@@ -682,6 +682,184 @@ class TransferJobManagerTest(unittest.TestCase):
|
|||||||
self.assertEqual("", errmsg)
|
self.assertEqual("", errmsg)
|
||||||
self.assertEqual([main_fileitem.path], planned)
|
self.assertEqual([main_fileitem.path], planned)
|
||||||
|
|
||||||
|
def test_manual_transfer_enables_sync_extra_files(self):
|
||||||
|
chain = make_transfer_chain()
|
||||||
|
captured = {}
|
||||||
|
fileitem = make_fileitem("/downloads/Test Show (2026)/Test.Show.S01E01.2026.mkv")
|
||||||
|
|
||||||
|
def fake_do_transfer(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
chain.do_transfer = fake_do_transfer
|
||||||
|
|
||||||
|
state, errmsg = TransferChain.manual_transfer(
|
||||||
|
chain,
|
||||||
|
fileitem=fileitem,
|
||||||
|
preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(state)
|
||||||
|
self.assertEqual("", errmsg)
|
||||||
|
self.assertTrue(captured["manual"])
|
||||||
|
self.assertTrue(captured["sync_extra_files"])
|
||||||
|
|
||||||
|
def test_manual_transfer_respects_sync_extra_files_argument(self):
|
||||||
|
chain = make_transfer_chain()
|
||||||
|
captured = {}
|
||||||
|
fileitem = make_fileitem("/downloads/Test Show (2026)/Test.Show.S01E01.2026.mkv")
|
||||||
|
|
||||||
|
def fake_do_transfer(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
chain.do_transfer = fake_do_transfer
|
||||||
|
|
||||||
|
state, errmsg = TransferChain.manual_transfer(
|
||||||
|
chain,
|
||||||
|
fileitem=fileitem,
|
||||||
|
preview=True,
|
||||||
|
sync_extra_files=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(state)
|
||||||
|
self.assertEqual("", errmsg)
|
||||||
|
self.assertFalse(captured["sync_extra_files"])
|
||||||
|
|
||||||
|
def test_do_transfer_keeps_manual_single_extra_file_when_epformat_misses(self):
|
||||||
|
chain = make_transfer_chain()
|
||||||
|
planned = []
|
||||||
|
subtitle_fileitem = make_fileitem(
|
||||||
|
"/downloads/Test Show (2026)/Show - 01.sc.ass"
|
||||||
|
)
|
||||||
|
|
||||||
|
chain._TransferChain__put_to_jobview = lambda task: True
|
||||||
|
chain._TransferChain__register_scrape_batch_task = lambda task: None
|
||||||
|
chain._TransferChain__close_scrape_batch = lambda batch_id: None
|
||||||
|
|
||||||
|
def fake_handle_transfer(task, callback=None):
|
||||||
|
planned.append((task.fileitem.path, task.meta.begin_episode))
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
chain._TransferChain__handle_transfer = fake_handle_transfer
|
||||||
|
transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
|
||||||
|
download_history_oper = SimpleNamespace(
|
||||||
|
get_by_hash=lambda download_hash: None,
|
||||||
|
get_file_by_fullpath=lambda fullpath: None,
|
||||||
|
get_files_by_savepath=lambda savepath: [],
|
||||||
|
get_by_path=lambda path: None,
|
||||||
|
)
|
||||||
|
system_config_oper = SimpleNamespace(get=lambda key: None)
|
||||||
|
storage_chain = SimpleNamespace(get_item=lambda fileitem: subtitle_fileitem)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.chain.transfer.TransferHistoryOper",
|
||||||
|
return_value=transfer_history_oper,
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.DownloadHistoryOper",
|
||||||
|
return_value=download_history_oper,
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.SystemConfigOper",
|
||||||
|
return_value=system_config_oper,
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.StorageChain",
|
||||||
|
return_value=storage_chain,
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.MetaInfoPath",
|
||||||
|
side_effect=lambda path, custom_words=None: FakeMeta(1),
|
||||||
|
):
|
||||||
|
state, errmsg = TransferChain.do_transfer(
|
||||||
|
chain,
|
||||||
|
fileitem=subtitle_fileitem,
|
||||||
|
background=False,
|
||||||
|
manual=True,
|
||||||
|
sync_extra_files=True,
|
||||||
|
epformat=EpisodeFormat(format="Show - {ep}.mkv"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(state)
|
||||||
|
self.assertEqual("", errmsg)
|
||||||
|
self.assertEqual([(subtitle_fileitem.path, 1)], planned)
|
||||||
|
|
||||||
|
def test_do_transfer_syncs_extra_files_when_epformat_only_matches_main_video(self):
|
||||||
|
chain = make_transfer_chain()
|
||||||
|
planned = []
|
||||||
|
main_fileitem = make_fileitem(
|
||||||
|
"/downloads/Test Show (2026)/Show - 01.mkv"
|
||||||
|
)
|
||||||
|
subtitle_fileitem = make_fileitem(
|
||||||
|
"/downloads/Test Show (2026)/Show - 01.sc.ass"
|
||||||
|
)
|
||||||
|
parent_fileitem = FileItem(
|
||||||
|
storage="local",
|
||||||
|
path="/downloads/Test Show (2026)/",
|
||||||
|
type="dir",
|
||||||
|
name="Test Show (2026)",
|
||||||
|
)
|
||||||
|
|
||||||
|
chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [
|
||||||
|
(main_fileitem, False)
|
||||||
|
]
|
||||||
|
chain._TransferChain__put_to_jobview = lambda task: True
|
||||||
|
chain._TransferChain__register_scrape_batch_task = lambda task: None
|
||||||
|
chain._TransferChain__close_scrape_batch = lambda batch_id: None
|
||||||
|
|
||||||
|
def fake_handle_transfer(task, callback=None):
|
||||||
|
planned.append((task.fileitem.path, task.meta.begin_episode))
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
chain._TransferChain__handle_transfer = fake_handle_transfer
|
||||||
|
transfer_history_oper = SimpleNamespace(get_by_src=lambda src, storage=None: None)
|
||||||
|
download_history_oper = SimpleNamespace(
|
||||||
|
get_by_hash=lambda download_hash: None,
|
||||||
|
get_file_by_fullpath=lambda fullpath: None,
|
||||||
|
get_files_by_savepath=lambda savepath: [],
|
||||||
|
get_by_path=lambda path: None,
|
||||||
|
)
|
||||||
|
system_config_oper = SimpleNamespace(get=lambda key: None)
|
||||||
|
storage_chain = SimpleNamespace(
|
||||||
|
get_parent_item=lambda fileitem: parent_fileitem,
|
||||||
|
list_files=lambda fileitem, recursion=False: [
|
||||||
|
main_fileitem,
|
||||||
|
subtitle_fileitem,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.chain.transfer.TransferHistoryOper",
|
||||||
|
return_value=transfer_history_oper,
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.DownloadHistoryOper",
|
||||||
|
return_value=download_history_oper,
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.SystemConfigOper",
|
||||||
|
return_value=system_config_oper,
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.StorageChain",
|
||||||
|
return_value=storage_chain,
|
||||||
|
), patch(
|
||||||
|
"app.chain.transfer.MetaInfoPath",
|
||||||
|
side_effect=lambda path, custom_words=None: FakeMeta(1),
|
||||||
|
):
|
||||||
|
state, errmsg = TransferChain.do_transfer(
|
||||||
|
chain,
|
||||||
|
fileitem=main_fileitem,
|
||||||
|
background=False,
|
||||||
|
manual=True,
|
||||||
|
sync_extra_files=True,
|
||||||
|
epformat=EpisodeFormat(format="Show - {ep}.mkv"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(state)
|
||||||
|
self.assertEqual("", errmsg)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
(main_fileitem.path, 1),
|
||||||
|
(subtitle_fileitem.path, 1),
|
||||||
|
],
|
||||||
|
planned,
|
||||||
|
)
|
||||||
|
|
||||||
def test_do_transfer_syncs_matching_extra_files_for_each_main_video(self):
|
def test_do_transfer_syncs_matching_extra_files_for_each_main_video(self):
|
||||||
chain = make_transfer_chain()
|
chain = make_transfer_chain()
|
||||||
planned = []
|
planned = []
|
||||||
|
|||||||
Reference in New Issue
Block a user