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

277 lines
11 KiB
Python

"""事件 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.DownloadAdded,
EventType.TransferComplete,
EventType.TransferFailed,
}
_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)