From 2eb7f57a4c3b879b9d1dcae29970451b64134d42 Mon Sep 17 00:00:00 2001 From: Album <51018113+Mister-album@users.noreply.github.com> Date: Sat, 23 May 2026 09:23:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=9A=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=89=8B=E5=8A=A8=E6=95=B4=E7=90=86=E4=B8=8E=E9=9B=86?= =?UTF-8?q?=E6=95=B0=E5=AE=9A=E4=BD=8D=E6=A8=A1=E6=9D=BF=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=20(#5820)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/transfer.py | 140 ++++++++++++++- app/chain/transfer.py | 96 ++++++++-- app/schemas/transfer.py | 5 +- tests/test_episode_format_helper.py | 95 ++++++++++ tests/test_transfer_history_retransfer.py | 207 +++++++++++++++++++++- tests/test_transfer_job_manager.py | 178 +++++++++++++++++++ 6 files changed, 703 insertions(+), 18 deletions(-) diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index d2d4fc4a..de178da2 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -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}") diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 730848cb..b11e1740 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -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 diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 0fb2fd73..dc7a623c 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -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 # 目标存储 diff --git a/tests/test_episode_format_helper.py b/tests/test_episode_format_helper.py index 41f2432f..0a9b572e 100644 --- a/tests/test_episode_format_helper.py +++ b/tests/test_episode_format_helper.py @@ -485,6 +485,101 @@ def test_transfer_chain_recommend_episode_format_passes_helper_data(monkeypatch) 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): chain = object.__new__(TransferChain) directory = FileItem( diff --git a/tests/test_transfer_history_retransfer.py b/tests/test_transfer_history_retransfer.py index 33c8bad8..225010c1 100644 --- a/tests/test_transfer_history_retransfer.py +++ b/tests/test_transfer_history_retransfer.py @@ -6,8 +6,8 @@ import sys sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites")) setattr(sys.modules["app.helper.sites"], "SitesHelper", object) -from app.api.endpoints.transfer import manual_transfer -from app.schemas import ManualTransferItem +from app.api.endpoints.transfer import manual_transfer, recommend_episode_format +from app.schemas import ManualTransferItem, EpisodeFormatRecommendItem 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["episode_group"] == "WEB-DL" 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 + ] diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index 09355476..dd6d3b06 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -682,6 +682,184 @@ class TransferJobManagerTest(unittest.TestCase): self.assertEqual("", errmsg) 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): chain = make_transfer_chain() planned = []