mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: add durable transfer planning checkpoints
This commit is contained in:
+638
-7
@@ -14,12 +14,24 @@ TransferJob / TransferJobTask,那两个用 app.schemas 的同名 DTO——一
|
||||
视图,分开表达之后两边都不必再迁就对方。
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
from typing import Callable, Dict, List, Optional, Protocol, Tuple, Union
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
Tuple,
|
||||
TypeAlias,
|
||||
Union,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, PrivateAttr
|
||||
|
||||
@@ -49,6 +61,571 @@ from app.schemas.types import (
|
||||
)
|
||||
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
|
||||
|
||||
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 = 1
|
||||
TRANSFER_PROVIDER_INVOCATION_VERSION = 1
|
||||
|
||||
|
||||
def _copy_json_mapping(value: Optional[dict[str, JSONValue]]) -> Optional[dict[str, JSONValue]]:
|
||||
if value is None:
|
||||
return None
|
||||
return deepcopy(value)
|
||||
|
||||
|
||||
def _read_json_mapping(payload: dict[str, Any], key: str) -> Optional[dict[str, JSONValue]]:
|
||||
value = payload.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"整理计划字段 {key} 必须是 JSON 对象")
|
||||
return deepcopy(value)
|
||||
|
||||
|
||||
def _read_json_tuple(payload: dict[str, Any], key: str) -> tuple[dict[str, JSONValue], ...]:
|
||||
value = payload.get(key, [])
|
||||
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
||||
raise ValueError(f"整理计划字段 {key} 必须是 JSON 对象数组")
|
||||
return tuple(deepcopy(item) for item in value)
|
||||
|
||||
|
||||
def _canonical_json(payload: dict[str, JSONValue]) -> str:
|
||||
try:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("整理计划只能包含有限 JSON 值") from error
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransferPlanningInput:
|
||||
"""保存可跨重启重放的版本化整理规划输入。"""
|
||||
|
||||
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", _copy_json_mapping(self.meta))
|
||||
object.__setattr__(self, "mediainfo", _copy_json_mapping(self.mediainfo))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"target_directory",
|
||||
_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("整理规划输入缺少源文件存储或路径")
|
||||
_canonical_json(self.to_payload())
|
||||
|
||||
@classmethod
|
||||
def legacy(cls, *, storage: str, src_path: str) -> "TransferPlanningInput":
|
||||
"""为升级前只有存储与路径的登记构造保守重规划输入。"""
|
||||
return cls(
|
||||
source_fileitem={"storage": storage, "path": src_path},
|
||||
options={"legacy_replan": True},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanningInput":
|
||||
"""从受版本约束的 JSON 对象恢复规划输入。"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("整理规划输入必须是 JSON 对象")
|
||||
source_fileitem = _read_json_mapping(payload, "source_fileitem")
|
||||
if source_fileitem is None:
|
||||
raise ValueError("整理规划输入缺少 source_fileitem")
|
||||
return cls(
|
||||
source_fileitem=source_fileitem,
|
||||
meta=_read_json_mapping(payload, "meta"),
|
||||
mediainfo=_read_json_mapping(payload, "mediainfo"),
|
||||
target_directory=_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=_read_json_tuple(payload, "episodes_info"),
|
||||
preview=payload.get("preview", False),
|
||||
options=_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": _copy_json_mapping(self.meta),
|
||||
"mediainfo": _copy_json_mapping(self.mediainfo),
|
||||
"target_directory": _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(_canonical_json(self.to_payload()).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransferPlanItem:
|
||||
"""描述规划后按序执行的一条叶子文件操作。"""
|
||||
|
||||
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("整理计划项缺少源文件身份")
|
||||
_canonical_json(self.to_payload())
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanItem":
|
||||
"""从 JSON 对象恢复单条叶子文件操作。"""
|
||||
source_fileitem = _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 provider 引用。"""
|
||||
|
||||
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")
|
||||
_canonical_json(self.to_payload())
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> "TransferProviderReference":
|
||||
"""从 JSON 对象恢复旧 transfer provider 引用。"""
|
||||
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 中可跨重启恢复的精确参数值。"""
|
||||
|
||||
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:
|
||||
"""拒绝缺少源身份、类型不稳定或版本未知的 provider 快照。"""
|
||||
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", _copy_json_mapping(self.meta))
|
||||
object.__setattr__(self, "mediainfo", _copy_json_mapping(self.mediainfo))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"target_directory",
|
||||
_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")
|
||||
_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 = _read_json_mapping(payload, "fileitem")
|
||||
if fileitem is None:
|
||||
raise ValueError("旧 transfer provider 调用快照缺少 fileitem")
|
||||
return cls(
|
||||
fileitem=fileitem,
|
||||
meta=_read_json_mapping(payload, "meta"),
|
||||
meta_kind=payload.get("meta_kind"),
|
||||
mediainfo=_read_json_mapping(payload, "mediainfo"),
|
||||
mediainfo_kind=payload.get("mediainfo_kind"),
|
||||
target_directory=_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=_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": _copy_json_mapping(self.meta),
|
||||
"meta_kind": self.meta_kind,
|
||||
"mediainfo": _copy_json_mapping(self.mediainfo),
|
||||
"mediainfo_kind": self.mediainfo_kind,
|
||||
"target_directory": _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:
|
||||
"""保存无需重触发识别或重命名即可执行的完整有序计划。"""
|
||||
|
||||
planning_input: TransferPlanningInput
|
||||
target_storage: str
|
||||
root_target_path: str
|
||||
final_target_path: str
|
||||
resolved_transfer_type: str
|
||||
items: tuple[TransferPlanItem, ...]
|
||||
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
|
||||
schema_version: int = TRANSFER_PLAN_CHECKPOINT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""验证版本、目标身份和计划项顺序组成完整检查点。"""
|
||||
if self.schema_version != TRANSFER_PLAN_CHECKPOINT_VERSION:
|
||||
raise ValueError(f"不支持的整理计划检查点版本: {self.schema_version}")
|
||||
if not isinstance(self.pre_execution_cleanup_completed, bool):
|
||||
raise ValueError("整理计划预执行 cleanup 完成标记必须是 bool")
|
||||
object.__setattr__(self, "resolved_meta", _copy_json_mapping(self.resolved_meta))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"resolved_mediainfo",
|
||||
_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
|
||||
):
|
||||
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 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
|
||||
):
|
||||
raise ValueError("非预览空计划必须记录合法跳过原因")
|
||||
_canonical_json(self.to_payload())
|
||||
|
||||
@property
|
||||
def is_provider_pending(self) -> bool:
|
||||
"""返回该检查点是否只冻结 provider 调用、尚未生成宿主计划。"""
|
||||
return self.provider_invocation is not None
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanCheckpoint":
|
||||
"""从受版本约束的 JSON 对象恢复完整执行检查点。"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("整理计划检查点必须是 JSON 对象")
|
||||
planning_input_payload = _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 对象")
|
||||
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),
|
||||
resolved_meta=_read_json_mapping(payload, "resolved_meta"),
|
||||
resolved_meta_kind=payload.get("resolved_meta_kind"),
|
||||
resolved_mediainfo=_read_json_mapping(payload, "resolved_mediainfo"),
|
||||
resolved_mediainfo_kind=payload.get("resolved_mediainfo_kind"),
|
||||
resolved_episodes_info=_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"),
|
||||
schema_version=payload.get("schema_version", 0),
|
||||
)
|
||||
|
||||
def to_payload(self) -> dict[str, JSONValue]:
|
||||
"""生成可原子落库的完整版本化 JSON 检查点。"""
|
||||
return {
|
||||
"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": _copy_json_mapping(self.resolved_meta),
|
||||
"resolved_meta_kind": self.resolved_meta_kind,
|
||||
"resolved_mediainfo": _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,
|
||||
}
|
||||
|
||||
|
||||
class TransferAdmissionConflictError(ValueError):
|
||||
"""同一源文件以不同规划输入重复准入时抛出的冲突错误。"""
|
||||
|
||||
|
||||
class TransferPlanningStateError(RuntimeError):
|
||||
"""计划检查点无法从当前持久状态推进时抛出的状态错误。"""
|
||||
|
||||
|
||||
class TransferTask(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
@@ -81,6 +658,9 @@ class TransferTask(OptionalMediaIdentityMixin, BaseModel):
|
||||
background: Optional[bool] = True
|
||||
preview: Optional[bool] = False
|
||||
_admission_task_id: Optional[str] = PrivateAttr(default=None)
|
||||
_planning_input: Optional[TransferPlanningInput] = PrivateAttr(default=None)
|
||||
_plan_checkpoint: Optional[TransferPlanCheckpoint] = PrivateAttr(default=None)
|
||||
_planning_context_restored: bool = PrivateAttr(default=False)
|
||||
|
||||
@property
|
||||
def admission_task_id(self) -> Optional[str]:
|
||||
@@ -91,6 +671,33 @@ class TransferTask(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""绑定持久准入生成的稳定身份,不改变插件可见序列化字段。"""
|
||||
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 planning_context_restored(self) -> bool:
|
||||
"""返回当前领域上下文是否来自持久快照。"""
|
||||
return self._planning_context_restored
|
||||
|
||||
def mark_planning_context_restored(self) -> None:
|
||||
"""标记领域上下文已离线恢复,禁止旧流程再次在线补充。"""
|
||||
self._planning_context_restored = True
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
返回字典。
|
||||
@@ -122,9 +729,6 @@ class TransferQueue(BaseModel):
|
||||
result: Optional[TransferInfo] = None
|
||||
|
||||
|
||||
TRANSFER_ADMISSION_ACCEPTED = "accepted"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransferAdmission:
|
||||
"""描述已经持久化、可在进程退出后恢复的整理任务准入事实。"""
|
||||
@@ -136,13 +740,22 @@ class TransferAdmission:
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_error: Optional[str] = None
|
||||
input_fingerprint: Optional[str] = None
|
||||
planning_input: Optional[TransferPlanningInput] = None
|
||||
checkpoint: Optional[TransferPlanCheckpoint] = None
|
||||
|
||||
|
||||
class TransferAdmissionRepository(Protocol):
|
||||
"""整理任务 durable admission 所需的类型化持久化端口。"""
|
||||
|
||||
def admit(self, *, storage: str, src_path: str) -> TransferAdmission:
|
||||
"""幂等登记源文件并返回稳定任务身份。"""
|
||||
def admit(
|
||||
self,
|
||||
*,
|
||||
storage: str,
|
||||
src_path: str,
|
||||
planning_input: Optional[TransferPlanningInput] = None,
|
||||
) -> TransferAdmission:
|
||||
"""按规划输入幂等登记源文件并返回稳定任务身份。"""
|
||||
...
|
||||
|
||||
def list_accepted(self, limit: int = 5000) -> list[TransferAdmission]:
|
||||
@@ -153,6 +766,24 @@ class TransferAdmissionRepository(Protocol):
|
||||
"""记录内存队列接收失败,保留任务供后续恢复。"""
|
||||
...
|
||||
|
||||
def checkpoint_plan(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
input_fingerprint: str,
|
||||
checkpoint: TransferPlanCheckpoint,
|
||||
) -> TransferAdmission:
|
||||
"""原子保存完整计划并将匹配输入的任务推进到已规划。"""
|
||||
...
|
||||
|
||||
def record_planning_failure(self, *, task_id: str, error: str) -> None:
|
||||
"""记录规划失败但保留接纳状态供下次恢复重试。"""
|
||||
...
|
||||
|
||||
def list_recoverable(self, limit: int = 5000) -> list[TransferAdmission]:
|
||||
"""按登记顺序返回接纳或已规划的可恢复任务。"""
|
||||
...
|
||||
|
||||
def discard_task(self, *, task_id: str) -> int:
|
||||
"""按稳定任务身份删除已经到达终态的登记。"""
|
||||
...
|
||||
|
||||
Reference in New Issue
Block a user