From 3ec0964a01871560e3631f49b26b5b448fa5c557 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 20 May 2026 13:08:45 +0800 Subject: [PATCH] fix: handle OpenList delayed transfer metadata --- app/chain/transfer.py | 78 ++++++++++++++++++----- app/modules/filemanager/storages/alist.py | 53 +++++++++++++++ app/modules/filemanager/transhandler.py | 36 ++++++++--- tests/test_alist_storage.py | 38 +++++++++++ tests/test_transfer_job_manager.py | 50 +++++++++++++++ 5 files changed, 229 insertions(+), 26 deletions(-) diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 09a5c81bb..3a51d055a 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -882,6 +882,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) transferhis = TransferHistoryOper() + target_dir_path = self.__get_transfer_target_dir_path(transferinfo) # 转移失败 if not transferinfo.success: @@ -999,9 +1000,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): else: # 转移成功 - logger.info( - f"{task.fileitem.name} 入库成功:{transferinfo.target_diritem.path}" - ) + logger.info(f"{task.fileitem.name} 入库成功:{target_dir_path or ''}") # 新增task转移成功历史记录 history = transferhis.add_success( @@ -1059,13 +1058,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) # task登记转移成功文件清单 - target_dir_path = transferinfo.target_diritem.path target_files = transferinfo.file_list_new - with job_lock: - if self._success_target_files.get(target_dir_path): - self._success_target_files[target_dir_path].extend(target_files) - else: - self._success_target_files[target_dir_path] = target_files + if target_dir_path: + with job_lock: + if self._success_target_files.get(target_dir_path): + self._success_target_files[target_dir_path].extend(target_files) + else: + self._success_target_files[target_dir_path] = target_files # 设置任务成功 self.jobview.finish_task(task) @@ -1077,9 +1076,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if self.jobview.is_finished(task): # 更新文件清单 with job_lock: - transferinfo.file_list_new = self._success_target_files.pop( - transferinfo.target_diritem.path, [] - ) + if target_dir_path: + transferinfo.file_list_new = self._success_target_files.pop( + target_dir_path, [] + ) + else: + transferinfo.file_list_new = transferinfo.file_list_new or [] __notify() if not task.transfer_batch_id: self.__send_metadata_scrape_event(task, transferinfo) @@ -1121,6 +1123,45 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return ret_status, ret_message + def __get_transfer_target_dir_path( + self, transferinfo: Optional[TransferInfo] + ) -> Optional[str]: + """ + 获取整理目标目录路径,兼容 OpenList 等成功后目录项短时间不可见的存储。 + """ + if not transferinfo: + return None + if transferinfo.target_diritem and transferinfo.target_diritem.path: + return transferinfo.target_diritem.path + if transferinfo.target_item and transferinfo.target_item.path: + return Path(transferinfo.target_item.path).parent.as_posix() + if transferinfo.file_list_new: + 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 + 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: """ 添加到待整理队列 @@ -1170,17 +1211,20 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): not task or not transferinfo or not transferinfo.need_scrape - or not transferinfo.target_diritem or not self.__is_media_file(task.fileitem) ): return + target_diritem = self.__build_transfer_target_diritem(transferinfo) + if not target_diritem: + return + self.eventmanager.send_event( EventType.MetadataScrape, { "meta": task.meta, "mediainfo": task.mediainfo, - "fileitem": transferinfo.target_diritem, + "fileitem": target_diritem, "file_list": transferinfo.file_list_new, "overwrite": False, }, @@ -1230,12 +1274,14 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): or not task.transfer_batch_id or not transferinfo or not transferinfo.need_scrape - or not transferinfo.target_diritem or not self.__is_media_file(task.fileitem) ): return - target_diritem = transferinfo.target_diritem + target_diritem = self.__build_transfer_target_diritem(transferinfo) + if not target_diritem: + return + target_files = transferinfo.file_list_new or [] target_key = (target_diritem.storage, target_diritem.path) with job_lock: diff --git a/app/modules/filemanager/storages/alist.py b/app/modules/filemanager/storages/alist.py index bf1421b10..ee5f538d3 100644 --- a/app/modules/filemanager/storages/alist.py +++ b/app/modules/filemanager/storages/alist.py @@ -61,6 +61,24 @@ class Alist(StorageBase, metaclass=WeakSingleton): return fileitem return None + def __build_transfer_item( + self, source_item: schemas.FileItem, target_path: Path + ) -> schemas.FileItem: + """ + 根据目标路径构造文件项,用于 OpenList 操作成功但元数据短时间不可见的场景。 + """ + return schemas.FileItem( + storage=self.schema.value, + type=source_item.type, + path=target_path.as_posix(), + name=target_path.name, + basename=target_path.stem, + extension=target_path.suffix[1:] if source_item.type != "dir" else None, + size=getattr(source_item, "size", None), + modify_time=getattr(source_item, "modify_time", None), + thumbnail=getattr(source_item, "thumbnail", None), + ) + @property def __get_base_url(self) -> str: """ @@ -799,6 +817,28 @@ class Alist(StorageBase, metaclass=WeakSingleton): self.rename(new_item, new_name) return True + def copy_item( + self, fileitem: schemas.FileItem, path: Path, new_name: str + ) -> Optional[schemas.FileItem]: + """ + 复制文件并返回目标文件项,兼容 OpenList 成功响应不携带目标对象的格式。 + """ + if not self.copy(fileitem=fileitem, path=path, new_name=new_name): + return None + target_path = path / new_name + target_item = self._delay_get_item(target_path, refresh=True) + if target_item: + return target_item + if fileitem.name == new_name: + return self.__build_transfer_item(fileitem, target_path) + + copied_item = self._delay_get_item(path / fileitem.name, refresh=True) + if copied_item and self.rename(copied_item, new_name): + return self._delay_get_item( + target_path, refresh=True + ) or self.__build_transfer_item(fileitem, target_path) + return None + def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 移动文件 @@ -852,6 +892,19 @@ class Alist(StorageBase, metaclass=WeakSingleton): return False return True + def move_item( + self, fileitem: schemas.FileItem, path: Path, new_name: str + ) -> Optional[schemas.FileItem]: + """ + 移动文件并返回目标文件项,兼容 OpenList 成功响应不携带目标对象的格式。 + """ + if not self.move(fileitem=fileitem, path=path, new_name=new_name): + return None + target_path = path / new_name + return self._delay_get_item(target_path, refresh=True) or self.__build_transfer_item( + fileitem, target_path + ) + def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: """ 硬链接文件 diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index 1bea26a19..fa84f6c1e 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -709,12 +709,20 @@ class TransHandler: # 复制文件到新目录 target_fileitem = target_oper.get_folder(target_file.parent) if target_fileitem: - if source_oper.copy( - fileitem, Path(target_fileitem.path), target_file.name + copy_item = getattr(source_oper, "copy_item", None) + if callable(copy_item): + new_item = copy_item( + fileitem, Path(target_fileitem.path), target_file.name + ) + if new_item: + return new_item, "" + elif source_oper.copy( + fileitem, Path(target_fileitem.path), target_file.name ): - return target_oper.get_item(target_file), "" - else: - return None, f"【{target_storage}】{fileitem.path} 复制文件失败" + new_item = target_oper.get_item(target_file) + if new_item: + return new_item, "" + return None, f"【{target_storage}】{fileitem.path} 复制文件失败" else: return ( None, @@ -724,12 +732,20 @@ class TransHandler: # 移动文件到新目录 target_fileitem = target_oper.get_folder(target_file.parent) if target_fileitem: - if source_oper.move( - fileitem, Path(target_fileitem.path), target_file.name + move_item = getattr(source_oper, "move_item", None) + if callable(move_item): + new_item = move_item( + fileitem, Path(target_fileitem.path), target_file.name + ) + if new_item: + return new_item, "" + elif source_oper.move( + fileitem, Path(target_fileitem.path), target_file.name ): - return target_oper.get_item(target_file), "" - else: - return None, f"【{target_storage}】{fileitem.path} 移动文件失败" + new_item = target_oper.get_item(target_file) + if new_item: + return new_item, "" + return None, f"【{target_storage}】{fileitem.path} 移动文件失败" else: return ( None, diff --git a/tests/test_alist_storage.py b/tests/test_alist_storage.py index ba8411502..f1d6ff10f 100644 --- a/tests/test_alist_storage.py +++ b/tests/test_alist_storage.py @@ -200,6 +200,44 @@ class AlistStorageTest(unittest.TestCase): self.assertEqual(50, len(items)) self.assertEqual(1, request_utils.post_res.call_count) + def test_move_item_returns_target_when_openlist_metadata_is_delayed(self): + """ + OpenList 操作成功但目标元数据延迟可见时,应返回可用的目标文件项。 + """ + source = FileItem( + storage="alist", + type="file", + path="/downloads/Test.Show.S01E01.mkv", + name="Test.Show.S01E01.mkv", + basename="Test.Show.S01E01", + extension="mkv", + size=1024, + modify_time=1715939275.0, + ) + request_utils = MagicMock() + request_utils.post_res.return_value = _FakeResponse( + {"code": 200, "message": "success", "data": None} + ) + + with patch.object(Alist, "get_conf", return_value={"url": "http://openlist.test", "token": "token"}): + with patch.object(self.storage, "_Alist__get_header_with_token", return_value={}): + with patch.object(alist_module, "RequestUtils", return_value=request_utils): + with patch.object(self.storage, "_delay_get_item", return_value=None): + target = self.storage.move_item( + source, + Path("/library/Test Show (2026)/Season 1"), + "Test.Show.S01E01.mkv", + ) + + self.assertIsNotNone(target) + self.assertEqual( + "/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv", + target.path, + ) + self.assertEqual("alist", target.storage) + self.assertEqual("file", target.type) + self.assertEqual(1024, target.size) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index c833d0c0f..e028b92f3 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -725,6 +725,56 @@ 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()