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

211 lines
7.0 KiB
Python

"""
整理计划检查点的 JSON 与分类快照编解码。
"""
import json
from copy import deepcopy
from typing import Any, Optional, TypeAlias, Union
from app.application.classification.reference import (
EffectiveClassificationSnapshot,
persisted_classification_snapshot,
)
__all__ = ["EffectiveClassificationSnapshot"]
JSONValue: TypeAlias = Union[
None,
bool,
int,
float,
str,
list["JSONValue"],
dict[str, "JSONValue"],
]
CLASSIFICATION_SNAPSHOT_FIELDS = (
"category_id",
"library_category",
"classification_rule_id",
"classification_policy_revision",
"classification_source",
)
def copy_json_mapping(
value: Optional[dict[str, JSONValue]],
) -> Optional[dict[str, JSONValue]]:
"""
复制可选 JSON 对象,隔离检查点与调用方的可变状态。
"""
if value is None:
return None
return deepcopy(value)
def read_json_mapping(
payload: dict[str, Any],
key: str,
) -> Optional[dict[str, JSONValue]]:
"""
读取可选 JSON 对象并拒绝不匹配的持久类型。
"""
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], ...]:
"""
读取 JSON 对象数组并冻结为独立元组。
"""
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:
"""
生成用于验证和指纹计算的确定性 JSON。
"""
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
def classification_snapshot_payload(
snapshot: EffectiveClassificationSnapshot,
) -> dict[str, JSONValue]:
"""
把最终分类快照投影为稳定的五字段检查点对象。
"""
return {
"category_id": snapshot.category_id,
"library_category": snapshot.path,
"classification_rule_id": snapshot.rule_id,
"classification_policy_revision": snapshot.policy_revision,
"classification_source": snapshot.source,
}
def read_classification_snapshot(
payload: object,
*,
require_all_fields: bool,
) -> EffectiveClassificationSnapshot:
"""
从检查点标量恢复安全分类快照,禁止路径穿越与描述性分类。
"""
if not isinstance(payload, dict):
raise ValueError("整理计划分类快照必须是 JSON 对象")
if require_all_fields and any(
key not in payload for key in CLASSIFICATION_SNAPSHOT_FIELDS
):
raise ValueError("整理计划分类快照缺少必需字段")
if require_all_fields:
for key in (
"category_id",
"library_category",
"classification_rule_id",
"classification_source",
):
if payload.get(key) is not None and not isinstance(payload.get(key), str):
raise ValueError(f"整理计划分类字段 {key} 必须是字符串或空值")
raw_path = payload.get("library_category")
if isinstance(raw_path, str) and (
raw_path.startswith(("/", "\\")) or "\\" in raw_path
):
raise ValueError("整理计划分类路径必须是 POSIX 相对路径")
snapshot = persisted_classification_snapshot(
category_id=payload.get("category_id"),
category_path=raw_path,
rule_id=payload.get("classification_rule_id"),
policy_revision=payload.get("classification_policy_revision"),
source=payload.get("classification_source"),
)
if raw_path not in (None, "", [], ()) and snapshot.path is None:
raise ValueError("整理计划分类路径无效")
if require_all_fields and raw_path not in (None, "") and raw_path != snapshot.path:
raise ValueError("整理计划分类路径必须是规范 POSIX 相对路径")
revision = payload.get("classification_policy_revision")
if revision is not None and (
isinstance(revision, bool)
or not isinstance(revision, (int, str))
or snapshot.policy_revision is None
):
raise ValueError("整理计划分类策略版本必须是整数或空值")
return snapshot
def legacy_classification_snapshot(
payload: dict[str, Any],
) -> EffectiveClassificationSnapshot:
"""
只从 v1 检查点自身媒体载荷恢复分类,不读取当前活动策略。
"""
media_payload = payload.get("resolved_mediainfo")
if not isinstance(media_payload, dict):
invocation = payload.get("provider_invocation")
if isinstance(invocation, dict):
media_payload = invocation.get("mediainfo")
if not isinstance(media_payload, dict):
return EffectiveClassificationSnapshot()
raw_classification = media_payload.get("classification")
classification = raw_classification if isinstance(raw_classification, dict) else None
effective = (
classification.get("effective")
if classification is not None
and isinstance(classification.get("effective"), dict)
else None
)
if isinstance(effective, dict):
try:
return read_classification_snapshot(
{
"category_id": effective.get("category_id"),
"library_category": effective.get("category_path"),
"classification_rule_id": effective.get("rule_id"),
"classification_policy_revision": (
classification.get("policy_revision")
if classification is not None
else None
),
"classification_source": effective.get("source"),
},
require_all_fields=False,
)
except ValueError:
# v1 分类只是兼容辅助信息,历史脏值不能阻断冻结计划恢复。
return EffectiveClassificationSnapshot()
legacy_path = media_payload.get("library_category") or media_payload.get(
"category"
)
try:
return read_classification_snapshot(
{
"category_id": None,
"library_category": legacy_path,
"classification_rule_id": None,
"classification_policy_revision": None,
"classification_source": "legacy" if legacy_path else None,
},
require_all_fields=False,
)
except ValueError:
# 保留 v1 原始 payload 和指纹,仅放弃不安全的兼容分类快照。
return EffectiveClassificationSnapshot()