Files
MoviePilot/app/application/subscription/complete.py
T

169 lines
6.1 KiB
Python

"""订阅完成应用命令及其同步事务端口。"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from contextlib import AbstractContextManager
from datetime import datetime, timezone
from typing import Any, Protocol
from app.application.outbox import OutboxIntent, SyncOutboxTransaction, SyncUnitOfWork
class SubscriptionCompletionRepository(Protocol):
"""订阅完成命令需要的最小同步持久化端口。"""
def add_history(self, **payload: Any) -> None:
"""在当前事务中暂存订阅历史。"""
...
def delete(self, subscribe_id: int) -> None:
"""在当前事务中暂存订阅删除。"""
...
CompletionEffect = Callable[[], None]
CompletionReporter = Callable[[Mapping[str, Any]], object]
class CompleteSubscriptionCommand:
"""原子完成订阅,并按通知、事件、统计顺序执行提交后副作用。"""
def __init__(
self,
repository: SubscriptionCompletionRepository,
unit_of_work: SyncUnitOfWork,
outbox: SyncOutboxTransaction | None,
publish: Callable[[dict[str, Any]], None],
) -> None:
"""注入共享同步会话、事件发布端口和可选 durable outbox。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._outbox = outbox
self._publish = publish
def execute(
self,
subscribe_id: int,
subscribe_info: Mapping[str, Any],
mediainfo: Mapping[str, Any],
notify: CompletionEffect,
report: CompletionReporter,
notification: Mapping[str, Any] | None = None,
) -> None:
"""在同一事务中写历史、删订阅并暂存完成事件、通知与统计意图。"""
info = dict(subscribe_info)
event_payload = {
"subscribe_id": subscribe_id,
"subscribe_info": info,
"mediainfo": dict(mediainfo),
"idempotency_key": completion_event_key(subscribe_id, info),
}
event_key = event_payload["idempotency_key"]
report_key = completion_report_key(subscribe_id, info)
notification_key = completion_notification_key(subscribe_id, info)
report_payload = {"subscribe_info": _completion_report_payload(info, report_key)}
try:
self._repository.add_history(**info)
self._repository.delete(subscribe_id)
if self._outbox:
now = datetime.now(timezone.utc)
self._outbox.stage(
OutboxIntent(
event_key=event_key,
topic="subscribe.complete",
payload=event_payload,
),
now,
)
if notification:
self._outbox.stage(
OutboxIntent(
event_key=notification_key,
topic="subscribe.complete.notification",
payload={
"idempotency_key": notification_key,
"message": dict(notification),
},
),
now,
)
self._outbox.stage(
OutboxIntent(
event_key=report_key,
topic="subscribe.complete.report",
payload=report_payload,
),
now,
)
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
notify()
if self._outbox and notification:
self._outbox.complete_by_event_key(
notification_key,
datetime.now(timezone.utc),
)
self._publish(event_payload)
if self._outbox:
self._outbox.complete_by_event_key(event_key, datetime.now(timezone.utc))
if report(report_payload["subscribe_info"]) is False:
raise RuntimeError("订阅完成统计上报未确认")
if self._outbox:
self._outbox.complete_by_event_key(report_key, datetime.now(timezone.utc))
def completion_event_key(subscribe_id: int, subscribe_info: Mapping[str, Any]) -> str:
"""构造跨重试稳定的订阅完成事件幂等键。"""
return (
f"subscribe.complete:{subscribe_id}:"
f"{subscribe_info.get('media_source') or 'unknown'}:"
f"{subscribe_info.get('media_id') or 'unknown'}:v1"
)
def completion_report_key(subscribe_id: int, subscribe_info: Mapping[str, Any]) -> str:
"""构造可独立重试的订阅完成统计幂等键。"""
return f"{completion_event_key(subscribe_id, subscribe_info)}:report"
def completion_notification_key(
subscribe_id: int,
subscribe_info: Mapping[str, Any],
) -> str:
"""构造订阅完成通知的稳定幂等键,避免恢复时重复生成不同消息。"""
return f"{completion_event_key(subscribe_id, subscribe_info)}:notification"
def _completion_report_payload(
subscribe_info: Mapping[str, Any],
report_key: str,
) -> dict[str, Any]:
"""保留旧统计接口字段,同时为恢复 handler 固化幂等键。"""
return {
"media_source": subscribe_info.get("media_source"),
"media_id": subscribe_info.get("media_id"),
"season": subscribe_info.get("season"),
"idempotency_key": report_key,
}
CompletionScope = Callable[[], AbstractContextManager[CompleteSubscriptionCommand]]
_configured_completion_scope: CompletionScope | None = None
def configure_subscription_completion_scope(provider: CompletionScope) -> None:
"""由启动组合根登记订阅完成独占事务作用域。"""
global _configured_completion_scope
_configured_completion_scope = provider
def get_subscription_completion_scope() -> AbstractContextManager[CompleteSubscriptionCommand]:
"""返回一次独占同步订阅完成事务作用域。"""
if _configured_completion_scope is None:
raise RuntimeError("订阅完成事务作用域尚未配置")
return _configured_completion_scope()