Files
MoviePilot/app/modules/filemanager/transhandler.py
DDSRem e0642eafa7 chore(types): reduce v3 host mypy debt (#7057)
* chore(types): clear AniList endpoint mypy debt

* chore(types): clear Douban endpoint mypy debt

* chore(types): clear recommendation endpoint mypy debt

* chore(types): clear subscription notification mypy debt

* chore(types): clear SDK string compatibility mypy debt

* chore(types): ratchet v3 host mypy across remaining files

* chore(types): reduce v3 host mypy debt and retire ledger

* chore(types): reconcile host snapshot with upstream HTTP pool

* test(types): cover host paths exposed by mypy cleanup
2026-10-18 09:37:49 +08:00

2710 lines
113 KiB
Python
Raw 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, 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
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):
"""
按字段类型合并整理结果,原地修改结果对象。
忽略不存在的属性;布尔值直接替换,不按整数累加。
: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 cast(dict[str, Any], 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 []:
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(**planning_input.source_fileitem)
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 / 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 / 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],
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],
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],
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],
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
)
if target_size < source_size:
return True, False, None
return False, False, TransferInfo(
success=False,
message="媒体库存在同名文件,且质量更好",
fileitem=fileitem,
target_item=target_item,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify,
overwrite_skipped=True,
)
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 语义,硬链接和软链接均比较实际内容的身份。
旧快照没有 fileid 时允许用完整大小、时间凭据;缺少这两项则不能自动删除。
:param expected: 冻结的文件身份,必须包含大小和修改时间
:param current: 当前严格查询结果;不可见时视为冲突
:raises TransferExecutionConflictError: 文件消失、身份字段变化或类型不一致
:raises StorageQueryError: 缺少必要身份凭据
"""
if current is None:
raise TransferExecutionConflictError(f"文件 {expected.path} 已不可见,保留源文件和旧备份")
for field in ("size", "modify_time"):
frozen_value, live_value = getattr(expected, field), getattr(current, field)
if frozen_value is None or live_value is None:
raise StorageQueryError(f"文件 {expected.path} 缺少 {field} 身份凭据,需人工复核")
if frozen_value != live_value:
raise TransferExecutionConflictError(f"文件 {expected.path} 的 {field} 已变化,禁止清理")
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} 已被替换,禁止清理")
@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
:return: 完成状态及文件证据;无法证明内容或查询失败时返回未知状态
"""
try:
source_item = source_oper.get_item_strict(Path(cast(str, 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),
})
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(cast(str, 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()
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(**source_fileitem)
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 "")
def execute_source_delete() -> TransferStepResult:
"""
在目标已落地后单独删除跨存储 move 的源文件。
"""
confirm_materialized_target()
current_source = source_oper.get_item_strict(Path(cast(str, fileitem.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, Path(cast(str, fileitem.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,
) -> FileItem:
"""
执行同目录无覆盖重命名,并用两端严格查询恢复中断操作。
两端同时存在或同时消失视为冲突;查询或身份无法确认时不自动重放。
:param step_runner: 步骤执行器;None 时同步执行
:param storage_oper: 负责查询和重命名的存储适配器
:param storage: 用于步骤身份和诊断的存储标识
:param source: 原路径,必须存在
:param target: 新路径,必须不存在且由调用方保持在同一目录
:param kind: 步骤操作类别
:param expected_source: 可选的冻结源身份;提供时在操作前后核对
:return: 重命名后的文件项
"""
def execute() -> TransferStepResult:
"""
目标必须不存在,避免适配器的 rename 意外替换其它文件。
"""
source_item = storage_oper.get_item_strict(source)
if source_item is None or storage_oper.get_item_strict(target) 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:
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",
)
new_item = 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,
),
)
@classmethod
def __resolve_leaf_overwrite_with_step(
cls,
*,
step_runner: Optional[TransferStepRunner],
planned_item: TransferPlanItem,
target_oper: StorageBase,
transfer_type: str,
) -> bool:
"""
冻结目录叶子目标的存在性,恢复时沿用同一操作序列。
此处只判断叶子目标是否存在,不再次执行单文件覆盖策略。
:param step_runner: 步骤执行器;None 时直接查询
:param planned_item: 含冻结源快照和目标路径的叶子操作
:param target_oper: 负责严格查询的目标适配器
:param transfer_type: 整理方式,参与步骤身份
:return: 目标已存在且需要替换时为 True
"""
def execute() -> TransferStepResult:
"""
严格查询当前叶目标,失败时禁止对旧文件执行任何操作。
"""
exists = target_oper.get_item_strict(Path(planned_item.target_path)) is not None
return TransferStepResult(payload={"over_flag": exists})
decision = cls.__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"}),
),
)
return decision.payload.get("over_flag") is True
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_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(**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=[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(**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:
if (
planned_item.action != "transfer"
or planned_item.target_storage != target_storage
):
return TransferInfo(
success=False,
message="整理计划包含不支持的操作或目标存储",
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
source_item = FileItem(**planned_item.source_fileitem)
over_flag = self.__resolve_leaf_overwrite_with_step(
step_runner=step_runner, planned_item=planned_item,
target_oper=target_oper, transfer_type=transfer_type,
)
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 TransferInfo(
success=False,
message=error,
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=checkpoint.need_notify,
)
self.__update_result(
result=result,
file_list=[source_item.path],
file_list_new=[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],
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],
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],
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],
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],
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_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,支持范围取决于存储组合
: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":
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 == "link":
state = source_oper.link(fileitem, target_file)
elif transfer_type == "softlink":
state = source_oper.softlink(fileitem, target_file)
else:
return None, f"不支持的整理方式:{transfer_type}"
if state:
return __get_targetitem(target_file), ""
else:
return None, f"{fileitem.path} {transfer_type} 失败"
elif fileitem.storage == "local" and target_storage != "local":
filepath = Path(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:
if not source_oper.is_support_transtype(transfer_type):
return None, f"存储 {fileitem.storage} 不支持 {transfer_type} 整理方式"
if transfer_type == "copy":
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
copy_item = getattr(source_oper, "copy_item", None)
if callable(copy_item):
new_item = copy_item(
fileitem, Path(target_fileitem.path), target_file.name
)
if new_item:
return new_item, ""
elif source_oper.copy(
fileitem, Path(target_fileitem.path), target_file.name
):
return __get_remote_targetitem(fileitem, target_file), ""
return None, f"【{target_storage}】{fileitem.path} 复制文件失败"
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:
move_item = getattr(source_oper, "move_item", None)
if callable(move_item):
new_item = move_item(
fileitem, Path(target_fileitem.path), target_file.name
)
if new_item:
return new_item, ""
elif source_oper.move(
fileitem, Path(target_fileitem.path), target_file.name
):
return __get_remote_targetitem(fileitem, target_file), ""
return None, f"【{target_storage}】{fileitem.path} 移动文件失败"
else:
return (
None,
f"【{target_storage}】{target_file.parent} 目录获取失败",
)
elif transfer_type == "link":
if source_oper.link(fileitem, target_file):
return __get_remote_targetitem(fileitem, target_file), ""
else:
return None, f"【{target_storage}】{fileitem.path} 创建硬链接失败"
else:
return None, f"不支持的整理方式:{transfer_type}"
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, re.I):
new_file_type = ".zh-tw"
elif re.search(_zhcn_sub_re, sub_item.name, re.I):
new_file_type = ".chi.zh-cn"
elif re.search(_ja_sub_re, sub_item.name, re.I):
new_file_type = ".ja"
elif re.search(_eng_sub_re, sub_item.name, 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: MediaInfo,
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: MediaInfo,
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 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: MediaInfo,
file_ext: Optional[str] = None,
episodes_info: Optional[List[TmdbEpisode]] = None,
) -> dict:
"""
构造重命名模板使用的媒体变量和安全分类路径。
保留各数据源原有标识变量,不暴露通用 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:
media_path = Path(media_file.path)
if media_path == path or media_file.type != "file":
continue
if f".{cast(str, 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, 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:
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,
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 build_event.event_data:
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 event.event_data:
event_data: TransferRenameEventData = 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)