Files
MoviePilot/app/application/chain/events.py
T

270 lines
10 KiB
Python

"""Chain 持久事件的事务写端口与可重放 payload 转换。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, fields
from datetime import date, datetime
from enum import Enum
from pathlib import Path
from typing import Any, Protocol, cast
from uuid import uuid4
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
from app.application.transfer.execution import TransferSettlementResult
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaType
class ChainDurableEventWriter(Protocol):
"""下载与整理 Chain 原子写业务记录和 outbox 的宿主端口。"""
def download_added(
self,
*,
history_payload: dict[str, Any],
file_payloads: list[dict[str, Any]],
event_payload: dict[str, Any],
after_commit: Callable[[], None],
publish: Callable[[dict[str, Any]], None],
) -> None:
"""提交下载历史与 DownloadAdded intent,再执行原有提交后编排。"""
def transfer_result(
self,
*,
topic: str | None,
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
event_payload: dict[str, Any],
publish: Callable[[dict[str, Any]], None] | None,
settlement: "TransferResultSettlement | None" = None,
) -> TransferHistoryRecord | TransferSettlementResult | None:
"""提交历史、可选任务终态和结果 intent,再按 topic 广播事件。"""
@dataclass(frozen=True, slots=True)
class TransferResultSettlement:
"""描述一次受 lease fencing 保护的整理任务终态结算。"""
task_id: str
lease_token: str
execution_fingerprint: str
outcome: str
error: str | None = None
def __post_init__(self) -> None:
"""拒绝缺少稳定身份、非法结果或不可诊断的失败终态。"""
if not self.task_id or not self.lease_token or not self.execution_fingerprint:
raise ValueError("整理终态结算缺少任务、租约或执行检查点身份")
if self.outcome not in {"succeeded", "failed"}:
raise ValueError(f"不支持的整理终态:{self.outcome}")
if self.outcome == "failed" and not self.error:
raise ValueError("整理失败终态必须包含可诊断原因")
@dataclass(frozen=True, slots=True)
class TransferHistoryRef:
"""事务关闭后仍可安全读取的最小整理历史投影。"""
id: int
status: bool
src: str | None
src_storage: str | None
src_fileitem: dict[str, Any] | None
def download_added_event_key(history_id: int) -> str:
"""由下载历史 ID 与本次事实标识构造 DownloadAdded 幂等键。"""
return f"download.added:{history_id}:{uuid4().hex}:v1"
def transfer_result_event_key(
topic: str,
history_id: int,
*,
settlement: TransferResultSettlement | None = None,
settlement_revision: int | None = None,
) -> str:
"""为旧结果事实生成 occurrence key,为任务结算生成确定性幂等键。"""
if settlement is not None:
if settlement_revision is None or settlement_revision <= 0:
raise ValueError("整理终态事件键缺少有效结算修订号")
return (
f"transfer.result:{settlement.task_id}:{settlement_revision}:"
f"{settlement.outcome}:v1"
)
if settlement_revision is not None:
raise ValueError("旧整理结果事件键不能单独指定结算修订号")
return f"{topic}:{history_id}:{uuid4().hex}:v1"
def snapshot_download_added(payload: dict[str, Any]) -> dict[str, Any]:
"""把插件运行时 Context 转为 outbox 可 JSON 序列化的稳定快照。"""
context = payload.get("context")
return cast(dict[str, Any], _json_snapshot({
"hash": payload.get("hash"),
"context": (
cast(Callable[[], dict[str, Any]], context.to_dict)()
if isinstance(context, Context)
else context
),
"username": payload.get("username"),
"downloader": payload.get("downloader"),
"episodes": list(payload.get("episodes") or []),
"source": payload.get("source"),
"idempotency_key": payload.get("idempotency_key"),
}))
def restore_download_added(payload: dict[str, Any]) -> dict[str, Any]:
"""从 outbox 快照恢复插件既有 DownloadAdded 运行时对象形状。"""
restored = dict(payload)
context = payload.get("context")
if isinstance(context, dict):
restored["context"] = _restore_context(context)
return restored
def snapshot_transfer_result(payload: dict[str, Any]) -> dict[str, Any]:
"""把整理事件中的领域对象转换为可恢复 JSON 快照。"""
return cast(dict[str, Any], _json_snapshot({
"fileitem": _model_snapshot(payload.get("fileitem")),
"meta": _object_snapshot(payload.get("meta")),
"mediainfo": _object_snapshot(payload.get("mediainfo")),
"transferinfo": _model_snapshot(payload.get("transferinfo")),
"downloader": payload.get("downloader"),
"download_hash": payload.get("download_hash"),
"transfer_history_id": payload.get("transfer_history_id"),
"idempotency_key": payload.get("idempotency_key"),
}))
def restore_transfer_result(payload: dict[str, Any]) -> dict[str, Any]:
"""从 outbox 快照恢复 TransferComplete/Failed 的旧对象 payload。"""
restored = dict(payload)
fileitem = payload.get("fileitem")
meta = payload.get("meta")
mediainfo = payload.get("mediainfo")
transferinfo = payload.get("transferinfo")
restored["fileitem"] = (
FileItem.model_validate(fileitem) if isinstance(fileitem, dict) else fileitem
)
restored["meta"] = _restore_meta(meta) if isinstance(meta, dict) else meta
restored["mediainfo"] = (
_restore_media(mediainfo) if isinstance(mediainfo, dict) else mediainfo
)
restored["transferinfo"] = (
TransferInfo.model_validate(transferinfo)
if isinstance(transferinfo, dict)
else transferinfo
)
return restored
def _model_snapshot(value: Any) -> Any:
"""序列化 Pydantic 风格对象,空值和已有 JSON 值原样返回。"""
if hasattr(value, "model_dump"):
return value.model_dump(mode="json")
return value
def _object_snapshot(value: Any) -> Any:
"""序列化领域对象,避免把不可持久化实例写入 JSON 列。"""
if hasattr(value, "to_dict"):
return value.to_dict()
return _model_snapshot(value)
def _json_snapshot(value: Any) -> Any:
"""递归归一化快照,确保 SQLAlchemy JSON 不接收运行时专用对象。"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Enum):
return _json_snapshot(value.value)
if isinstance(value, (Path, date, datetime)):
return value.isoformat() if hasattr(value, "isoformat") else str(value)
if isinstance(value, dict):
return {
str(key): _json_snapshot(item)
for key, item in value.items()
}
if isinstance(value, (list, tuple, set, frozenset)):
return [_json_snapshot(item) for item in value]
if hasattr(value, "model_dump"):
return _json_snapshot(value.model_dump(mode="json"))
if hasattr(value, "to_dict"):
return _json_snapshot(value.to_dict())
return str(value)
def _restore_context(payload: dict[str, Any]) -> Context:
"""恢复 DownloadAdded 插件依赖的 Context 聚合对象。"""
meta_payload = payload.get("meta_info")
media_payload = payload.get("media_info")
torrent_payload = payload.get("torrent_info")
allowed_episodes = payload.get("allowed_episodes")
return Context(
meta_info=(
_restore_meta(meta_payload) if isinstance(meta_payload, dict) else None
),
media_info=(
_restore_media(media_payload) if isinstance(media_payload, dict) else None
),
torrent_info=cast(TorrentInfo, (
_restore_torrent(torrent_payload)
if isinstance(torrent_payload, dict)
else None
)),
media_recognize_fail_count=int(
payload.get("media_recognize_fail_count") or 0
),
resource_source=str(payload.get("resource_source") or "unknown"),
match_source=str(payload.get("match_source") or "unknown"),
candidate_recognized=bool(payload.get("candidate_recognized")),
media_info_is_target=bool(payload.get("media_info_is_target")),
allowed_episodes=(
set(allowed_episodes) if allowed_episodes is not None else None
),
confirmed_full_coverage=bool(payload.get("confirmed_full_coverage")),
)
def _restore_meta(payload: dict[str, Any]) -> MetaBase:
"""恢复影视或音乐文件名解析对象,并保留快照中的解析字段。"""
if payload.get("type") in {MediaType.MUSIC, MediaType.MUSIC.value, "music"}:
return MetaMusic.from_dict(payload)
title = str(
payload.get("org_string")
or payload.get("title")
or payload.get("name")
or ""
)
meta = MetaInfo(title)
for key, value in payload.items():
if key in {"season_episode", "edition", "name", "episode_list"}:
continue
if key == "type" and value:
value = MediaType(value)
setattr(meta, key, value)
return meta
def _restore_media(payload: dict[str, Any]) -> MediaInfo | MusicInfo:
"""恢复影视或音乐媒体对象,不触发任何远端识别。"""
if payload.get("type") in {MediaType.MUSIC, MediaType.MUSIC.value, "music"}:
return MusicInfo.from_dict(payload)
media = MediaInfo()
media.from_dict(payload)
return media
def _restore_torrent(payload: dict[str, Any]) -> TorrentInfo:
"""按 dataclass 构造字段恢复种子对象,忽略快照中的计算属性。"""
allowed = {item.name for item in fields(TorrentInfo) if item.init}
return TorrentInfo(**{key: value for key, value in payload.items() if key in allowed})