From 6c3c5e042d7e90df3fd2d7e651f90f35a5676e18 Mon Sep 17 00:00:00 2001 From: Album <51018113+Mister-album@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:55:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=8B=E5=8A=A8=E6=95=B4?= =?UTF-8?q?=E7=90=86=E6=8C=89=E9=9B=86=E6=95=B0=E5=AE=9A=E4=BD=8D=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E8=BF=87=E6=BB=A4=20(#6043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/transfer.py | 13 +- app/chain/transfer.py | 84 ++-- tests/test_transfer_history_retransfer.py | 105 +++++ tests/test_transfer_job_manager.py | 15 +- tests/test_transfer_sync_extra_files.py | 449 +++++++++++++++++++++- 5 files changed, 630 insertions(+), 36 deletions(-) diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 9a599da6..05f1825f 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -6,7 +6,6 @@ from sqlalchemy.orm import Session from app import schemas from app.chain.media import MediaChain -from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.core.config import settings, global_vars from app.core.security import verify_token, verify_apitoken @@ -256,6 +255,7 @@ def manual_transfer( downloader = None download_hash = None src_fileitems: List[FileItem] = [] + cleanup_dest_fileitem: Optional[FileItem] = None target_path = Path(transer_item.target_path) if transer_item.target_path else None if transer_item.logid: # 查询历史记录 @@ -274,15 +274,8 @@ def manual_transfer( else: # 源路径 src_fileitems = [FileItem(**history.src_fileitem)] - # 目的路径 if history.dest_fileitem and not transer_item.preview: - # 删除旧的已整理文件 - dest_fileitem = FileItem(**history.dest_fileitem) - state = StorageChain().delete_media_file(dest_fileitem) - if not state: - return schemas.Response( - success=False, message=f"{dest_fileitem.path} 删除失败" - ) + cleanup_dest_fileitem = FileItem(**history.dest_fileitem) # 从历史数据获取信息 if transer_item.from_history: @@ -427,6 +420,7 @@ def manual_transfer( download_hash=download_hash, preview=transer_item.preview, sync_extra_files=False, + cleanup_dest_fileitem=cleanup_dest_fileitem, ) if transer_item.preview: if isinstance(errormsg, dict): @@ -508,6 +502,7 @@ def manual_transfer( download_hash=download_hash, preview=transer_item.preview, sync_extra_files=True, + cleanup_dest_fileitem=cleanup_dest_fileitem, ) # 失败 if not state: diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 341d684d..3e35a564 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -2530,6 +2530,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): manual: Optional[bool] = False, preview: Optional[bool] = False, sync_extra_files: Optional[bool] = False, + cleanup_dest_fileitem: Optional[FileItem] = None, continue_callback: Callable = None, ) -> Tuple[bool, Union[str, dict]]: """ @@ -2554,6 +2555,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param manual: 是否手动整理 :param preview: 是否仅预览 :param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件 + :param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件 :param continue_callback: 继续处理回调 返回:成功标识,错误信息 """ @@ -2563,9 +2565,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if preview: # 预览模式始终同步执行,避免进入异步队列 background = False - manual_single_file = bool(manual and fileitem and fileitem.type == "file") - # 自定义格式 + has_episode_format_template = bool(epformat and epformat.format) formaterHandler = ( FormatParser( eformat=epformat.format, @@ -2583,6 +2584,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) # 汇总错误信息 err_msgs: List[str] = [] + matched_episode_format_template = False def _build_file_meta( source_path: Path, @@ -2641,29 +2643,20 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return current_meta - def _filter(item: FileItem, is_bluray_dir: bool) -> bool: + def _is_allowed_transfer_item(item: FileItem, is_bluray_dir: bool) -> bool: """ - 过滤文件项 + 判断候选文件项是否允许进入整理规划。 :return: True 表示保留,False 表示排除 """ + nonlocal matched_episode_format_template 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 should_bypass_epformat_match - and not formaterHandler.match(item.name) - ): - return False + # 存在集数定位模板时,模板匹配结果作为手动整理的硬过滤条件。 + if has_episode_format_template and formaterHandler: + if not formaterHandler.match(item.name): + return False + matched_episode_format_template = True # 过滤后缀和大小(蓝光目录、附加文件不过滤) if ( not is_bluray_dir @@ -2690,6 +2683,32 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return False return True + def _keep_candidate_item(item: FileItem, is_bluray_dir: bool) -> bool: + """ + 收集候选文件时仅检查中断状态,不套用整理业务过滤。 + """ + if continue_callback and not continue_callback(): + raise OperationInterrupted() + return True + + def _collect_candidate_file_items() -> List[Tuple[FileItem, bool]]: + """ + 收集来源下的候选文件项,不在此阶段套用整理业务过滤。 + """ + return self.__get_trans_fileitems(fileitem, predicate=_keep_candidate_item) + + def _filter_allowed_file_items( + candidates: List[Tuple[FileItem, bool]] + ) -> List[Tuple[FileItem, bool]]: + """ + 将候选文件项筛选为本轮允许整理的文件项。 + """ + return [ + (candidate_item, candidate_bluray_dir) + for candidate_item, candidate_bluray_dir in candidates + if _is_allowed_transfer_item(candidate_item, candidate_bluray_dir) + ] + def _build_main_meta( main_fileitem: FileItem, main_bluray_dir: bool, @@ -2771,7 +2790,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): continue if not (self.__is_subtitle_file(item) or self.__is_audio_file(item)): continue - if not _filter(item, False): + if not _is_allowed_transfer_item(item, False): continue extra_items.append((item, False)) return main_fileitems, extra_items @@ -2918,19 +2937,36 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return planned_items, inherited_map + candidate_file_items: List[Tuple[FileItem, bool]] = [] try: - # 获取经过筛选后的待整理文件项列表 - file_items = self.__get_trans_fileitems(fileitem, predicate=_filter) + candidate_file_items = _collect_candidate_file_items() + file_items = _filter_allowed_file_items(candidate_file_items) except OperationInterrupted: return False, f"{fileitem.name} 已取消" + finally: + candidate_file_items.clear() if not file_items: + if has_episode_format_template and not matched_episode_format_template: + logger.info(f"{fileitem.path} 未匹配到集数定位模板,跳过整理") + if preview: + return True, { + "summary": {"total": 0, "success": 0, "failed": 0}, + "items": [], + "message": "", + } + return True, "" logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件") return False, f"{fileitem.name} 没有找到可整理的媒体文件" file_items, inherited_meta_map = _plan_file_items(file_items) planned_file_count = len(file_items) + if cleanup_dest_fileitem and planned_file_count and not preview: + state = StorageChain().delete_media_file(cleanup_dest_fileitem) + if not state: + return False, f"{cleanup_dest_fileitem.path} 删除失败" + if preview: logger.info(f"正在预览 {planned_file_count} 个文件的整理路径...") else: @@ -3394,6 +3430,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): download_hash: Optional[str] = None, preview: Optional[bool] = False, sync_extra_files: Optional[bool] = True, + cleanup_dest_fileitem: Optional[FileItem] = None, ) -> Tuple[bool, Union[str, dict]]: """ 手动整理,支持复杂条件,带进度显示 @@ -3417,6 +3454,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param download_hash: 下载任务哈希 :param preview: 是否仅预览 :param sync_extra_files: 是否同步整理同媒体附加文件 + :param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件 """ logger.info(f"手动整理:{fileitem.path} ...") if tmdbid or doubanid: @@ -3457,6 +3495,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): download_hash=download_hash, preview=preview, sync_extra_files=sync_extra_files, + cleanup_dest_fileitem=cleanup_dest_fileitem, ) if not state: return False, errmsg @@ -3483,6 +3522,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): download_hash=download_hash, preview=preview, sync_extra_files=sync_extra_files, + cleanup_dest_fileitem=cleanup_dest_fileitem, ) return state, errmsg diff --git a/tests/test_transfer_history_retransfer.py b/tests/test_transfer_history_retransfer.py index 59a22666..f7522d18 100644 --- a/tests/test_transfer_history_retransfer.py +++ b/tests/test_transfer_history_retransfer.py @@ -53,6 +53,111 @@ def test_manual_transfer_from_history_preserves_download_context(monkeypatch): assert captured["season"] == 1 +def test_manual_transfer_from_history_passes_old_dest_cleanup_to_chain(monkeypatch): + history = SimpleNamespace( + status=0, + mode="copy", + src_fileitem={ + "storage": "local", + "path": "/downloads/test.mkv", + "name": "test.mkv", + "type": "file", + }, + dest_fileitem={ + "storage": "local", + "path": "/library/test.mkv", + "name": "test.mkv", + "type": "file", + }, + downloader="qbittorrent", + download_hash="abc123", + type=None, + tmdbid=None, + doubanid=None, + seasons=None, + episodes=None, + episode_group=None, + ) + captured = {} + + def fake_get(_db, logid): + assert logid == 1 + return history + + class FakeTransferChain: + def manual_transfer(self, **kwargs): + captured.update(kwargs) + return True, "" + + monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get) + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) + + resp = manual_transfer( + transer_item=ManualTransferItem(logid=1), + background=False, + db=object(), + _="token", + ) + + assert resp.success is True + assert captured["fileitem"].path == "/downloads/test.mkv" + assert captured["cleanup_dest_fileitem"].path == "/library/test.mkv" + + +def test_manual_transfer_from_history_preview_does_not_cleanup_old_dest(monkeypatch): + history = SimpleNamespace( + status=0, + mode="copy", + src_fileitem={ + "storage": "local", + "path": "/downloads/test.mkv", + "name": "test.mkv", + "type": "file", + }, + dest_fileitem={ + "storage": "local", + "path": "/library/test.mkv", + "name": "test.mkv", + "type": "file", + }, + downloader="qbittorrent", + download_hash="abc123", + type=None, + tmdbid=None, + doubanid=None, + seasons=None, + episodes=None, + episode_group=None, + ) + captured = {} + + def fake_get(_db, logid): + assert logid == 1 + return history + + class FakeTransferChain: + def manual_transfer(self, **kwargs): + captured.update(kwargs) + return True, { + "summary": {"total": 0, "success": 0, "failed": 0}, + "items": [], + "message": "", + } + + monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get) + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain) + + resp = manual_transfer( + transer_item=ManualTransferItem(logid=1, preview=True), + background=False, + db=object(), + _="token", + ) + + assert resp.success is True + assert captured["cleanup_dest_fileitem"] is None + + def test_manual_transfer_preview_uses_explicit_fileitems_instead_of_directory(monkeypatch): dir_item = { "storage": "local", diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index 7e02f74b..25814a4a 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -807,7 +807,7 @@ class TransferJobManagerTest(unittest.TestCase): self.assertEqual("", errmsg) self.assertFalse(captured["sync_extra_files"]) - def test_do_transfer_keeps_manual_single_extra_file_when_epformat_misses(self): + def test_do_transfer_skips_manual_single_file_when_epformat_misses(self): chain = make_transfer_chain() planned = [] subtitle_fileitem = make_fileitem( @@ -854,13 +854,21 @@ class TransferJobManagerTest(unittest.TestCase): fileitem=subtitle_fileitem, background=False, manual=True, + preview=True, sync_extra_files=True, epformat=EpisodeFormat(format="Show - {ep}.mkv"), ) self.assertTrue(state) - self.assertEqual("", errmsg) - self.assertEqual([(subtitle_fileitem.path, 1)], planned) + self.assertEqual( + { + "summary": {"total": 0, "success": 0, "failed": 0}, + "items": [], + "message": "", + }, + errmsg, + ) + self.assertEqual([], planned) def test_do_transfer_syncs_extra_files_when_epformat_only_matches_main_video(self): chain = make_transfer_chain() @@ -936,7 +944,6 @@ class TransferJobManagerTest(unittest.TestCase): self.assertEqual( [ (main_fileitem.path, 1), - (subtitle_fileitem.path, 1), ], planned, ) diff --git a/tests/test_transfer_sync_extra_files.py b/tests/test_transfer_sync_extra_files.py index e661e2a7..ceb054ef 100644 --- a/tests/test_transfer_sync_extra_files.py +++ b/tests/test_transfer_sync_extra_files.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from app.chain.transfer import JobManager, TransferChain from app.core.config import settings -from app.schemas import FileItem +from app.schemas import EpisodeFormat, FileItem from app.schemas.types import MediaType @@ -356,3 +356,450 @@ def test_single_video_transfer_lists_parent_once_for_same_name_extra(monkeypatch assert errmsg == "" assert planned == [main_fileitem.path, subtitle_fileitem.path] assert list_files_calls == [(parent_fileitem.path, False)] + + +def test_episode_format_filters_extra_files_before_sync_planning(monkeypatch): + """ + 存在集数定位模板时,不匹配模板的附加文件不应被主视频带入整理计划。 + """ + 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)", + ) + + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + lambda fileitem, predicate: [ + (main_fileitem, False), + (subtitle_fileitem, False), + ], + ) + monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) + monkeypatch.setattr( + chain, + "_TransferChain__register_scrape_batch_task", + lambda task: None, + ) + monkeypatch.setattr( + chain, + "_TransferChain__close_scrape_batch", + lambda batch_id: None, + ) + + def fake_handle_transfer(task, callback=None): + """ + 记录进入整理执行阶段的文件。 + """ + planned.append(task.fileitem.path) + return True, "" + + monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer) + monkeypatch.setattr( + "app.chain.transfer.TransferHistoryOper", + lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), + ) + monkeypatch.setattr( + "app.chain.transfer.DownloadHistoryOper", + lambda: 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, + ), + ) + monkeypatch.setattr( + "app.chain.transfer.SystemConfigOper", + lambda: SimpleNamespace(get=lambda key: None), + ) + monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1)) + + state, errmsg = TransferChain.do_transfer( + chain, + fileitem=parent_fileitem, + background=False, + sync_extra_files=True, + epformat=EpisodeFormat(format="Show - {ep}.mkv"), + ) + + assert state is True + assert errmsg == "" + assert planned == [main_fileitem.path] + + +def test_episode_format_keeps_matching_extra_files_following_main(monkeypatch): + """ + 附加文件自身匹配集数定位模板时,仍可跟随同名主视频整理。 + """ + 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.ass" + ) + parent_fileitem = FileItem( + storage="local", + path="/downloads/Test Show (2026)/", + type="dir", + name="Test Show (2026)", + ) + + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + lambda fileitem, predicate: [ + (main_fileitem, False), + (subtitle_fileitem, False), + ], + ) + monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) + monkeypatch.setattr( + chain, + "_TransferChain__register_scrape_batch_task", + lambda task: None, + ) + monkeypatch.setattr( + 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, "" + + monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer) + monkeypatch.setattr( + "app.chain.transfer.TransferHistoryOper", + lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), + ) + monkeypatch.setattr( + "app.chain.transfer.DownloadHistoryOper", + lambda: 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, + ), + ) + monkeypatch.setattr( + "app.chain.transfer.SystemConfigOper", + lambda: SimpleNamespace(get=lambda key: None), + ) + monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1)) + + state, errmsg = TransferChain.do_transfer( + chain, + fileitem=parent_fileitem, + background=False, + sync_extra_files=True, + epformat=EpisodeFormat(format="Show - {ep}.{a}"), + ) + + assert state is True + assert errmsg == "" + assert planned == [ + (main_fileitem.path, 1), + (subtitle_fileitem.path, 1), + ] + + +def test_single_matching_subtitle_uses_unmatched_video_only_as_context(monkeypatch): + """ + 单独整理匹配模板的字幕时,同名主视频只提供识别上下文,不会被额外加入整理计划。 + """ + chain = make_transfer_chain() + planned = [] + main_fileitem = make_fileitem( + "/downloads/Test Show (2026)/Show - 02.mkv" + ) + subtitle_fileitem = make_fileitem( + "/downloads/Test Show (2026)/Show - 02.ass" + ) + parent_fileitem = FileItem( + storage="local", + path="/downloads/Test Show (2026)/", + type="dir", + name="Test Show (2026)", + ) + + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + lambda fileitem, predicate: [(subtitle_fileitem, False)], + ) + monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) + monkeypatch.setattr( + chain, + "_TransferChain__register_scrape_batch_task", + lambda task: None, + ) + monkeypatch.setattr( + 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, "" + + def fake_meta_info_path(path, custom_words=None): + """ + 模拟字幕自身识别不准,但同名主视频可提供正确集数。 + """ + file_name = Path(path).name + if file_name.endswith(".mkv"): + return FakeMeta(2) + return FakeMeta(1) + + monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer) + monkeypatch.setattr( + "app.chain.transfer.TransferHistoryOper", + lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), + ) + monkeypatch.setattr( + "app.chain.transfer.DownloadHistoryOper", + lambda: 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, + ), + ) + monkeypatch.setattr( + "app.chain.transfer.SystemConfigOper", + lambda: SimpleNamespace(get=lambda key: None), + ) + monkeypatch.setattr( + "app.chain.transfer.StorageChain", + lambda: SimpleNamespace( + get_parent_item=lambda fileitem: parent_fileitem, + list_files=lambda fileitem, recursion=False: [ + main_fileitem, + subtitle_fileitem, + ], + ), + ) + monkeypatch.setattr("app.chain.transfer.MetaInfoPath", fake_meta_info_path) + + state, errmsg = TransferChain.do_transfer( + chain, + fileitem=subtitle_fileitem, + background=False, + sync_extra_files=True, + epformat=EpisodeFormat(format="Show - {ep}.ass"), + ) + + assert state is True + assert errmsg == "" + assert planned == [(subtitle_fileitem.path, 2)] + + +def test_cleanup_dest_fileitem_is_deleted_only_after_allowed_items_exist(monkeypatch): + """ + 旧目标文件只应在模板筛选后确实存在待整理任务时清理。 + """ + chain = make_transfer_chain() + delete_calls = [] + planned = [] + main_fileitem = make_fileitem( + "/downloads/Test Show (2026)/Show - 01.mkv" + ) + old_dest_fileitem = make_fileitem( + "/library/Test Show/Show - 01.mkv" + ) + + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + lambda fileitem, predicate: [(main_fileitem, False)], + ) + monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) + monkeypatch.setattr( + chain, + "_TransferChain__register_scrape_batch_task", + lambda task: None, + ) + monkeypatch.setattr( + chain, + "_TransferChain__close_scrape_batch", + lambda batch_id: None, + ) + + def fake_handle_transfer(task, callback=None): + """ + 记录旧目标清理后的整理任务。 + """ + planned.append(task.fileitem.path) + return True, "" + + monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer) + monkeypatch.setattr( + "app.chain.transfer.TransferHistoryOper", + lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), + ) + monkeypatch.setattr( + "app.chain.transfer.DownloadHistoryOper", + lambda: 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, + ), + ) + monkeypatch.setattr( + "app.chain.transfer.SystemConfigOper", + lambda: SimpleNamespace(get=lambda key: None), + ) + monkeypatch.setattr( + "app.chain.transfer.StorageChain", + lambda: SimpleNamespace( + delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True, + ), + ) + monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1)) + + state, errmsg = TransferChain.do_transfer( + chain, + fileitem=main_fileitem, + background=False, + epformat=EpisodeFormat(format="Show - {ep}.mkv"), + cleanup_dest_fileitem=old_dest_fileitem, + ) + + assert state is True + assert errmsg == "" + assert delete_calls == [old_dest_fileitem.path] + assert planned == [main_fileitem.path] + + +def test_cleanup_dest_fileitem_is_kept_when_episode_format_matches_nothing(monkeypatch): + """ + 集数定位模板匹配不到文件时,不应清理历史记录中的旧目标文件。 + """ + chain = make_transfer_chain() + delete_calls = [] + source_fileitem = make_fileitem( + "/downloads/Test Show (2026)/Show - 01.sc.ass" + ) + old_dest_fileitem = make_fileitem( + "/library/Test Show/Show - 01.sc.ass" + ) + + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + lambda fileitem, predicate: [(source_fileitem, False)], + ) + monkeypatch.setattr( + "app.chain.transfer.SystemConfigOper", + lambda: SimpleNamespace(get=lambda key: None), + ) + monkeypatch.setattr( + "app.chain.transfer.StorageChain", + lambda: SimpleNamespace( + delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True, + ), + ) + + state, errmsg = TransferChain.do_transfer( + chain, + fileitem=source_fileitem, + background=False, + epformat=EpisodeFormat(format="Show - {ep}.mkv"), + cleanup_dest_fileitem=old_dest_fileitem, + ) + + assert state is True + assert errmsg == "" + assert delete_calls == [] + + +def test_episode_format_matched_but_filtered_by_size_returns_failure(monkeypatch): + """ + 文件名匹配集数定位模板但被大小过滤时,不应误报为模板无匹配的安全跳过。 + """ + chain = make_transfer_chain() + source_fileitem = make_fileitem( + "/downloads/Test Show (2026)/Show - 01.mkv" + ) + + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + lambda fileitem, predicate: [(source_fileitem, False)], + ) + monkeypatch.setattr( + "app.chain.transfer.SystemConfigOper", + lambda: SimpleNamespace(get=lambda key: None), + ) + + state, errmsg = TransferChain.do_transfer( + chain, + fileitem=source_fileitem, + background=False, + epformat=EpisodeFormat(format="Show - {ep}.mkv"), + min_filesize=2, + ) + + assert state is False + assert errmsg == f"{source_fileitem.name} 没有找到可整理的媒体文件" + + +def test_candidate_collection_checks_continue_callback(monkeypatch): + """ + 候选文件收集阶段应响应取消,避免大目录或远程存储继续完整遍历。 + """ + chain = make_transfer_chain() + source_fileitem = make_fileitem( + "/downloads/Test Show (2026)/Show - 01.mkv" + ) + callback_calls = [] + + def fake_get_trans_fileitems(fileitem, predicate): + """ + 模拟递归收集候选文件时调用 predicate。 + """ + callback_calls.append("collect") + predicate(source_fileitem, False) + return [(source_fileitem, False)] + + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + fake_get_trans_fileitems, + ) + monkeypatch.setattr( + "app.chain.transfer.SystemConfigOper", + lambda: SimpleNamespace(get=lambda key: None), + ) + + state, errmsg = TransferChain.do_transfer( + chain, + fileitem=source_fileitem, + background=False, + continue_callback=lambda: False, + ) + + assert state is False + assert errmsg == f"{source_fileitem.name} 已取消" + assert callback_calls == ["collect"]