mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor(runtime): lazily activate host modules (#6331)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__()
|
||||
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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"]
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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 = []
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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"]
|
||||
@@ -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 = []
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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__))
|
||||
@@ -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 已进入关闭态,禁止启动或重新物化能力。"""
|
||||
@@ -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({})
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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]):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user