Files
MoviePilot/app/application/transfer/models.py

885 lines
38 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.
"""整理规划、任务对象与持久准入契约。"""
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, 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:
"""保存可跨重启重放的版本化整理规划输入。"""
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:
"""描述规划后按序执行的一条叶子文件操作。"""
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 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")
checkpoint_codec.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", 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:
"""保存无需重触发识别或重命名即可执行的完整有序计划。"""
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:
"""返回该检查点是否只冻结 provider 调用、尚未生成宿主计划。"""
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):
"""整理 worker 已失去持久租约、不得继续推进任务时抛出的错误。"""
class TransferTask(OptionalMediaIdentityMixin, _ApplicationModel):
"""
文件整理任务。
"""
# MetaBase 与 MediaInfo / MusicInfo 都是普通类而非 BaseModel,pydantic 需要显式放行
model_config = ConfigDict(arbitrary_types_allowed=True)
fileitem: FileItem
meta: Optional[MetaBase] = None
mediainfo: Optional[Union[MusicInfo, MediaInfo]] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
mtype: Optional[MediaType] = None
target_directory: Optional[TransferDirectoryConf] = None
target_storage: Optional[str] = None
target_path: Optional[Path] = None
transfer_type: Optional[str] = None
scrape: Optional[bool] = False
library_type_folder: Optional[bool] = False
library_category_folder: Optional[bool] = False
episodes_info: Optional[List[TmdbEpisode]] = None
username: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
download_history: Optional[DownloadHistorySnapshot] = None
transfer_batch_id: Optional[str] = None
manual: Optional[bool] = False
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)
_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:
"""返回历史、事件与 pending 是否已由同一 UoW 提交。"""
return self._terminal_settled
def mark_terminal_settled(self) -> None:
"""仅在 task-aware writer 成功返回后标记终态已经提交。"""
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]:
"""返回当前任务绑定的进程级 worker owner。"""
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:
"""绑定持久 claim 结果,且不改变插件可见的旧任务序列化字段。"""
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() 而非 model_dump():它们是领域对象,没有
model_dump。此前这里写的是 model_dump(),仓内无人调用才一直没炸——字段类型
标成 Any 时,这种错配静态检查也看不出来。
"""
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):
"""
异步整理队列信息。
和 TransferTask 一起从 app/schemas 搬来:它装着一个 TransferTask 和一个回调函数,
回调根本不可序列化,因此从来就不是 DTO,只是恰好和视图模型住在同一个文件里。
"""
# 任务信息
task: Optional[TransferTask] = None
# 回调函数
callback: Optional[TransferCallback] = None
# 整理结果
result: Optional[TransferInfo] = None
@dataclass(frozen=True, slots=True)
class TransferAdmission:
"""描述已经持久化、可在进程退出后恢复的整理任务准入事实。"""
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):
"""整理任务 durable admission 所需的类型化持久化端口。"""
def defer_planning(self, *, task_id: str, lease_token: str, error: str,
retry_after: int, max_retries: int) -> bool:
"""仅将无计划、无步骤的接纳任务退避;预算用尽返回False,租约或状态冲突抛错。"""
...
def admit(
self,
*,
storage: str,
src_path: str,
planning_input: TransferPlanningInput,
replace_inactive: bool = False,
) -> TransferAdmission:
"""幂等登记源文件;显式重做可原子替换无有效租约且无历史绑定的旧任务。"""
...
def record_enqueue_failure(self, *, task_id: str, error: str) -> None:
"""记录内存队列接收失败,保留任务供后续恢复。"""
...
def checkpoint_plan(
self,
*,
task_id: str,
lease_token: str,
input_fingerprint: str,
checkpoint: TransferPlanCheckpoint,
) -> TransferAdmission:
"""按有效租约原子保存完整计划并推进匹配输入的任务。"""
...
def record_planning_failure(
self, *, task_id: str, lease_token: str, error: str
) -> None:
"""按有效租约记录规划失败,保留业务状态供恢复重试。"""
...
def claim_task(
self,
*,
task_id: str,
owner_id: str,
lease_seconds: int,
) -> Optional[TransferAdmission]:
"""为指定任务取得唯一执行租约;已有有效租约时返回 None。"""
...
def claim_recoverable(
self,
*,
owner_id: str,
limit: int,
lease_seconds: int,
) -> list[TransferAdmission]:
"""按登记顺序原子取得可恢复任务的唯一执行租约。"""
...
def heartbeat(
self,
*,
task_id: str,
lease_token: str,
lease_seconds: int,
) -> Optional[TransferAdmission]:
"""仅为当前且未过期的 token 延长租约;过期 token 不得复活。"""
...
def release_claim(
self,
*,
task_id: str,
lease_token: str,
error: Optional[str] = None,
) -> bool:
"""按 token 释放当前 claim,并可记录可恢复错误。"""
...
def abandon_unstarted(self, *, task_id: str, lease_token: str) -> int:
"""仅允许当前 lease owner 删除确认未开始且源已消失的登记。"""
...