refactor(runtime): lazily activate host modules (#6331)

This commit is contained in:
InfinityPacer
2026-08-16 16:07:38 +08:00
committed by GitHub
parent 98276a68a8
commit 24671f8f18
67 changed files with 7364 additions and 253 deletions
+12 -3
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import json
import time
from typing import Union, Any, List, Optional
from typing import Protocol, Union, Any, List, Optional
from fastapi import BackgroundTasks, Depends, Request
from pywebpush import WebPushException, webpush
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse
@@ -27,7 +28,13 @@ router = ResponseAPIRouter()
_WNS_DEFAULT_TTL = 86400
def is_webpush_subscription_gone(error: WebPushException) -> bool:
class WebPushError(Protocol):
"""Web Push 订阅状态判断所需的最小异常协议。"""
response: Any # 推送服务响应,状态码字段由具体 SDK 提供
def is_webpush_subscription_gone(error: WebPushError) -> bool:
"""判断 Web Push 订阅是否已在浏览器或推送服务侧失效。"""
response: Any = getattr(error, "response", None)
status_code = getattr(response, "status_code", None) or getattr(
@@ -359,6 +366,8 @@ def send_notification(
"""
发送webpush通知
"""
from pywebpush import WebPushException, webpush
for sub in global_vars.get_subscriptions():
try:
webpush(
+3 -2
View File
@@ -1405,8 +1405,9 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
查询已加载的模块ID列表
"""
modules = []
for module_id, module in ModuleManager().get_modules().items():
name = module.get_name()
for spec in ModuleManager().list_specs():
module_id = spec.id
name = str(spec.metadata["name"])
modules.append(
{
"id": module_id,
+3 -1
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import copy
import inspect
import pickle
import traceback
from abc import ABCMeta
from collections.abc import Callable
from collections.abc import Callable, Sequence
from datetime import datetime
from pathlib import Path
from typing import Optional, Any, Tuple, List, Set, Union, Dict
+10 -6
View File
@@ -9,6 +9,7 @@ class Singleton(abc.ABCMeta, type):
"""
_instances: dict = {}
_lock = threading.RLock()
def get_existing_instance(cls, *args, **kwargs):
"""按相同参数返回已创建实例,不触发初始化"""
@@ -18,9 +19,10 @@ class Singleton(abc.ABCMeta, type):
def __call__(cls, *args, **kwargs):
"""按类和构造参数创建或复用实例。"""
key = (cls, args, frozenset(kwargs.items()))
if key not in cls._instances:
cls._instances[key] = super().__call__(*args, **kwargs)
return cls._instances[key]
with cls._lock:
if key not in cls._instances:
cls._instances[key] = super().__call__(*args, **kwargs)
return cls._instances[key]
class AbstractSingleton(abc.ABC, metaclass=Singleton):
@@ -36,6 +38,7 @@ class SingletonClass(abc.ABCMeta, type):
"""
_instances: dict = {}
_lock = threading.RLock()
def get_existing_instance(cls):
"""返回已创建实例,不触发初始化"""
@@ -43,9 +46,10 @@ class SingletonClass(abc.ABCMeta, type):
def __call__(cls, *args, **kwargs):
"""按类创建或复用唯一实例。"""
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
with cls._lock:
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class AbstractSingletonClass(abc.ABC, metaclass=SingletonClass):
+4 -1
View File
@@ -3,7 +3,7 @@ from abc import abstractmethod, ABCMeta
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
from pathlib import Path
from app.runtime.extensions.service_registry import ServiceConfigHelper
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.runtime.log import logger
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
@@ -17,6 +17,9 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
输入参数与输出参数一致的,或没有输出的,可以被多个模块重复实现
"""
# Host Module 的配置事件由统一 Adapter 协调,避免同一 generation 被双重重载。
CONFIG_RELOAD_MANAGED_EXTERNALLY = True
def __init__(self) -> None:
"""初始化模块生命周期锁"""
super().__init__()
+19
View File
@@ -0,0 +1,19 @@
schema_version = 1
id = "AcoustIdModule"
kind = "host_module"
entrypoint = "app.modules.acoustid:AcoustIdModule"
depends_on = []
[metadata]
name = "AcoustID"
type = "other"
subtype = "AcoustId"
priority = 0
[activation]
policy = "when_configured"
watch = ["ACOUSTID_API_KEY"]
[activation.selector]
kind = "setting_truthy"
key = "ACOUSTID_API_KEY"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "AniListModule"
kind = "host_module"
entrypoint = "app.modules.anilist:AniListModule"
depends_on = []
[metadata]
name = "AniList"
type = "mediarecognize"
subtype = "AniList"
priority = 4
[activation]
policy = "bootstrap"
watch = ["PROXY_HOST"]
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "BangumiModule"
kind = "host_module"
entrypoint = "app.modules.bangumi:BangumiModule"
depends_on = []
[metadata]
name = "Bangumi"
type = "mediarecognize"
subtype = "Bangumi"
priority = 3
[activation]
policy = "bootstrap"
watch = ["PROXY_HOST"]
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "DiscordModule"
kind = "host_module"
entrypoint = "app.modules.discord:DiscordModule"
depends_on = []
[metadata]
name = "Discord"
type = "notification"
subtype = "Discord"
priority = 4
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "discord"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "DoubanModule"
kind = "host_module"
entrypoint = "app.modules.douban:DoubanModule"
depends_on = []
[metadata]
name = "豆瓣"
type = "mediarecognize"
subtype = "Douban"
priority = 2
[activation]
policy = "bootstrap"
watch = []
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "EmbyModule"
kind = "host_module"
entrypoint = "app.modules.emby:EmbyModule"
depends_on = []
[metadata]
name = "Emby"
type = "mediaserver"
subtype = "Emby"
priority = 1
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "emby"
enabled_field = "enabled"
+19
View File
@@ -0,0 +1,19 @@
schema_version = 1
id = "FanartModule"
kind = "host_module"
entrypoint = "app.modules.fanart:FanartModule"
depends_on = []
[metadata]
name = "Fanart"
type = "other"
subtype = "Fanart"
priority = 0
[activation]
policy = "when_configured"
watch = ["FANART_API_KEY"]
[activation.selector]
kind = "setting_truthy"
key = "FANART_API_KEY"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "FeishuModule"
kind = "host_module"
entrypoint = "app.modules.feishu:FeishuModule"
depends_on = []
[metadata]
name = "飞书"
type = "notification"
subtype = "Feishu"
priority = 2
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "feishu"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "FileManagerModule"
kind = "host_module"
entrypoint = "app.modules.filemanager:FileManagerModule"
depends_on = []
[metadata]
name = "文件整理"
type = "other"
subtype = "FileManager"
priority = 4
[activation]
policy = "bootstrap"
watch = []
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "FilterModule"
kind = "host_module"
entrypoint = "app.modules.filter:FilterModule"
depends_on = []
[metadata]
name = "过滤器"
type = "other"
subtype = "Filter"
priority = 4
[activation]
policy = "bootstrap"
watch = ["CustomFilterRules", "CustomIdentifiers", "CustomReleaseGroups", "Customization"]
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "IndexerModule"
kind = "host_module"
entrypoint = "app.modules.indexer:IndexerModule"
depends_on = []
[metadata]
name = "站点索引"
type = "indexer"
subtype = "Indexer"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "JellyfinModule"
kind = "host_module"
entrypoint = "app.modules.jellyfin:JellyfinModule"
depends_on = []
[metadata]
name = "Jellyfin"
type = "mediaserver"
subtype = "Jellyfin"
priority = 2
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "jellyfin"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "ListenBrainzModule"
kind = "host_module"
entrypoint = "app.modules.listenbrainz:ListenBrainzModule"
depends_on = []
[metadata]
name = "ListenBrainz"
type = "other"
subtype = "ListenBrainz"
priority = 5
[activation]
policy = "bootstrap"
watch = []
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "LrclibModule"
kind = "host_module"
entrypoint = "app.modules.lrclib:LrclibModule"
depends_on = []
[metadata]
name = "LRCLIB"
type = "other"
subtype = "Lrclib"
priority = 5
[activation]
policy = "bootstrap"
watch = []
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "MusicBrainzModule"
kind = "host_module"
entrypoint = "app.modules.musicbrainz:MusicBrainzModule"
depends_on = []
[metadata]
name = "MusicBrainz"
type = "mediarecognize"
subtype = "MusicBrainz"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "NavidromeModule"
kind = "host_module"
entrypoint = "app.modules.navidrome:NavidromeModule"
depends_on = []
[metadata]
name = "Navidrome"
type = "mediaserver"
subtype = "Navidrome"
priority = 7
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "navidrome"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "PlexModule"
kind = "host_module"
entrypoint = "app.modules.plex:PlexModule"
depends_on = []
[metadata]
name = "Plex"
type = "mediaserver"
subtype = "Plex"
priority = 3
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "plex"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "PostgreSQLModule"
kind = "host_module"
entrypoint = "app.modules.postgresql:PostgreSQLModule"
depends_on = []
[metadata]
name = "PostgreSQL"
type = "other"
subtype = "PostgreSQL"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "QbittorrentModule"
kind = "host_module"
entrypoint = "app.modules.qbittorrent:QbittorrentModule"
depends_on = []
[metadata]
name = "Qbittorrent"
type = "downloader"
subtype = "Qbittorrent"
priority = 1
[activation]
policy = "when_configured"
watch = ["Downloaders"]
[activation.selector]
kind = "system_config_item"
key = "Downloaders"
match_field = "type"
match_value = "qbittorrent"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "QQBotModule"
kind = "host_module"
entrypoint = "app.modules.qqbot:QQBotModule"
depends_on = []
[metadata]
name = "QQ"
type = "notification"
subtype = "QQ"
priority = 10
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "qqbot"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "RedisModule"
kind = "host_module"
entrypoint = "app.modules.redis:RedisModule"
depends_on = []
[metadata]
name = "Redis缓存"
type = "other"
subtype = "Redis"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "RtorrentModule"
kind = "host_module"
entrypoint = "app.modules.rtorrent:RtorrentModule"
depends_on = []
[metadata]
name = "Rtorrent"
type = "downloader"
subtype = "Rtorrent"
priority = 3
[activation]
policy = "when_configured"
watch = ["Downloaders"]
[activation.selector]
kind = "system_config_item"
key = "Downloaders"
match_field = "type"
match_value = "rtorrent"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "SlackModule"
kind = "host_module"
entrypoint = "app.modules.slack:SlackModule"
depends_on = []
[metadata]
name = "Slack"
type = "notification"
subtype = "Slack"
priority = 3
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "slack"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "SubtitleModule"
kind = "host_module"
entrypoint = "app.modules.subtitle:SubtitleModule"
depends_on = []
[metadata]
name = "站点字幕"
type = "other"
subtype = "Subtitle"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "SynologyChatModule"
kind = "host_module"
entrypoint = "app.modules.synologychat:SynologyChatModule"
depends_on = []
[metadata]
name = "Synology Chat"
type = "notification"
subtype = "SynologyChat"
priority = 5
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "synologychat"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "TelegramModule"
kind = "host_module"
entrypoint = "app.modules.telegram:TelegramModule"
depends_on = []
[metadata]
name = "Telegram"
type = "notification"
subtype = "Telegram"
priority = 0
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "telegram"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "TheAudioDbModule"
kind = "host_module"
entrypoint = "app.modules.theaudiodb:TheAudioDbModule"
depends_on = []
[metadata]
name = "TheAudioDB"
type = "mediarecognize"
subtype = "TheAudioDB"
priority = 1
[activation]
policy = "bootstrap"
watch = []
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "TheMovieDbModule"
kind = "host_module"
entrypoint = "app.modules.themoviedb:TheMovieDbModule"
depends_on = []
[metadata]
name = "TheMovieDb"
type = "mediarecognize"
subtype = "TMDB"
priority = 1
[activation]
policy = "bootstrap"
watch = ["PROXY_HOST", "TMDB_API_DOMAIN", "TMDB_API_KEY", "TMDB_LOCALE"]
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "TheTvDbModule"
kind = "host_module"
entrypoint = "app.modules.thetvdb:TheTvDbModule"
depends_on = []
[metadata]
name = "TheTvDb"
type = "mediarecognize"
subtype = "TVDB"
priority = 4
[activation]
policy = "bootstrap"
watch = []
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "TransmissionModule"
kind = "host_module"
entrypoint = "app.modules.transmission:TransmissionModule"
depends_on = []
[metadata]
name = "Transmission"
type = "downloader"
subtype = "Transmission"
priority = 2
[activation]
policy = "when_configured"
watch = ["Downloaders"]
[activation.selector]
kind = "system_config_item"
key = "Downloaders"
match_field = "type"
match_value = "transmission"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "TrimeMediaModule"
kind = "host_module"
entrypoint = "app.modules.trimemedia:TrimeMediaModule"
depends_on = []
[metadata]
name = "飞牛影视"
type = "mediaserver"
subtype = "TrimeMedia"
priority = 4
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "trimemedia"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "UgreenModule"
kind = "host_module"
entrypoint = "app.modules.ugreen:UgreenModule"
depends_on = []
[metadata]
name = "绿联影视"
type = "mediaserver"
subtype = "Ugreen"
priority = 5
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "ugreen"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "VoceChatModule"
kind = "host_module"
entrypoint = "app.modules.vocechat:VoceChatModule"
depends_on = []
[metadata]
name = "VoceChat"
type = "notification"
subtype = "VoceChat"
priority = 4
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "vocechat"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "WebPushModule"
kind = "host_module"
entrypoint = "app.modules.webpush:WebPushModule"
depends_on = []
[metadata]
name = "WebPush"
type = "notification"
subtype = "WebPush"
priority = 6
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "webpush"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "WechatModule"
kind = "host_module"
entrypoint = "app.modules.wechat:WechatModule"
depends_on = []
[metadata]
name = "企业微信"
type = "notification"
subtype = "Wechat"
priority = 1
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "wechat"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "WechatClawBotModule"
kind = "host_module"
entrypoint = "app.modules.wechatclawbot:WechatClawBotModule"
depends_on = []
[metadata]
name = "微信 ClawBot"
type = "notification"
subtype = "WechatClawBot"
priority = 2
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "wechatclawbot"
enabled_field = "enabled"
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "ZSpaceModule"
kind = "host_module"
entrypoint = "app.modules.zspace:ZSpaceModule"
depends_on = []
[metadata]
name = "极影视"
type = "mediaserver"
subtype = "ZSpace"
priority = 6
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "zspace"
enabled_field = "enabled"
+47
View File
@@ -0,0 +1,47 @@
"""MoviePilot 内部能力运行时的惰性公共导出。"""
from __future__ import annotations
from importlib import import_module
from typing import Any
_EXPORT_MODULES = {
"ActivationPolicy": "app.runtime.capabilities.model",
"AdapterExecutionMode": "app.runtime.capabilities.model",
"AsyncCapabilityAdapter": "app.runtime.capabilities.model",
"CapabilityAdapterContractError": "app.runtime.capabilities.errors",
"CapabilityAdapterModeError": "app.runtime.capabilities.errors",
"CapabilityError": "app.runtime.capabilities.errors",
"CapabilityLifecycleState": "app.runtime.capabilities.model",
"CapabilityManifestError": "app.runtime.capabilities.errors",
"CapabilityMaterializationState": "app.runtime.capabilities.model",
"CapabilityObservation": "app.runtime.capabilities.model",
"CapabilityOperationError": "app.runtime.capabilities.errors",
"CapabilityRegistry": "app.runtime.capabilities.registry",
"CapabilityRuntime": "app.runtime.capabilities.runtime",
"CapabilityRuntimeClosedError": "app.runtime.capabilities.errors",
"CapabilitySnapshot": "app.runtime.capabilities.model",
"CapabilitySpec": "app.runtime.capabilities.model",
"SelectorSchema": "app.runtime.capabilities.model",
"SelectorSpec": "app.runtime.capabilities.model",
"SyncCapabilityAdapter": "app.runtime.capabilities.model",
"UnknownCapabilityError": "app.runtime.capabilities.errors",
}
__all__ = sorted(_EXPORT_MODULES)
def __getattr__(name: str) -> Any:
"""仅在显式访问公共符号时导入对应叶模块,并缓存 canonical 对象。"""
module_name = _EXPORT_MODULES.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(import_module(module_name), name)
globals()[name] = value
return value
def __dir__() -> list[str]:
"""向交互式工具公开稳定导出名,而不触发叶模块导入。"""
return sorted(set(globals()) | set(__all__))
+32
View File
@@ -0,0 +1,32 @@
class CapabilityError(RuntimeError):
"""Capability Runtime 错误基类。"""
class CapabilityManifestError(CapabilityError, ValueError):
"""能力声明不完整、冲突或违反当前 schema。"""
class UnknownCapabilityError(CapabilityError, KeyError):
"""请求了 Registry 中不存在的能力。"""
class CapabilityAdapterModeError(CapabilityError, TypeError):
"""调用入口与适配器并发模型不匹配。"""
class CapabilityAdapterContractError(CapabilityError, TypeError):
"""适配器回调返回值不符合已声明的并发模型。"""
class CapabilityOperationError(CapabilityError):
"""能力物化或资源生命周期转换失败。"""
def __init__(self, capability_id: str, operation: str, error: BaseException):
self.capability_id = capability_id
self.operation = operation
self.error = error
super().__init__(f"能力 {capability_id} 执行 {operation} 失败:{error}")
class CapabilityRuntimeClosedError(CapabilityError):
"""Runtime 已进入关闭态,禁止启动或重新物化能力。"""
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from types import MappingProxyType
from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol
class ActivationPolicy(str, Enum):
"""能力的启动触发策略。"""
BOOTSTRAP = "bootstrap"
WHEN_CONFIGURED = "when_configured"
ON_FIRST_USE = "on_first_use"
class AdapterExecutionMode(str, Enum):
"""领域适配器执行回调所使用的并发模型。"""
SYNC = "sync"
ASYNC = "async"
class CapabilityMaterializationState(str, Enum):
"""Python 实现对象的解析状态。"""
UNRESOLVED = "unresolved"
RESOLVING = "resolving"
RESOLVED = "resolved"
FAILED = "failed"
class CapabilityLifecycleState(str, Enum):
"""能力所拥有外部资源的生命周期状态。"""
DISCOVERED = "discovered"
STARTING = "starting"
RUNNING = "running"
RELOADING = "reloading"
STOPPING = "stopping"
STOPPED = "stopped"
FAILED = "failed"
@dataclass(frozen=True, slots=True)
class SelectorSchema:
"""声明一个 selector 可接受的精确参数集合。"""
required_fields: frozenset[str] = frozenset()
optional_fields: frozenset[str] = frozenset()
validator: Optional[Callable[[Mapping[str, Any]], None]] = None
@dataclass(frozen=True, slots=True)
class SelectorSpec:
"""由领域适配器解释的配置选择器。"""
kind: str
config: Mapping[str, Any]
@dataclass(frozen=True, slots=True)
class CapabilitySpec:
"""从 data-only manifest 构建的不可变能力声明。"""
schema_version: int
id: str
kind: str
entrypoint: str
activation: ActivationPolicy
metadata: Mapping[str, Any]
selector: Optional[SelectorSpec]
watch: tuple[str, ...]
depends_on: tuple[str, ...]
source: Path
@dataclass(frozen=True, slots=True)
class CapabilitySnapshot:
"""能力状态的只读快照。"""
capability_id: str
materialization: CapabilityMaterializationState
lifecycle: CapabilityLifecycleState
generation: int
visible: bool
error: Optional[str]
@dataclass(frozen=True, slots=True)
class CapabilityObservation:
"""单次状态转换的可复现观测记录。"""
capability_id: str
generation: int
operation: str
outcome: str
reason: str
materialization: CapabilityMaterializationState
lifecycle: CapabilityLifecycleState
duration_ms: float
error: Optional[str]
class SyncCapabilityAdapter(Protocol):
"""同步领域适配器合同。"""
execution_mode: AdapterExecutionMode
def materialize(self, spec: CapabilitySpec) -> Any:
"""解析 manifest entrypoint 对应的 canonical 实现对象。"""
def create(
self,
spec: CapabilitySpec,
implementation: Any,
generation: int,
previous: Any = None,
) -> Any:
"""创建尚未对外发布的候选资源。"""
def start(self, spec: CapabilitySpec, candidate: Any, generation: int) -> None:
"""启动候选资源;返回前候选不会对普通查询可见。"""
def stop(self, spec: CapabilitySpec, instance: Any, generation: int) -> None:
"""停止已经撤销运行态可见性的资源。"""
def cleanup(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> None:
"""清理由失败启动留下的候选资源。"""
class AsyncCapabilityAdapter(Protocol):
"""异步领域适配器合同。"""
execution_mode: AdapterExecutionMode
def materialize(self, spec: CapabilitySpec) -> Awaitable[Any]:
"""异步解析 manifest entrypoint 对应的 canonical 实现对象。"""
def create(
self,
spec: CapabilitySpec,
implementation: Any,
generation: int,
previous: Any = None,
) -> Awaitable[Any]:
"""异步创建尚未对外发布的候选资源。"""
def start(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
) -> Awaitable[None]:
"""异步启动候选资源。"""
def stop(
self,
spec: CapabilitySpec,
instance: Any,
generation: int,
) -> Awaitable[None]:
"""异步停止已经撤销运行态可见性的资源。"""
def cleanup(
self,
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> Awaitable[None]:
"""异步清理由失败启动留下的候选资源。"""
EMPTY_MAPPING: Mapping[str, Any] = MappingProxyType({})
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import re
import tomllib
from pathlib import Path
from types import MappingProxyType
from typing import Any, Collection, Iterable, Mapping
from app.runtime.capabilities.errors import (
CapabilityManifestError,
UnknownCapabilityError,
)
from app.runtime.capabilities.model import (
ActivationPolicy,
CapabilitySpec,
SelectorSchema,
SelectorSpec,
)
_SCHEMA_VERSION = 1
_MANIFEST_NAME = "capability.toml"
_TOP_LEVEL_FIELDS = frozenset({
"schema_version",
"id",
"kind",
"entrypoint",
"metadata",
"activation",
"depends_on",
})
_REQUIRED_FIELDS = _TOP_LEVEL_FIELDS
_ACTIVATION_FIELDS = frozenset({"policy", "watch", "selector"})
_ACTIVATION_REQUIRED_FIELDS = frozenset({"policy", "watch"})
_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,127}$")
_KIND_PATTERN = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
_ENTRYPOINT_PATTERN = re.compile(
r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
r":[A-Za-z_][A-Za-z0-9_]*$"
)
def _freeze(value: Any, *, field: str) -> Any:
"""把 TOML 容器递归转换为不可变结构,并拒绝非配置标量。"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, list):
return tuple(_freeze(item, field=field) for item in value)
if isinstance(value, dict):
if not all(isinstance(key, str) and key for key in value):
raise CapabilityManifestError(f"{field} 包含非法键")
return MappingProxyType({
key: _freeze(item, field=f"{field}.{key}")
for key, item in value.items()
})
raise CapabilityManifestError(f"{field} 包含不支持的 TOML 值类型 {type(value).__name__}")
def _string_list(value: Any, *, field: str, path: Path) -> tuple[str, ...]:
"""校验无重复的非空字符串列表。"""
if not isinstance(value, list):
raise CapabilityManifestError(f"{path}: {field} 必须是字符串数组")
if any(not isinstance(item, str) or not item.strip() for item in value):
raise CapabilityManifestError(f"{path}: {field} 只能包含非空字符串")
normalized = tuple(item.strip() for item in value)
if len(set(normalized)) != len(normalized):
raise CapabilityManifestError(f"{path}: {field} 不能包含重复值")
return normalized
class CapabilityRegistry:
"""只读取 data-only manifest 的不可变能力注册表。"""
def __init__(
self,
specs: Mapping[str, CapabilitySpec],
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> None:
self._specs = MappingProxyType(dict(specs))
self._kinds = frozenset(kinds)
self._selector_schemas = MappingProxyType(dict(selector_schemas))
@classmethod
def discover(
cls,
roots: Iterable[Path | str],
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> "CapabilityRegistry":
"""扫描全部声明根;任何根或 manifest 非法都会阻止 Registry 构建。"""
normalized_kinds = frozenset(kinds)
if not normalized_kinds:
raise CapabilityManifestError("至少需要注册一个 capability kind")
for kind in normalized_kinds:
if not isinstance(kind, str) or not _KIND_PATTERN.fullmatch(kind):
raise CapabilityManifestError(f"非法 capability kind{kind!r}")
normalized_selectors = dict(selector_schemas)
for selector_type, schema in normalized_selectors.items():
if not isinstance(selector_type, str) or not _KIND_PATTERN.fullmatch(selector_type):
raise CapabilityManifestError(f"非法 selector type{selector_type!r}")
if not isinstance(schema, SelectorSchema):
raise CapabilityManifestError(f"selector {selector_type} 未提供 SelectorSchema")
overlap = schema.required_fields & schema.optional_fields
if overlap:
raise CapabilityManifestError(
f"selector {selector_type} 字段同时声明为 required/optional{sorted(overlap)}"
)
specs: dict[str, CapabilitySpec] = {}
normalized_roots = tuple(Path(root) for root in roots)
if not normalized_roots:
raise CapabilityManifestError("至少需要一个 capability 声明根")
for root in normalized_roots:
if not root.is_dir():
raise CapabilityManifestError(f"声明根不存在或不是目录:{root}")
manifests = sorted(root.rglob(_MANIFEST_NAME))
if not manifests:
raise CapabilityManifestError(f"声明根没有 {_MANIFEST_NAME}{root}")
for manifest_path in manifests:
spec = cls._load_manifest(
manifest_path,
kinds=normalized_kinds,
selector_schemas=normalized_selectors,
)
previous = specs.get(spec.id)
if previous:
raise CapabilityManifestError(
f"capability id 重复:{spec.id},来源 {previous.source}{spec.source}"
)
specs[spec.id] = spec
return cls(
specs,
kinds=normalized_kinds,
selector_schemas=normalized_selectors,
)
@classmethod
def _load_manifest(
cls,
path: Path,
*,
kinds: Collection[str],
selector_schemas: Mapping[str, SelectorSchema],
) -> CapabilitySpec:
try:
with path.open("rb") as file:
data = tomllib.load(file)
except (OSError, tomllib.TOMLDecodeError) as error:
raise CapabilityManifestError(f"无法读取 {path}{error}") from error
unknown_fields = set(data) - _TOP_LEVEL_FIELDS
if unknown_fields:
raise CapabilityManifestError(f"{path}: 未知字段 {sorted(unknown_fields)}")
missing_fields = _REQUIRED_FIELDS - set(data)
if missing_fields:
raise CapabilityManifestError(f"{path}: 缺少字段 {sorted(missing_fields)}")
schema_version = data["schema_version"]
if type(schema_version) is not int or schema_version != _SCHEMA_VERSION:
raise CapabilityManifestError(
f"{path}: 不支持 schema_version={schema_version!r}"
)
capability_id = data["id"]
if not isinstance(capability_id, str) or not _IDENTIFIER_PATTERN.fullmatch(capability_id):
raise CapabilityManifestError(f"{path}: 非法 capability id={capability_id!r}")
kind = data["kind"]
if not isinstance(kind, str) or kind not in kinds:
raise CapabilityManifestError(f"{path}: 未注册 capability kind={kind!r}")
entrypoint = data["entrypoint"]
if not isinstance(entrypoint, str) or not _ENTRYPOINT_PATTERN.fullmatch(entrypoint):
raise CapabilityManifestError(f"{path}: 非法 entrypoint={entrypoint!r}")
metadata = data["metadata"]
if not isinstance(metadata, dict):
raise CapabilityManifestError(f"{path}: metadata 必须是 table")
name = metadata.get("name")
if not isinstance(name, str) or not name.strip():
raise CapabilityManifestError(f"{path}: metadata.name 必须是非空字符串")
immutable_metadata = _freeze(metadata, field="metadata")
activation_data = data["activation"]
if not isinstance(activation_data, dict):
raise CapabilityManifestError(f"{path}: activation 必须是 table")
unknown_activation_fields = set(activation_data) - _ACTIVATION_FIELDS
missing_activation_fields = _ACTIVATION_REQUIRED_FIELDS - set(activation_data)
if unknown_activation_fields or missing_activation_fields:
raise CapabilityManifestError(
f"{path}: activation 字段非法,missing={sorted(missing_activation_fields)} "
f"unknown={sorted(unknown_activation_fields)}"
)
try:
activation = ActivationPolicy(activation_data["policy"])
except (TypeError, ValueError) as error:
raise CapabilityManifestError(
f"{path}: 非法 activation.policy={activation_data['policy']!r}"
) from error
selector = cls._parse_selector(
path,
activation=activation,
data=activation_data.get("selector"),
selector_schemas=selector_schemas,
)
watch = _string_list(activation_data["watch"], field="activation.watch", path=path)
depends_on = _string_list(data["depends_on"], field="depends_on", path=path)
if depends_on:
raise CapabilityManifestError(
f"{path}: 当前 schema 不支持非空 depends_on={list(depends_on)!r}"
)
return CapabilitySpec(
schema_version=schema_version,
id=capability_id,
kind=kind,
entrypoint=entrypoint,
activation=activation,
metadata=immutable_metadata,
selector=selector,
watch=watch,
depends_on=depends_on,
source=path,
)
@staticmethod
def _parse_selector(
path: Path,
*,
activation: ActivationPolicy,
data: Any,
selector_schemas: Mapping[str, SelectorSchema],
) -> SelectorSpec | None:
if activation is not ActivationPolicy.WHEN_CONFIGURED:
if data is not None:
raise CapabilityManifestError(
f"{path}: activation={activation.value} 时不允许 selector"
)
return None
if not isinstance(data, dict):
raise CapabilityManifestError(f"{path}: when_configured 必须提供 selector table")
selector_kind = data.get("kind")
if not isinstance(selector_kind, str) or selector_kind not in selector_schemas:
raise CapabilityManifestError(f"{path}: 未注册 selector kind={selector_kind!r}")
config = {key: value for key, value in data.items() if key != "kind"}
schema = selector_schemas[selector_kind]
missing = schema.required_fields - set(config)
unknown = set(config) - schema.required_fields - schema.optional_fields
if missing or unknown:
raise CapabilityManifestError(
f"{path}: selector {selector_kind} 字段非法,"
f"missing={sorted(missing)} unknown={sorted(unknown)}"
)
immutable_config = _freeze(config, field="selector")
if schema.validator:
try:
schema.validator(immutable_config)
except Exception as error:
raise CapabilityManifestError(
f"{path}: selector {selector_kind} 校验失败:{error}"
) from error
return SelectorSpec(kind=selector_kind, config=immutable_config)
@property
def kinds(self) -> frozenset[str]:
"""返回该 Registry 接受的 capability kind。"""
return self._kinds
def get_spec(self, capability_id: str) -> CapabilitySpec | None:
"""查询声明;不存在时返回 None。"""
return self._specs.get(capability_id)
def require_spec(self, capability_id: str) -> CapabilitySpec:
"""查询必需声明;不存在时给出稳定的领域错误。"""
spec = self.get_spec(capability_id)
if spec is None:
raise UnknownCapabilityError(f"未知 capability{capability_id}")
return spec
def list_specs(self) -> tuple[CapabilitySpec, ...]:
"""按 ID 返回稳定排序的声明快照。"""
return tuple(self._specs[key] for key in sorted(self._specs))
File diff suppressed because it is too large Load Diff
+28 -10
View File
@@ -415,21 +415,28 @@ class EventManager(metaclass=Singleton):
同步方式调度链式事件按优先级顺序逐个调用事件处理器并记录每个处理器的处理时间
:param event: 要调度的事件对象
"""
handlers = self.__chain_subscribers.get(event.event_type, {})
# 运行期可以动态注册或移除处理器;当前事件始终使用调度开始时的快照。
with self.__lock:
handlers = tuple(
self.__chain_subscribers.get(event.event_type, {}).items()
)
if not handlers:
logger.debug(f"No handlers found for chain event: {event}")
return False
# 过滤出启用的处理器
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
if self.__is_handler_enabled(handler)}
enabled_handlers = tuple(
(handler_id, priority, handler)
for handler_id, (priority, handler) in handlers
if self.__is_handler_enabled(handler)
)
if not enabled_handlers:
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
return False
self.__log_event_lifecycle(event, "Started")
for handler_id, (priority, handler) in enabled_handlers.items():
for handler_id, priority, handler in enabled_handlers:
start_time = time.time()
self.__safe_invoke_handler(handler, event)
logger.debug(
@@ -444,21 +451,28 @@ class EventManager(metaclass=Singleton):
异步方式调度链式事件按优先级顺序逐个调用事件处理器并记录每个处理器的处理时间
:param event: 要调度的事件对象
"""
handlers = self.__chain_subscribers.get(event.event_type, {})
# 快照在锁内建立、在锁外执行,处理器可以安全地修改后续订阅。
with self.__lock:
handlers = tuple(
self.__chain_subscribers.get(event.event_type, {}).items()
)
if not handlers:
logger.debug(f"No handlers found for chain event: {event}")
return False
# 过滤出启用的处理器
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
if self.__is_handler_enabled(handler)}
enabled_handlers = tuple(
(handler_id, priority, handler)
for handler_id, (priority, handler) in handlers
if self.__is_handler_enabled(handler)
)
if not enabled_handlers:
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
return False
self.__log_event_lifecycle(event, "Started")
for handler_id, (priority, handler) in enabled_handlers.items():
for handler_id, priority, handler in enabled_handlers:
start_time = time.time()
await self.__safe_invoke_handler_async(handler, event)
logger.debug(
@@ -473,7 +487,11 @@ class EventManager(metaclass=Singleton):
异步方式调度广播事件通过线程池逐个调用事件处理器
:param event: 要调度的事件对象
"""
handlers = self.__broadcast_subscribers.get(event.event_type, {})
# 快照隔离当前调度与运行期订阅变更;变更从下一个事件开始生效。
with self.__lock:
handlers = tuple(
self.__broadcast_subscribers.get(event.event_type, {}).items()
)
if not handlers:
logger.debug(f"No handlers found for broadcast event: {event}")
return
@@ -481,7 +499,7 @@ class EventManager(metaclass=Singleton):
if event.event_type == EventType.MessageAction and isinstance(event.event_data, dict):
target_plugin_id = event.event_data.get("__mp_target_plugin_id")
# 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器
for handler_id, handler in handlers.items():
for handler_id, handler in handlers:
if target_plugin_id and not self.__should_dispatch_to_target_plugin(
handler, handler_id, str(target_plugin_id)
):
@@ -0,0 +1,260 @@
from __future__ import annotations
import importlib
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Any, Mapping
from app.runtime.capabilities.model import (
ActivationPolicy,
AdapterExecutionMode,
CapabilitySpec,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.config import settings
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.schemas.types import (
DownloaderType,
MediaRecognizeType,
MediaServerType,
MessageChannel,
ModuleType,
OtherModulesType,
StorageSchema,
SystemConfigKey,
)
HOST_MODULE_KIND = "host_module"
_SETTING_SELECTOR = "setting_truthy"
_SERVICE_SELECTOR = "system_config_item"
_MODULE_ROOT = Path(__file__).resolve().parents[2] / "modules"
_SERVICE_CONFIG_GETTERS = MappingProxyType({
SystemConfigKey.Downloaders.value: ServiceConfigHelper.get_downloader_configs,
SystemConfigKey.MediaServers.value: ServiceConfigHelper.get_mediaserver_configs,
SystemConfigKey.Notifications.value: ServiceConfigHelper.get_notification_configs,
})
_SUBTYPE_NAMES = frozenset(
item.name
for enum_type in (
DownloaderType,
MediaServerType,
MessageChannel,
StorageSchema,
OtherModulesType,
MediaRecognizeType,
)
for item in enum_type
)
@dataclass(frozen=True, slots=True)
class HostModuleConfigSnapshot:
"""一次 reconcile 使用的不可变配置视图,避免每个能力重复查询配置。"""
settings: Mapping[str, Any]
services: Mapping[str, tuple[Any, ...]]
def _validate_setting_selector(config: Mapping[str, Any]) -> None:
"""限制 setting selector 只能读取已声明的应用设置。"""
key = config["key"]
if not isinstance(key, str) or not key or not hasattr(settings, key):
raise ValueError(f"未知应用设置:{key!r}")
def _validate_service_selector(config: Mapping[str, Any]) -> None:
"""限制服务 selector 使用经过 Schema 校验的三个宿主服务配置。"""
key = config["key"]
if key not in _SERVICE_CONFIG_GETTERS:
raise ValueError(f"不支持的服务配置:{key!r}")
if config["match_field"] != "type":
raise ValueError("服务 selector 的 match_field 必须是 type")
if config["enabled_field"] != "enabled":
raise ValueError("服务 selector 的 enabled_field 必须是 enabled")
match_value = config["match_value"]
if not isinstance(match_value, str) or not match_value:
raise ValueError("服务 selector 的 match_value 必须是非空字符串")
HOST_MODULE_SELECTOR_SCHEMAS = MappingProxyType({
_SETTING_SELECTOR: SelectorSchema(
required_fields=frozenset({"key"}),
validator=_validate_setting_selector,
),
_SERVICE_SELECTOR: SelectorSchema(
required_fields=frozenset({
"key",
"match_field",
"match_value",
"enabled_field",
}),
validator=_validate_service_selector,
),
})
def _validate_manifest_inventory(registry: CapabilityRegistry) -> None:
"""校验一级模块包与 manifest 一一对应,并固定宿主声明合同。"""
module_packages = {
child.name
for child in _MODULE_ROOT.iterdir()
if child.is_dir()
and not child.name.startswith("_")
and (child / "__init__.py").is_file()
}
specs = registry.list_specs()
manifest_packages = {spec.source.parent.name for spec in specs}
if module_packages != manifest_packages:
missing = sorted(module_packages - manifest_packages)
unknown = sorted(manifest_packages - module_packages)
raise ValueError(
f"Host Module manifest inventory 不一致:missing={missing} unknown={unknown}"
)
allowed_metadata = {"name", "type", "subtype", "priority"}
module_type_values = {item.value for item in ModuleType}
for spec in specs:
if spec.source.parent.parent != _MODULE_ROOT:
raise ValueError(f"Host Module manifest 必须位于一级模块包:{spec.source}")
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
expected_module = f"app.modules.{spec.source.parent.name}"
if module_name != expected_module or symbol_name != spec.id:
raise ValueError(
f"{spec.source}: entrypoint 必须指向同包且类名等于 capability id"
)
if set(spec.metadata) != allowed_metadata:
raise ValueError(
f"{spec.source}: metadata 字段必须是 {sorted(allowed_metadata)}"
)
if spec.metadata["type"] not in module_type_values:
raise ValueError(f"{spec.source}: 非法 metadata.type={spec.metadata['type']!r}")
if spec.metadata["subtype"] not in _SUBTYPE_NAMES:
raise ValueError(
f"{spec.source}: 非法 metadata.subtype={spec.metadata['subtype']!r}"
)
priority = spec.metadata["priority"]
if isinstance(priority, bool) or not isinstance(priority, int):
raise ValueError(f"{spec.source}: metadata.priority 必须是整数")
if spec.activation is ActivationPolicy.WHEN_CONFIGURED:
selector_key = str(spec.selector.config["key"])
if selector_key not in spec.watch:
raise ValueError(
f"{spec.source}: activation.watch 必须包含 selector 配置键"
)
def build_host_module_registry() -> CapabilityRegistry:
"""从现有物理模块包构建 import-free Host Module Registry。"""
registry = CapabilityRegistry.discover(
(_MODULE_ROOT,),
kinds={HOST_MODULE_KIND},
selector_schemas=HOST_MODULE_SELECTOR_SCHEMAS,
)
_validate_manifest_inventory(registry)
return registry
def capture_host_module_config(
specs: tuple[CapabilitySpec, ...],
) -> HostModuleConfigSnapshot:
"""对本轮涉及的设置和服务配置各读取一次并冻结容器。"""
setting_keys: set[str] = set()
service_keys: set[str] = set()
for spec in specs:
selector = spec.selector
if selector is None:
continue
key = str(selector.config["key"])
if selector.kind == _SETTING_SELECTOR:
setting_keys.add(key)
elif selector.kind == _SERVICE_SELECTOR:
service_keys.add(key)
setting_values = {
key: getattr(settings, key)
for key in sorted(setting_keys)
}
service_values = {
key: tuple(_SERVICE_CONFIG_GETTERS[key]())
for key in sorted(service_keys)
}
return HostModuleConfigSnapshot(
settings=MappingProxyType(setting_values),
services=MappingProxyType(service_values),
)
def should_run_host_module(
spec: CapabilitySpec,
snapshot: HostModuleConfigSnapshot,
) -> bool:
"""依据有限 selector 语法判断能力是否应拥有运行资源。"""
if spec.activation is ActivationPolicy.BOOTSTRAP:
return True
if spec.activation is ActivationPolicy.ON_FIRST_USE:
return False
selector = spec.selector
if selector is None:
return False
if selector.kind == _SETTING_SELECTOR:
return bool(snapshot.settings[selector.config["key"]])
if selector.kind == _SERVICE_SELECTOR:
config = selector.config
return any(
getattr(item, config["match_field"]) == config["match_value"]
and bool(getattr(item, config["enabled_field"]))
for item in snapshot.services[config["key"]]
)
raise ValueError(f"未支持的 Host Module selector{selector.kind}")
class HostModuleAdapter:
"""把现有模块类接入 Capability Runtime,保持类路径与对象 identity 不变。"""
execution_mode = AdapterExecutionMode.SYNC
@staticmethod
def materialize(spec: CapabilitySpec) -> type:
"""按 manifest entrypoint 导入并返回原始模块类。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
implementation = getattr(importlib.import_module(module_name), symbol_name)
if not isinstance(implementation, type):
raise TypeError(f"{spec.entrypoint} 不是模块类")
return implementation
@staticmethod
def create(
spec: CapabilitySpec,
implementation: type,
generation: int,
previous: Any = None,
) -> Any:
"""首次创建实例;配置重载继续使用原实例以保留既有模块语义。"""
del spec, generation
return previous if previous is not None else implementation()
@staticmethod
def start(spec: CapabilitySpec, candidate: Any, generation: int) -> None:
"""初始化候选实例拥有的连接、线程或客户端资源。"""
del spec, generation
candidate.init_module()
@staticmethod
def stop(spec: CapabilitySpec, instance: Any, generation: int) -> None:
"""停止实例拥有的资源;Runtime 会先撤销其运行态可见性。"""
del spec, generation
instance.stop()
@staticmethod
def cleanup(
spec: CapabilitySpec,
candidate: Any,
generation: int,
error: BaseException,
) -> None:
"""启动失败后尽力回收候选实例已创建的部分资源。"""
del spec, generation, error
candidate.stop()
+268 -138
View File
@@ -1,22 +1,42 @@
import traceback
from typing import Generator, Optional, Tuple, Any, Union, List
from __future__ import annotations
import sys
import threading
from typing import Any, Generator, List, Optional, Tuple, Union
from app.runtime.config import settings
from app.runtime.events import EventHandlerBinding, eventmanager
from app.foundation.reflection import ModuleHelper
from app.runtime.log import logger
from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType, MediaRecognizeType
from app.foundation.reflection import ObjectUtils
from app.foundation.singleton import Singleton
from app.runtime.capabilities.model import (
CapabilityLifecycleState,
CapabilityObservation,
CapabilitySpec,
)
from app.runtime.capabilities.runtime import CapabilityRuntime
from app.runtime.config import settings
from app.runtime.events import Event, EventHandlerBinding, eventmanager
from app.runtime.extensions.host_module_adapter import (
HOST_MODULE_KIND,
HostModuleAdapter,
build_host_module_registry,
capture_host_module_config,
should_run_host_module,
)
from app.runtime.log import logger
from app.schemas.types import (
DownloaderType,
EventType,
MediaRecognizeType,
MediaServerType,
MessageChannel,
ModuleType,
OtherModulesType,
StorageSchema,
)
class ModuleManager(metaclass=Singleton):
"""
模块管理器
"""
"""以 Capability Runtime 管理宿主模块,并保留旧插件同步查询合同。"""
# 子模块类型集合
SubType = Union[
DownloaderType,
MediaServerType,
@@ -26,176 +46,286 @@ class ModuleManager(metaclass=Singleton):
MediaRecognizeType,
]
def __init__(self):
"""初始化模块注册表并装载当前启用的运行模块。"""
# 模块列表
self._modules: dict = {}
# 运行态模块列表
self._running_modules: dict = {}
# 事件总线通过该解析器绑定已启用的模块实例。
def __init__(self) -> None:
"""发现 data-only manifest,并按当前配置激活所需宿主模块。"""
self._lock = threading.RLock()
self._lifecycle_lock = threading.RLock()
self._modules: dict[str, type] = {}
self._running_modules: dict[str, Any] = {}
registry = build_host_module_registry()
self._runtime = CapabilityRuntime(
registry,
adapters={HOST_MODULE_KIND: HostModuleAdapter()},
observer=self._observe_transition,
)
# pkgutil 的既有发现顺序按一级包名稳定排列,兼容视图继续保持该顺序。
self._specs = tuple(
sorted(self._runtime.list_specs(), key=lambda item: item.source.parent.name)
)
eventmanager.register_handler_instance_resolver(
"modules",
self.resolve_event_handler_instance,
)
eventmanager.add_event_listener(
EventType.ConfigChanged,
self.handle_config_changed,
)
self.load_modules()
@staticmethod
def _observe_transition(observation: CapabilityObservation) -> None:
"""把 Runtime 的稳定转换结果接入现有日志面。"""
if observation.outcome == "failed":
logger.error(
"Host Module %s %s 失败:%s",
observation.capability_id,
observation.operation,
observation.error,
)
elif observation.outcome == "succeeded":
logger.debug(
"Host Module %s %s 完成,generation=%s,耗时=%.2fms",
observation.capability_id,
observation.operation,
observation.generation,
observation.duration_ms,
)
@staticmethod
def _event_changed_keys(event: Optional[Event]) -> set[str]:
"""兼容对象和 dict 两种配置事件载荷。"""
if not event:
return set()
event_data = event.event_data
if isinstance(event_data, dict):
keys = event_data.get("key", set())
else:
keys = getattr(event_data, "key", set())
if isinstance(keys, str):
return {keys}
return {str(key) for key in (keys or set())}
def _remember_materialized(self, module_id: str, implementation: type) -> type:
"""更新旧 `_modules` 视图,但不改变能力资源生命周期。"""
with self._lock:
self._modules[module_id] = implementation
return implementation
def _consumer_materialized_class(self, spec: CapabilitySpec) -> Optional[type]:
"""识别插件显式旧导入产生的真实类,不触发新的 Python import。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
module = sys.modules.get(module_name)
namespace = getattr(module, "__dict__", None) if module is not None else None
if not isinstance(namespace, dict):
return None
implementation = namespace.get(symbol_name)
return implementation if isinstance(implementation, type) else None
def _refresh_running_projection(self) -> None:
"""从 Runtime 已发布实例重建插件可见的运行模块字典。"""
running = {
spec.id: instance
for spec in self._specs
if (instance := self._runtime.get_running(spec.id)) is not None
}
with self._lock:
self._running_modules = running
def resolve_event_handler_instance(
self,
owner_class: type,
self,
owner_class: type,
) -> Optional[EventHandlerBinding]:
"""为模块声明的事件方法解析当前运行实例"""
module_id = owner_class.__name__
if module_id not in self._modules:
return None
module = self._running_modules.get(module_id)
owner_name = module_id
if module and callable(getattr(module, "get_name", None)):
owner_name = module.get_name()
return EventHandlerBinding(
instance=module,
owner_name=owner_name,
"""按 canonical class identity 绑定当前 generation,停止态阻断 fallback 构造"""
for spec in self._specs:
with self._lock:
implementation = self._modules.get(spec.id)
if implementation is None:
implementation = self._consumer_materialized_class(spec)
if implementation is not None:
self._remember_materialized(spec.id, implementation)
# 同步 Runtime 的物化观测,但不创建或启动实例。
self._runtime.snapshot(spec.id)
if implementation is not owner_class:
continue
return EventHandlerBinding(
instance=self._runtime.get_running(spec.id),
owner_name=str(spec.metadata["name"]),
)
return None
def _reconcile(
self,
*,
reason: str,
changed_keys: Optional[set[str]] = None,
reload_running: bool = False,
) -> None:
"""以一次配置快照串行协调需要启动、重载或停止的能力。"""
with self._lifecycle_lock:
selected = tuple(
spec
for spec in self._specs
if changed_keys is None or changed_keys.intersection(spec.watch)
)
snapshot = capture_host_module_config(selected)
for spec in selected:
desired = should_run_host_module(spec, snapshot)
running = self._runtime.get_running(spec.id)
try:
if desired and running is None:
instance = self._runtime.activate(
spec.id,
reason=reason,
retry=True,
)
self._remember_materialized(spec.id, type(instance))
elif desired and running is not None and reload_running:
instance = self._runtime.reload(spec.id, reason=reason)
self._remember_materialized(spec.id, type(instance))
elif not desired and running is not None:
self._runtime.stop(spec.id, reason=reason)
except Exception:
# 单能力失败由 Runtime 完整记录;其它无依赖能力继续 reconcile。
continue
self._refresh_running_projection()
def load_modules(self) -> None:
"""按当前配置启动未运行模块;已运行模块保持当前 generation。"""
self._reconcile(reason="module_manager_load")
def handle_config_changed(self, event: Event) -> None:
"""配置变更时仅协调 watch 命中的能力,并保证单一生命周期 writer。"""
changed_keys = self._event_changed_keys(event)
if not changed_keys:
return
self._reconcile(
reason="config_changed",
changed_keys=changed_keys,
reload_running=True,
)
def load_modules(self):
"""
加载所有模块
"""
# 扫描模块目录
modules = ModuleHelper.load(
"app.modules",
filter_func=lambda _, obj: hasattr(obj, 'init_module') and hasattr(obj, 'init_setting')
)
self._running_modules = {}
self._modules = {}
for module in modules:
module_id = module.__name__
self._modules[module_id] = module
try:
# 生成实例
_module = module()
# 初始化模块
if self.check_setting(_module.init_setting()):
# 通过模板开关控制加载
_module.init_module()
self._running_modules[module_id] = _module
logger.debug(f"Moudle Loaded{module_id}")
except Exception as err:
logger.error(f"Load Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
def stop(self):
"""
停止所有模块
"""
def stop(self) -> None:
"""停止全部运行模块但保留 Runtime,使旧插件可随后再次 load。"""
logger.info("正在停止所有模块...")
for module_id, module in self._running_modules.items():
try:
module.stop()
logger.debug(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
with self._lifecycle_lock:
for spec in reversed(self._specs):
snapshot = self._runtime.snapshot(spec.id)
if (
self._runtime.get_running(spec.id) is None
and snapshot.lifecycle is not CapabilityLifecycleState.FAILED
):
continue
try:
self._runtime.stop(spec.id, reason="module_manager_stop")
except Exception:
continue
self._refresh_running_projection()
logger.info("所有模块停止完成")
def reload(self):
"""
重新加载所有模块
"""
self.stop()
self.load_modules()
eventmanager.send_event(etype=EventType.ModuleReload, data={})
def shutdown(self) -> None:
"""进程关闭时不可逆停止 Runtime,阻止并发能力重新发布。"""
logger.info("正在关闭模块运行时...")
with self._lifecycle_lock:
self._runtime.shutdown(reason="application_shutdown")
self._refresh_running_projection()
logger.info("模块运行时关闭完成")
def reload(self) -> None:
"""保留旧插件可观察的 stop、load、ModuleReload 同步顺序。"""
with self._lifecycle_lock:
self.stop()
self.load_modules()
eventmanager.send_event(etype=EventType.ModuleReload, data={})
def test(self, modleid: str) -> Tuple[bool, str]:
"""
测试模块
"""
if modleid not in self._running_modules:
"""测试已运行模块;未启用模块保持旧合同返回 `(False, "")`。"""
module = self.get_running_module(modleid)
if module is None:
return False, ""
module = self._running_modules[modleid]
if hasattr(module, "test") \
and ObjectUtils.check_method(getattr(module, "test")):
if hasattr(module, "test") and ObjectUtils.check_method(module.test):
result = module.test()
if not result:
return False, ""
return result
return result if result else (False, "")
return True, "模块不支持测试"
@staticmethod
def check_setting(setting: Optional[tuple]) -> bool:
"""
检查开关是否己打开开关使用,分隔多个值符合其中即代表开启
"""
"""保留旧模块开关的 truthy 与 membership 判定语义。"""
if not setting:
return True
switch, value = setting
option = getattr(settings, switch)
if not option:
return False
if option and value is True:
if value is True:
return True
if value in option:
return True
return False
return value in option
def get_running_module(self, module_id: str) -> Any:
"""
根据模块id获取模块运行实例
"""
if not module_id:
"""根据模块 ID 返回已发布的运行实例,不触发物化。"""
if not module_id or self._runtime.get_spec(module_id) is None:
return None
if not self._running_modules:
return None
return self._running_modules.get(module_id)
return self._runtime.get_running(module_id)
def _running_snapshot(self) -> tuple[Any, ...]:
"""直接读取 Runtime 发布视图,转换期间不暴露旧或候选实例。"""
return tuple(
instance
for spec in self._specs
if (instance := self._runtime.get_running(spec.id)) is not None
)
def get_running_modules(self, method: str) -> Generator:
"""
获取实现了同一方法的模块列表
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, method) \
and ObjectUtils.check_method(getattr(module, method)):
"""返回实现了指定方法的运行模块快照。"""
for module in self._running_snapshot():
candidate = getattr(module, method, None)
if callable(candidate) and ObjectUtils.check_method(candidate):
yield module
def get_running_type_modules(self, module_type: ModuleType) -> Generator:
"""
获取指定类型的模块列表
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, 'get_type') \
and module.get_type() == module_type:
"""返回指定类型的运行模块快照。"""
for module in self._running_snapshot():
if module.get_type() == module_type:
yield module
def get_running_subtype_module(self, module_subtype: SubType) -> Generator:
"""
获取指定子类型的模块
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, 'get_subtype') \
and module.get_subtype() == module_subtype:
"""返回指定子类型的运行模块快照。"""
for module in self._running_snapshot():
if module.get_subtype() == module_subtype:
yield module
def get_module(self, module_id: str) -> Any:
"""
根据模块id获取模块
"""
if not module_id:
"""显式物化并返回 canonical 模块类;失败保持旧合同返回 None。"""
if not module_id or self._runtime.get_spec(module_id) is None:
return None
if not self._modules:
with self._lock:
implementation = self._modules.get(module_id)
if implementation is not None:
return implementation
try:
implementation = self._runtime.materialize(
module_id,
reason="compat_get_module",
retry=True,
)
except Exception:
return None
return self._modules.get(module_id)
return self._remember_materialized(module_id, implementation)
def get_modules(self) -> dict:
"""
获取模块列表
"""
return self._modules
def get_modules(self) -> dict[str, type]:
"""兼容性显式物化全部真实类;单个失败不阻断其它模块。"""
for spec in self._specs:
self.get_module(spec.id)
with self._lock:
return dict(self._modules)
def get_module_ids(self) -> List[str]:
"""
获取模块id列表
"""
return list(self._modules.keys())
"""从 manifest 返回全部模块 ID,不物化实现。"""
return [spec.id for spec in self._specs]
def list_specs(self) -> tuple[CapabilitySpec, ...]:
"""返回全部轻量模块声明,包含物化或启动失败的能力。"""
return self._specs
def get_specs(self) -> tuple[CapabilitySpec, ...]:
"""兼容内部调用命名,返回与 `list_specs` 相同的声明快照。"""
return self.list_specs()
+76
View File
@@ -0,0 +1,76 @@
from typing import List, Optional, Type
from pydantic import ValidationError
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
from app.schemas import (
DownloaderConf,
MediaServerConf,
NotificationConf,
NotificationSwitchConf,
)
from app.schemas.types import NotificationType, SystemConfigKey
class ServiceConfigHelper:
"""读取并校验通知、下载器和媒体服务器的宿主配置。"""
@staticmethod
def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List:
"""按指定 Schema 过滤单条非法配置,避免影响同组其它服务。"""
config_data = SystemConfigOper().get(config_key)
if not config_data:
return []
configs = []
for conf in config_data:
if not isinstance(conf, dict):
logger.warning(f"{config_key.value} 配置格式不正确,已跳过:{conf}")
continue
try:
configs.append(conf_type(**conf))
except ValidationError as err:
logger.error(
f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{err}"
)
return configs
@staticmethod
def get_downloader_configs() -> List[DownloaderConf]:
"""返回已通过结构校验的下载器配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.Downloaders,
DownloaderConf,
)
@staticmethod
def get_mediaserver_configs() -> List[MediaServerConf]:
"""返回已通过结构校验的媒体服务器配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.MediaServers,
MediaServerConf,
)
@staticmethod
def get_notification_configs() -> List[NotificationConf]:
"""返回已通过结构校验的通知配置。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.Notifications,
NotificationConf,
)
@staticmethod
def get_notification_switches() -> List[NotificationSwitchConf]:
"""返回已通过结构校验的通知场景开关。"""
return ServiceConfigHelper.get_configs(
SystemConfigKey.NotificationSwitchs,
NotificationSwitchConf,
)
@staticmethod
def get_notification_switch(mtype: NotificationType) -> Optional[str]:
"""返回指定通知场景的目标范围。"""
for switch in ServiceConfigHelper.get_notification_switches():
if switch.type == mtype.value:
return switch.action
return None
+9 -75
View File
@@ -1,84 +1,18 @@
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
from pydantic import ValidationError
from app.runtime.extensions.module_manager import ModuleManager
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo
from app.schemas.types import NotificationType, SystemConfigKey, ModuleType
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.service_config import ServiceConfigHelper
from app.schemas import ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType
TConf = TypeVar("TConf")
class ServiceConfigHelper:
"""
配置帮助类获取不同类型的服务配置
"""
@staticmethod
def get_configs(config_key: SystemConfigKey, conf_type: Type) -> List:
"""
通用获取配置的方法根据 config_key 获取相应的配置并返回指定类型的配置列表
:param config_key: 系统配置的 key
:param conf_type: 用于实例化配置对象的类类型
:return: 配置对象列表
"""
config_data = SystemConfigOper().get(config_key)
if not config_data:
return []
configs = []
for conf in config_data:
if not isinstance(conf, dict):
logger.warn(f"{config_key.value} 配置格式不正确,已跳过:{conf}")
continue
try:
# 直接使用 conf_type 来实例化配置对象
configs.append(conf_type(**conf))
except ValidationError as e:
# 单条配置存在非法值时跳过,避免影响其它服务的初始化
logger.error(f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{e}")
return configs
@staticmethod
def get_downloader_configs() -> List[DownloaderConf]:
"""
获取下载器的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.Downloaders, DownloaderConf)
@staticmethod
def get_mediaserver_configs() -> List[MediaServerConf]:
"""
获取媒体服务器的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf)
@staticmethod
def get_notification_configs() -> List[NotificationConf]:
"""
获取消息通知渠道的配置
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.Notifications, NotificationConf)
@staticmethod
def get_notification_switches() -> List[NotificationSwitchConf]:
"""
获取消息通知场景的开关
"""
return ServiceConfigHelper.get_configs(SystemConfigKey.NotificationSwitchs, NotificationSwitchConf)
@staticmethod
def get_notification_switch(mtype: NotificationType) -> Optional[str]:
"""
获取指定类型的消息通知场景的开关
"""
switchs = ServiceConfigHelper.get_notification_switches()
for switch in switchs:
if switch.type == mtype.value:
return switch.action
return None
__all__ = [
"ServiceBaseHelper",
"ServiceConfigHelper",
"SystemConfigOper",
]
class ServiceBaseHelper(Generic[TConf]):
+6
View File
@@ -14,10 +14,16 @@ class ConfigReloadMixin:
可选地重写 get_reload_name 方法提供模块名称用于日志显示
"""
# 统一生命周期管理器可以继承此 Mixin 的重载方法,但由外部唯一负责事件绑定。
CONFIG_RELOAD_MANAGED_EXTERNALLY: bool = False
def __init_subclass__(cls, **kwargs):
"""为声明了 CONFIG_WATCH 的子类生成配置变更处理器。"""
super().__init_subclass__(**kwargs)
if getattr(cls, "CONFIG_RELOAD_MANAGED_EXTERNALLY", False):
return
config_watch = getattr(cls, "CONFIG_WATCH", None)
if not config_watch:
return
+1 -1
View File
@@ -184,7 +184,7 @@ async def stop_modules():
logger.error(f"关闭{name}失败:{err}")
await run_step("AI智能体", stop_agent)
await run_step("模块", lambda: ModuleManager().stop())
await run_step("模块", lambda: ModuleManager().shutdown())
await run_step("事件消费", lambda: EventManager().stop())
await run_step("虚拟显示", lambda: DisplayHelper().stop())
await run_step("DoH服务", lambda: DohHelper().shutdown())