feat(transfer): 增加手动整理预览模式(preview mode)

- 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 字段有默认值,旧客户端不传参时行为不变
This commit is contained in:
album
2026-05-12 00:10:55 +08:00
committed by jxxghp
parent 99fbeecfa1
commit 05b34b9c26
6 changed files with 198 additions and 45 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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