Files
MoviePilot/app/runtime/extensions/module_manager.py
T

334 lines
13 KiB
Python

from __future__ import annotations
import sys
import threading
from typing import Any, Generator, List, Optional, Tuple, Union
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.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
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,
NotificationChannel,
ModuleType,
OtherModulesType,
StorageSchema,
)
class ModuleManager(metaclass=Singleton):
"""以 Capability Runtime 管理宿主模块,并保留旧插件同步查询合同。"""
SubType = Union[
DownloaderType,
MediaServerType,
NotificationChannel,
StorageSchema,
OtherModulesType,
MediaRecognizeType,
]
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,
) -> Optional[EventHandlerBinding]:
"""按 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 stop(self) -> None:
"""停止全部运行模块但保留 Runtime,使旧插件可随后再次 load。"""
logger.info("正在停止所有模块...")
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 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]:
"""测试已运行模块;未启用模块保持旧合同返回 `(False, "")`。"""
module = self.get_running_module(modleid)
if module is None:
return False, ""
if hasattr(module, "test") and ObjectUtils.check_method(module.test):
result = module.test()
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 value is True:
return True
return value in option
def get_running_module(self, module_id: str) -> Any:
"""根据模块 ID 返回已发布的运行实例,不触发物化。"""
if not module_id or self._runtime.get_spec(module_id) is None:
return None
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:
"""返回实现了指定方法的运行模块快照。"""
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:
"""返回指定类型的运行模块快照。"""
for module in self._running_snapshot():
if module.get_type() == module_type:
yield module
def get_running_subtype_module(self, module_subtype: SubType) -> Generator:
"""返回指定子类型的运行模块快照。"""
for module in self._running_snapshot():
if module.get_subtype() == module_subtype:
yield module
def get_module(self, module_id: str) -> Any:
"""显式物化并返回 canonical 模块类;失败保持旧合同返回 None。"""
if not module_id or self._runtime.get_spec(module_id) is None:
return None
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._remember_materialized(module_id, implementation)
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]:
"""从 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()