mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
229 lines
8.6 KiB
Python
229 lines
8.6 KiB
Python
"""Chain durable 事件的事务写端口与可重放 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.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,
|
|
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
|
|
event_payload: dict[str, Any],
|
|
publish: Callable[[dict[str, Any]], None],
|
|
) -> TransferHistoryRecord | None:
|
|
"""提交整理历史与结果 intent,并在提交后广播兼容事件。"""
|
|
|
|
|
|
@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) -> str:
|
|
"""由结果 topic、整理历史 ID 与本次事实标识构造幂等键。"""
|
|
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": 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=(
|
|
_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})
|