From 05b34b9c26e95d1dad303cd5cb149ab26542ff50 Mon Sep 17 00:00:00 2001 From: album Date: Tue, 12 May 2026 00:10:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(transfer):=20=E5=A2=9E=E5=8A=A0=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E6=95=B4=E7=90=86=E9=A2=84=E8=A7=88=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=88preview=20mode=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ManualTransferItem/TransferTask 增加 preview 字段,支持同一接口双模式 - /api/v1/transfer/manual 透传 preview,预览时返回结构化结果不落盘 - ChainBase.transfer 增加 preview 参数并透传到 run_module - TransferChain.do_transfer 预览分支复用完整命名/覆盖判定逻辑(dry-run) - TransferChain.do_transfer 预览结束后显式 finish_task/fail_task,避免任务残留 running 状态导致重复预览失败 - TransHandler.transfer_media 预览分支跳过实际 copy/move/link/delete,仅返回目标路径 - FileManagerModule.transfer 透传 preview 参数 - 修复 /manual 失败分支 dict 类型导致 Response.message 校验错误 - 兼容性:preview 字段有默认值,旧客户端不传参时行为不变 --- app/api/endpoints/transfer.py | 13 ++- app/chain/__init__.py | 3 + app/chain/transfer.py | 133 ++++++++++++++++++------ app/modules/filemanager/__init__.py | 4 +- app/modules/filemanager/transhandler.py | 87 +++++++++++++--- app/schemas/transfer.py | 3 + 6 files changed, 198 insertions(+), 45 deletions(-) diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index bcbe8e01..1aca6995 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -113,7 +113,7 @@ def manual_transfer(transer_item: ManualTransferItem, # 源路径 src_fileitem = FileItem(**history.src_fileitem) # 目的路径 - if history.dest_fileitem: + if history.dest_fileitem and not transer_item.preview: # 删除旧的已整理文件 dest_fileitem = FileItem(**history.dest_fileitem) state = StorageChain().delete_media_file(dest_fileitem) @@ -181,14 +181,23 @@ def manual_transfer(transer_item: ManualTransferItem, force=force, background=background, downloader=downloader, - download_hash=download_hash + download_hash=download_hash, + preview=transer_item.preview, ) # 失败 if not state: if isinstance(errormsg, list): errormsg = f"整理完成,{len(errormsg)} 个文件转移失败!" + if isinstance(errormsg, dict): + return schemas.Response( + success=True, + message=errormsg.get("message"), + data=errormsg, + ) return schemas.Response(success=False, message=errormsg) # 成功 + if transer_item.preview: + return schemas.Response(success=True, data=errormsg or {}) return schemas.Response(success=True) diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 7f7ab4f8..889a269a 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -1102,6 +1102,7 @@ class ChainBase(metaclass=ABCMeta): episodes_info: List[TmdbEpisode] = None, source_oper: Callable = None, target_oper: Callable = None, + preview: bool = False, ) -> Optional[TransferInfo]: """ 文件转移 @@ -1118,6 +1119,7 @@ class ChainBase(metaclass=ABCMeta): :param episodes_info: 当前季的全部集信息 :param source_oper: 源存储操作类 :param target_oper: 目标存储操作类 + :param preview: 是否仅预览,不执行实际转移 :return: {path, target_path, message} """ return self.run_module( @@ -1135,6 +1137,7 @@ class ChainBase(metaclass=ABCMeta): episodes_info=episodes_info, source_oper=source_oper, target_oper=target_oper, + preview=preview, ) def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None: diff --git a/app/chain/transfer.py b/app/chain/transfer.py index b5781ee0..ee34d0ba 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -1310,6 +1310,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): self.obtain_images(mediainfo=mediainfo) if not mediainfo: + if task.preview: + return False, "未识别到媒体信息" # 新增整理失败历史记录 his = transferhis.add_fail( fileitem=task.fileitem, @@ -1470,6 +1472,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): library_category_folder=task.library_category_folder, source_oper=source_oper, target_oper=target_oper, + preview=task.preview, ) if not transferinfo: logger.error("文件整理模块运行失败") @@ -1871,8 +1874,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): force: Optional[bool] = False, background: Optional[bool] = True, manual: Optional[bool] = False, + preview: Optional[bool] = False, continue_callback: Callable = None, - ) -> Tuple[bool, str]: + ) -> Tuple[bool, Union[str, dict]]: """ 执行一个复杂目录的整理操作 :param fileitem: 文件项 @@ -1893,11 +1897,15 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param force: 是否强制整理 :param background: 是否后台运行 :param manual: 是否手动整理 + :param preview: 是否仅预览 :param continue_callback: 继续处理回调 返回:成功标识,错误信息 """ # 是否全部成功 all_success = True + if preview: + # 预览模式始终同步执行,避免进入异步队列 + background = False # 自定义格式 formaterHandler = ( @@ -1981,7 +1989,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): file_path = Path(file_item.path) # 整理成功的不再处理 - if not force: + if not force and not preview: transferd = TransferHistoryOper().get_by_src( file_item.path, storage=file_item.storage ) @@ -2077,6 +2085,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): download_history=download_history, manual=manual, background=background, + preview=preview, ) if background: if self.put_to_queue(task=transfer_task): @@ -2096,6 +2105,28 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): del file_items # 实时整理 + preview_items: List[dict] = [] + + def _preview_callback(task: TransferTask, transferinfo: TransferInfo) -> Tuple[bool, str]: + item_meta = task.meta + item_media = task.mediainfo + preview_items.append( + { + "source": task.fileitem.path, + "target": transferinfo.target_item.path if transferinfo.target_item else None, + "target_dir": transferinfo.target_diritem.path if transferinfo.target_diritem else None, + "success": transferinfo.success, + "message": transferinfo.message, + "type": item_media.type.value if item_media and item_media.type else None, + "title": item_media.title_year if item_media else None, + "season": item_meta.begin_season if item_meta else None, + "episode": item_meta.begin_episode if item_meta else None, + "episode_end": item_meta.end_episode if item_meta else None, + "part": item_meta.part if item_meta else None, + } + ) + return transferinfo.success, transferinfo.message + if transfer_tasks: # 总数量 total_num = len(transfer_tasks) @@ -2106,46 +2137,75 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 已完成文件 finished_files = [] - # 启动进度 - progress = ProgressHelper(ProgressKey.FileTransfer) - progress.start() - __process_msg = f"开始整理,共 {total_num} 个文件 ..." - logger.info(__process_msg) - progress.update(value=0, text=__process_msg) + progress = None + if not preview: + # 启动进度 + progress = ProgressHelper(ProgressKey.FileTransfer) + progress.start() + __process_msg = f"开始整理,共 {total_num} 个文件 ..." + logger.info(__process_msg) + progress.update(value=0, text=__process_msg) try: for transfer_task in transfer_tasks: if global_vars.is_system_stopped: break if continue_callback and not continue_callback(): break - # 更新进度 - __process_msg = f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..." - logger.info(__process_msg) - progress.update( - value=(processed_num + fail_num) / total_num * 100, - text=__process_msg, - data={ - "current": Path(transfer_task.fileitem.path).as_posix(), - "finished": finished_files, - }, - ) + if not preview: + # 更新进度 + __process_msg = f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..." + logger.info(__process_msg) + progress.update( + value=(processed_num + fail_num) / total_num * 100, + text=__process_msg, + data={ + "current": Path(transfer_task.fileitem.path).as_posix(), + "finished": finished_files, + }, + ) try: state, err_msg = self.__handle_transfer( - task=transfer_task, callback=self.__default_callback + task=transfer_task, + callback=_preview_callback if preview else self.__default_callback, ) except Exception as e: logger.error( f"{transfer_task.fileitem.name} 整理任务处理出现错误:" f"{e} - {traceback.format_exc()}" ) - self.__fail_transfer_task(transfer_task) + if not preview: + self.__fail_transfer_task(transfer_task) state, err_msg = False, str(e) if not state: all_success = False logger.warn(f"{transfer_task.fileitem.name} {err_msg}") err_msgs.append(f"{transfer_task.fileitem.name} {err_msg}") + if preview: + # 预览模式不走默认回调,这里需要手动收敛任务状态,避免残留 running + self.jobview.fail_task(transfer_task) + self.jobview.try_remove_job(transfer_task) + if preview and (not preview_items or preview_items[-1].get("source") != transfer_task.fileitem.path): + preview_items.append( + { + "source": transfer_task.fileitem.path, + "target": None, + "target_dir": None, + "success": False, + "message": err_msg, + "type": None, + "title": None, + "season": transfer_task.meta.begin_season if transfer_task.meta else None, + "episode": transfer_task.meta.begin_episode if transfer_task.meta else None, + "episode_end": transfer_task.meta.end_episode if transfer_task.meta else None, + "part": transfer_task.meta.part if transfer_task.meta else None, + } + ) fail_num += 1 else: + if preview: + # 预览模式手动标记完成,确保可重复预览 + self.jobview.finish_task(transfer_task) + self.jobview.try_remove_job(transfer_task) processed_num += 1 # 记录已完成 finished_files.append(Path(transfer_task.fileitem.path).as_posix()) @@ -2154,12 +2214,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): del transfer_tasks # 整理结束 - __end_msg = ( - f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个" - ) - logger.info(__end_msg) - progress.update(value=100, text=__end_msg, data={}) - progress.end() + if not preview: + __end_msg = ( + f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个" + ) + logger.info(__end_msg) + progress.update(value=100, text=__end_msg, data={}) + progress.end() # 下载器任务在这一轮可能因为历史记录全部命中而没有进入整理队列, # 这里补打一遍已整理标签,避免同一种子被重复扫描。 @@ -2176,6 +2237,16 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): error_msg = "、".join(err_msgs[:2]) + ( f",等{len(err_msgs)}个文件错误!" if len(err_msgs) > 2 else "" ) + if preview: + return all_success, { + "summary": { + "total": len(preview_items), + "success": len([item for item in preview_items if item.get("success")]), + "failed": len([item for item in preview_items if not item.get("success")]), + }, + "items": preview_items, + "message": error_msg, + } return all_success, error_msg def remote_transfer( @@ -2360,7 +2431,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): background: Optional[bool] = False, downloader: Optional[str] = None, download_hash: Optional[str] = None, - ) -> Tuple[bool, Union[str, list]]: + preview: Optional[bool] = False, + ) -> Tuple[bool, Union[str, dict]]: """ 手动整理,支持复杂条件,带进度显示 :param fileitem: 文件项 @@ -2381,6 +2453,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param background: 是否后台运行 :param downloader: 下载器名称 :param download_hash: 下载任务哈希 + :param preview: 是否仅预览 """ logger.info(f"手动整理:{fileitem.path} ...") if tmdbid or doubanid: @@ -2419,12 +2492,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): manual=True, downloader=downloader, download_hash=download_hash, + preview=preview, ) if not state: return False, errmsg logger.info(f"{fileitem.path} 整理完成") - return True, "" + return True, errmsg if preview else "" else: # 没有输入TMDBID时,按文件识别 state, errmsg = self.do_transfer( @@ -2443,6 +2517,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): manual=True, downloader=downloader, download_hash=download_hash, + preview=preview, ) return state, errmsg diff --git a/app/modules/filemanager/__init__.py b/app/modules/filemanager/__init__.py index 42fbd019..61d3a6e4 100644 --- a/app/modules/filemanager/__init__.py +++ b/app/modules/filemanager/__init__.py @@ -406,7 +406,8 @@ class FileManagerModule(_ModuleBase): transfer_type: Optional[str] = None, scrape: Optional[bool] = None, library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None, episodes_info: List[TmdbEpisode] = None, - source_oper: Callable = None, target_oper: Callable = None) -> TransferInfo: + source_oper: Callable = None, target_oper: Callable = None, + preview: Optional[bool] = False) -> TransferInfo: """ 文件整理 :param fileitem: 文件信息 @@ -522,6 +523,7 @@ class FileManagerModule(_ModuleBase): need_notify=need_notify, overwrite_mode=overwrite_mode, episodes_info=episodes_info, + preview=preview, source_oper=source_oper, target_oper=target_oper) diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index 61e94d96..f02e2c21 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -78,6 +78,7 @@ class TransHandler: need_notify: Optional[bool] = True, overwrite_mode: Optional[str] = None, episodes_info: List[TmdbEpisode] = None, + preview: Optional[bool] = False, ) -> TransferInfo: """ 识别并整理一个文件或者一个目录下的所有文件 @@ -94,6 +95,7 @@ class TransHandler: :param need_notify: 是否需要通知 :param overwrite_mode: 覆盖模式 :param episodes_info: 当前季的全部集信息 + :param preview: 是否仅预览 :return: TransferInfo、错误信息 """ @@ -158,6 +160,27 @@ class TransHandler: return result else: new_path = target_path / fileitem.name + if preview: + preview_diritem = FileItem( + storage=target_storage, + path=new_path.as_posix(), + name=new_path.name, + basename=new_path.stem, + type="dir", + ) + self.__update_result( + result=result, + success=True, + fileitem=fileitem, + target_item=preview_diritem, + target_diritem=preview_diritem, + file_list=[fileitem.path], + file_list_new=[new_path.as_posix()], + need_scrape=need_scrape, + need_notify=False, + transfer_type=transfer_type, + ) + return result # 原盘大小只计算STREAM目录内的文件大小 if stream_fileitem := source_oper.get_item( Path(fileitem.path) / "BDMV" / "STREAM" @@ -267,19 +290,28 @@ class TransHandler: folder_path = target_path # 目标目录 - target_diritem = target_oper.get_folder(folder_path) - if not target_diritem: - logger.error(f"目标目录 {folder_path} 获取失败") - self.__update_result( - result=result, - success=False, - message=f"目标目录 {folder_path} 获取失败", - fileitem=fileitem, - fail_list=[fileitem.path], - transfer_type=transfer_type, - need_notify=need_notify, + if preview: + target_diritem = FileItem( + storage=target_storage, + path=folder_path.as_posix(), + name=folder_path.name, + basename=folder_path.stem, + type="dir", ) - return result + else: + target_diritem = target_oper.get_folder(folder_path) + if not target_diritem: + logger.error(f"目标目录 {folder_path} 获取失败") + self.__update_result( + result=result, + success=False, + message=f"目标目录 {folder_path} 获取失败", + fileitem=fileitem, + fail_list=[fileitem.path], + transfer_type=transfer_type, + need_notify=need_notify, + ) + return result # 判断是否要覆盖,附加文件强制覆盖 overflag = False @@ -407,11 +439,40 @@ class TransHandler: logger.info( f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ..." ) - self.__delete_version_files(target_oper, new_file) + if not preview: + self.__delete_version_files(target_oper, new_file) else: # 附加文件 总是需要覆盖 overflag = True + if preview: + target_item = target_oper.get_item(new_file) + if not target_item: + target_item = FileItem( + storage=target_storage, + path=new_file.as_posix(), + name=new_file.name, + basename=new_file.stem, + type="file", + extension=new_file.suffix.lstrip("."), + size=fileitem.size, + ) + self.__update_result( + result=result, + success=True, + fileitem=fileitem, + target_item=target_item, + target_diritem=target_diritem, + file_list=[fileitem.path], + file_list_new=[new_file.as_posix()], + file_count=1, + total_size=fileitem.size or 0, + need_scrape=need_scrape, + transfer_type=transfer_type, + need_notify=False, + ) + return result + # 整理文件 new_item, err_msg = self.__transfer_file( fileitem=fileitem, diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 642ef213..e949733e 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -68,6 +68,7 @@ class TransferTask(BaseModel): download_history: Optional[DownloadHistory] = None manual: Optional[bool] = False background: Optional[bool] = True + preview: Optional[bool] = False def to_dict(self): """ @@ -203,3 +204,5 @@ class ManualTransferItem(BaseModel): from_history: Optional[bool] = False # 剧集组 episode_group: Optional[str] = None + # 仅预览,不执行整理 + preview: Optional[bool] = False