Files
MoviePilot/app/modules/filemanager/transhandler.py

2868 lines
123 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
媒体文件整理的只读规划、可恢复执行、安全覆盖与命名策略。
"""
import filecmp
import re
from copy import deepcopy
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Callable, List, Optional, Tuple, Union, cast
from jinja2 import Template
from app.adapters.system.host import SystemUtils
from app.application.audio import AudioMetadataHelper
from app.application.classification.reference import (
append_classification_category_path,
category_path_below_media_type,
classification_media_type,
ensure_path_within_root,
)
from app.application.directory import DirectoryHelper
from app.application.messaging.message import TemplateHelper
from app.application.transfer.execution import (
TransferExecutionConflictError,
TransferOperationObservation,
TransferOperationObservationState,
TransferPlanningRejectedError,
TransferStepResult,
TransferStepRunner,
build_transfer_overwrite_paths,
)
from app.application.transfer.workflow import (
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
)
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfoPath
from app.modules.filemanager.storages import StorageBase
from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.event import (
TransferInterceptEventData,
TransferOverwriteCheckEventData,
TransferRenameBuildEventData,
TransferRenameEventData,
)
from app.schemas.exception import StorageQueryError
from app.schemas.file import FileItem
from app.schemas.system import TransferDirectoryConf
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import TransferInfo
from app.schemas.types import ChainEventType, MediaType
class TransHandler:
"""
规划媒体整理路径,并执行可恢复的文件转移和覆盖操作。
规划冻结路径、元数据和叶子操作顺序;执行阶段处理覆盖规则、插件裁决和存储副作用。
注入步骤执行器时记录操作证据并保守恢复,未注入时沿用同步调用方式。
"""
def __init__(self) -> None:
"""
初始化无状态整理执行器。
"""
pass
@staticmethod
def __normalize_disc_folder_name(value: Optional[str]) -> Optional[str]:
"""
从 Disc、Disk、DVD 或 CD 标识中提取盘号并规范名称。
:param value: 待识别的分片或目录名;可为空
:return: Disc N 格式的盘片名;无匹配时返回 None
"""
if not value:
return None
match = re.search(
r"(?:disc|disk|dvd|cd)[\s._-]*0*(\d{1,3})",
value,
re.IGNORECASE,
)
if not match:
return None
return f"Disc {int(match.group(1))}"
@classmethod
def __get_tv_bluray_dir_path(
cls,
rendered_path: Path,
source_item: FileItem,
meta: MetaBase,
) -> Path:
"""
为电视剧原盘保留季目录并追加盘片子目录。
:param rendered_path: 模板渲染的单集路径,父目录作为季目录
:param source_item: 原盘源目录,名称用于补充盘号
:param meta: 源元数据,优先使用 part 中的盘号
:return: 季目录下的盘片目录;无法提取盘号时沿用源目录名或使用 Disc
"""
disc_folder = cls.__normalize_disc_folder_name(getattr(meta, "part", None))
if not disc_folder and source_item:
source_name = source_item.name or (Path(source_item.path).name if source_item.path else "")
disc_folder = cls.__normalize_disc_folder_name(source_name)
if not disc_folder:
match = re.search(
r"(?:^|[^A-Za-z0-9])S\d{1,3}D0*(\d{1,3})(?:[^A-Za-z0-9]|$)",
source_name,
re.IGNORECASE,
)
if match:
disc_folder = f"Disc {int(match.group(1))}"
if not disc_folder:
disc_folder = source_name
return rendered_path.parent / (disc_folder or "Disc")
@staticmethod
def __update_result(result: TransferInfo, **kwargs: Any) -> None:
"""
按字段类型合并整理结果,原地修改结果对象。
忽略不存在的属性;布尔值直接替换,不按整数累加。
:param result: 累计整理结果
:param kwargs: 待合并字段;列表追加、字典更新、整数累加,其他值覆盖
"""
for key, value in kwargs.items():
if hasattr(result, key):
current_value = getattr(result, key)
if current_value is None:
current_value = value
elif isinstance(current_value, list):
if isinstance(value, list):
current_value.extend(value)
else:
current_value.append(value)
elif isinstance(current_value, dict):
if isinstance(value, dict):
current_value.update(value)
else:
current_value[key] = value
elif isinstance(current_value, bool):
current_value = value
elif isinstance(current_value, int):
current_value += value or 0
else:
current_value = value
setattr(result, key, current_value)
@staticmethod
def __build_preview_item(
storage: str,
path: Path,
item_type: str,
size: Optional[int] = None,
) -> FileItem:
"""
构造预览结果文件项,不访问真实存储。
:param storage: 目标存储标识
:param path: 计划中的目标路径
:param item_type: file 表示文件,dir 表示目录
:param size: 文件字节数;目录忽略该值
:return: 根据路径推导名称和扩展名的预览文件项
"""
return FileItem(
storage=storage,
path=path.as_posix(),
name=path.name,
basename=path.stem,
type=item_type,
extension=path.suffix.lstrip(".") if item_type == "file" else None,
size=size if item_type == "file" else None,
)
@staticmethod
def __music_quality_overwrite_decision(
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
target_item: FileItem,
) -> Optional[bool]:
"""
比较新旧音乐的音质分数,证据不足时交回原覆盖策略。
:param meta: 源文件元数据,优先从中读取音质分数
:param mediainfo: 媒体类型和备用音质信息
:param target_item: 已有目标文件;本地文件读取真实音频标签,远端按格式估分
:return: 源音质更好时为 True,更差时为 False;不适用、缺分或同分时为 None
"""
if getattr(mediainfo, "type", None) != MediaType.MUSIC or not target_item:
return None
if Path(str(target_item.path or "")).suffix.casefold() in {".jpg", ".jpeg", ".png", ".webp"}:
return None
source_score = getattr(meta, "audio_quality_score", 0) or getattr(
mediainfo, "audio_quality_score", 0
)
target_path = Path(target_item.path) if target_item.path else None
if (
target_path
and (target_item.storage or "local") == "local"
and target_path.is_file()
):
target_music = AudioMetadataHelper.read(target_path)
else:
target_format = target_item.extension
if not target_format and target_path:
target_format = target_path.suffix.lstrip(".")
target_music = MetaMusic(audio_format=target_format)
target_score = target_music.audio_quality_score
if not source_score or not target_score or source_score == target_score:
return None
return source_score > target_score
@staticmethod
def __serialize_fileitem(fileitem: FileItem) -> dict[str, Any]:
"""
将文件项序列化为可持久化的独立快照。
:param fileitem: 待冻结的源或目标文件项
:return: 采用 JSON 值类型的字典
"""
return fileitem.model_dump(mode="json")
@staticmethod
def __serialize_transfer_model(value: object) -> dict[str, Any]:
"""
校验领域对象可序列化为整理检查点使用的字典。
:raises TypeError: 对象不支持序列化或返回值不是字典
:param value: 提供 to_dict 方法的领域对象
:return: 对象序列化的字典
"""
serializer = getattr(value, "to_dict", None)
if not callable(serializer):
raise TypeError(f"{type(value).__name__} 不支持整理快照序列化")
payload = serializer()
if not isinstance(payload, dict):
raise TypeError(f"{type(value).__name__} 返回了无效整理快照")
return payload
@staticmethod
def __is_subtitle_file(fileitem: FileItem) -> bool:
"""
按运行配置检查文件是否为字幕附件。
:param fileitem: 带扩展名的文件项
:return: 扩展名属于字幕配置时为 True
"""
return bool(
fileitem.extension
and f".{fileitem.extension.lower()}" in get_runtime_setting("RMT_SUBEXT")
)
@staticmethod
def __is_extra_file(fileitem: FileItem, mediainfo: MediaInfo | MusicInfo) -> bool:
"""
识别不参与主媒体质量比较的附属文件。
:param fileitem: 待判断的文件项
:param mediainfo: 决定音频或歌词附件规则的主媒体信息
:return: 字幕、音乐歌词或影视外置音轨时为 True
"""
if not fileitem.extension:
return False
extension = f".{fileitem.extension.lower()}"
path = str(fileitem.path or fileitem.name or "").casefold()
if extension in get_runtime_setting("RMT_SUBEXT"):
return True
if mediainfo.type == MediaType.MUSIC and path.endswith(
(".lrc", ".txt", ".lyricsfile.yaml")
):
return True
return (
mediainfo.type != MediaType.MUSIC
and extension in get_runtime_setting("RMT_AUDIOEXT")
)
@staticmethod
def __is_special_extra_file(fileitem: FileItem) -> bool:
"""
识别缺少季集号时允许跳过的特典视频名称。
:param fileitem: 提供文件名的待整理项
:return: 名称匹配片头、片尾、预告、花絮等标记时为 True
"""
return bool(
re.search(
r"(?:^|[\s_.\-\[【(])(NC(?:OP|ED)|NCOP|NCED|OP|ED|MENU|PV|CM|TRAILER|"
r"TV\s*SPOT|SP|OVA|OAD|EVENT|IV|INTERVIEW|LOGO|PRODUCER\s*LOGO|"
r"BEHIND\s*THE\s*SCENES|FEATURETTE"
r")(?:\d*|[\s_.\-\]】)]|$)",
fileitem.name or "",
re.IGNORECASE,
)
)
def __plan_directory_items(
self,
*,
fileitem: FileItem,
target_storage: str,
target_path: Path,
source_oper: StorageBase,
) -> tuple[TransferPlanItem, ...]:
"""
只读遍历源目录并冻结叶子文件的执行顺序。
不冻结空目录,也不创建目标目录。
:param fileitem: 待整理的源目录
:param target_storage: 叶子操作的目标存储
:param target_path: 对应源目录的目标根路径
:param source_oper: 负责列举源目录的存储适配器
:return: 按存储返回顺序深度优先排列的叶子操作元组
"""
items: list[TransferPlanItem] = []
def collect(source_dir: FileItem, destination_dir: Path) -> None:
"""
递归收集当前目录叶子文件,追加到外层计划列表。
:param source_dir: 当前源目录
:param destination_dir: 对应的目标目录路径
"""
for source_item in source_oper.list(source_dir) or []:
if not source_item.path or not source_item.name:
raise ValueError("源文件项缺少路径或名称")
destination = destination_dir / source_item.name
if source_item.type == "dir":
collect(source_item, destination)
continue
items.append(
TransferPlanItem(
sequence=len(items),
source_fileitem=self.__serialize_fileitem(source_item),
target_storage=target_storage,
target_path=destination.as_posix(),
)
)
collect(fileitem, target_path)
return tuple(items)
def plan_transfer(
self,
planning_input: TransferPlanningInput,
*,
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
source_oper: StorageBase,
target_storage: str,
target_path: Path,
transfer_type: str,
need_scrape: bool,
need_rename: bool,
need_notify: bool,
overwrite_mode: Optional[str],
episodes_info: Optional[List[TmdbEpisode]],
preview: bool,
) -> TransferPlanCheckpoint:
"""
冻结整理目标和有序操作,不执行宿主文件或目录写入。
命名过程会发送插件事件,插件行为不在此处隔离;特典缺少集号时生成合法跳过计划。
:raises TransferPlanningRejectedError: 普通剧集缺少集号
:raises ValueError: 命名模板或目标路径无效
:param planning_input: 请求接纳阶段冻结的源文件和请求参数
:param meta: 源元数据;内部复制后归一化季集,不修改调用方对象
:param mediainfo: 已识别媒体信息,用于命名和分类
:param source_oper: 源存储适配器;非预览目录规划会只读列举子项
:param target_storage: 已经解析的目标存储标识
:param target_path: 已经解析的媒体库根路径
:param transfer_type: 已经解析的整理方式
:param need_scrape: 执行结果是否请求后续刮削
:param need_rename: 是否根据模板生成目标名称
:param need_notify: 执行结果是否允许后续通知
:param overwrite_mode: 执行期采用的覆盖策略;可为空
:param episodes_info: 当前季的集详情,用于命名;可为空
:param preview: 是否仅预览;目录预览不遍历并冻结叶子操作
:return: 包含最终路径、元数据快照和操作清单的检查点
"""
fileitem = FileItem.model_validate(planning_input.source_fileitem)
final_target: Optional[Path]
rename_format = get_runtime_setting("RENAME_FORMAT")(mediainfo.type)
planning_meta = deepcopy(meta)
resolved_mediainfo = self.__serialize_transfer_model(mediainfo)
resolved_episodes_info = tuple(
episode.model_dump(mode="json") for episode in episodes_info or []
)
if fileitem.type == "dir":
if need_rename:
rendered_path = self.get_rename_path(
path=target_path,
template_string=rename_format,
rename_dict=self.get_naming_dict(
meta=planning_meta,
mediainfo=mediainfo,
),
source_path=fileitem.path,
source_item=fileitem,
)
if mediainfo.type == MediaType.TV:
final_target = self.__get_tv_bluray_dir_path(
rendered_path=rendered_path,
source_item=fileitem,
meta=planning_meta,
)
else:
final_target = DirectoryHelper.get_media_root_path(
rename_format,
rename_path=rendered_path,
media_type=mediainfo.type,
)
if not final_target:
raise ValueError("重命名格式无效")
else:
final_target = target_path / cast(str, fileitem.name)
items = (
()
if preview
else self.__plan_directory_items(
fileitem=fileitem,
target_storage=target_storage,
target_path=final_target,
source_oper=source_oper,
)
)
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage=target_storage,
root_target_path=target_path.as_posix(),
final_target_path=final_target.as_posix(),
resolved_transfer_type=transfer_type,
items=items,
resolved_meta=self.__serialize_transfer_model(planning_meta),
resolved_meta_kind=(
type(planning_meta).__name__ if planning_meta else None
),
resolved_mediainfo=resolved_mediainfo,
resolved_mediainfo_kind=(
type(mediainfo).__name__ if mediainfo else None
),
resolved_episodes_info=resolved_episodes_info,
need_scrape=need_scrape,
need_rename=need_rename,
need_notify=need_notify,
overwrite_mode=overwrite_mode,
preview=preview,
skip_reason=(
"源目录中没有可整理文件"
if not preview and not items
else None
),
)
if mediainfo.type == MediaType.TV:
if planning_meta.begin_episode is None:
if self.__is_special_extra_file(fileitem):
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage=target_storage,
root_target_path=target_path.as_posix(),
final_target_path=target_path.as_posix(),
resolved_transfer_type=transfer_type,
items=(),
resolved_meta=(
self.__serialize_transfer_model(planning_meta)
),
resolved_meta_kind=(
type(planning_meta).__name__ if planning_meta else None
),
resolved_mediainfo=resolved_mediainfo,
resolved_mediainfo_kind=(
type(mediainfo).__name__ if mediainfo else None
),
resolved_episodes_info=resolved_episodes_info,
need_scrape=need_scrape,
need_rename=need_rename,
need_notify=False,
overwrite_mode=overwrite_mode,
preview=preview,
skip_reason="未识别到文件集数,识别为特典/附加视频文件",
)
raise TransferPlanningRejectedError("未识别到文件集数")
planning_meta.end_season = None
if planning_meta.total_season:
planning_meta.total_season = 1
if planning_meta.total_episode > 2:
planning_meta.total_episode = 1
planning_meta.end_episode = None
if need_rename:
file_extension = (
".lyricsfile.yaml"
if str(fileitem.path or "").casefold().endswith(".lyricsfile.yaml")
else f".{fileitem.extension}"
)
final_target = self.get_rename_path(
path=target_path,
template_string=rename_format,
rename_dict=self.get_naming_dict(
meta=planning_meta,
mediainfo=mediainfo,
episodes_info=episodes_info,
file_ext=file_extension,
),
source_path=fileitem.path,
source_item=fileitem,
)
if self.__is_subtitle_file(fileitem):
final_target = self.__rename_subtitles(fileitem, final_target)
target_directory_path = DirectoryHelper.get_media_root_path(
rename_format,
rename_path=final_target,
media_type=mediainfo.type,
)
if not target_directory_path:
raise ValueError("重命名格式无效")
if (mediainfo.type == MediaType.MUSIC
and Path(str(fileitem.path)).suffix.casefold() in {".jpg", ".jpeg", ".png", ".webp"}):
# 封面保持原名并放到专辑根,不套用曲序/曲名模板或进入 Disc 子目录。
final_target = target_directory_path / Path(str(fileitem.path)).name
if getattr(planning_meta, "music_layout", None) == "image_cue":
# 整轨与索引只调整专辑目录,保留原名才能让 CUE FILE 引用继续有效。
final_target = final_target.parent / Path(str(fileitem.path)).name
else:
final_target = target_path / cast(str, fileitem.name)
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage=target_storage,
root_target_path=target_path.as_posix(),
final_target_path=final_target.as_posix(),
resolved_transfer_type=transfer_type,
items=(
TransferPlanItem(
sequence=0,
source_fileitem=self.__serialize_fileitem(fileitem),
target_storage=target_storage,
target_path=final_target.as_posix(),
),
),
resolved_meta=self.__serialize_transfer_model(planning_meta),
resolved_meta_kind=(
type(planning_meta).__name__ if planning_meta else None
),
resolved_mediainfo=resolved_mediainfo,
resolved_mediainfo_kind=(
type(mediainfo).__name__ if mediainfo else None
),
resolved_episodes_info=resolved_episodes_info,
need_scrape=need_scrape,
need_rename=need_rename,
need_notify=need_notify,
overwrite_mode=overwrite_mode,
preview=preview,
)
@staticmethod
def __intercept_transfer(
*,
fileitem: FileItem,
meta: Optional[MetaBase],
mediainfo: MediaInfo | MusicInfo,
target_storage: str,
target_path: Path,
transfer_type: str,
over_flag: Optional[bool] = None,
) -> tuple[bool, str]:
"""
在宿主写入前发送整理拦截事件并读取插件裁决。
:param fileitem: 源文件或目录项
:param meta: 源元数据;目录事件不传入该字段
:param mediainfo: 已识别媒体信息
:param target_storage: 目标存储标识
:param target_path: 冻结的目标路径
:param transfer_type: 整理方式
:param over_flag: 是否覆盖;None 表示目录事件,不携带覆盖选项
:return: 是否允许执行及取消原因;未取消时原因为空字符串
"""
if over_flag is None:
event_data = TransferInterceptEventData(
fileitem=fileitem,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_path,
transfer_type=transfer_type,
)
else:
event_data = TransferInterceptEventData(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_path,
transfer_type=transfer_type,
options={"over_flag": over_flag},
)
event = eventmanager.send_event(ChainEventType.TransferIntercept, event_data)
if (
event
and isinstance(event.event_data, TransferInterceptEventData)
and event.event_data.cancel
):
canceled = event.event_data
logger.debug(
f"Transfer canceled by event: {canceled.source},Reason: {canceled.reason}"
)
return False, canceled.reason
return True, ""
def __resolve_overwrite(
self,
*,
fileitem: FileItem,
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
target_oper: StorageBase,
target_storage: str,
target_file: Path,
transfer_type: str,
overwrite_mode: Optional[str],
need_notify: bool,
) -> tuple[bool, bool, Optional[TransferInfo]]:
"""
在执行期读取目标状态、插件裁决和覆盖策略。
未知目标状态返回失败结果;明确策略拒绝标记为覆盖跳过,以保留已有成功历史。
字幕及其他附件优先覆盖;普通文件允许插件先作裁决,再按配置和音乐音质或大小判断。
:param fileitem: 源文件项,提供大小及附件判断信息
:param meta: 源元数据,提供音乐音质信息
:param mediainfo: 已识别媒体信息
:param target_oper: 用于严格查询目标的存储适配器
:param target_storage: 目标存储标识
:param target_file: 已冻结的目标文件路径
:param transfer_type: 整理方式,传入插件并写入失败结果
:param overwrite_mode: always、latest、never 或 size;未知或空值不主动覆盖
:param need_notify: 失败结果是否请求通知
:return: 是否覆盖、是否清理其他版本、可选失败结果
"""
is_extra_file = self.__is_extra_file(fileitem, mediainfo)
try:
target_item = target_oper.get_item_strict(target_file)
except StorageQueryError as query_error:
message = (
f"无法确认目标文件状态,已跳过整理以避免误覆盖:"
f"{target_file} - {query_error}"
)
logger.warn(message)
return False, False, TransferInfo(
success=False,
message=message,
fileitem=fileitem,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=need_notify,
)
if is_extra_file:
return target_item is not None, False, None
if not target_item:
return False, overwrite_mode == "latest" and target_file.suffix.casefold() not in {
".jpg", ".jpeg", ".png", ".webp"
}, None
checked_target = target_file
over_flag = False
if target_storage == "local" and target_file.is_symlink():
checked_target = target_file.readlink()
if not checked_target.exists():
over_flag = True
if over_flag:
return True, False, None
logger.info(
f"目的文件系统中已经存在同名文件 {checked_target},"
f"当前整理覆盖模式设置为 {overwrite_mode}"
)
event_data = TransferOverwriteCheckEventData(
fileitem=fileitem,
target_item=target_item,
target_storage=target_storage,
target_path=target_file,
overwrite_mode=overwrite_mode or "",
transfer_type=transfer_type,
)
event = eventmanager.send_event(
ChainEventType.TransferOverwriteCheck,
event_data,
)
plugin_overwrite: Optional[bool] = None
plugin_source_size: Optional[int] = None
plugin_target_size: Optional[int] = None
plugin_reason: Optional[str] = None
if event and isinstance(
event.event_data,
TransferOverwriteCheckEventData,
):
plugin_event_data = event.event_data
plugin_overwrite = plugin_event_data.overwrite
plugin_source_size = plugin_event_data.source_size
plugin_target_size = plugin_event_data.target_size
plugin_reason = plugin_event_data.reason
if plugin_overwrite is True:
return True, False, None
if plugin_overwrite is False:
return False, False, TransferInfo(
success=False,
message=plugin_reason or "插件决定不覆盖已有文件",
fileitem=fileitem,
target_item=target_item,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=need_notify,
overwrite_skipped=True,
)
if overwrite_mode in {"always", "latest"}:
return True, False, None
if overwrite_mode == "never":
return False, False, TransferInfo(
success=False,
message="媒体库存在同名文件,当前覆盖模式为不覆盖",
fileitem=fileitem,
target_item=target_item,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=need_notify,
overwrite_skipped=True,
)
if overwrite_mode == "size":
music_overwrite = self.__music_quality_overwrite_decision(
meta=meta,
mediainfo=mediainfo,
target_item=target_item,
)
if music_overwrite is True:
return True, False, None
if music_overwrite is False:
return False, False, TransferInfo(
success=False,
message="媒体库存在同名音乐文件,且目标音质更好",
fileitem=fileitem,
target_item=target_item,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=need_notify,
overwrite_skipped=True,
)
source_size = (
plugin_source_size
if plugin_source_size is not None
else fileitem.size
)
target_size = (
plugin_target_size
if plugin_target_size is not None
else target_item.size
)
sizes_known = source_size is not None and target_size is not None
if sizes_known and cast(int, target_size) < cast(int, source_size):
return True, False, None
return False, False, TransferInfo(
success=False,
message=(
"媒体库存在同名文件,且质量更好"
if sizes_known else "无法确认源文件或目标文件大小,已跳过覆盖"
),
fileitem=fileitem,
target_item=target_item,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=need_notify,
overwrite_skipped=sizes_known,
)
return False, False, None
@staticmethod
def __serialize_step_item(fileitem: Optional[FileItem]) -> Optional[dict[str, Any]]:
"""
将步骤中的可选文件项投影为 JSON 字典。
:param fileitem: 待记录的文件项;可为空
:return: 文件项字典;输入为空时返回 None
"""
return fileitem.model_dump(mode="json") if fileitem else None
@staticmethod
def __restore_step_item(result: TransferStepResult) -> Optional[FileItem]:
"""
从已持久化的成功证据恢复目标文件,不重新访问存储。
:param result: 含 item 结果字段的步骤证据
:return: 恢复的文件项;没有有效字典字段时返回 None
"""
payload = result.payload.get("item")
return FileItem.model_validate(payload) if isinstance(payload, dict) else None
@staticmethod
def __assert_file_identity(expected: FileItem, current: Optional[FileItem]) -> None:
"""
清理前比对冻结文件的大小、类型及可用身份;证据不足时保留文件。
本地链接的查询沿用 stat 语义,硬链接和软链接均比较实际内容的身份。
本地必须核对修改时间,优先纳秒证据;同路径还核对状态时间,防止等长改写后还原 mtime。
rename 合法改变状态时间,因此跨路径只比较修改时间。旧本地快照回退到秒级修改时间,
缺少时间凭据时转人工复核。云盘修改时间可能在上传后变化或缺失,不参与校验。
:param expected: 冻结的文件身份,必须包含大小
:param current: 当前严格查询结果;不可见时视为冲突
:raises TransferExecutionConflictError: 文件消失、身份字段变化或类型不一致
:raises StorageQueryError: 缺少必要身份凭据
"""
if current is None:
raise TransferExecutionConflictError(f"文件 {expected.path} 已不可见,保留源文件和旧备份")
if expected.size is None or current.size is None:
raise StorageQueryError(f"文件 {expected.path} 缺少 size 身份凭据,需人工复核")
if expected.size != current.size:
raise TransferExecutionConflictError(f"文件 {expected.path} 的 size 已变化,禁止清理")
if expected.type != current.type:
raise TransferExecutionConflictError(f"文件 {expected.path} 的类型已变化,禁止清理")
if expected.fileid:
if not current.fileid:
raise StorageQueryError(f"文件 {expected.path} 缺少 fileid 身份凭据,需人工复核")
if expected.fileid != current.fileid:
raise TransferExecutionConflictError(f"文件 {expected.path} 已被替换,禁止清理")
if expected.storage == "local":
expected_time: Optional[int | float]
current_time: Optional[int | float]
if expected.modify_time_ns is not None:
expected_time, current_time = expected.modify_time_ns, current.modify_time_ns
else:
expected_time, current_time = expected.modify_time, current.modify_time
if expected_time is None or current_time is None:
raise StorageQueryError(f"文件 {expected.path} 缺少修改时间身份凭据,需人工复核")
if expected_time != current_time:
raise TransferExecutionConflictError(f"文件 {expected.path} 的修改时间已变化,禁止清理")
if expected.path == current.path and expected.change_time_ns is not None:
if current.change_time_ns is None:
raise StorageQueryError(f"文件 {expected.path} 缺少状态时间身份凭据,需人工复核")
if expected.change_time_ns != current.change_time_ns:
raise TransferExecutionConflictError(f"文件 {expected.path} 的状态时间已变化,禁止清理")
@staticmethod
def __restore_move_target_identity(source: FileItem, target: FileItem) -> FileItem:
"""
恢复旧版 115 秒传回执的精确大小,保留原文件 ID 和持久账本不变。
仅本地文件上传到 115 且回执包含大小和文件 ID 时,使用冻结源的字节数。
删除前仍须严格核对云端大小、原文件 ID 及本地源身份,不能凭路径接纳替换文件。
:param source: 上传前冻结的本地源文件
:param target: 已持久成功的上传回执,旧版本可能记录舍入后的显示大小
:return: 用于严格核验和结算的目标身份;证据不完整或其它存储沿用原值
"""
if (source.storage == "local" and target.storage == "u115"
and source.type == target.type == "file" and target.fileid
and source.size is not None and target.size is not None):
return target.model_copy(update={"size": source.size})
return target
@classmethod
def __observe_safe_delete(
cls, storage_oper: StorageBase, path: Path, *,
frozen_item: Optional[FileItem], before_delete: Optional[Callable[[], None]],
) -> TransferOperationObservation:
"""
只在删除对象和保留副本的身份都可确认时,允许未执行删除步骤重试。
:param storage_oper: 负责严格查询待删除路径的适配器
:param path: 待删除文件路径
:param frozen_item: 可选的待删除文件冻结身份
:param before_delete: 删除前核验保留副本的回调;可为空
:return: 可恢复删除状态和证据;身份冲突或查询未知时拒绝猜测
"""
try:
current = storage_oper.get_item_strict(path)
if current is None:
state = TransferOperationObservationState.APPLIED
else:
if before_delete:
before_delete()
if frozen_item:
cls.__assert_file_identity(frozen_item, current)
state = TransferOperationObservationState.NOT_APPLIED
except TransferExecutionConflictError as error:
return TransferOperationObservation(
state=TransferOperationObservationState.CONFLICT,
evidence=TransferStepResult(payload={"identity_error": str(error)}),
)
except Exception as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"query_error": str(error)}),
)
return TransferOperationObservation(
state=state, evidence=TransferStepResult(payload={"path": path.as_posix(), "deleted": current is None}),
)
@staticmethod
def __observe_item_presence(
storage_oper: StorageBase,
path: Path,
*,
applied_when_present: bool,
) -> TransferOperationObservation:
"""
用严格查询判断目标是否符合操作完成条件。
任何查询异常都返回未知状态,不据此推断路径不存在。
:param storage_oper: 提供严格路径查询的适配器
:param path: 待观察路径
:param applied_when_present: 为 True 时存在即完成,为 False 时不存在即完成
:return: 已完成、未完成或未知的观察结果,并附查询证据
"""
try:
item = storage_oper.get_item_strict(path)
except Exception as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={
"path": path.as_posix(),
"query_error": str(error),
}),
)
exists = item is not None
applied = exists if applied_when_present else not exists
return TransferOperationObservation(
state=(
TransferOperationObservationState.APPLIED
if applied
else TransferOperationObservationState.NOT_APPLIED
),
evidence=TransferStepResult(payload={
"path": path.as_posix(),
"exists": exists,
"item": TransHandler.__serialize_step_item(item),
}),
)
@staticmethod
def __run_persisted_step(
step_runner: Optional[TransferStepRunner],
*,
phase: str,
kind: str,
payload: dict[str, Any],
execute: Callable[[], TransferStepResult],
observe: Callable[[], TransferOperationObservation],
) -> TransferStepResult:
"""
委托步骤执行器记录或恢复操作,无执行器时直接调用。
:param step_runner: 可恢复步骤执行器;None 表示同步调用
:param phase: 步骤所属阶段,参与稳定操作身份
:param kind: 阶段中的操作类别
:param payload: 冻结的步骤意图参数
:param execute: 实际执行并返回成功证据的回调
:param observe: 只读观察外部状态的回调,供执行器恢复未回执操作
:return: 已执行或从账本恢复的步骤结果
"""
if step_runner is None:
return execute()
return step_runner.run(
phase=phase,
kind=kind,
payload=payload,
execute=execute,
observe=observe,
)
@staticmethod
def __observe_transfer_operation(
*,
fileitem: FileItem,
target_storage: str,
source_oper: StorageBase,
target_oper: StorageBase,
target_file: Path,
transfer_type: str,
) -> TransferOperationObservation:
"""
保守观察传输结果,证据不足时禁止自动重放。
移动根据源与目标的存在性判断;本地复制比较完整内容,链接核对文件身份或链接指向。
非本地目标已存在时,仅凭存在性不能认定复制已完成。
:param fileitem: 冻结的源文件项
:param target_storage: 目标存储标识
:param source_oper: 源严格查询适配器
:param target_oper: 目标严格查询适配器
:param target_file: 已冻结目标文件路径
:param transfer_type: move、copy、link、softlink 或 reflink
:return: 完成状态及文件证据;无法证明内容或查询失败时返回未知状态
"""
if not fileitem.path:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"reason": "源文件项缺少路径"}),
)
try:
source_item = source_oper.get_item_strict(Path(fileitem.path))
target_item = target_oper.get_item_strict(target_file)
except Exception as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"query_error": str(error)}),
)
source_exists = source_item is not None
target_exists = target_item is not None
evidence = TransferStepResult(payload={
"source_exists": source_exists,
"target_exists": target_exists,
"item": TransHandler.__serialize_step_item(target_item),
})
# 115 盘内操作包含复制/移动及改名。最终路径缺失不能排除中间副本已产生,
# 包括旧版适配器留下的原名或编号副本,禁止据此自动重复复制。
if fileitem.storage == target_storage == "u115":
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=evidence,
)
if transfer_type == "move":
if not source_exists and target_exists:
return TransferOperationObservation(
state=TransferOperationObservationState.APPLIED,
evidence=evidence,
)
if source_exists and not target_exists:
return TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=evidence,
)
return TransferOperationObservation(
state=TransferOperationObservationState.CONFLICT,
evidence=evidence,
)
if not target_exists:
return TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=evidence,
)
if fileitem.storage != "local" or target_storage != "local":
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=evidence,
)
source_path = Path(fileitem.path)
try:
if transfer_type == "copy":
applied = source_path.is_file() and filecmp.cmp(
source_path, target_file, shallow=False
)
elif transfer_type == "link":
applied = source_path.is_file() and target_file.samefile(source_path)
elif transfer_type == "softlink":
applied = target_file.is_symlink() and target_file.resolve() == source_path.resolve()
elif transfer_type == "reflink":
applied = (
source_path.is_file()
and target_file.is_file()
and not target_file.is_symlink()
and not target_file.samefile(source_path)
and filecmp.cmp(source_path, target_file, shallow=False)
)
else:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=evidence,
)
except OSError as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={
**evidence.payload,
"verification_error": str(error),
}),
)
return TransferOperationObservation(
state=(
TransferOperationObservationState.APPLIED
if applied
else TransferOperationObservationState.CONFLICT
),
evidence=evidence,
)
@classmethod
def __execute_transfer_with_steps(
cls,
*,
step_runner: Optional[TransferStepRunner],
source_fileitem: dict[str, Any],
target_storage: str,
source_oper: StorageBase,
target_oper: StorageBase,
target_file: Path,
transfer_type: str,
require_target_absent: bool = False,
materialize_kind: str = "materialize_target",
) -> tuple[Optional[FileItem], str]:
"""
按冻结叶节点执行传输,并把跨存储 move 拆为落地与源删除。
意图直接消费原始快照,避免旧快照补默认字段或适配器更新运行期文件信息
导致步骤身份漂移;每个外部操作单独恢复文件对象。
:param step_runner: 步骤执行器;None 时同步执行
:param source_fileitem: 原始冻结源快照,不补写运行期字段
:param target_storage: 目标存储标识
:param source_oper: 源存储适配器
:param target_oper: 目标存储适配器
:param target_file: 最终或暂存目标路径
:param transfer_type: 整理方式;跨存储 move 拆为 copy 和独立删除
:param require_target_absent: 是否要求目标事先不存在并核验落地大小
:param materialize_kind: 目标落地步骤类别,默认 materialize_target
:return: 目标文件项及提示;缺少成功目标证据时文件项为 None
"""
fileitem = FileItem.model_validate(source_fileitem)
if not fileitem.path:
return None, "源文件项缺少路径"
source_path = Path(fileitem.path)
cross_storage_move = (
transfer_type == "move" and fileitem.storage != target_storage
)
materialize_type = "copy" if cross_storage_move else transfer_type
intent_payload = {
"source": source_fileitem,
"target_storage": target_storage,
"target_path": target_file.as_posix(),
"transfer_type": materialize_type,
}
def execute_materialize() -> TransferStepResult:
"""
执行一次目标落地并冻结其返回对象。
"""
if require_target_absent and target_oper.get_item_strict(target_file) is not None:
raise RuntimeError(f"暂存目标已存在,需确认上次整理结果:{target_file}")
new_item, error = cls.__transfer_command(
fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=materialize_type,
)
if not new_item:
raise RuntimeError(error or f"{fileitem.path} 整理失败")
if require_target_absent:
# SDK 的成功返回不能替代暂存文件的完整性证据,旧目标此时仍未移动。
staged_item = target_oper.get_item_strict(target_file)
if (fileitem.size is None or staged_item is None
or staged_item.size != fileitem.size):
raise TransferExecutionConflictError(f"暂存目标 {target_file} 大小无法确认,保留旧目标")
new_item = staged_item
return TransferStepResult(payload={
"item": cls.__serialize_step_item(new_item),
"message": error,
})
materialized = cls.__run_persisted_step(
step_runner,
phase="transfer",
kind=materialize_kind,
payload=intent_payload,
execute=execute_materialize,
observe=lambda: cls.__observe_transfer_operation(
fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=materialize_type,
),
)
new_item = cls.__restore_step_item(materialized)
if not new_item:
return None, "整理步骤成功证据缺少目标文件"
if not cross_storage_move:
return new_item, str(materialized.payload.get("message") or "")
new_item = cls.__restore_move_target_identity(fileitem, new_item)
def execute_source_delete() -> TransferStepResult:
"""
在目标已落地后单独删除跨存储 move 的源文件。
"""
confirm_materialized_target()
current_source = source_oper.get_item_strict(source_path)
if current_source is not None:
cls.__assert_file_identity(fileitem, current_source)
if current_source is not None and not source_oper.delete(current_source):
raise RuntimeError(f"{fileitem.path} 源文件删除失败")
return TransferStepResult(payload={
"source_path": fileitem.path,
"deleted": True,
})
def confirm_materialized_target() -> None:
"""
删除跨存储移动源前核对当前目标仍与持久落地结果一致。
"""
cls.__assert_file_identity(new_item, target_oper.get_item_strict(target_file))
cls.__run_persisted_step(
step_runner,
phase="transfer",
kind="delete_move_source",
payload={
"source": source_fileitem,
"target_storage": target_storage,
"target_path": target_file.as_posix(),
},
execute=execute_source_delete,
observe=lambda: cls.__observe_safe_delete(
source_oper, source_path,
frozen_item=fileitem, before_delete=confirm_materialized_target,
),
)
return new_item, str(materialized.payload.get("message") or "")
@classmethod
def __rename_with_step(
cls,
*,
step_runner: Optional[TransferStepRunner],
storage_oper: StorageBase,
storage: str,
source: Path,
target: Path,
kind: str,
expected_source: Optional[FileItem] = None,
allow_missing_source: bool = False,
) -> Optional[FileItem]:
"""
执行同目录无覆盖重命名,并用两端严格查询恢复中断操作。
两端同时存在或遗留步骤中同时消失视为冲突;只有覆盖备份在执行前明确
发现旧目标缺失时,才记录为无副作用的跳过。查询或身份无法确认时不自动重放。
:param step_runner: 步骤执行器;None 时同步执行
:param storage_oper: 负责查询和重命名的存储适配器
:param storage: 用于步骤身份和诊断的存储标识
:param source: 原路径,必须存在
:param target: 新路径,必须不存在且由调用方保持在同一目录
:param kind: 步骤操作类别
:param expected_source: 可选的冻结源身份;提供时在操作前后核对
:param allow_missing_source: 旧目标已被外部删除时是否将重命名记录为跳过
:return: 重命名后的文件项;允许旧目标缺失并跳过时返回 None
"""
def execute() -> TransferStepResult:
"""
目标必须不存在,避免适配器的 rename 意外替换其它文件。
"""
source_item = storage_oper.get_item_strict(source)
target_item = storage_oper.get_item_strict(target)
if allow_missing_source and source_item is None and target_item is None:
return TransferStepResult(payload={"item": None, "skipped": True})
if source_item is None or target_item is not None:
raise RuntimeError(f"重命名路径状态冲突:{source} -> {target}")
if expected_source is not None:
cls.__assert_file_identity(expected_source, source_item)
if not storage_oper.rename(source_item, target.name):
raise RuntimeError(f"【{storage}】{source} 重命名失败")
target_item = storage_oper.get_item_strict(target)
if target_item is None:
raise RuntimeError(f"【{storage}】{target} 重命名后不可见")
if expected_source is not None:
cls.__assert_file_identity(expected_source, target_item)
return TransferStepResult(payload={"item": cls.__serialize_step_item(target_item)})
def observe() -> TransferOperationObservation:
"""
只接受一端存在的明确结果,两端同时存在或消失均禁止重放。
"""
try:
source_item = storage_oper.get_item_strict(source)
target_item = storage_oper.get_item_strict(target)
if source_item is None and target_item is not None and expected_source is not None:
cls.__assert_file_identity(expected_source, target_item)
except TransferExecutionConflictError as error:
return TransferOperationObservation(
state=TransferOperationObservationState.CONFLICT,
evidence=TransferStepResult(payload={"identity_error": str(error)}),
)
except Exception as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"query_error": str(error)}),
)
if source_item is None and target_item is not None:
state = TransferOperationObservationState.APPLIED
elif source_item is not None and target_item is None:
state = TransferOperationObservationState.NOT_APPLIED
else:
state = TransferOperationObservationState.CONFLICT
return TransferOperationObservation(
state=state,
evidence=TransferStepResult(payload={
"source_exists": source_item is not None,
"target_exists": target_item is not None,
"item": cls.__serialize_step_item(target_item),
}),
)
renamed = cls.__run_persisted_step(
step_runner, phase="transfer", kind=kind,
payload={"storage": storage, "source_path": source.as_posix(),
"target_path": target.as_posix()},
execute=execute, observe=observe,
)
item = cls.__restore_step_item(renamed)
if item is None and not (
allow_missing_source and renamed.payload.get("skipped") is True
):
raise RuntimeError("重命名成功证据缺少目标文件")
return item
@classmethod
def __replace_target_with_steps(
cls,
*,
step_runner: Optional[TransferStepRunner],
source_fileitem: dict[str, Any],
target_storage: str,
source_oper: StorageBase,
target_oper: StorageBase,
target_file: Path,
transfer_type: str,
) -> tuple[Optional[FileItem], str]:
"""
先完整暂存新版本,再备份旧版本、提升新文件,最后清理备份和移动源。
临时名由冻结输入确定,崩溃恢复会使用相同操作身份。备份和提升各自记账,
查询失败或远端结果不明时交给 步骤执行器 隔离,绝不猜测完成或删除备份。
:param step_runner: 步骤执行器;None 时同步执行
:param source_fileitem: 用于临时名称及步骤身份的冻结源快照
:param target_storage: 目标存储标识
:param source_oper: 源存储适配器
:param target_oper: 目标存储适配器
:param target_file: 需要替换的旧目标路径
:param transfer_type: 整理方式;move 在新目标提升成功前保留源文件
:return: 新目标文件项及提示;源目标相同或落地失败时文件项为 None
"""
if (source_fileitem.get("storage") == target_storage
and Path(source_fileitem["path"]) == target_file):
return None, "源文件和目标文件相同,已跳过覆盖以保留原文件"
stage_path, backup_path = build_transfer_overwrite_paths(
source=source_fileitem, target_storage=target_storage,
target_path=target_file.as_posix(), transfer_type=transfer_type,
)
stage, backup = Path(stage_path), Path(backup_path)
# move 也先 copy,提升成功前始终保留源文件。
new_item, message = cls.__execute_transfer_with_steps(
step_runner=step_runner, source_fileitem=source_fileitem,
target_storage=target_storage, source_oper=source_oper,
target_oper=target_oper, target_file=stage,
transfer_type="copy" if transfer_type == "move" else transfer_type,
require_target_absent=True,
materialize_kind="stage_overwrite_target",
)
if new_item is None:
return None, message
backup_item = cls.__rename_with_step(
step_runner=step_runner, storage_oper=target_oper,
storage=target_storage, source=target_file, target=backup,
kind="backup_overwrite_target",
allow_missing_source=True,
)
new_item = cast(FileItem, cls.__rename_with_step(
step_runner=step_runner, storage_oper=target_oper,
storage=target_storage, source=stage, target=target_file,
kind="promote_overwrite_target", expected_source=new_item,
))
def confirm_replacement() -> None:
"""
清理前核对新目标的持久提升凭据,不能仅凭路径存在删除其它副本。
"""
cls.__assert_file_identity(new_item, target_oper.get_item_strict(target_file))
if transfer_type == "move":
cls.__delete_target_with_step(
step_runner=step_runner, target_oper=source_oper,
target_storage=str(source_fileitem["storage"]),
target_file=Path(source_fileitem["path"]), kind="delete_overwrite_source",
before_delete=confirm_replacement, frozen_item=FileItem(**source_fileitem),
)
cls.__delete_target_with_step(
step_runner=step_runner, target_oper=target_oper,
target_storage=target_storage, target_file=backup,
kind="delete_overwrite_backup",
before_delete=confirm_replacement, frozen_item=backup_item,
)
return new_item, message
@classmethod
def __ensure_directory_with_step(
cls,
*,
step_runner: Optional[TransferStepRunner],
target_oper: StorageBase,
target_storage: str,
path: Path,
) -> Optional[FileItem]:
"""
记录可能创建目录的操作并恢复其结果。
实际取得目录失败时抛出 RuntimeError。
:param step_runner: 步骤执行器;None 时同步执行
:param target_oper: 提供 get_folder 和严格查询的目标适配器
:param target_storage: 目标存储标识
:param path: 需要存在的目录路径
:return: 恢复的目录项;成功证据缺少文件项时返回 None
"""
def execute() -> TransferStepResult:
"""
获取或创建目标目录并冻结目录对象。
"""
directory = target_oper.get_folder(path)
if not directory:
raise RuntimeError(f"目标目录 {path} 获取失败")
return TransferStepResult(payload={
"item": cls.__serialize_step_item(directory),
})
result = cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="ensure_target_directory",
payload={"storage": target_storage, "path": path.as_posix()},
execute=execute,
observe=lambda: cls.__observe_item_presence(
target_oper,
path,
applied_when_present=True,
),
)
return cls.__restore_step_item(result)
@classmethod
def __cleanup_with_step(
cls,
*,
step_runner: Optional[TransferStepRunner],
cleanup: Optional[Callable[[], None]],
observe_cleanup: Optional[Callable[[], bool]],
source_path: str,
) -> None:
"""
把历史目标清理纳入步骤账本。
只有清理回调正常返回才记录成功;缺少观察能力时不能自动猜测遗留调用是否完成。
:param step_runner: 步骤执行器;None 时直接调用清理回调
:param cleanup: 实际清理回调;为空时跳过
:param observe_cleanup: 只读确认清理结果的回调;缺失时遗留结果视为未知
:param source_path: 用于稳定步骤身份的源路径
"""
if cleanup is None:
return
def execute() -> TransferStepResult:
"""
执行统一旧目标清理能力。
"""
cleanup()
return TransferStepResult(payload={"cleaned": True})
def observe() -> TransferOperationObservation:
"""
通过只读兼容能力确认旧目标是否已经消失。
"""
if observe_cleanup is None:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={
"reason": "cleanup observer unavailable",
}),
)
try:
cleaned = observe_cleanup()
except Exception as error:
return TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={"query_error": str(error)}),
)
return TransferOperationObservation(
state=(
TransferOperationObservationState.APPLIED
if cleaned
else TransferOperationObservationState.NOT_APPLIED
),
evidence=TransferStepResult(payload={"cleaned": cleaned}),
)
cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="cleanup_previous_destination",
payload={"source_path": source_path},
execute=execute,
observe=observe,
)
@classmethod
def __delete_target_with_step(
cls,
*,
step_runner: Optional[TransferStepRunner],
target_oper: StorageBase,
target_storage: str,
target_file: Path,
kind: str,
before_delete: Optional[Callable[[], None]] = None,
frozen_item: Optional[FileItem] = None,
) -> None:
"""
在身份核验通过后删除目标,并记录可恢复的清理证据。
目标已经不存在视为成功;查询未知、身份冲突或删除失败时向上传播异常。
:param step_runner: 步骤执行器;None 时同步执行
:param target_oper: 负责严格查询和删除的适配器
:param target_storage: 待删除项所在存储标识
:param target_file: 待删除项的冻结路径
:param kind: 清理操作类别
:param before_delete: 删除前核对保留副本的回调;为空时不作此项核对
:param frozen_item: 可选的待删除文件身份;提供时必须与当前文件一致
"""
def execute() -> TransferStepResult:
"""
只删除当前冻结目标,目标已不存在视为成功。
"""
if before_delete:
before_delete()
current = target_oper.get_item_strict(target_file)
if current is not None and frozen_item is not None:
cls.__assert_file_identity(frozen_item, current)
if current is not None and not target_oper.delete(current):
raise RuntimeError(f"【{target_storage}】{target_file} 删除失败")
return TransferStepResult(payload={"deleted": True})
cls.__run_persisted_step(
step_runner,
phase="cleanup",
kind=kind,
payload={"storage": target_storage, "path": target_file.as_posix()},
execute=execute,
observe=lambda: cls.__observe_safe_delete(
target_oper, target_file, frozen_item=frozen_item, before_delete=before_delete,
),
)
def __resolve_leaf_overwrite_with_step(
self,
*,
step_runner: Optional[TransferStepRunner],
planned_item: TransferPlanItem,
target_oper: StorageBase,
transfer_type: str,
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
overwrite_mode: Optional[str],
need_notify: bool,
) -> tuple[bool, Optional[TransferInfo]]:
"""
按冻结覆盖策略裁决目录叶目标,保持既有叶子步骤的稳定身份。
:param step_runner: 步骤执行器;None 时直接查询
:param planned_item: 含冻结源快照和目标路径的叶子操作
:param target_oper: 负责严格查询的目标适配器
:param transfer_type: 整理方式,参与步骤身份
:param meta: 目录媒体元数据
:param mediainfo: 已识别媒体信息
:param overwrite_mode: 检查点冻结的覆盖策略
:param need_notify: 拒绝覆盖时是否请求通知
:return: 是否覆盖及可选的拒绝结果
"""
def execute() -> TransferStepResult:
"""
目录叶与单文件共用覆盖合同,并冻结拒绝原因。
"""
over_flag, _, failure = self.__resolve_overwrite(
fileitem=FileItem.model_validate(planned_item.source_fileitem),
meta=meta, mediainfo=mediainfo, target_oper=target_oper,
target_storage=planned_item.target_storage, target_file=Path(planned_item.target_path),
transfer_type=transfer_type, overwrite_mode=overwrite_mode, need_notify=need_notify,
)
return TransferStepResult(payload={
"over_flag": over_flag, "policy_checked": True,
"failure": failure.model_dump(mode="json") if failure else None,
})
decision = self.__run_persisted_step(
step_runner, phase="decision", kind="resolve_leaf_overwrite",
payload={"source": planned_item.source_fileitem,
"target_storage": planned_item.target_storage,
"target_path": planned_item.target_path, "transfer_type": transfer_type},
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={"reason": "read-only leaf decision may be repeated"}),
),
)
failure = decision.payload.get("failure")
if (decision.payload.get("over_flag") is True and overwrite_mode == "never"
and decision.payload.get("policy_checked") is not True):
# 旧版本只记录存在性,不能用旧回执授权本次禁止的破坏性覆盖。
return False, TransferInfo(
success=False, overwrite_skipped=True,
message="旧目录覆盖裁决未校验不覆盖策略,保留源文件和目标,需人工复核",
fileitem=FileItem.model_validate(planned_item.source_fileitem),
transfer_type=transfer_type, need_notify=need_notify,
)
return decision.payload.get("over_flag") is True, (
TransferInfo.model_validate(failure) if isinstance(failure, dict) else None
)
def __resolve_overwrite_with_step(
self,
*,
step_runner: Optional[TransferStepRunner],
fileitem: FileItem,
source_fileitem: dict[str, Any],
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
target_oper: StorageBase,
target_storage: str,
target_file: Path,
transfer_type: str,
overwrite_mode: Optional[str],
need_notify: bool,
) -> tuple[bool, bool, Optional[TransferInfo]]:
"""
以原始源快照冻结覆盖判定,避免模型补字段改变旧任务的步骤身份。
:param step_runner: 步骤执行器;None 时直接判断
:param fileitem: 当前源文件投影
:param source_fileitem: 原始冻结源快照,参与稳定步骤身份
:param meta: 源文件元数据
:param mediainfo: 识别后的媒体信息
:param target_oper: 目标严格查询适配器
:param target_storage: 目标存储标识
:param target_file: 冻结目标文件路径
:param transfer_type: 整理方式
:param overwrite_mode: 已冻结的覆盖策略;可为空
:param need_notify: 失败结果是否请求通知
:return: 是否覆盖、是否删除其他版本、可选失败结果
"""
def execute() -> TransferStepResult:
"""
执行一次覆盖策略判定并冻结完整裁决。
"""
over_flag, delete_versions, failure = self.__resolve_overwrite(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_oper=target_oper,
target_storage=target_storage,
target_file=target_file,
transfer_type=transfer_type,
overwrite_mode=overwrite_mode,
need_notify=need_notify,
)
return TransferStepResult(payload={
"over_flag": over_flag,
"delete_versions": delete_versions,
"failure": failure.model_dump(mode="json") if failure else None,
})
result = self.__run_persisted_step(
step_runner,
phase="decision",
kind="resolve_overwrite",
payload={
"source": source_fileitem,
"target_storage": target_storage,
"target_path": target_file.as_posix(),
"transfer_type": transfer_type,
"overwrite_mode": overwrite_mode,
"need_notify": need_notify,
},
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={
"reason": "read-only overwrite decision may be repeated",
}),
),
)
failure_payload = result.payload.get("failure")
return (
bool(result.payload.get("over_flag")),
bool(result.payload.get("delete_versions")),
(
TransferInfo.model_validate(failure_payload)
if isinstance(failure_payload, dict)
else None
),
)
def __intercept_with_step(
self,
*,
step_runner: Optional[TransferStepRunner],
payload: dict[str, Any],
invoke: Callable[[], tuple[bool, str]],
) -> tuple[bool, str]:
"""
冻结插件拦截裁决,将没有回执的遗留调用视为未知。
插件可能有副作用,没有稳定回执的遗留调用不自动重试。
:param step_runner: 步骤执行器;None 时直接调用插件
:param payload: 稳定的插件调用意图
:param invoke: 返回是否允许和拒绝原因的插件调用回调
:return: 是否允许执行及取消原因
"""
def execute() -> TransferStepResult:
"""
执行插件拦截并冻结允许标记与原因。
"""
allowed, reason = invoke()
return TransferStepResult(payload={
"allowed": allowed,
"reason": reason,
})
result = self.__run_persisted_step(
step_runner,
phase="decision",
kind="plugin_transfer_intercept",
payload=payload,
execute=execute,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.UNKNOWN,
evidence=TransferStepResult(payload={
"reason": "plugin intercept has no stable invocation receipt",
}),
),
)
return bool(result.payload.get("allowed")), str(result.payload.get("reason") or "")
def __execute_directory_leaf(
self, *, planned_item: TransferPlanItem, checkpoint: TransferPlanCheckpoint,
meta: MetaBase, mediainfo: MediaInfo | MusicInfo,
source_oper: StorageBase, target_oper: StorageBase,
step_runner: Optional[TransferStepRunner],
) -> tuple[Optional[FileItem], Optional[TransferInfo]]:
"""
校验目录叶操作并消费覆盖裁决,拒绝时不执行文件副作用。
:param planned_item: 冻结的目录叶整理操作
:param checkpoint: 目录计划及其覆盖、通知策略
:param meta: 目录媒体元数据
:param mediainfo: 目录媒体识别结果
:param source_oper: 源存储适配器
:param target_oper: 目标存储适配器
:param step_runner: 持久步骤执行器;为空时同步执行
:return: 新目标及可选失败结果;拒绝覆盖保留 overwrite_skipped 语义
"""
transfer_type = checkpoint.resolved_transfer_type
if planned_item.action != "transfer" or planned_item.target_storage != checkpoint.target_storage:
return None, TransferInfo(
success=False, message="整理计划包含不支持的操作或目标存储",
fileitem=FileItem.model_validate(checkpoint.planning_input.source_fileitem),
transfer_type=transfer_type, need_notify=checkpoint.need_notify,
)
over_flag, failure = self.__resolve_leaf_overwrite_with_step(
step_runner=step_runner, planned_item=planned_item,
target_oper=target_oper, transfer_type=transfer_type,
meta=meta, mediainfo=mediainfo, overwrite_mode=checkpoint.overwrite_mode,
need_notify=checkpoint.need_notify,
)
if failure:
return None, failure
execute_transfer = self.__replace_target_with_steps if over_flag else self.__execute_transfer_with_steps
new_item, error = execute_transfer(
step_runner=step_runner, source_fileitem=planned_item.source_fileitem,
target_storage=planned_item.target_storage, source_oper=source_oper, target_oper=target_oper,
target_file=Path(planned_item.target_path), transfer_type=transfer_type,
)
if not new_item:
return None, TransferInfo(
success=False, message=error,
fileitem=FileItem.model_validate(checkpoint.planning_input.source_fileitem),
transfer_type=transfer_type, need_notify=checkpoint.need_notify,
)
return new_item, None
def execute_transfer_plan(
self,
checkpoint: TransferPlanCheckpoint,
*,
meta: MetaBase,
mediainfo: MediaInfo | MusicInfo,
source_oper: StorageBase,
target_oper: StorageBase,
cleanup_before_transfer: Optional[Callable[[], None]] = None,
observe_cleanup_before_transfer: Optional[Callable[[], bool]] = None,
step_runner: Optional[TransferStepRunner] = None,
) -> TransferInfo:
"""
只消费冻结目标和有序操作,并在执行期处理覆盖与插件拦截。
持久步骤意图中的源文件必须使用规划输入快照;目录执行时可能重新统计
STREAM 大小,但这类运行期投影不属于步骤身份。
预览只构造结果,不创建目录或执行插件拦截。执行冲突、证据不足或存储异常可向上传播。
:param checkpoint: 包含冻结目标、有序操作和策略的检查点
:param meta: 恢复后的源元数据,用于覆盖和插件事件
:param mediainfo: 恢复后的媒体信息
:param source_oper: 源存储适配器
:param target_oper: 目标存储适配器
:param cleanup_before_transfer: 兼容清理回调;尽管参数名沿用旧名,当前在目标成功落地后调用
:param observe_cleanup_before_transfer: 只读确认兼容清理是否完成的回调
:param step_runner: 可恢复步骤执行器;None 时沿用同步执行
:return: 整理结果,包含目标和成功文件统计或拒绝原因
"""
fileitem = FileItem.model_validate(checkpoint.planning_input.source_fileitem)
frozen_source_payload = checkpoint.planning_input.source_fileitem
target_storage = checkpoint.target_storage
target_path = Path(checkpoint.final_target_path)
transfer_type = checkpoint.resolved_transfer_type
result = TransferInfo()
if checkpoint.skip_reason:
logger.info(f"文件 {fileitem.path} 跳过整理:{checkpoint.skip_reason}")
return TransferInfo(
success=True,
message=checkpoint.skip_reason,
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=False,
)
if checkpoint.preview:
item_type = "dir" if fileitem.type == "dir" else "file"
preview_target_item = self.__build_preview_item(
storage=target_storage,
path=target_path,
item_type=item_type,
size=fileitem.size,
)
preview_target_diritem = (
preview_target_item
if item_type == "dir"
else self.__build_preview_item(
storage=target_storage,
path=target_path.parent,
item_type="dir",
)
)
return TransferInfo(
success=True,
fileitem=fileitem,
target_item=preview_target_item,
target_diritem=preview_target_diritem,
file_list=[cast(str, fileitem.path)],
file_list_new=[target_path.as_posix()],
file_count=0 if item_type == "dir" else 1,
total_size=0 if item_type == "dir" else fileitem.size or 0,
need_scrape=checkpoint.need_scrape,
transfer_type=transfer_type,
need_notify=False,
)
if fileitem.type == "dir":
stream_path = Path(cast(str, fileitem.path)) / "BDMV" / "STREAM"
stream_sizes = [
FileItem.model_validate(item.source_fileitem).size or 0
for item in checkpoint.items
if Path(str(item.source_fileitem.get("path", ""))).parent
== stream_path
]
if stream_sizes:
fileitem.size = sum(stream_sizes)
allowed, reason = self.__intercept_with_step(
step_runner=step_runner,
payload={
"source": frozen_source_payload,
"target_storage": target_storage,
"target_path": target_path.as_posix(),
"transfer_type": transfer_type,
},
invoke=lambda: self.__intercept_transfer(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_path,
transfer_type=transfer_type,
),
)
if not allowed:
return TransferInfo(
success=False,
message=reason,
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
target_diritem = self.__ensure_directory_with_step(
step_runner=step_runner,
target_oper=target_oper,
target_storage=target_storage,
path=target_path,
)
if not target_diritem:
return TransferInfo(
success=False,
message=f"获取目标目录失败:{target_path}",
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
for planned_item in checkpoint.items:
new_item, failure = self.__execute_directory_leaf(
planned_item=planned_item, checkpoint=checkpoint, meta=meta, mediainfo=mediainfo,
source_oper=source_oper, target_oper=target_oper, step_runner=step_runner,
)
if failure:
return failure
self.__update_result(
result=result,
file_list=[planned_item.source_fileitem.get("path")],
file_list_new=[cast(FileItem, new_item).path],
)
self.__cleanup_with_step(
step_runner=step_runner,
cleanup=cleanup_before_transfer,
observe_cleanup=observe_cleanup_before_transfer,
source_path=cast(str, fileitem.path),
)
self.__update_result(
result=result,
success=True,
fileitem=fileitem,
target_item=target_diritem,
target_diritem=target_diritem,
need_scrape=checkpoint.need_scrape,
need_notify=checkpoint.need_notify,
transfer_type=transfer_type,
)
return result
if len(checkpoint.items) != 1:
return TransferInfo(
success=False,
message="单文件整理计划必须且只能包含一个操作",
fileitem=fileitem,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
planned_item = checkpoint.items[0]
if (
planned_item.action != "transfer"
or planned_item.target_storage != target_storage
or planned_item.target_path != checkpoint.final_target_path
):
return TransferInfo(
success=False,
message="单文件整理计划与冻结目标不一致",
fileitem=fileitem,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
target_file = Path(planned_item.target_path)
over_flag, delete_versions, overwrite_failure = self.__resolve_overwrite_with_step(
step_runner=step_runner,
fileitem=fileitem,
source_fileitem=frozen_source_payload,
meta=meta,
mediainfo=mediainfo,
target_oper=target_oper,
target_storage=target_storage,
target_file=target_file,
transfer_type=transfer_type,
overwrite_mode=checkpoint.overwrite_mode,
need_notify=checkpoint.need_notify,
)
if overwrite_failure:
return overwrite_failure
allowed, reason = self.__intercept_with_step(
step_runner=step_runner,
payload={
"source": frozen_source_payload,
"target_storage": target_storage,
"target_path": target_file.as_posix(),
"transfer_type": transfer_type,
"over_flag": over_flag,
},
invoke=lambda: self.__intercept_transfer(
fileitem=fileitem,
meta=meta,
mediainfo=mediainfo,
target_storage=target_storage,
target_path=target_file,
transfer_type=transfer_type,
over_flag=over_flag,
),
)
if not allowed:
return TransferInfo(
success=False,
message=reason,
fileitem=fileitem,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
target_diritem = self.__ensure_directory_with_step(
step_runner=step_runner,
target_oper=target_oper,
target_storage=target_storage,
path=target_file.parent,
)
if not target_diritem:
return TransferInfo(
success=False,
message=f"目标目录 {target_file.parent} 获取失败",
fileitem=fileitem,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
if step_runner is None:
new_item, error = self.__transfer_file(
fileitem=fileitem,
target_storage=target_storage,
target_file=target_file,
transfer_type=transfer_type,
over_flag=over_flag,
source_oper=source_oper,
target_oper=target_oper,
result=result,
)
else:
execute_transfer = (
self.__replace_target_with_steps if over_flag
else self.__execute_transfer_with_steps
)
new_item, error = execute_transfer(
step_runner=step_runner,
source_fileitem=planned_item.source_fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type,
)
if new_item:
self.__update_result(
result=result,
file_list=[fileitem.path],
file_list_new=[new_item.path],
file_count=1,
total_size=fileitem.size,
)
if not new_item:
error = error or f"{fileitem.path} 整理后未获取到目标文件信息"
return TransferInfo(
success=False,
message=error,
fileitem=fileitem,
fail_list=[fileitem.path] if fileitem.path else [],
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
if delete_versions:
if step_runner is None:
self.__delete_version_files(target_oper, target_file)
else:
self.__delete_version_files_with_steps(
step_runner=step_runner,
storage_oper=target_oper,
target_storage=target_storage,
path=target_file,
)
self.__cleanup_with_step(
step_runner=step_runner,
cleanup=cleanup_before_transfer,
observe_cleanup=observe_cleanup_before_transfer,
source_path=cast(str, fileitem.path),
)
self.__update_result(
result=result,
success=True,
fileitem=fileitem,
target_item=new_item,
target_diritem=target_diritem,
need_scrape=checkpoint.need_scrape,
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
return result
@staticmethod
def __download_to_local(
fileitem: FileItem,
source_oper: StorageBase,
target_file: Path,
transfer_type: str,
) -> tuple[bool, str]:
"""
通过隔离临时目录下载,落地后按需删除远端源文件。
临时目录与目标在同一父目录下,退出时自动清理;move 的源删除失败时目标可能已落地。
:param fileitem: 待下载的远端源文件
:param source_oper: 负责下载及可选删除的源适配器
:param target_file: 最终本地路径;自动创建父目录
:param transfer_type: 仅接受 copy 或 move
:return: 是否完成及错误信息;成功时错误文本为空
"""
if transfer_type not in ("copy", "move"):
return False, f"不支持的整理方式:{transfer_type}"
target_file.parent.mkdir(parents=True, exist_ok=True)
with TemporaryDirectory(prefix=".mp-download-", dir=target_file.parent) as temp_dir:
tmp_file = source_oper.download(fileitem=fileitem, path=Path(temp_dir))
if not tmp_file:
return False, f"{fileitem.path} {fileitem.storage} 下载失败"
move_result, move_error = SystemUtils.move(tmp_file, target_file)
if move_result != 0:
return False, f"{fileitem.path} 下载后移动到 {target_file} 失败:{move_error}"
if transfer_type == "move" and not source_oper.delete(fileitem):
return False, f"{fileitem.path} 源文件删除失败"
return True, ""
@staticmethod
def __transfer_local_to_local(
source_oper: StorageBase,
fileitem: FileItem,
target_file: Path,
transfer_type: str,
) -> Tuple[bool, str]:
"""
执行本地存储之间的单文件传输操作。
:param source_oper: 本地存储适配器
:param fileitem: 源文件项
:param target_file: 目标文件完整路径
:param transfer_type: 整理方式
:return: 操作成功标记及错误说明
"""
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True, exist_ok=True)
if transfer_type == "copy":
state = source_oper.copy(fileitem, target_file.parent, target_file.name)
elif transfer_type == "move":
state = source_oper.move(fileitem, target_file.parent, target_file.name)
elif transfer_type in ("link", "softlink", "reflink"):
action = getattr(source_oper, transfer_type, None)
state = bool(callable(action) and action(fileitem, target_file))
else:
return False, f"不支持的整理方式:{transfer_type}"
if state:
return True, ""
return False, f"{fileitem.path} {transfer_type} 失败"
@staticmethod
def __transfer_same_remote_storage(
fileitem: FileItem,
target_storage: str,
source_oper: StorageBase,
target_oper: StorageBase,
target_file: Path,
transfer_type: str,
get_remote_targetitem: Callable[[FileItem, Path], FileItem],
) -> Tuple[Optional[FileItem], str]:
"""
在相同的非本地远端存储间执行文件复制、移动或链接操作。
:param fileitem: 源文件项
:param target_storage: 目标存储标识
:param source_oper: 源存储适配器
:param target_oper: 目标存储适配器
:param target_file: 目标文件完整路径
:param transfer_type: 整理方式
:param get_remote_targetitem: 获取远端目标项回调
:return: 目标文件项及错误说明
"""
if not source_oper.is_support_transtype(transfer_type):
return None, f"存储 {fileitem.storage} 不支持 {transfer_type} 整理方式"
if transfer_type in ("copy", "move"):
target_fileitem = target_oper.get_folder(target_file.parent)
if not target_fileitem or not target_fileitem.path:
return None, f"【{target_storage}】{target_file.parent} 目录获取失败"
action_name = "copy_item" if transfer_type == "copy" else "move_item"
custom_item_action = getattr(source_oper, action_name, None)
fallback_action = getattr(source_oper, transfer_type, None)
if callable(custom_item_action):
new_item = custom_item_action(
fileitem, Path(target_fileitem.path), target_file.name
)
if new_item:
return new_item, ""
elif callable(fallback_action) and fallback_action(
fileitem, Path(target_fileitem.path), target_file.name
):
return get_remote_targetitem(fileitem, target_file), ""
action_desc = "复制" if transfer_type == "copy" else "移动"
return None, f"【{target_storage}】{fileitem.path} {action_desc}文件失败"
if transfer_type in ("link", "reflink"):
action = getattr(source_oper, transfer_type, None)
if callable(action) and action(fileitem, target_file):
return get_remote_targetitem(fileitem, target_file), ""
action_desc = "引用链接" if transfer_type == "reflink" else "硬链接"
return None, f"【{target_storage}】{fileitem.path} 创建{action_desc}失败"
return None, f"不支持的整理方式:{transfer_type}"
@staticmethod
def __transfer_command(
fileitem: FileItem,
target_storage: str,
source_oper: StorageBase,
target_oper: StorageBase,
target_file: Path,
transfer_type: str,
) -> Tuple[Optional[FileItem], str]:
"""
执行单文件存储操作,按本地与远端组合选择传输路径。
不同远端存储之间不支持直接整理。远端元数据延迟可见时可按目标路径合成结果。
兼容的本地到远端 move 分支不检查源删除返回值;持久任务在上层拆分目标落地与源删除。
:param fileitem: 源文件项及所属存储
:param target_storage: 目标存储标识
:param source_oper: 源存储适配器
:param target_oper: 目标存储适配器
:param target_file: 最终文件路径,必要时创建父目录
:param transfer_type: copy、move、link、softlink 或 reflink,支持范围取决于存储组合
:return: 目标文件项及错误文本;失败时文件项为 None
"""
def __get_targetitem(_path: Path) -> FileItem:
"""
读取已落地本地目标的大小和修改时间。
:param _path: 已存在的本地目标文件
:return: 实际本地文件元数据
"""
return FileItem(
storage=target_storage,
path=_path.as_posix(),
name=_path.name,
basename=_path.stem,
type="file",
size=_path.stat().st_size,
extension=_path.suffix.lstrip("."),
modify_time=_path.stat().st_mtime,
)
def __build_remote_targetitem(_source_item: FileItem, _path: Path) -> FileItem:
"""
为元数据延迟可见的远端目标合成临时文件项。
:param _source_item: 提供大小、修改时间和缩略图的源项
:param _path: 已确认的目标路径
:return: 没有远端文件标识的目标文件项
"""
return FileItem(
storage=target_storage,
path=_path.as_posix(),
name=_path.name,
basename=_path.stem,
type=_source_item.type or "file",
size=_source_item.size,
extension=_path.suffix.lstrip("."),
modify_time=_source_item.modify_time,
thumbnail=_source_item.thumbnail,
)
def __get_remote_targetitem(_source_item: FileItem, _path: Path) -> FileItem:
"""
查询远端目标,索引未刷新时按源信息构造兼容结果。
:param _source_item: 用于缺失元数据回退的源文件项
:param _path: 目标路径
:return: 远端查询结果或按路径合成的文件项
"""
target_item = target_oper.get_item(_path)
if target_item:
return target_item
logger.warn(
f"目标文件【{target_storage}】{_path} 元数据暂不可见,使用目标路径构造整理结果"
)
return __build_remote_targetitem(_source_item, _path)
if (
fileitem.storage != target_storage
and fileitem.storage != "local"
and target_storage != "local"
):
return None, f"不支持 {fileitem.storage} 到 {target_storage} 的文件整理"
if fileitem.storage == "local" and target_storage == "local":
state, error = TransHandler.__transfer_local_to_local(
source_oper=source_oper,
fileitem=fileitem,
target_file=target_file,
transfer_type=transfer_type,
)
if state:
return __get_targetitem(target_file), ""
return None, error
elif fileitem.storage == "local" and target_storage != "local":
filepath = Path(cast(str, fileitem.path))
if not filepath.exists():
return None, f"文件 {filepath} 不存在"
if transfer_type == "copy":
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
new_item = target_oper.upload(
target_fileitem, filepath, target_file.name
)
if new_item:
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return (
None,
f"【{target_storage}】{target_file.parent} 目录获取失败",
)
elif transfer_type == "move":
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
new_item = target_oper.upload(
target_fileitem, filepath, target_file.name
)
if new_item:
source_oper.delete(fileitem)
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return (
None,
f"【{target_storage}】{target_file.parent} 目录获取失败",
)
elif fileitem.storage != "local" and target_storage == "local":
if target_file.exists():
logger.warn(f"文件已存在:{target_file}")
return __get_targetitem(target_file), ""
state, error = TransHandler.__download_to_local(
fileitem, source_oper, target_file, transfer_type,
)
return (__get_targetitem(target_file), "") if state else (None, error)
elif fileitem.storage == target_storage:
return TransHandler.__transfer_same_remote_storage(
fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type,
get_remote_targetitem=__get_remote_targetitem,
)
return None, "未知错误"
@staticmethod
def __rename_subtitles(sub_item: FileItem, new_file: Path) -> Path:
"""
在主媒体目标名中加入字幕语言及默认字幕标记。
繁中优先于简中匹配;匹配 DEFAULT_SUB 配置时添加 default 标记。
:param sub_item: 提供字幕名称和扩展名的源文件项
:param new_file: 模板生成的目标文件路径
:return: 保留字幕扩展名并追加已识别语言标记的路径
"""
_zhcn_sub_re = (
r"([.\[(\s](((zh[-_])?(cn|ch[si]|sg|sc))|zho?"
r"|chinese|(cn|ch[si]|sg|zho?)[-_&]?(cn|ch[si]|sg|zho?|eng|jap|ja|jpn)"
r"|eng[-_&]?(cn|ch[si]|sg|zho?)|(jap|ja|jpn)[-_&]?(cn|ch[si]|sg|zho?)"
r"|简[体中]?)[.\])\s])"
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})"
r"|简体|简中|JPSC|sc_jp"
r"|(?<![a-z0-9])gb(?![a-z0-9])"
)
_zhtw_sub_re = (
r"([.\[(\s](((zh[-_])?(hk|tw|cht|tc))"
r"|cht[-_&]?(cht|eng|jap|ja|jpn)"
r"|eng[-_&]?cht|(jap|ja|jpn)[-_&]?cht"
r"|繁[体中]?)[.\])\s])"
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC|tc_jp"
r"|(?<![a-z0-9])big5(?![a-z0-9])"
)
_ja_sub_re = (
r"([.\[(\s](ja-jp|jap|ja|jpn"
r"|(jap|ja|jpn)[-_&]?eng|eng[-_&]?(jap|ja|jpn))[.\])\s])"
r"|日本語|日語"
)
_eng_sub_re = r"[.\[(\s]eng[.\])\s]"
file_ext = f".{sub_item.extension}"
new_file_type = ""
# 先识别繁中,避免“繁体中文/繁中字”等名称被后面的“中文/中字”简中兜底规则误判。
if re.search(_zhtw_sub_re, sub_item.name or "", re.I):
new_file_type = ".zh-tw"
elif re.search(_zhcn_sub_re, sub_item.name or "", re.I):
new_file_type = ".chi.zh-cn"
elif re.search(_ja_sub_re, sub_item.name or "", re.I):
new_file_type = ".ja"
elif re.search(_eng_sub_re, sub_item.name or "", re.I):
new_file_type = ".eng"
if (
(get_runtime_setting('DEFAULT_SUB') == "zh-cn" and new_file_type == ".chi.zh-cn")
or (get_runtime_setting('DEFAULT_SUB') == "zh-tw" and new_file_type == ".zh-tw")
or (get_runtime_setting('DEFAULT_SUB') == "ja" and new_file_type == ".ja")
or (get_runtime_setting('DEFAULT_SUB') == "eng" and new_file_type == ".eng")
):
new_sub_tag = ".default" + new_file_type
else:
new_sub_tag = new_file_type
return new_file.with_name(new_file.stem + new_sub_tag + file_ext)
def __transfer_file(
self,
fileitem: FileItem,
source_oper: StorageBase,
target_oper: StorageBase,
target_storage: str,
target_file: Path,
transfer_type: str,
result: TransferInfo,
over_flag: Optional[bool] = False,
) -> Tuple[Optional[FileItem], str]:
"""
同步整理单个文件,按需安全替换旧目标并累加成功统计。
不允许覆盖时若目标已存在则拒绝;覆盖流程中的 RuntimeError 转为失败原因。
:param fileitem: 源文件项
:param source_oper: 源存储适配器
:param target_oper: 目标存储适配器
:param target_storage: 目标存储标识
:param target_file: 最终目标文件路径
:param transfer_type: 整理方式
:param result: 原地累加成功文件列表、数量和字节数的结果对象
:param over_flag: 是否覆盖;为真时先完整暂存再替换旧目标
:return: 成功目标及提示,或 None 和失败原因
"""
logger.info(
f"正在整理文件:【{fileitem.storage}】{fileitem.path} 到 【{target_storage}】{target_file},"
f"操作类型:{transfer_type}"
)
if not over_flag and target_oper.get_item_strict(target_file) is not None:
logger.warn(f"文件已存在:【{target_storage}】{target_file}")
return None, f"【{target_storage}】{target_file} 已存在"
if over_flag:
try:
new_item, errmsg = self.__replace_target_with_steps(
step_runner=None, source_fileitem=fileitem.model_dump(mode="json"),
target_storage=target_storage, source_oper=source_oper,
target_oper=target_oper, target_file=target_file,
transfer_type=transfer_type,
)
except RuntimeError as error:
return None, str(error)
else:
new_item, errmsg = self.__transfer_command(
fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type,
)
if new_item:
self.__update_result(
result=result,
file_list=[fileitem.path],
file_list_new=[new_item.path],
file_count=1,
total_size=fileitem.size,
)
return new_item, errmsg
return None, errmsg
@staticmethod
def get_dest_path(
mediainfo: Union[MediaInfo, MusicInfo],
target_path: Path,
need_type_folder: Optional[bool] = False,
need_category_folder: Optional[bool] = False,
) -> Path:
"""
在自定义目标根目录下追加媒体类型和分类路径。
分类路径经过安全检查,并避免重复追加媒体类型层级。
:param mediainfo: 决定媒体类型和分类的识别结果
:param target_path: 调用方提供的目标根路径
:param need_type_folder: 是否追加媒体类型目录,默认不追加
:param need_category_folder: 是否追加分类目录,默认不追加
:return: 拼接后的目标路径;不创建目录
"""
if need_type_folder and mediainfo.type:
target_path = target_path / mediainfo.type.value
if need_category_folder:
category_path = DirectoryHelper().resolve_media_category(mediainfo).path
if category_path:
category_path = category_path_below_media_type(
category_path,
mediainfo.type,
type_folder_enabled=bool(need_type_folder),
)
if category_path:
target_path = append_classification_category_path(
target_path,
category_path,
)
return target_path
@staticmethod
def get_dest_dir(
mediainfo: Union[MediaInfo, MusicInfo],
target_dir: TransferDirectoryConf,
need_type_folder: Optional[bool] = None,
need_category_folder: Optional[bool] = None,
) -> Path:
"""
根据目录配置解析媒体库类型和分类子目录。
手动指定媒体类型时优先使用配置类型;分类路径按固定分类标识或安全路径快照解析。
:param mediainfo: 用于自动分类的媒体信息
:param target_dir: 包含媒体库根路径和分类配置的目录设置
:param need_type_folder: 是否添加类型目录;None 时沿用目录设置
:param need_category_folder: 是否添加分类目录;None 时沿用目录设置
:return: 最终媒体库目录路径;不创建目录
"""
if not target_dir.library_path:
raise ValueError("媒体库目录路径为空")
if need_type_folder is None:
need_type_folder = target_dir.library_type_folder
if need_category_folder is None:
need_category_folder = target_dir.library_category_folder
if not target_dir.media_type and need_type_folder and mediainfo.type:
library_dir = Path(target_dir.library_path) / mediainfo.type.value
elif target_dir.media_type and need_type_folder:
type_folder = (
classification_media_type(target_dir.media_type)
or target_dir.media_type
)
library_dir = Path(target_dir.library_path) / type_folder
else:
library_dir = Path(target_dir.library_path)
if need_category_folder:
helper = DirectoryHelper()
category_path = helper.category_path_for_directory(
target_dir,
mediainfo,
)
if category_path:
category_path = category_path_below_media_type(
category_path,
target_dir.media_type or mediainfo.type,
type_folder_enabled=bool(need_type_folder),
)
if category_path:
# 固定 ID 使用当前策略路径,旧配置和人工覆盖使用安全路径快照。
library_dir = append_classification_category_path(
library_dir,
category_path,
)
return library_dir
@staticmethod
def get_naming_dict(
meta: MetaBase,
mediainfo: Union[MediaInfo, MusicInfo],
file_ext: Optional[str] = None,
episodes_info: Optional[List[TmdbEpisode]] = None,
) -> dict[str, Any]:
"""
构造重命名模板使用的媒体变量和安全分类路径。
保留各数据源原有标识变量,不暴露通用 media_source 和 media_id。
:raises ValueError: 媒体分类配置解析为无效路径
:param meta: 文件名及音视频属性等源元数据
:param mediainfo: 已识别的媒体详情
:param file_ext: 包含点号的文件扩展名;可为空
:param episodes_info: 当前季全部集详情;可为空
:return: 重命名模板上下文字典
"""
naming_context = TemplateHelper().builder.build(
meta=meta,
mediainfo=mediainfo,
file_extension=file_ext,
episodes_info=episodes_info,
)
category_resolution = DirectoryHelper().resolve_media_category(mediainfo)
if category_resolution.state == "invalid_path":
raise ValueError(
category_resolution.message or "媒体分类目录路径无效"
)
naming_context["category"] = "/".join(category_resolution.path)
# 重命名格式是独立的用户配置契约,继续只暴露各数据源原有 ID 变量。
naming_context.pop("media_source", None)
naming_context.pop("media_id", None)
return naming_context
@staticmethod
def __find_version_files(
storage_oper: StorageBase,
path: Path,
) -> list[FileItem]:
"""
稳定列出目标同目录、同季集且分片标识不冲突的其他视频版本。
仅当双方都有 Part 标识且不同才排除;不会把新目标或非视频附件列入候选。
:param storage_oper: 用于严格查询父目录和列举文件的适配器
:param path: 应保留的新版本文件路径
:return: 按路径和文件标识排序的候选列表
"""
meta = MetaInfoPath(path)
parent_item = storage_oper.get_item_strict(path.parent)
if not parent_item:
return []
media_files = storage_oper.list(parent_item) or []
result: list[FileItem] = []
for media_file in media_files:
if not media_file.path or not media_file.extension:
continue
media_path = Path(media_file.path)
if media_path == path or media_file.type != "file":
continue
if f".{media_file.extension.lower()}" not in get_runtime_setting('RMT_MEDIAEXT'):
continue
filemeta = MetaInfoPath(media_path)
if filemeta.season != meta.season or filemeta.episode != meta.episode:
continue
if meta.part and filemeta.part and filemeta.part != meta.part:
continue
result.append(media_file)
return sorted(result, key=lambda item: (item.path or "", item.fileid or ""))
@classmethod
def __delete_version_files_with_steps(
cls,
*,
step_runner: TransferStepRunner,
storage_oper: StorageBase,
target_storage: str,
path: Path,
) -> None:
"""
先冻结旧版本候选清单,再逐个记录删除步骤。
候选路径已不存在视为成功;当前删除仅按冻结路径查询,不额外核对候选文件身份。
:param step_runner: 持久化步骤执行器
:param storage_oper: 负责候选发现、查询和删除的适配器
:param target_storage: 媒体库所在的存储标识
:param path: 已落地且应保留的新版本路径
"""
def discover() -> TransferStepResult:
"""
读取并冻结当前版本删除候选,读失败不产生副作用。
"""
candidates = cls.__find_version_files(storage_oper, path)
return TransferStepResult(payload={
"items": [item.model_dump(mode="json") for item in candidates],
})
discovery = cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="discover_version_targets",
payload={"storage": target_storage, "path": path.as_posix()},
execute=discover,
observe=lambda: TransferOperationObservation(
state=TransferOperationObservationState.NOT_APPLIED,
evidence=TransferStepResult(payload={
"reason": "read-only discovery may be repeated",
}),
),
)
raw_items = discovery.payload.get("items")
if not isinstance(raw_items, list):
raise RuntimeError("版本删除候选检查点格式无效")
for raw_item in raw_items:
candidate = FileItem.model_validate(raw_item)
def delete_candidate(item: FileItem = candidate) -> TransferStepResult:
"""
删除冻结候选路径当前指向的文件,不存在时保持成功。
删除失败时抛出 RuntimeError;不核对路径当前文件是否仍是冻结的同一版本。
:param item: 本次冻结的版本候选,默认使用循环中的候选
:return: 记录路径和删除成功的步骤结果
"""
current = storage_oper.get_item_strict(Path(cast(str, item.path)))
if current is not None and not storage_oper.delete(current):
raise RuntimeError(f"版本文件 {item.path} 删除失败")
return TransferStepResult(payload={
"path": item.path,
"deleted": True,
})
def observe_candidate(item: FileItem = candidate) -> TransferOperationObservation:
"""
只读观察冻结候选路径是否已经消失。
:param item: 本次冻结的版本候选,默认使用循环中的候选
:return: 删除完成、尚未完成或查询未知的观察结果
"""
return cls.__observe_item_presence(
storage_oper,
Path(cast(str, item.path)),
applied_when_present=False,
)
cls.__run_persisted_step(
step_runner,
phase="prepare",
kind="delete_version_target",
payload={
"storage": target_storage,
"item": candidate.model_dump(mode="json"),
},
execute=delete_candidate,
observe=observe_candidate,
)
@staticmethod
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
"""
同步删除同目录中季集相同且分片标识不冲突的其他视频版本。
仅处理配置的视频扩展名;未检查逐个删除的返回值,True 不保证所有旧版本均已删除。
:param storage_oper: 负责列举和删除的目标存储适配器
:param path: 应保留的新版本文件路径,并非目录路径
:return: 完成候选遍历时为 True;存储、父目录或目录内容不可用时为 False
"""
if not storage_oper:
return False
meta = MetaInfoPath(path)
season = meta.season
episode = meta.episode
part = meta.part
logger.warn(f"正在删除目标目录中其它版本的文件:{path.parent}")
parent_item = storage_oper.get_item(path.parent)
if not parent_item:
logger.warn(f"目录 {path.parent} 不存在")
return False
media_files = storage_oper.list(parent_item)
if not media_files:
logger.info(f"目录 {path.parent} 中没有文件")
return False
for media_file in media_files:
if not media_file.path or not media_file.extension:
continue
media_path = Path(media_file.path)
if media_path == path:
continue
if media_file.type != "file":
continue
# 当前只有视频文件需要保留最新版本,其余格式无需处理,以避免误删 (issue 5449)
if f".{media_file.extension.lower()}" not in get_runtime_setting('RMT_MEDIAEXT'):
continue
filemeta = MetaInfoPath(media_path)
if filemeta.season != season or filemeta.episode != episode:
continue
# 相同 Part 的文件才删除,避免误删多 Part 文件 (issue #5862)
if part and filemeta.part and filemeta.part != part:
continue
logger.info(f"正在删除文件:{media_file.name}")
storage_oper.delete(media_file)
return True
@staticmethod
def get_rename_path(
template_string: str,
rename_dict: dict[str, Any],
path: Optional[Path] = None,
source_path: Optional[str] = None,
source_item: Optional[FileItem] = None,
) -> Path:
"""
渲染命名模板并经过插件事件生成目标路径。
先发送变量构建事件,再渲染模板并发送路径调整事件。
:raises ValueError: 提供根目录时生成路径越出该目录
:param template_string: Jinja2 重命名模板
:param rename_dict: 模板上下文;渲染前插件可修改或替换此字典
:param path: 目标根路径;提供时验证生成路径仍在根目录内
:param source_path: 供插件参考的源路径;可为空
:param source_item: 供插件参考的源文件项;可为空
:return: 生成的目标路径;未提供根目录时直接返回渲染路径
"""
# 渲染前先发事件,让插件有机会往 rename_dict 写字段
build_event_data = TransferRenameBuildEventData(
template_string=template_string,
rename_dict=rename_dict,
source_path=source_path,
source_item=source_item,
)
build_event = eventmanager.send_event(
ChainEventType.TransferRenameBuild, build_event_data
)
if build_event and isinstance(build_event.event_data, TransferRenameBuildEventData):
rename_dict = build_event.event_data.rename_dict
template = Template(template_string)
render_str = template.render(rename_dict)
logger.debug(f"Initial render string: {render_str}")
event_data = TransferRenameEventData(
template_string=template_string,
rename_dict=rename_dict,
render_str=render_str,
path=path,
source_path=source_path,
source_item=source_item,
)
event = eventmanager.send_event(ChainEventType.TransferRename, event_data)
if event and isinstance(event.event_data, TransferRenameEventData):
event_data = event.event_data
if event_data.updated and event_data.updated_str:
logger.debug(
f"Render string updated by event: "
f"{render_str} -> {event_data.updated_str} (source: {event_data.source})"
)
render_str = event_data.updated_str
if path:
return ensure_path_within_root(path, path / render_str)
else:
return Path(render_str)