Files
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

1157 lines
52 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.
"""
整理规划、任务对象与持久准入契约。
"""
from __future__ import annotations
import hashlib
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Protocol, TypeAlias, Union
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from app.application.history import DownloadHistorySnapshot
from app.application.transfer import checkpoint as checkpoint_codec
from app.application.transfer.execution import TransferExecutionCheckpoint
from app.application.transfer.projection import (
domain_to_dict as _domain_to_dict,
)
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.file import FileItem
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.system import TransferDirectoryConf
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaSource, MediaType
if TYPE_CHECKING:
class _ApplicationModel:
"""
描述当前模块依赖的最小 Pydantic 模型类型形状。
"""
def __init__(self, **data: Any) -> None:
"""
接受模型字段关键字参数。
"""
raise NotImplementedError
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""
返回模型字段字典。
"""
raise NotImplementedError
else:
_ApplicationModel = BaseModel
JSONValue: TypeAlias = Union[None, bool, int, float, str, list["JSONValue"], dict[str, "JSONValue"]]
# 任务已持久接纳,尚未生成可执行计划
TRANSFER_ADMISSION_ACCEPTED = "accepted"
# 已冻结旧插件提供方和调用输入,等待提供方完成规划或执行
TRANSFER_ADMISSION_PROVIDER_PENDING = "provider_pending"
# 完整计划已持久化,恢复时直接使用检查点
TRANSFER_ADMISSION_PLANNED = "planned"
# 整理规划输入的当前持久格式版本
TRANSFER_PLANNING_INPUT_VERSION = 1
# 包含最终分类快照的当前整理计划格式版本
TRANSFER_PLAN_CHECKPOINT_VERSION = 2
# 仍可读取的历史整理计划格式版本,不包含最终分类快照
TRANSFER_PLAN_CHECKPOINT_LEGACY_VERSION = 1
# 旧插件调用参数快照的当前格式版本
TRANSFER_PROVIDER_INVOCATION_VERSION = 1
@dataclass(frozen=True, slots=True)
class TransferPlanningInput:
"""
保存可跨重启重放的版本化整理规划输入。
:param source_fileitem: 源文件项的可序列化快照,必须包含存储标识和路径
:param meta: 已解析发布名元数据快照;None 表示尚未解析
:param mediainfo: 已识别媒体详情快照;None 表示尚未识别
:param target_directory: 目标资源目录配置快照;None 表示尚未选择
:param target_storage: 目标存储配置标识
:param target_path: 目标存储内的文件或目录路径
:param requested_transfer_type: 调用方要求的整理方式;None 由目录策略决定
:param media_source: 媒体身份来源,与 media_id 配对使用
:param media_id: 该来源中的原生媒体标识
:param media_type: 电影、电视剧或音乐等目标媒体类型
:param need_scrape: 是否需要生成媒体刮削资料
:param need_rename: 是否按照命名规则重命名文件
:param need_notify: 是否发送整理结果通知
:param overwrite_mode: 目标已存在时的覆盖策略;None 使用默认策略
:param episodes_info: 计划使用的剧集详情快照,空元组表示未提供
:param preview: 是否仅生成预览而不执行真实整理
:param options: 影响规划且可持久化的附加选项
:param schema_version: 持久快照结构版本;只接受当前支持版本
"""
source_fileitem: dict[str, JSONValue]
meta: Optional[dict[str, JSONValue]] = None
mediainfo: Optional[dict[str, JSONValue]] = None
target_directory: Optional[dict[str, JSONValue]] = None
target_storage: Optional[str] = None
target_path: Optional[str] = None
requested_transfer_type: Optional[str] = None
media_source: Optional[str] = None
media_id: Optional[str] = None
media_type: Optional[str] = None
need_scrape: bool = False
need_rename: bool = True
need_notify: bool = True
overwrite_mode: Optional[str] = None
episodes_info: tuple[dict[str, JSONValue], ...] = ()
preview: bool = False
options: dict[str, JSONValue] = field(default_factory=dict)
schema_version: int = TRANSFER_PLANNING_INPUT_VERSION
def __post_init__(self) -> None:
"""
拒绝不可恢复的输入版本或缺少源文件身份的快照。
"""
if self.schema_version != TRANSFER_PLANNING_INPUT_VERSION:
raise ValueError(f"不支持的整理规划输入版本: {self.schema_version}")
object.__setattr__(self, "source_fileitem", deepcopy(self.source_fileitem))
object.__setattr__(self, "meta", checkpoint_codec.copy_json_mapping(self.meta))
object.__setattr__(self, "mediainfo", checkpoint_codec.copy_json_mapping(self.mediainfo))
object.__setattr__(
self,
"target_directory",
checkpoint_codec.copy_json_mapping(self.target_directory),
)
object.__setattr__(
self,
"episodes_info",
tuple(deepcopy(item) for item in self.episodes_info),
)
object.__setattr__(self, "options", deepcopy(self.options))
storage = self.source_fileitem.get("storage")
path = self.source_fileitem.get("path")
if not isinstance(storage, str) or not storage or not isinstance(path, str) or not path:
raise ValueError("整理规划输入缺少源文件存储或路径")
checkpoint_codec.canonical_json(self.to_payload())
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanningInput":
"""
从受版本约束的 JSON 对象恢复规划输入。
"""
if not isinstance(payload, dict):
raise ValueError("整理规划输入必须是 JSON 对象")
source_fileitem = checkpoint_codec.read_json_mapping(payload, "source_fileitem")
if source_fileitem is None:
raise ValueError("整理规划输入缺少 source_fileitem")
return cls(
source_fileitem=source_fileitem,
meta=checkpoint_codec.read_json_mapping(payload, "meta"),
mediainfo=checkpoint_codec.read_json_mapping(payload, "mediainfo"),
target_directory=checkpoint_codec.read_json_mapping(payload, "target_directory"),
target_storage=payload.get("target_storage"),
target_path=payload.get("target_path"),
requested_transfer_type=payload.get("requested_transfer_type"),
media_source=payload.get("media_source"),
media_id=payload.get("media_id"),
media_type=payload.get("media_type"),
need_scrape=payload.get("need_scrape", False),
need_rename=payload.get("need_rename", True),
need_notify=payload.get("need_notify", True),
overwrite_mode=payload.get("overwrite_mode"),
episodes_info=checkpoint_codec.read_json_tuple(payload, "episodes_info"),
preview=payload.get("preview", False),
options=checkpoint_codec.read_json_mapping(payload, "options") or {},
schema_version=payload.get("schema_version", 0),
)
def to_payload(self) -> dict[str, JSONValue]:
"""
生成仅含 JSON 值且字段稳定的规划输入投影。
"""
return {
"schema_version": self.schema_version,
"source_fileitem": deepcopy(self.source_fileitem),
"meta": checkpoint_codec.copy_json_mapping(self.meta),
"mediainfo": checkpoint_codec.copy_json_mapping(self.mediainfo),
"target_directory": checkpoint_codec.copy_json_mapping(self.target_directory),
"target_storage": self.target_storage,
"target_path": self.target_path,
"requested_transfer_type": self.requested_transfer_type,
"media_source": self.media_source,
"media_id": self.media_id,
"media_type": self.media_type,
"need_scrape": self.need_scrape,
"need_rename": self.need_rename,
"need_notify": self.need_notify,
"overwrite_mode": self.overwrite_mode,
"episodes_info": [deepcopy(item) for item in self.episodes_info],
"preview": self.preview,
"options": deepcopy(self.options),
}
@property
def fingerprint(self) -> str:
"""
返回规范 JSON 的稳定 SHA-256 指纹。
"""
return hashlib.sha256(checkpoint_codec.canonical_json(self.to_payload()).encode("utf-8")).hexdigest()
@dataclass(frozen=True, slots=True)
class TransferPlanItem:
"""
描述规划后按序执行的一条叶子文件操作。
:param sequence: 文件在整理计划中的连续序号,从 0 开始
:param source_fileitem: 源文件项的可序列化快照,必须包含存储标识和路径
:param target_storage: 目标存储配置标识
:param target_path: 目标存储内的文件或目录路径
:param action: 计划动作,默认 transfer 表示转移文件
"""
sequence: int
source_fileitem: dict[str, JSONValue]
target_storage: str
target_path: str
action: str = "transfer"
def __post_init__(self) -> None:
"""
拒绝无法定位源文件或目标文件的计划项。
"""
object.__setattr__(self, "source_fileitem", deepcopy(self.source_fileitem))
if self.sequence < 0:
raise ValueError("整理计划项序号不能小于零")
if not self.target_storage or not self.target_path or not self.action:
raise ValueError("整理计划项缺少目标身份或动作")
if not self.source_fileitem.get("storage") or not self.source_fileitem.get("path"):
raise ValueError("整理计划项缺少源文件身份")
checkpoint_codec.canonical_json(self.to_payload())
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanItem":
"""
从 JSON 对象恢复单条叶子文件操作。
"""
source_fileitem = checkpoint_codec.read_json_mapping(payload, "source_fileitem")
if source_fileitem is None:
raise ValueError("整理计划项缺少 source_fileitem")
return cls(
sequence=payload.get("sequence", -1),
source_fileitem=source_fileitem,
target_storage=payload.get("target_storage", ""),
target_path=payload.get("target_path", ""),
action=payload.get("action", "transfer"),
)
def to_payload(self) -> dict[str, JSONValue]:
"""
生成单条叶子文件操作的 JSON 投影。
"""
return {
"sequence": self.sequence,
"source_fileitem": deepcopy(self.source_fileitem),
"target_storage": self.target_storage,
"target_path": self.target_path,
"action": self.action,
}
@dataclass(frozen=True, slots=True)
class TransferProviderReference:
"""
保存无需依赖运行时 dispatcher 即可持久化的旧 transfer 提供方引用。
:param plugin_id: 提供旧整理能力的插件标识
:param plugin_name: 提供方插件的展示名称
:param method: 调用插件的方法名,默认 transfer
"""
plugin_id: str
plugin_name: str
method: str = "transfer"
def __post_init__(self) -> None:
"""
拒绝无法稳定定位插件或指向非 transfer 方法的引用。
"""
if not isinstance(self.plugin_id, str) or not self.plugin_id.strip():
raise ValueError("旧 transfer provider 的 plugin_id 必须是非空字符串")
if not isinstance(self.plugin_name, str) or not self.plugin_name.strip():
raise ValueError("旧 transfer provider 的 plugin_name 必须是非空字符串")
if self.method != "transfer":
raise ValueError("旧 transfer provider 的 method 必须是 transfer")
checkpoint_codec.canonical_json(self.to_payload())
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TransferProviderReference":
"""
从 JSON 对象恢复旧 transfer 提供方引用。
"""
if not isinstance(payload, dict):
raise ValueError("旧 transfer provider 引用必须是 JSON 对象")
return cls(
plugin_id=payload.get("plugin_id", ""),
plugin_name=payload.get("plugin_name", ""),
method=payload.get("method", "transfer"),
)
def to_payload(self) -> dict[str, JSONValue]:
"""
生成仅包含稳定插件身份与方法名的 JSON 投影。
"""
return {
"plugin_id": self.plugin_id,
"plugin_name": self.plugin_name,
"method": self.method,
}
@dataclass(frozen=True, slots=True)
class TransferProviderInvocationSnapshot:
"""
冻结旧 transfer ABI 中可跨重启恢复的精确参数值。
:param fileitem: 传给提供方的源文件项快照
:param meta: 已解析发布名元数据快照;None 表示尚未解析
:param meta_kind: 发布名元数据的对象种类,用于正确恢复影视或音乐对象
:param mediainfo: 已识别媒体详情快照;None 表示尚未识别
:param mediainfo_kind: 媒体详情的对象种类,用于恢复对应领域类型
:param target_directory: 目标资源目录配置快照;None 表示尚未选择
:param target_storage: 目标存储配置标识
:param target_path: 目标存储内的文件或目录路径
:param transfer_type: 调用提供方时指定的整理方式;None 表示未显式指定
:param scrape: 调用提供方时是否要求刮削;None 保留未指定语义
:param library_type_folder: 是否在媒体库按媒体类型建立目录;None 表示未指定
:param library_category_folder: 是否在媒体库按分类建立目录;None 表示未指定
:param episodes_info: 计划使用的剧集详情快照,空元组表示未提供
:param preview: 是否仅生成预览而不执行真实整理
:param schema_version: 持久快照结构版本;只接受当前支持版本
"""
fileitem: dict[str, JSONValue]
meta: Optional[dict[str, JSONValue]]
meta_kind: Optional[str]
mediainfo: Optional[dict[str, JSONValue]]
mediainfo_kind: Optional[str]
target_directory: Optional[dict[str, JSONValue]] = None
target_storage: Optional[str] = None
target_path: Optional[str] = None
transfer_type: Optional[str] = None
scrape: Optional[bool] = None
library_type_folder: Optional[bool] = None
library_category_folder: Optional[bool] = None
episodes_info: tuple[dict[str, JSONValue], ...] = ()
preview: bool = False
schema_version: int = TRANSFER_PROVIDER_INVOCATION_VERSION
def __post_init__(self) -> None:
"""
拒绝缺少源身份、类型不稳定或版本未知的提供方快照。
"""
if self.schema_version != TRANSFER_PROVIDER_INVOCATION_VERSION:
raise ValueError(
f"不支持的旧 transfer provider 调用快照版本: {self.schema_version}"
)
object.__setattr__(self, "fileitem", deepcopy(self.fileitem))
object.__setattr__(self, "meta", checkpoint_codec.copy_json_mapping(self.meta))
object.__setattr__(self, "mediainfo", checkpoint_codec.copy_json_mapping(self.mediainfo))
object.__setattr__(
self,
"target_directory",
checkpoint_codec.copy_json_mapping(self.target_directory),
)
object.__setattr__(
self,
"episodes_info",
tuple(deepcopy(item) for item in self.episodes_info),
)
if not self.fileitem.get("storage") or not self.fileitem.get("path"):
raise ValueError("旧 transfer provider 调用快照缺少源文件身份")
for kind in (self.meta_kind, self.mediainfo_kind):
if kind is not None and (not isinstance(kind, str) or not kind):
raise ValueError("旧 transfer provider 调用快照类型必须是非空字符串")
for value in (
self.target_storage,
self.target_path,
self.transfer_type,
):
if value is not None and not isinstance(value, str):
raise ValueError("旧 transfer provider 可选文本参数必须是字符串或 None")
for bool_value in (
self.scrape,
self.library_type_folder,
self.library_category_folder,
):
if bool_value is not None and not isinstance(bool_value, bool):
raise ValueError("旧 transfer provider 可选布尔参数必须是 bool 或 None")
if not isinstance(self.preview, bool):
raise ValueError("旧 transfer provider preview 参数必须是 bool")
checkpoint_codec.canonical_json(self.to_payload())
@classmethod
def from_payload(
cls,
payload: dict[str, Any],
) -> "TransferProviderInvocationSnapshot":
"""
从版本化 JSON 对象严格恢复旧 transfer ABI 调用快照。
"""
if not isinstance(payload, dict):
raise ValueError("旧 transfer provider 调用快照必须是 JSON 对象")
fileitem = checkpoint_codec.read_json_mapping(payload, "fileitem")
if fileitem is None:
raise ValueError("旧 transfer provider 调用快照缺少 fileitem")
return cls(
fileitem=fileitem,
meta=checkpoint_codec.read_json_mapping(payload, "meta"),
meta_kind=payload.get("meta_kind"),
mediainfo=checkpoint_codec.read_json_mapping(payload, "mediainfo"),
mediainfo_kind=payload.get("mediainfo_kind"),
target_directory=checkpoint_codec.read_json_mapping(payload, "target_directory"),
target_storage=payload.get("target_storage"),
target_path=payload.get("target_path"),
transfer_type=payload.get("transfer_type"),
scrape=payload.get("scrape"),
library_type_folder=payload.get("library_type_folder"),
library_category_folder=payload.get("library_category_folder"),
episodes_info=checkpoint_codec.read_json_tuple(payload, "episodes_info"),
preview=payload.get("preview", False),
schema_version=payload.get("schema_version", 0),
)
def to_payload(self) -> dict[str, JSONValue]:
"""
生成仅含 JSON 值且保留 None 与 False 差异的调用投影。
"""
return {
"schema_version": self.schema_version,
"fileitem": deepcopy(self.fileitem),
"meta": checkpoint_codec.copy_json_mapping(self.meta),
"meta_kind": self.meta_kind,
"mediainfo": checkpoint_codec.copy_json_mapping(self.mediainfo),
"mediainfo_kind": self.mediainfo_kind,
"target_directory": checkpoint_codec.copy_json_mapping(self.target_directory),
"target_storage": self.target_storage,
"target_path": self.target_path,
"transfer_type": self.transfer_type,
"scrape": self.scrape,
"library_type_folder": self.library_type_folder,
"library_category_folder": self.library_category_folder,
"episodes_info": [deepcopy(item) for item in self.episodes_info],
"preview": self.preview,
}
@dataclass(frozen=True, slots=True)
class TransferPlanCheckpoint:
"""
保存无需重触发识别或重命名即可执行的完整有序计划。
:param planning_input: 生成该计划的稳定输入快照
:param target_storage: 目标存储配置标识
:param root_target_path: 所选资源目录对应的目标根路径
:param final_target_path: 规则与分类解析后的最终整理路径
:param resolved_transfer_type: 规划后已确定的整理方式
:param items: 按执行顺序保存的文件计划项
:param classification_snapshot: 规划时已确定的最终分类,不在恢复执行时重新匹配策略
:param resolved_meta: 规划后解析出的发布名元数据快照
:param resolved_meta_kind: 用于恢复已解析元数据的对象种类
:param resolved_mediainfo: 规划后识别出的完整媒体详情快照
:param resolved_mediainfo_kind: 用于恢复媒体详情的对象种类
:param resolved_episodes_info: 规划时已获取的剧集详情快照
:param legacy_transfer_providers: 本轮规划选定的旧插件提供方,恢复时保持该顺序
:param provider_invocation: 调用旧提供方所需的完整输入;无提供方任务时为 None
:param pre_execution_cleanup_completed: 执行前清理步骤是否已经完成,避免恢复时重复处理
:param need_scrape: 是否需要生成媒体刮削资料
:param need_rename: 是否按照命名规则重命名文件
:param need_notify: 是否发送整理结果通知
:param overwrite_mode: 目标已存在时的覆盖策略;None 使用默认策略
:param preview: 是否仅生成预览而不执行真实整理
:param skip_reason: 未产生外部操作的明确原因;有步骤时可为 None
:param rejection_error: 计划被业务规则拒绝的原因;未拒绝时为 None
:param schema_version: 持久快照结构版本;只接受当前支持版本
"""
planning_input: TransferPlanningInput
target_storage: str
root_target_path: str
final_target_path: str
resolved_transfer_type: str
items: tuple[TransferPlanItem, ...]
classification_snapshot: checkpoint_codec.EffectiveClassificationSnapshot = field(
default_factory=checkpoint_codec.EffectiveClassificationSnapshot
)
resolved_meta: Optional[dict[str, JSONValue]] = None
resolved_meta_kind: Optional[str] = None
resolved_mediainfo: Optional[dict[str, JSONValue]] = None
resolved_mediainfo_kind: Optional[str] = None
resolved_episodes_info: tuple[dict[str, JSONValue], ...] = ()
legacy_transfer_providers: tuple[TransferProviderReference, ...] = ()
provider_invocation: Optional[TransferProviderInvocationSnapshot] = None
pre_execution_cleanup_completed: bool = False
need_scrape: bool = False
need_rename: bool = False
need_notify: bool = True
overwrite_mode: Optional[str] = None
preview: bool = False
skip_reason: Optional[str] = None
rejection_error: Optional[str] = None
schema_version: int = TRANSFER_PLAN_CHECKPOINT_VERSION
def __post_init__(self) -> None:
"""
验证版本、目标身份和计划项顺序组成完整检查点。
"""
if self.schema_version not in {
TRANSFER_PLAN_CHECKPOINT_LEGACY_VERSION,
TRANSFER_PLAN_CHECKPOINT_VERSION,
}:
raise ValueError(f"不支持的整理计划检查点版本: {self.schema_version}")
if not isinstance(
self.classification_snapshot,
checkpoint_codec.EffectiveClassificationSnapshot,
):
raise ValueError("整理计划分类快照必须使用类型化对象")
if not isinstance(self.pre_execution_cleanup_completed, bool):
raise ValueError("整理计划预执行 cleanup 完成标记必须是 bool")
object.__setattr__(self, "resolved_meta", checkpoint_codec.copy_json_mapping(self.resolved_meta))
object.__setattr__(
self,
"resolved_mediainfo",
checkpoint_codec.copy_json_mapping(self.resolved_mediainfo),
)
object.__setattr__(
self,
"resolved_episodes_info",
tuple(deepcopy(item) for item in self.resolved_episodes_info),
)
if not isinstance(self.legacy_transfer_providers, tuple) or any(
not isinstance(provider, TransferProviderReference)
for provider in self.legacy_transfer_providers
):
raise ValueError("整理计划的旧 transfer provider 引用必须是类型化元组")
provider_ids = tuple(
provider.plugin_id for provider in self.legacy_transfer_providers
)
if len(set(provider_ids)) != len(provider_ids):
raise ValueError("整理计划的旧 transfer provider plugin_id 不能重复")
for resolved_kind in (
self.resolved_meta_kind,
self.resolved_mediainfo_kind,
):
if resolved_kind is not None and (
not isinstance(resolved_kind, str) or not resolved_kind
):
raise ValueError("整理计划的已解析上下文类型必须是非空字符串")
if self.provider_invocation is not None:
if not self.legacy_transfer_providers:
raise ValueError("provider_pending 检查点缺少冻结 provider")
if self.pre_execution_cleanup_completed:
raise ValueError("provider_pending 提交时 cleanup 尚未执行")
if (
not self.provider_invocation.meta
or not self.provider_invocation.meta_kind
or not self.provider_invocation.mediainfo
or not self.provider_invocation.mediainfo_kind
):
raise ValueError("provider_pending 检查点缺少可重放的媒体上下文")
if (
self.target_storage
or self.root_target_path
or self.final_target_path
or self.resolved_transfer_type
or self.items
or self.skip_reason
or self.rejection_error
):
raise ValueError("provider_pending 检查点不得包含宿主执行计划")
else:
if not self.target_storage or not self.root_target_path or not self.final_target_path:
raise ValueError("整理计划检查点缺少目标身份")
if not self.resolved_transfer_type:
raise ValueError("整理计划检查点缺少已解析的整理方式")
if self.rejection_error is not None and (
not isinstance(self.rejection_error, str)
or not self.rejection_error.strip()
or self.items
):
raise ValueError("整理拒绝检查点必须包含非空错误且不得包含文件步骤")
if tuple(item.sequence for item in self.items) != tuple(range(len(self.items))):
raise ValueError("整理计划项必须按从零开始的连续序号保存")
if (
self.provider_invocation is None
and not self.items
and not self.preview
and not self.skip_reason
and not self.rejection_error
):
raise ValueError("非预览空计划必须记录合法跳过原因")
checkpoint_codec.canonical_json(self.to_payload())
@property
def is_provider_pending(self) -> bool:
"""
返回该检查点是否只冻结提供方调用、尚未生成宿主计划。
"""
return self.provider_invocation is not None
@property
def fingerprint(self) -> str:
"""
返回完整冻结计划规范 JSON 的稳定 SHA-256 指纹。
"""
return hashlib.sha256(
checkpoint_codec.canonical_json(self.to_payload()).encode("utf-8")
).hexdigest()
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanCheckpoint":
"""
从受版本约束的 JSON 对象恢复完整执行检查点。
"""
if not isinstance(payload, dict):
raise ValueError("整理计划检查点必须是 JSON 对象")
planning_input_payload = checkpoint_codec.read_json_mapping(payload, "planning_input")
if planning_input_payload is None:
raise ValueError("整理计划检查点缺少 planning_input")
item_payloads = payload.get("items", [])
if not isinstance(item_payloads, list) or not all(
isinstance(item, dict) for item in item_payloads
):
raise ValueError("整理计划检查点 items 必须是 JSON 对象数组")
legacy_provider_payloads = payload.get("legacy_transfer_providers", [])
if not isinstance(legacy_provider_payloads, list):
raise ValueError(
"整理计划检查点 legacy_transfer_providers 必须是 JSON 对象数组"
)
provider_invocation_payload = payload.get("provider_invocation")
if provider_invocation_payload is not None and not isinstance(
provider_invocation_payload,
dict,
):
raise ValueError("整理计划检查点 provider_invocation 必须是 JSON 对象")
schema_version = payload.get("schema_version", 0)
if schema_version == TRANSFER_PLAN_CHECKPOINT_VERSION:
classification_payload = payload.get("classification_snapshot")
classification_snapshot = checkpoint_codec.read_classification_snapshot(
classification_payload,
require_all_fields=True,
)
elif schema_version == TRANSFER_PLAN_CHECKPOINT_LEGACY_VERSION:
classification_snapshot = checkpoint_codec.legacy_classification_snapshot(payload)
else:
raise ValueError(f"不支持的整理计划检查点版本: {schema_version}")
return cls(
planning_input=TransferPlanningInput.from_payload(planning_input_payload),
target_storage=payload.get("target_storage", ""),
root_target_path=payload.get("root_target_path", ""),
final_target_path=payload.get("final_target_path", ""),
resolved_transfer_type=payload.get("resolved_transfer_type", ""),
items=tuple(TransferPlanItem.from_payload(item) for item in item_payloads),
classification_snapshot=classification_snapshot,
resolved_meta=checkpoint_codec.read_json_mapping(payload, "resolved_meta"),
resolved_meta_kind=payload.get("resolved_meta_kind"),
resolved_mediainfo=checkpoint_codec.read_json_mapping(
payload,
"resolved_mediainfo",
),
resolved_mediainfo_kind=payload.get("resolved_mediainfo_kind"),
resolved_episodes_info=checkpoint_codec.read_json_tuple(
payload,
"resolved_episodes_info",
),
legacy_transfer_providers=tuple(
TransferProviderReference.from_payload(provider)
for provider in legacy_provider_payloads
),
provider_invocation=(
TransferProviderInvocationSnapshot.from_payload(
provider_invocation_payload
)
if provider_invocation_payload is not None
else None
),
pre_execution_cleanup_completed=payload.get(
"pre_execution_cleanup_completed",
False,
),
need_scrape=payload.get("need_scrape", False),
need_rename=payload.get("need_rename", False),
need_notify=payload.get("need_notify", True),
overwrite_mode=payload.get("overwrite_mode"),
preview=payload.get("preview", False),
skip_reason=payload.get("skip_reason"),
rejection_error=payload.get("rejection_error"),
schema_version=schema_version,
)
def to_payload(self) -> dict[str, JSONValue]:
"""
生成可原子落库的完整版本化 JSON 检查点。
"""
payload: dict[str, JSONValue] = {
"schema_version": self.schema_version,
"planning_input": self.planning_input.to_payload(),
"target_storage": self.target_storage,
"root_target_path": self.root_target_path,
"final_target_path": self.final_target_path,
"resolved_transfer_type": self.resolved_transfer_type,
"items": [item.to_payload() for item in self.items],
"resolved_meta": checkpoint_codec.copy_json_mapping(self.resolved_meta),
"resolved_meta_kind": self.resolved_meta_kind,
"resolved_mediainfo": checkpoint_codec.copy_json_mapping(
self.resolved_mediainfo
),
"resolved_mediainfo_kind": self.resolved_mediainfo_kind,
"resolved_episodes_info": [
deepcopy(item) for item in self.resolved_episodes_info
],
"legacy_transfer_providers": [
provider.to_payload()
for provider in self.legacy_transfer_providers
],
"provider_invocation": (
self.provider_invocation.to_payload()
if self.provider_invocation
else None
),
"pre_execution_cleanup_completed": (
self.pre_execution_cleanup_completed
),
"need_scrape": self.need_scrape,
"need_rename": self.need_rename,
"need_notify": self.need_notify,
"overwrite_mode": self.overwrite_mode,
"preview": self.preview,
"skip_reason": self.skip_reason,
"rejection_error": self.rejection_error,
}
if self.schema_version == TRANSFER_PLAN_CHECKPOINT_VERSION:
payload["classification_snapshot"] = checkpoint_codec.classification_snapshot_payload(
self.classification_snapshot
)
return payload
class TransferAdmissionConflictError(ValueError):
"""
同一源文件以不同规划输入重复准入时抛出的冲突错误。
"""
class TransferPlanningStateError(RuntimeError):
"""
计划检查点无法从当前持久状态推进时抛出的状态错误。
"""
class TransferAdmissionProjectionError(RuntimeError):
"""
持久登记无法安全恢复为应用层整理准入投影时抛出的错误。
"""
class TransferLeaseLostError(TransferPlanningStateError):
"""
整理执行者已失去持久租约、不得继续推进任务时抛出的错误。
"""
class TransferTask(OptionalMediaIdentityMixin, _ApplicationModel):
"""
文件整理任务与宿主恢复状态。
公开字段供业务编排和插件读取;租约、检查点及临时预览证据保存在私有属性。
:ivar _admission_task_id: 宿主持久准入标识,不进入插件可见序列化字段
:ivar _planning_input: 恢复规划用的持久输入;尚未绑定时为 None
:ivar _plan_checkpoint: 恢复计划执行用的检查点;尚未生成时为 None
:ivar _execution_checkpoint: 恢复终态结算用的执行结果检查点;尚未生成时为 None
:ivar _terminal_settled: 历史、事件和待处理记录是否已共同提交,初始为 False
:ivar _planning_context_restored: 当前领域上下文是否已从持久快照离线恢复
:ivar _music_preview_context: 只供本次音乐预览展示的临时证据,不参与持久规划
:ivar _lease_owner: 任务当前绑定的执行者标识;未认领时为 None
:ivar _lease_token: 任务当前绑定的持久租约令牌;未认领时为 None
"""
# MetaBase 与 MediaInfo / MusicInfo 都是普通类而非 BaseModel,pydantic 需要显式放行
model_config = ConfigDict(arbitrary_types_allowed=True)
fileitem: FileItem = Field(description='本次整理的源文件项,必须包含有效存储与路径')
meta: Optional[MetaBase] = Field(default=None, description='已解析的发布名元数据;None 表示尚未解析')
mediainfo: Optional[Union[MusicInfo, MediaInfo]] = Field(default=None, description='已识别的影视或音乐详情;None 表示尚未识别')
media_source: Optional[MediaSource] = Field(default=None, description='显式指定的媒体来源;None 表示自动选择')
media_id: Optional[str] = Field(default=None, description='指定来源中的原生媒体标识;None 表示自动识别')
mtype: Optional[MediaType] = Field(default=None, description='限定的媒体类型;None 表示未限定')
target_directory: Optional[TransferDirectoryConf] = Field(default=None, description='指定的目标资源目录配置;None 使用目录匹配规则')
target_storage: Optional[str] = Field(default=None, description='指定的目标存储标识;None 使用选定目录的存储')
target_path: Optional[Path] = Field(default=None, description='指定的目标路径;None 按规划规则生成')
transfer_type: Optional[str] = Field(default=None, description='指定的整理方式;None 使用目录默认方式')
scrape: Optional[bool] = Field(default=False, description='是否生成刮削资料,默认 False')
library_type_folder: Optional[bool] = Field(default=False, description='是否按媒体类型生成库目录,默认 False')
library_category_folder: Optional[bool] = Field(default=False, description='是否按媒体分类生成库目录,默认 False')
episodes_info: Optional[List[TmdbEpisode]] = Field(default=None, description='已取得的剧集详情;None 表示尚未取得')
username: Optional[str] = Field(default=None, description='触发整理的本地用户名;未绑定时为 None')
downloader: Optional[str] = Field(default=None, description='关联下载器的配置名称;无下载器任务时为 None')
download_hash: Optional[str] = Field(default=None, description='关联下载器任务的哈希;无下载任务时为 None')
download_history: Optional[DownloadHistorySnapshot] = Field(default=None, description='关联的下载历史快照;未匹配时为 None')
transfer_batch_id: Optional[str] = Field(default=None, description='关联的整理批次标识;未分配批次时为 None')
manual: Optional[bool] = Field(default=False, description='是否为用户手动发起的整理,默认 False')
background: Optional[bool] = Field(default=True, description='是否作为后台任务执行,默认 True')
preview: Optional[bool] = Field(default=False, description='是否只返回预览而不执行真实整理,默认 False')
_admission_task_id: Optional[str] = PrivateAttr(default=None)
_planning_input: Optional[TransferPlanningInput] = PrivateAttr(default=None)
_plan_checkpoint: Optional[TransferPlanCheckpoint] = PrivateAttr(default=None)
_execution_checkpoint: Optional[TransferExecutionCheckpoint] = PrivateAttr(default=None)
_terminal_settled: bool = PrivateAttr(default=False)
_planning_context_restored: bool = PrivateAttr(default=False)
_music_preview_context: dict[str, Any] = PrivateAttr(default_factory=dict)
_lease_owner: Optional[str] = PrivateAttr(default=None)
_lease_token: Optional[str] = PrivateAttr(default=None)
@property
def music_preview_context(self) -> dict[str, Any]:
"""
返回本次扫描的展示证据副本,不作为持久重试范围或文件访问凭证。
"""
return dict(getattr(self, "_music_preview_context", {}))
def bind_music_preview_context(self, context: dict[str, Any]) -> None:
"""
绑定临时音乐分组与可读性,保持旧插件序列化和持久规划契约。
"""
self._music_preview_context = dict(context)
@property
def admission_task_id(self) -> Optional[str]:
"""
返回仅供宿主持久准入和终态结算使用的内部任务标识。
"""
return self._admission_task_id
def bind_admission_task_id(self, task_id: str) -> None:
"""
绑定持久准入生成的稳定身份,不改变插件可见序列化字段。
"""
self._admission_task_id = task_id
@property
def planning_input(self) -> Optional[TransferPlanningInput]:
"""
返回宿主恢复规划使用的内部输入快照。
"""
return self._planning_input
@property
def plan_checkpoint(self) -> Optional[TransferPlanCheckpoint]:
"""
返回宿主直接执行已规划任务使用的内部检查点。
"""
return self._plan_checkpoint
def bind_planning_input(self, planning_input: TransferPlanningInput) -> None:
"""
绑定持久规划输入,不改变插件可见序列化字段。
"""
self._planning_input = planning_input
def bind_plan_checkpoint(self, checkpoint: TransferPlanCheckpoint) -> None:
"""
绑定持久执行检查点,不改变插件可见序列化字段。
"""
self._plan_checkpoint = checkpoint
@property
def execution_checkpoint(self) -> Optional[TransferExecutionCheckpoint]:
"""
返回仅供宿主终态结算使用的内部执行检查点。
"""
return self._execution_checkpoint
def bind_execution_checkpoint(
self, checkpoint: TransferExecutionCheckpoint
) -> None:
"""
绑定执行结果检查点,不改变插件可见的旧任务序列化字段。
"""
self._execution_checkpoint = checkpoint
@property
def terminal_settled(self) -> bool:
"""
返回历史、事件与待处理记录是否已由同一 UoW 提交。
"""
return self._terminal_settled
def mark_terminal_settled(self) -> None:
"""
仅在支持任务结算的写入端口成功返回后标记终态已经提交。
"""
self._terminal_settled = True
@property
def planning_context_restored(self) -> bool:
"""
返回当前领域上下文是否来自持久快照。
"""
return self._planning_context_restored
def mark_planning_context_restored(self) -> None:
"""
标记领域上下文已离线恢复,禁止旧流程再次在线补充。
"""
self._planning_context_restored = True
@property
def lease_owner(self) -> Optional[str]:
"""
返回当前任务绑定的进程级执行者标识。
"""
return self._lease_owner
@property
def lease_token(self) -> Optional[str]:
"""
返回当前任务绑定的持久租约令牌。
"""
return self._lease_token
def bind_execution_lease(self, *, owner_id: str, lease_token: str) -> None:
"""
绑定持久认领结果,且不改变插件可见的旧任务序列化字段。
"""
if not owner_id or not lease_token:
raise ValueError("整理执行租约缺少 owner 或 token")
self._lease_owner = owner_id
self._lease_token = lease_token
def to_dict(self) -> dict[str, Any]:
"""
返回用于任务展示和兼容调用的独立字段字典。
meta 与 mediainfo 是领域对象,使用各自的 to_dict();文件项与目录配置使用
Pydantic 的 model_dump(),避免把两类对象当作同一种序列化接口。
"""
dicts = vars(self).copy()
dicts["fileitem"] = self.fileitem.model_dump() if self.fileitem else None
dicts["meta"] = _domain_to_dict(self.meta) if self.meta else None
dicts["mediainfo"] = _domain_to_dict(self.mediainfo) if self.mediainfo else None
dicts["target_directory"] = self.target_directory.model_dump() if self.target_directory else None
return dicts
TransferCallback: TypeAlias = Callable[[TransferTask, TransferInfo], tuple[bool, str]]
class TransferQueue(_ApplicationModel):
"""
进程内整理队列项,保存任务、完成回调和执行结果。
回调属于运行期对象,本类不承担跨进程持久化格式。
"""
task: Optional[TransferTask] = Field(default=None, description='队列中待处理的整理任务;None 表示尚未绑定')
callback: Optional[TransferCallback] = Field(default=None, description='整理完成后的业务回调;None 表示无附加回调')
result: Optional[TransferInfo] = Field(default=None, description='已经产生的整理结果;尚未执行时为 None')
@dataclass(frozen=True, slots=True)
class TransferAdmission:
"""
描述已经持久化、可在进程退出后恢复的整理任务准入事实。
:param task_id: 持久整理任务的稳定标识
:param storage: 源文件所在的存储配置标识
:param src_path: 任务源文件在存储中的路径
:param state: 规划准入状态,区分 accepted、provider_pending 和 planned
:param created_at: 任务首次持久登记时间文本
:param updated_at: 最近一次持久状态更新时间文本
:param planning_input: 跨重启恢复规划所需的版本化输入
:param last_error: 最近失败的可读原因;未失败时为 None
:param input_fingerprint: 规划输入的稳定内容指纹;旧数据可能为 None
:param checkpoint: 已完成的整理计划;尚未规划时为 None
:param lease_owner: 当前租约所属执行者标识;未认领时为 None
:param lease_token: 当前执行租约令牌;未认领时为 None
:param lease_expires_at: 租约到期时间文本;无租约时为 None
:param heartbeat_at: 最近一次成功续租时间;未续租时为 None
:param attempt_count: 任务已被认领的累计次数
"""
task_id: str
storage: str
src_path: str
state: str
created_at: str
updated_at: str
planning_input: TransferPlanningInput
last_error: Optional[str] = None
input_fingerprint: Optional[str] = None
checkpoint: Optional[TransferPlanCheckpoint] = None
lease_owner: Optional[str] = None
lease_token: Optional[str] = None
lease_expires_at: Optional[str] = None
heartbeat_at: Optional[str] = None
attempt_count: int = 0
class TransferAdmissionRepository(Protocol):
"""
整理任务持久准入所需的类型化持久化端口。
实现方拥有短事务及提交、回滚;文件操作和媒体识别由调用方在事务外执行。
认领和状态推进必须核对当前租约,不能让过期执行者覆盖新执行者的结果。
"""
def defer_planning(self, *, task_id: str, lease_token: str, error: str,
retry_after: int, max_retries: int) -> bool:
"""
仅将无计划、无步骤的接纳任务退避;预算用尽返回 False,租约或状态冲突抛错。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param error: 需要持久记录的可读失败原因
:param retry_after: 规划重试前的等待时长,单位为秒
:param max_retries: 本轮规划允许的最大重试次数
:return: 成功安排退避时为 True;重试预算用尽时为 False
"""
...
def admit(
self,
*,
storage: str,
src_path: str,
planning_input: TransferPlanningInput,
replace_inactive: bool = False,
) -> TransferAdmission:
"""
幂等登记源文件;显式重做可原子替换无有效租约且无历史绑定的旧任务。
:param storage: 源文件所在的存储配置标识
:param src_path: 源文件在存储中的规范路径
:param planning_input: 跨重启恢复规划所需的版本化输入快照
:param replace_inactive: 是否替换无有效租约且没有历史绑定的旧任务,默认 False
:return: 新建或幂等复用的持久准入事实
"""
...
def record_enqueue_failure(self, *, task_id: str, error: str) -> None:
"""
记录内存队列接收失败,保留任务供后续恢复。
:param task_id: 目标持久整理任务的稳定标识
:param error: 需要持久记录的可读失败原因
"""
...
def checkpoint_plan(
self,
*,
task_id: str,
lease_token: str,
input_fingerprint: str,
checkpoint: TransferPlanCheckpoint,
) -> TransferAdmission:
"""
按有效租约原子保存完整计划并推进匹配输入的任务。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param input_fingerprint: 预期规划输入的内容指纹,防止保存到已变更的任务
:param checkpoint: 拟持久化的完整规划检查点
:return: 已绑定计划检查点的准入事实
"""
...
def record_planning_failure(
self, *, task_id: str, lease_token: str, error: str
) -> None:
"""
按有效租约记录规划失败,保留业务状态供恢复重试。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param error: 需要持久记录的可读失败原因
"""
...
def claim_task(
self,
*,
task_id: str,
owner_id: str,
lease_seconds: int,
) -> Optional[TransferAdmission]:
"""
为指定任务取得唯一执行租约;已有有效租约时返回 None。
:param task_id: 目标持久整理任务的稳定标识
:param owner_id: 本次认领任务的执行者标识
:param lease_seconds: 认领或续租后有效的租约时长,单位为秒
:return: 带当前租约的任务;不存在或暂时不可认领时为 None
"""
...
def claim_recoverable(
self,
*,
owner_id: str,
limit: int,
lease_seconds: int,
) -> list[TransferAdmission]:
"""
按登记顺序原子取得可恢复任务的唯一执行租约。
:param owner_id: 本次认领任务的执行者标识
:param limit: 本次最多认领的可恢复任务数量
:param lease_seconds: 认领或续租后有效的租约时长,单位为秒
:return: 本次成功认领的任务列表,可能少于 limit
"""
...
def heartbeat(
self,
*,
task_id: str,
lease_token: str,
lease_seconds: int,
) -> Optional[TransferAdmission]:
"""
仅为当前且未过期的令牌延长租约;过期令牌不得复活。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param lease_seconds: 认领或续租后有效的租约时长,单位为秒
:return: 延长租约后的任务;令牌失效或任务不存在时为 None
"""
...
def release_claim(
self,
*,
task_id: str,
lease_token: str,
error: Optional[str] = None,
) -> bool:
"""
按令牌释放当前认领,并可记录可恢复错误。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:param error: 释放租约时附带的可恢复错误;None 表示无附加错误
:return: 成功释放当前有效租约时为 True;租约不匹配时为 False
"""
...
def abandon_unstarted(self, *, task_id: str, lease_token: str) -> int:
"""
仅允许当前租约持有者删除确认未开始且源已消失的登记。
:param task_id: 目标持久整理任务的稳定标识
:param lease_token: 当前认领获得的有效租约令牌,用于拒绝过期执行者
:return: 实际删除的任务数量,未满足安全删除条件时为 0
"""
...