Files
MoviePilot/app/runtime/event/contracts.py

282 lines
11 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.
"""事件 payload、可见性、投递与可靠性契约清单。"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ValidationError
import app.schemas.event as event_schemas
from app.schemas.mediaserver import WebhookEventInfo
from app.schemas.types import ChainEventType, EventType
from app.schemas.workflow import ActionContext
class EventDelivery(StrEnum):
"""描述事件当前的交付保证。"""
EPHEMERAL = "ephemeral"
DURABLE_REQUIRED = "durable_required"
class EventVisibility(StrEnum):
"""描述事件是否属于插件公开扩展面。"""
HOST_ONLY = "host_only"
PLUGIN_PUBLIC = "plugin_public"
TARGET_PLUGIN = "target_plugin"
class EventErrorBehavior(StrEnum):
"""描述 handler 异常后的传播规则。"""
ISOLATE = "isolate"
STOP_CHAIN = "stop_chain"
NOTIFY = "notify"
class EventPayloadMode(StrEnum):
"""描述事件 payload 是运行时对象还是稳定快照。"""
RUNTIME = "runtime"
SNAPSHOT = "snapshot"
EXTENSION = "extension"
class EventValidationMode(StrEnum):
"""描述契约校验失败后的兼容策略。"""
DIAGNOSTIC = "diagnostic"
STRICT = "strict"
@dataclass(frozen=True, slots=True)
class EventContract:
"""冻结一个事件的 payload 与运行语义。"""
event_name: str
payload_model: type[BaseModel] | None
payload_contract: str
mode: str
visibility: EventVisibility
delivery: EventDelivery
error_behavior: EventErrorBehavior
ordering: str
input_model: type[BaseModel] | None = None
output_model: type[BaseModel] | None = None
schema_version: int = 1
payload_mode: EventPayloadMode = EventPayloadMode.RUNTIME
validation_mode: EventValidationMode = EventValidationMode.DIAGNOSTIC
sensitive_fields: tuple[str, ...] = ()
legacy_reason: str | None = None
_PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = {
EventType.ConfigChanged: event_schemas.ConfigChangeEventData,
EventType.PluginReload: event_schemas.PluginReloadEventData,
EventType.PluginAction: event_schemas.PluginActionEventData,
EventType.PluginTriggered: event_schemas.PluginTriggeredEventData,
EventType.CommandExcute: event_schemas.CommandExecuteEventData,
EventType.SiteDeleted: event_schemas.SiteEventData,
EventType.SiteUpdated: event_schemas.SiteEventData,
EventType.SiteRefreshed: event_schemas.SiteEventData,
EventType.AgentTokensUsage: event_schemas.AgentTokensUsageEventData,
EventType.SubscribeAdded: event_schemas.SubscribeAddedEventData,
EventType.SubscribeDeleted: event_schemas.SubscribeDeletedEventData,
EventType.SubscribeModified: event_schemas.SubscribeModifiedEventData,
EventType.DownloadAdded: event_schemas.DownloadAddedContractData,
EventType.TransferComplete: event_schemas.TransferResultContractData,
EventType.TransferFailed: event_schemas.TransferResultContractData,
EventType.SubtitleTransferComplete: event_schemas.TransferResultContractData,
EventType.SubtitleTransferFailed: event_schemas.TransferResultContractData,
EventType.AudioTransferComplete: event_schemas.TransferResultContractData,
EventType.AudioTransferFailed: event_schemas.TransferResultContractData,
EventType.HistoryDeleted: event_schemas.HistoryDeletedEventData,
EventType.DownloadFileDeleted: event_schemas.DownloadFileDeletedEventData,
EventType.DownloadDeleted: event_schemas.DownloadDeletedEventData,
EventType.UserMessage: event_schemas.UserMessageEventData,
EventType.WebhookMessage: WebhookEventInfo,
EventType.NoticeMessage: event_schemas.NoticeMessageEventData,
EventType.SubscribeComplete: event_schemas.SubscribeCompleteEventData,
EventType.SystemError: event_schemas.SystemErrorEventData,
EventType.MetadataScrape: event_schemas.MetadataScrapeContractData,
EventType.ModuleReload: event_schemas.EmptyEventData,
EventType.MessageAction: event_schemas.MessageActionEventData,
EventType.WorkflowExecute: event_schemas.WorkflowExecuteEventData,
ChainEventType.PluginDataReset: event_schemas.PluginDataResetEventData,
ChainEventType.AuthVerification: event_schemas.AuthCredentials,
ChainEventType.AuthIntercept: event_schemas.AuthInterceptCredentials,
ChainEventType.CommandRegister: event_schemas.CommandRegisterEventData,
ChainEventType.TransferRename: event_schemas.TransferRenameEventData,
ChainEventType.TransferRenameBuild: event_schemas.TransferRenameBuildEventData,
ChainEventType.TransferIntercept: event_schemas.TransferInterceptEventData,
ChainEventType.TransferOverwriteCheck: event_schemas.TransferOverwriteCheckEventData,
ChainEventType.ResourceSelection: event_schemas.ResourceSelectionContractData,
ChainEventType.ResourceDownload: event_schemas.ResourceDownloadContractData,
ChainEventType.DiscoverSource: event_schemas.DiscoverSourceEventData,
ChainEventType.MediaRecognizeConvert: event_schemas.MediaRecognizeConvertEventData,
ChainEventType.RecommendSource: event_schemas.RecommendSourceEventData,
ChainEventType.StorageOperSelection: event_schemas.StorageOperSelectionEventData,
ChainEventType.AgentLLMProvider: event_schemas.AgentLLMProviderEventData,
ChainEventType.SubscribeEpisodesRefresh: event_schemas.SubscribeEpisodesRefreshEventData,
ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckContractData,
ChainEventType.NameRecognize: event_schemas.NameRecognizeEventData,
ChainEventType.MusicNameRecognize: event_schemas.MusicNameRecognizeEventData,
ChainEventType.MediaRecognize: event_schemas.MediaRecognizeEventData,
ChainEventType.MusicMediaRecognize: event_schemas.MusicMediaRecognizeEventData,
ChainEventType.WorkflowExecution: ActionContext,
}
_SNAPSHOT_EVENTS = {
EventType.DownloadAdded,
EventType.TransferComplete,
EventType.TransferFailed,
EventType.SubtitleTransferComplete,
EventType.SubtitleTransferFailed,
EventType.AudioTransferComplete,
EventType.AudioTransferFailed,
EventType.MetadataScrape,
ChainEventType.ResourceSelection,
ChainEventType.ResourceDownload,
ChainEventType.SubscribeCompletionCheck,
}
_INPUT_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = {
ChainEventType.ResourceSelection: event_schemas.ResourceSelectionInputContractData,
ChainEventType.ResourceDownload: event_schemas.ResourceDownloadInputContractData,
ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckInputContractData,
}
_OUTPUT_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = {
ChainEventType.ResourceSelection: event_schemas.ResourceSelectionOutputContractData,
ChainEventType.ResourceDownload: event_schemas.ResourceDownloadOutputContractData,
ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckOutputContractData,
}
_DURABLE_REQUIRED = {
EventType.SubscribeAdded,
EventType.SubscribeModified,
EventType.SubscribeDeleted,
EventType.SubscribeComplete,
EventType.DownloadAdded,
EventType.TransferComplete,
EventType.TransferFailed,
EventType.SubtitleTransferComplete,
EventType.SubtitleTransferFailed,
EventType.AudioTransferComplete,
EventType.AudioTransferFailed,
}
_TARGET_PLUGIN = {EventType.PluginAction, EventType.PluginTriggered}
_HOST_ONLY = {EventType.SystemError, EventType.ConfigChanged, EventType.ModuleReload}
_SENSITIVE_FIELDS = {
ChainEventType.AuthVerification: ("password", "token", "mfa_code"),
ChainEventType.AuthIntercept: ("token",),
ChainEventType.AgentLLMProvider: ("api_key",),
}
def _build_contract(event_type: EventType | ChainEventType) -> EventContract:
"""按 enum 类别和首批 model 映射构造完整契约。"""
payload_model = _PAYLOAD_MODELS.get(event_type)
is_chain = isinstance(event_type, ChainEventType)
visibility = EventVisibility.PLUGIN_PUBLIC
if event_type in _TARGET_PLUGIN:
visibility = EventVisibility.TARGET_PLUGIN
elif event_type in _HOST_ONLY:
visibility = EventVisibility.HOST_ONLY
return EventContract(
event_name=f"{event_type.__class__.__name__}.{event_type.name}",
payload_model=payload_model,
payload_contract=payload_model.__name__ if payload_model else "legacy_dict",
mode="chain" if is_chain else "broadcast",
visibility=visibility,
delivery=(
EventDelivery.DURABLE_REQUIRED
if event_type in _DURABLE_REQUIRED
else EventDelivery.EPHEMERAL
),
error_behavior=(
EventErrorBehavior.STOP_CHAIN if is_chain else EventErrorBehavior.NOTIFY
),
ordering="priority_serial" if is_chain else "priority_queue",
input_model=_INPUT_MODELS.get(event_type, payload_model),
output_model=_OUTPUT_MODELS.get(event_type),
payload_mode=(
EventPayloadMode.SNAPSHOT
if event_type in _SNAPSHOT_EVENTS
else EventPayloadMode.RUNTIME
),
validation_mode=EventValidationMode.DIAGNOSTIC,
sensitive_fields=_SENSITIVE_FIELDS.get(event_type, ()),
legacy_reason=(
None
if payload_model
else "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI"
),
)
_ALL_EVENT_TYPES: tuple[EventType | ChainEventType, ...] = (
*tuple(EventType),
*tuple(ChainEventType),
)
EVENT_CONTRACTS: dict[EventType | ChainEventType, EventContract] = {
event_type: _build_contract(event_type)
for event_type in _ALL_EVENT_TYPES
}
def normalize_event_type(
event_type: EventType | ChainEventType | str,
) -> EventType | ChainEventType | str:
"""把旧 SDK 传入的已知字符串恢复为 enum,未知扩展值保持原样。"""
if not isinstance(event_type, str):
return event_type
for enum_type in (EventType, ChainEventType):
try:
return enum_type(event_type)
except ValueError:
continue
return event_type
def get_event_contract(
event_type: EventType | ChainEventType | str,
) -> EventContract:
"""返回已登记事件的完整契约,兼容传入 enum value 字符串。"""
normalized = normalize_event_type(event_type)
if isinstance(normalized, str):
raise KeyError(normalized)
return EVENT_CONTRACTS[normalized]
def validate_event_payload(
event_type: EventType | ChainEventType | str,
payload: Any,
) -> tuple[str, ...]:
"""在发送边界诊断首批 typed payload,保持原对象和插件 dict 形状不变。"""
try:
contract = get_event_contract(event_type)
models = tuple(
model
for model in (contract.input_model, contract.output_model)
if model is not None
)
except KeyError:
# 动态插件在旧 ABI 下可能使用宿主枚举之外的字符串事件。
return ()
if not models or payload is None:
return ()
problems: list[str] = []
for model in models:
if isinstance(payload, model):
continue
try:
model.model_validate(payload)
except (ValidationError, TypeError, ValueError) as error:
problems.append(f"{model.__name__}: {error}")
return tuple(problems)