From 617692616c51c377deb30c156ce0aa82b0234877 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 20 May 2026 22:09:57 +0800 Subject: [PATCH] fix: build complete transfer result at source --- app/chain/transfer.py | 32 +--- app/modules/filemanager/transhandler.py | 44 ++++++ app/schemas/transfer.py | 81 ---------- tests/test_transfer_info.py | 97 ------------ tests/test_transfer_job_manager.py | 195 +++++++++++++++++------- 5 files changed, 190 insertions(+), 259 deletions(-) delete mode 100644 tests/test_transfer_info.py diff --git a/app/chain/transfer.py b/app/chain/transfer.py index b564056a..f3c29f9e 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -871,9 +871,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 整理完成后处理 """ - if transferinfo: - transferinfo.ensure_target_items() - # 状态 ret_status = True # 错误信息 @@ -1162,7 +1159,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ if not transferinfo: return None - transferinfo.ensure_target_items() if transferinfo.target_diritem and transferinfo.target_diritem.path: return transferinfo.target_diritem.path if transferinfo.target_item and transferinfo.target_item.path: @@ -1171,30 +1167,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return Path(transferinfo.file_list_new[0]).parent.as_posix() return None - def __build_transfer_target_diritem( - self, transferinfo: Optional[TransferInfo] - ) -> Optional[FileItem]: - """ - 构建整理目标目录项,避免成功结果缺少 target_diritem 时阻断后续流程。 - """ - if not transferinfo: - return None - transferinfo.ensure_target_items() - if transferinfo.target_diritem: - return transferinfo.target_diritem - target_dir_path = self.__get_transfer_target_dir_path(transferinfo) - if not target_dir_path: - return None - target_path = Path(target_dir_path) - storage = transferinfo.target_item.storage if transferinfo.target_item else "local" - return FileItem( - storage=storage, - path=target_dir_path, - type="dir", - name=target_path.name, - basename=target_path.stem, - ) - def put_to_queue(self, task: TransferTask) -> bool: """ 添加到待整理队列 @@ -1248,7 +1220,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ): return - target_diritem = self.__build_transfer_target_diritem(transferinfo) + target_diritem = transferinfo.target_diritem if not target_diritem: return @@ -1311,7 +1283,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ): return - target_diritem = self.__build_transfer_target_diritem(transferinfo) + target_diritem = transferinfo.target_diritem if not target_diritem: return diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index 5585690b..240f9f33 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -127,6 +127,41 @@ class TransHandler: size=size if item_type == "file" else None, ) + @staticmethod + def __build_transfer_target_diritem( + target_storage: str, target_path: Path + ) -> FileItem: + """ + 按已确认的目标目录路径构造整理结果目录项。 + """ + return FileItem( + storage=target_storage, + path=TransHandler.__format_dir_path(target_path), + name=target_path.name, + basename=target_path.stem, + type="dir", + ) + + @staticmethod + def __format_dir_path(path: Path) -> str: + """ + 按 FileItem 目录路径约定返回以斜杠结尾的路径。 + """ + path_str = path.as_posix() + if path_str != "/" and not path_str.endswith("/"): + return f"{path_str}/" + return path_str + + @staticmethod + def __normalize_dir_path(path: Optional[str]) -> str: + """ + 比较目录路径时忽略尾部斜杠,兼容不同存储返回的目录项格式。 + """ + if not path: + return "" + path_str = Path(str(path)).as_posix().rstrip("/") + return path_str or "/" + def transfer_media( self, fileitem: FileItem, @@ -281,6 +316,10 @@ class TransHandler: return result logger.info(f"文件夹 {fileitem.path} 整理成功") + if self.__normalize_dir_path(new_diritem.path) != self.__normalize_dir_path(new_path.as_posix()): + new_diritem = self.__build_transfer_target_diritem( + target_storage=target_storage, target_path=new_path + ) # 返回整理后的路径 self.__update_result( result=result, @@ -401,6 +440,11 @@ class TransHandler: need_notify=need_notify, ) return result + # 目标目录已创建成功但元数据可能短暂缺字段,整理结果直接按目标路径补全。 + if self.__normalize_dir_path(target_diritem.path) != self.__normalize_dir_path(folder_path.as_posix()): + target_diritem = self.__build_transfer_target_diritem( + target_storage=target_storage, target_path=folder_path + ) # 判断是否要覆盖,附加文件强制覆盖 overflag = False diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 90e8813e..0fb2fd73 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -134,87 +134,6 @@ class TransferInfo(BaseModel): # 是否需要通知 need_notify: Optional[bool] = False - def ensure_target_items(self): - """ - 补齐整理目标文件项和目录项,兼容 OpenList 等成功后元数据延迟可见的存储。 - """ - if not self.target_item or not self.target_item.path: - self.target_item = self.__build_target_item() - if not self.target_diritem or not self.target_diritem.path: - self.target_diritem = self.__build_target_diritem() - return self - - def __build_target_item(self) -> Optional[FileItem]: - """ - 根据目标文件清单构造整理目标文件项。 - """ - if self.target_item and self.target_item.path: - return self.target_item - if not self.file_list_new: - return self.target_item - - target_path = self.file_list_new[0] - if not target_path: - return self.target_item - - path = Path(str(target_path)) - source_item = self.fileitem - return FileItem( - storage=self.__get_target_storage(), - path=path.as_posix(), - type=source_item.type if source_item and source_item.type else "file", - name=path.name, - basename=path.stem, - extension=path.suffix.lstrip("."), - size=source_item.size if source_item else None, - modify_time=source_item.modify_time if source_item else None, - thumbnail=source_item.thumbnail if source_item else None, - ) - - def __build_target_diritem(self) -> Optional[FileItem]: - """ - 根据目标文件项或目标文件清单构造整理目标目录项。 - """ - if self.target_diritem and self.target_diritem.path: - return self.target_diritem - - target_dir_path = self.__get_target_dir_path() - if not target_dir_path: - return self.target_diritem - - path = Path(str(target_dir_path)) - return FileItem( - storage=self.__get_target_storage(), - path=path.as_posix(), - type="dir", - name=path.name, - basename=path.stem, - ) - - def __get_target_dir_path(self) -> Optional[str]: - """ - 从已知整理结果中推导媒体目标目录路径。 - """ - if self.target_item and self.target_item.path: - if self.target_item.type == "dir": - return self.target_item.path - return Path(str(self.target_item.path)).parent.as_posix() - if self.file_list_new: - return Path(str(self.file_list_new[0])).parent.as_posix() - return None - - def __get_target_storage(self) -> str: - """ - 从目标项、目标目录项或源文件项中推导目标存储标识。 - """ - if self.target_item and self.target_item.storage: - return self.target_item.storage - if self.target_diritem and self.target_diritem.storage: - return self.target_diritem.storage - if self.fileitem and self.fileitem.storage: - return self.fileitem.storage - return "local" - def to_dict(self): """ 返回字典 diff --git a/tests/test_transfer_info.py b/tests/test_transfer_info.py deleted file mode 100644 index 3e99f24d..00000000 --- a/tests/test_transfer_info.py +++ /dev/null @@ -1,97 +0,0 @@ -import unittest - -from app.schemas import FileItem, TransferInfo - - -class TransferInfoTest(unittest.TestCase): - def test_ensure_target_items_fills_missing_target_items_from_target_path(self): - """ - 整理结果只有目标路径清单时,应补齐目标文件项和目录项。 - """ - transferinfo = TransferInfo( - success=True, - fileitem=FileItem( - storage="alist", - path="/downloads/Test.Show.S01E01.mkv", - type="file", - name="Test.Show.S01E01.mkv", - size=1024, - ), - file_list_new=[ - "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv" - ], - transfer_type="move", - ) - - transferinfo.ensure_target_items() - - self.assertIsNotNone(transferinfo.target_item) - self.assertIsNotNone(transferinfo.target_diritem) - self.assertEqual( - "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv", - transferinfo.target_item.path, - ) - self.assertEqual( - "/library/Test Show (2026)/Season 1", - transferinfo.target_diritem.path, - ) - self.assertEqual("alist", transferinfo.target_item.storage) - self.assertEqual("alist", transferinfo.target_diritem.storage) - - def test_ensure_target_items_keeps_new_model_initial_state(self): - """ - 新建整理结果模型不应立即改写目标项,避免影响失败记录等非事件流程。 - """ - transferinfo = TransferInfo( - success=True, - fileitem=FileItem( - storage="alist", - path="/downloads/Test.Show.S01E01.mkv", - type="file", - name="Test.Show.S01E01.mkv", - ), - file_list_new=[ - "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv" - ], - transfer_type="move", - ) - - self.assertIsNone(transferinfo.target_item) - self.assertIsNone(transferinfo.target_diritem) - - def test_ensure_target_items_fills_missing_target_diritem_from_target_item(self): - """ - 目标文件项已存在但目录项缺失时,应从目标文件路径推导目录项。 - """ - transferinfo = TransferInfo( - success=True, - fileitem=FileItem( - storage="alist", - path="/downloads/Test.Show.S01E02.mkv", - type="file", - name="Test.Show.S01E02.mkv", - ), - target_item=FileItem( - storage="alist", - path="/library/Test Show (2026)/Season 1/Test.Show.S01E02.mkv", - type="file", - name="Test.Show.S01E02.mkv", - ), - file_list_new=[ - "/library/Test Show (2026)/Season 1/Test.Show.S01E02.mkv" - ], - transfer_type="move", - ) - - transferinfo.ensure_target_items() - - self.assertIsNotNone(transferinfo.target_diritem) - self.assertEqual( - "/library/Test Show (2026)/Season 1", - transferinfo.target_diritem.path, - ) - self.assertEqual("alist", transferinfo.target_diritem.storage) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index ff205201..55abbc0f 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -4,6 +4,8 @@ from types import SimpleNamespace from unittest.mock import patch, MagicMock from app.core.config import settings +from app.core.context import MediaInfo +from app.core.meta import MetaVideo from app.chain.transfer import JobManager, TransferChain from app.modules.filemanager.transhandler import TransHandler from app.schemas import EpisodeFormat, FileItem, TransferInfo, TransferTask @@ -73,6 +75,20 @@ class FakeMedia: } +def make_media_info() -> MediaInfo: + media = MediaInfo() + media.type = MediaType.TV + media.title = "Test Show" + media.title_year = "Test Show (2026)" + media.year = "2026" + media.tmdb_id = 12345 + media.category = "" + media.actors = [] + media.season_years = {} + media.vote_average = 0 + return media + + def make_task(episode: int, season: int = 1) -> TransferTask: name = f"Test.Show.S{season:02d}E{episode:02d}.mkv" return TransferTask( @@ -176,6 +192,134 @@ class TransferJobManagerTest(unittest.TestCase): self.assertEqual("file", new_item.type) self.assertEqual(1024, new_item.size) + def test_transfer_media_passes_complete_result_when_target_metadata_is_delayed(self): + """ + 整理成功时应在结果源头带上完整目标项,回调和事件不再二次拼装。 + """ + handler = TransHandler() + source_item = FileItem( + storage="alist", + path="/downloads/Test.Show.S01E01.mkv", + type="file", + name="Test.Show.S01E01.mkv", + basename="Test.Show.S01E01", + extension="mkv", + size=1024, + modify_time=1715939275.0, + ) + target_path = Path("/library") + target_file = Path( + "/library/Test.Show.S01E01.mkv" + ) + target_folder = FileItem( + storage="alist", + type="dir", + ) + target_item = FileItem( + storage="alist", + path=target_file.as_posix(), + type="file", + name=target_file.name, + basename=target_file.stem, + extension="mkv", + size=1024, + ) + source_oper = SimpleNamespace( + is_support_transtype=lambda transfer_type: True, + move=lambda fileitem, path, name: True, + ) + target_oper = SimpleNamespace( + get_folder=lambda path: target_folder, + get_item=lambda path: None, + ) + + with patch.object( + TransHandler, "get_rename_path", return_value=target_file + ), patch( + "app.modules.filemanager.transhandler.DirectoryHelper.get_media_root_path", + return_value=Path("/library"), + ), patch.object( + TransHandler, + "_TransHandler__transfer_command", + return_value=(target_item, ""), + ), patch("app.modules.filemanager.transhandler.eventmanager") as eventmanager_mock: + eventmanager_mock.send_event.return_value = None + transferinfo = handler.transfer_media( + fileitem=source_item, + in_meta=MetaVideo("Test.Show.S01E01"), + mediainfo=make_media_info(), + target_storage="alist", + target_path=target_path, + transfer_type="move", + source_oper=source_oper, + target_oper=target_oper, + need_scrape=True, + need_notify=True, + ) + + self.assertTrue(transferinfo.success) + self.assertEqual(target_item, transferinfo.target_item) + self.assertIsNotNone(transferinfo.target_diritem) + self.assertEqual("/library/", transferinfo.target_diritem.path) + self.assertEqual("alist", transferinfo.target_diritem.storage) + self.assertEqual("dir", transferinfo.target_diritem.type) + + def test_success_callback_uses_transfer_result_target_diritem(self): + """ + 回调发送刮削事件时应直接使用整理结果里的目标目录项。 + """ + chain = make_transfer_chain() + chain.eventmanager = MagicMock() + chain.transfer_completed = lambda *args, **kwargs: None + + task = make_task(1) + task.mediainfo = FakeMedia() + task.background = False + task.manual = True + self.assertTrue(chain._TransferChain__put_to_jobview(task)) + + target_diritem = FileItem( + storage="alist", + path="/library/Test Show (2026)/Season 1/", + type="dir", + name="Season 1", + ) + target_item = FileItem( + storage="alist", + path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv", + type="file", + name="Test.Show.S01E01.mkv", + extension="mkv", + ) + transferinfo = TransferInfo( + success=True, + fileitem=task.fileitem, + target_item=target_item, + target_diritem=target_diritem, + file_list_new=[target_item.path], + transfer_type="copy", + need_scrape=True, + need_notify=False, + ) + + with patch( + "app.chain.transfer.TransferHistoryOper", + return_value=SimpleNamespace(add_success=lambda **kwargs: SimpleNamespace(id=1)), + ): + state, errmsg = chain._TransferChain__default_callback(task, transferinfo) + + self.assertTrue(state) + self.assertEqual("", errmsg) + metadata_calls = [ + call + for call in chain.eventmanager.send_event.call_args_list + if call.args[0] == EventType.MetadataScrape + ] + self.assertEqual(1, len(metadata_calls)) + event_data = metadata_calls[0].args[1] + self.assertEqual(target_diritem, event_data["fileitem"]) + self.assertEqual([target_item.path], event_data["file_list"]) + def test_manual_episode_offset_applies_once(self): chain = make_transfer_chain() source_fileitem = make_fileitem("/downloads/Test.Show.2026.S01E14.mkv") @@ -791,56 +935,5 @@ class TransferJobManagerTest(unittest.TestCase): event_data["file_list"], ) - def test_success_callback_handles_missing_target_diritem(self): - """ - 成功结果缺少目标目录项时,回调不应把已入库任务误判为失败。 - """ - chain = make_transfer_chain() - chain.eventmanager = MagicMock() - chain.transfer_completed = lambda *args, **kwargs: None - - task = make_task(1) - task.mediainfo = FakeMedia() - task.background = False - task.manual = True - self.assertTrue(chain._TransferChain__put_to_jobview(task)) - - target_item = FileItem( - storage="alist", - path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv", - type="file", - name="Test.Show.S01E01.mkv", - extension="mkv", - ) - transferinfo = TransferInfo( - success=True, - fileitem=task.fileitem, - target_item=target_item, - file_list_new=[target_item.path], - transfer_type="copy", - need_scrape=True, - need_notify=False, - ) - - with patch( - "app.chain.transfer.TransferHistoryOper", - return_value=SimpleNamespace(add_success=lambda **kwargs: SimpleNamespace(id=1)), - ): - state, errmsg = chain._TransferChain__default_callback(task, transferinfo) - - self.assertTrue(state) - self.assertEqual("", errmsg) - metadata_calls = [ - call - for call in chain.eventmanager.send_event.call_args_list - if call.args[0] == EventType.MetadataScrape - ] - self.assertEqual(1, len(metadata_calls)) - event_data = metadata_calls[0].args[1] - self.assertEqual("alist", event_data["fileitem"].storage) - self.assertEqual("/library/Test Show (2026)/Season 1", event_data["fileitem"].path) - self.assertEqual([target_item.path], event_data["file_list"]) - - if __name__ == "__main__": unittest.main()