mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor(runtime): activate managed resources on demand (#6334)
This commit is contained in:
@@ -9,6 +9,10 @@ from urllib.parse import urlparse
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.managed_resources import (
|
||||
acquire_managed_resource,
|
||||
acquire_managed_resource_async,
|
||||
)
|
||||
from app.adapters.network.http import RequestUtils, cookie_parse
|
||||
|
||||
|
||||
@@ -117,6 +121,47 @@ class BrowserPage(Protocol):
|
||||
...
|
||||
|
||||
|
||||
def launch_browser_context(headless: bool = True, **kwargs: Any) -> BrowserContext:
|
||||
"""
|
||||
启动同步浏览器上下文;有界面模式先显式获取宿主显示资源。
|
||||
|
||||
:param headless: 是否使用无头模式
|
||||
:param kwargs: 浏览器实现接受的其余启动参数
|
||||
:return: 浏览器上下文
|
||||
"""
|
||||
if not headless:
|
||||
acquire_managed_resource(
|
||||
"host.display",
|
||||
reason="headed_browser_launch",
|
||||
retry=True,
|
||||
)
|
||||
from cloakbrowser import launch_context
|
||||
|
||||
return launch_context(headless=headless, **kwargs)
|
||||
|
||||
|
||||
async def launch_browser_context_async(
|
||||
headless: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
启动异步浏览器上下文;有界面模式等待宿主显示资源就绪。
|
||||
|
||||
:param headless: 是否使用无头模式
|
||||
:param kwargs: 浏览器实现接受的其余启动参数
|
||||
:return: 浏览器上下文
|
||||
"""
|
||||
if not headless:
|
||||
await acquire_managed_resource_async(
|
||||
"host.display",
|
||||
reason="headed_browser_launch",
|
||||
retry=True,
|
||||
)
|
||||
from cloakbrowser import launch_context_async
|
||||
|
||||
return await launch_context_async(headless=headless, **kwargs)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BrowserSessionState:
|
||||
"""保存一个可复用浏览器上下文及其页面游标。"""
|
||||
@@ -662,10 +707,7 @@ class BrowserSessionHelper:
|
||||
viewport: Optional[dict[str, int]] = None,
|
||||
) -> BrowserContext:
|
||||
"""按宿主反检测配置创建 CloakBrowser 上下文。"""
|
||||
from cloakbrowser import launch_context
|
||||
|
||||
context_kwargs = {
|
||||
"headless": headless,
|
||||
"humanize": settings.CLOAKBROWSER_HUMANIZE,
|
||||
"human_preset": settings.CLOAKBROWSER_HUMAN_PRESET,
|
||||
}
|
||||
@@ -673,7 +715,7 @@ class BrowserSessionHelper:
|
||||
context_kwargs["user_agent"] = user_agent
|
||||
if viewport:
|
||||
context_kwargs["viewport"] = viewport
|
||||
return launch_context(**context_kwargs)
|
||||
return launch_browser_context(headless=headless, **context_kwargs)
|
||||
|
||||
def _get_or_create_session(
|
||||
self,
|
||||
@@ -883,13 +925,11 @@ class PlaywrightHelper:
|
||||
"""
|
||||
启动 CloakBrowser 上下文。
|
||||
"""
|
||||
from cloakbrowser import launch_context
|
||||
|
||||
return launch_context(headless=headless,
|
||||
proxy=proxies,
|
||||
user_agent=user_agent,
|
||||
humanize=settings.CLOAKBROWSER_HUMANIZE,
|
||||
human_preset=settings.CLOAKBROWSER_HUMAN_PRESET)
|
||||
return launch_browser_context(headless=headless,
|
||||
proxy=proxies,
|
||||
user_agent=user_agent,
|
||||
humanize=settings.CLOAKBROWSER_HUMANIZE,
|
||||
human_preset=settings.CLOAKBROWSER_HUMAN_PRESET)
|
||||
|
||||
@staticmethod
|
||||
def __fs_cookie_str(cookies: list) -> str:
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
from pyvirtualdisplay import Display
|
||||
|
||||
from app.runtime.log import logger
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.adapters.system.host import SystemUtils
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class DisplayHelper(metaclass=Singleton):
|
||||
"""在容器环境中管理浏览器所需的虚拟显示。"""
|
||||
|
||||
def __init__(self):
|
||||
"""仅在 Docker 内启动虚拟显示服务。"""
|
||||
self._display = None
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
try:
|
||||
self._display = Display(visible=False, size=(1024, 768), extra_args=[os.environ['DISPLAY']])
|
||||
self._display.start()
|
||||
except Exception as err:
|
||||
logger.error(f"DisplayHelper init error: {str(err)}")
|
||||
|
||||
def stop(self):
|
||||
"""停止已经启动的虚拟显示服务。"""
|
||||
if self._display:
|
||||
logger.info("正在停止虚拟显示...")
|
||||
self._display.stop()
|
||||
logger.info("虚拟显示已停止")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""虚拟显示适配器及旧 DisplayHelper 兼容入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.managed_resources import (
|
||||
acquire_managed_resource,
|
||||
stop_managed_resource,
|
||||
)
|
||||
|
||||
|
||||
DISPLAY_CAPABILITY_ID = "host.display"
|
||||
|
||||
|
||||
class DisplayHelper(metaclass=Singleton):
|
||||
"""保留旧构造 API,并把资源所有权委托给 host.display 能力。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""显式构造旧门面时激活虚拟显示,失败保持旧 API 的日志语义。"""
|
||||
try:
|
||||
acquire_managed_resource(
|
||||
DISPLAY_CAPABILITY_ID,
|
||||
reason="legacy_display_helper",
|
||||
retry=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("DisplayHelper init error: %s", error)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止已激活的虚拟显示;未配置 Runtime 时保持幂等。"""
|
||||
stop_managed_resource(
|
||||
DISPLAY_CAPABILITY_ID,
|
||||
reason="legacy_display_helper_stop",
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DISPLAY_CAPABILITY_ID", "DisplayHelper", "VirtualDisplayResource"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""按需公开资源实现,普通兼容导入不加载显示后端。"""
|
||||
if name != "VirtualDisplayResource":
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
value = getattr(
|
||||
import_module("app.adapters.system.display.resource"),
|
||||
"VirtualDisplayResource",
|
||||
)
|
||||
globals()[name] = value
|
||||
return value
|
||||
@@ -0,0 +1,12 @@
|
||||
schema_version = 1
|
||||
id = "host.display"
|
||||
kind = "managed_resource.sync"
|
||||
entrypoint = "app.adapters.system.display.resource:VirtualDisplayResource"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "Virtual Display"
|
||||
|
||||
[activation]
|
||||
policy = "on_first_use"
|
||||
watch = []
|
||||
@@ -0,0 +1,45 @@
|
||||
"""虚拟显示进程的托管资源实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.runtime.log import logger
|
||||
|
||||
|
||||
class VirtualDisplayResource:
|
||||
"""按需拥有一个容器内虚拟显示进程。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._display: Optional[Any] = None
|
||||
|
||||
@property
|
||||
def display(self) -> Optional[Any]:
|
||||
"""返回当前拥有的显示对象;未启动或已停止时为 None。"""
|
||||
return self._display
|
||||
|
||||
def start(self) -> None:
|
||||
"""仅在容器环境启动虚拟显示,重复启动保持幂等。"""
|
||||
if self._display is not None or not SystemUtils.is_docker():
|
||||
return
|
||||
from pyvirtualdisplay import Display
|
||||
|
||||
display = Display(
|
||||
visible=False,
|
||||
size=(1024, 768),
|
||||
extra_args=[os.environ["DISPLAY"]],
|
||||
)
|
||||
self._display = display
|
||||
display.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止当前资源拥有的显示进程,失败时保留句柄供 Runtime 重试。"""
|
||||
display = self._display
|
||||
if display is None:
|
||||
return
|
||||
logger.info("正在停止虚拟显示...")
|
||||
display.stop()
|
||||
self._display = None
|
||||
logger.info("虚拟显示已停止")
|
||||
@@ -0,0 +1,196 @@
|
||||
"""从旧插件源码导入中识别必须提前就绪的宿主资源。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import threading
|
||||
import tokenize
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, FrozenSet, Iterable, Set, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceImportRule:
|
||||
"""描述第三方模块导入与宿主资源能力之间的静态映射。"""
|
||||
|
||||
capability_id: str # 导入前必须准备的宿主能力标识
|
||||
module_prefixes: tuple[str, ...] # 按完整包边界匹配的第三方模块前缀
|
||||
headed_entrypoints: tuple[str, ...] # 已确认允许 headed 模式的公开入口
|
||||
|
||||
|
||||
# 旧插件可能绕过宿主浏览器门面直接调用 CloakBrowser。其六个 launch
|
||||
# 入口均允许 headed 模式,因此导入该包或任意子模块时保守准备虚拟显示。
|
||||
RESOURCE_IMPORT_RULES: tuple[ResourceImportRule, ...] = (
|
||||
ResourceImportRule(
|
||||
capability_id="host.display",
|
||||
module_prefixes=("cloakbrowser",),
|
||||
headed_entrypoints=(
|
||||
"launch",
|
||||
"launch_async",
|
||||
"launch_context",
|
||||
"launch_context_async",
|
||||
"launch_persistent_context",
|
||||
"launch_persistent_context_async",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_scan_cache_lock = threading.RLock()
|
||||
_scan_cache: Dict[Path, Tuple[int, int, int, int, int, FrozenSet[str]]] = {}
|
||||
|
||||
|
||||
class PluginResourceImportScanError(RuntimeError):
|
||||
"""表示单个插件源码无法生成可靠的精确资源集合。"""
|
||||
|
||||
|
||||
def _all_resource_capabilities() -> FrozenSet[str]:
|
||||
"""扫描不完整时返回全部已登记资源,避免漏失导入前置条件。"""
|
||||
return frozenset(rule.capability_id for rule in RESOURCE_IMPORT_RULES)
|
||||
|
||||
|
||||
def _matches_module(module_name: str, module_prefixes: Iterable[str]) -> bool:
|
||||
"""按完整包边界匹配模块,避免相似名称产生误报。"""
|
||||
return any(
|
||||
module_name == prefix or module_name.startswith(f"{prefix}.")
|
||||
for prefix in module_prefixes
|
||||
)
|
||||
|
||||
|
||||
def _dynamic_import_aliases(tree: ast.AST) -> tuple[Set[str], Set[str]]:
|
||||
"""收集 importlib 模块及 import_module 函数的本地别名。"""
|
||||
module_aliases = {"importlib"}
|
||||
function_aliases: Set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for imported in node.names:
|
||||
if imported.name == "importlib":
|
||||
module_aliases.add(imported.asname or imported.name)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module == "importlib":
|
||||
for imported in node.names:
|
||||
if imported.name == "import_module":
|
||||
function_aliases.add(imported.asname or imported.name)
|
||||
return module_aliases, function_aliases
|
||||
|
||||
|
||||
def _constant_dynamic_import(
|
||||
node: ast.Call,
|
||||
*,
|
||||
importlib_aliases: Set[str],
|
||||
import_module_aliases: Set[str],
|
||||
) -> str | None:
|
||||
"""提取受支持动态导入调用中的常量模块名。"""
|
||||
if not node.args:
|
||||
return None
|
||||
is_import_call = isinstance(node.func, ast.Name) and (
|
||||
node.func.id == "__import__" or node.func.id in import_module_aliases
|
||||
)
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id in importlib_aliases
|
||||
and node.func.attr == "import_module"
|
||||
):
|
||||
is_import_call = True
|
||||
if not is_import_call:
|
||||
return None
|
||||
argument = node.args[0]
|
||||
if isinstance(argument, ast.Constant) and isinstance(argument.value, str):
|
||||
return argument.value
|
||||
return None
|
||||
|
||||
|
||||
def _imported_modules(tree: ast.AST) -> FrozenSet[str]:
|
||||
"""提取静态导入以及可确定目标的动态导入模块名。"""
|
||||
modules: Set[str] = set()
|
||||
importlib_aliases, import_module_aliases = _dynamic_import_aliases(tree)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
modules.update(imported.name for imported in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
modules.add(node.module)
|
||||
elif isinstance(node, ast.Call):
|
||||
module_name = _constant_dynamic_import(
|
||||
node,
|
||||
importlib_aliases=importlib_aliases,
|
||||
import_module_aliases=import_module_aliases,
|
||||
)
|
||||
if module_name:
|
||||
modules.add(module_name)
|
||||
return frozenset(modules)
|
||||
|
||||
|
||||
def _scan_source(plugin_id: str, path: Path) -> FrozenSet[str]:
|
||||
"""读取并解析单个源码文件;不完整结果不能进入插件导入阶段。"""
|
||||
try:
|
||||
before_stat = path.stat()
|
||||
# 热加载工具可能保留 mtime,等长替换也不会改变 size;ctime 与 inode/device
|
||||
# 一并参与身份判断,避免把已替换源码误认为旧缓存。
|
||||
cache_key = (
|
||||
before_stat.st_mtime_ns,
|
||||
before_stat.st_ctime_ns,
|
||||
before_stat.st_size,
|
||||
before_stat.st_dev,
|
||||
before_stat.st_ino,
|
||||
)
|
||||
with _scan_cache_lock:
|
||||
cached = _scan_cache.get(path)
|
||||
if cached and cached[:5] == cache_key:
|
||||
return cached[5]
|
||||
with tokenize.open(path) as source_file:
|
||||
source = source_file.read()
|
||||
tree = ast.parse(source, filename=str(path))
|
||||
after_stat = path.stat()
|
||||
except (OSError, SyntaxError, UnicodeError) as error:
|
||||
raise PluginResourceImportScanError(
|
||||
f"无法扫描插件 {plugin_id} 源码 {path.name}:{error}"
|
||||
) from error
|
||||
after_key = (
|
||||
after_stat.st_mtime_ns,
|
||||
after_stat.st_ctime_ns,
|
||||
after_stat.st_size,
|
||||
after_stat.st_dev,
|
||||
after_stat.st_ino,
|
||||
)
|
||||
if cache_key != after_key:
|
||||
raise PluginResourceImportScanError(
|
||||
f"扫描插件 {plugin_id} 时源码 {path.name} 发生变化"
|
||||
)
|
||||
|
||||
capabilities: Set[str] = set()
|
||||
for module_name in _imported_modules(tree):
|
||||
for rule in RESOURCE_IMPORT_RULES:
|
||||
if _matches_module(module_name, rule.module_prefixes):
|
||||
capabilities.add(rule.capability_id)
|
||||
result = frozenset(capabilities)
|
||||
with _scan_cache_lock:
|
||||
_scan_cache[path] = (*cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def scan_plugin_resource_imports(
|
||||
plugin_id: str,
|
||||
plugin_dir: Path,
|
||||
) -> tuple[str, ...]:
|
||||
"""递归扫描插件源码并返回导入前必须准备的 capability ID。"""
|
||||
if not plugin_dir.is_dir():
|
||||
raise PluginResourceImportScanError(
|
||||
f"插件 {plugin_id} 源码目录不存在:{plugin_dir}"
|
||||
)
|
||||
|
||||
capabilities: Set[str] = set()
|
||||
try:
|
||||
source_files = sorted(plugin_dir.rglob("*.py"))
|
||||
except OSError:
|
||||
return tuple(sorted(_all_resource_capabilities()))
|
||||
for path in source_files:
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
try:
|
||||
capabilities.update(_scan_source(plugin_id, path))
|
||||
except PluginResourceImportScanError:
|
||||
# Python 最终只会导入真实依赖链;无法解析的残留或平台专用文件不应
|
||||
# 阻断整个插件,但必须按最保守资源集合准备后再交给 loader 判断。
|
||||
capabilities.update(_all_resource_capabilities())
|
||||
return tuple(sorted(capabilities))
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Managed Resource 的声明发现与 Capability Runtime 适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from app.runtime.capabilities.errors import CapabilityAdapterContractError
|
||||
from app.runtime.capabilities.model import (
|
||||
ActivationPolicy,
|
||||
AdapterExecutionMode,
|
||||
CapabilitySpec,
|
||||
)
|
||||
from app.runtime.capabilities.registry import CapabilityRegistry
|
||||
from app.runtime.managed_resources import (
|
||||
MANAGED_RESOURCE_ASYNC_KIND,
|
||||
MANAGED_RESOURCE_SYNC_KIND,
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_RESOURCE_ROOT = Path(__file__).resolve().parents[2] / "adapters"
|
||||
_RESOURCE_KINDS = {MANAGED_RESOURCE_SYNC_KIND, MANAGED_RESOURCE_ASYNC_KIND}
|
||||
|
||||
|
||||
def _load_entrypoint(spec: CapabilitySpec) -> Any:
|
||||
"""解析声明中的 canonical 实现对象,不创建资源实例。"""
|
||||
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
|
||||
module = importlib.import_module(module_name)
|
||||
try:
|
||||
return getattr(module, symbol_name)
|
||||
except AttributeError as error:
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint} 未公开 Managed Resource 实现"
|
||||
) from error
|
||||
|
||||
|
||||
def _create_candidate(spec: CapabilitySpec, implementation: Any) -> Any:
|
||||
"""通过零参数工厂创建资源候选,实例在 start 成功前不可见。"""
|
||||
if not callable(implementation):
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint} 不是可调用的 Managed Resource 工厂"
|
||||
)
|
||||
candidate = implementation()
|
||||
if candidate is None or inspect.isawaitable(candidate):
|
||||
close = getattr(candidate, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint} 必须同步返回资源候选"
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
def _resource_method(spec: CapabilitySpec, candidate: Any, name: str) -> Any:
|
||||
"""读取必需生命周期方法并生成稳定合同错误。"""
|
||||
callback = getattr(candidate, name, None)
|
||||
if not callable(callback):
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint} 的资源候选缺少 {name}()"
|
||||
)
|
||||
return callback
|
||||
|
||||
|
||||
class SyncManagedResourceAdapter:
|
||||
"""把同步 start/stop 资源接入 Capability Runtime。"""
|
||||
|
||||
execution_mode = AdapterExecutionMode.SYNC
|
||||
|
||||
@staticmethod
|
||||
def materialize(spec: CapabilitySpec) -> Any:
|
||||
"""解析资源工厂。"""
|
||||
return _load_entrypoint(spec)
|
||||
|
||||
@staticmethod
|
||||
def create(
|
||||
spec: CapabilitySpec,
|
||||
implementation: Any,
|
||||
_generation: int,
|
||||
_previous: Any = None,
|
||||
) -> Any:
|
||||
"""创建尚未发布的同步资源候选。"""
|
||||
return _create_candidate(spec, implementation)
|
||||
|
||||
@staticmethod
|
||||
def start(spec: CapabilitySpec, candidate: Any, _generation: int) -> None:
|
||||
"""启动同步候选;同步 kind 不接受 awaitable 返回值。"""
|
||||
result = _resource_method(spec, candidate, "start")()
|
||||
if inspect.isawaitable(result):
|
||||
close = getattr(result, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint}.start() 返回 awaitable,与同步 kind 不匹配"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def stop(spec: CapabilitySpec, instance: Any, _generation: int) -> None:
|
||||
"""停止同步资源,异常交由 Runtime 保留资源所有权并支持重试。"""
|
||||
result = _resource_method(spec, instance, "stop")()
|
||||
if inspect.isawaitable(result):
|
||||
close = getattr(result, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint}.stop() 返回 awaitable,与同步 kind 不匹配"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def cleanup(
|
||||
spec: CapabilitySpec,
|
||||
candidate: Any,
|
||||
generation: int,
|
||||
_error: BaseException,
|
||||
) -> None:
|
||||
"""启动失败时按同一 stop 合同清理尚未发布的候选。"""
|
||||
SyncManagedResourceAdapter.stop(spec, candidate, generation)
|
||||
|
||||
|
||||
class AsyncManagedResourceAdapter:
|
||||
"""把异步 start/stop 资源接入 Capability Runtime。"""
|
||||
|
||||
execution_mode = AdapterExecutionMode.ASYNC
|
||||
|
||||
@staticmethod
|
||||
async def materialize(spec: CapabilitySpec) -> Any:
|
||||
"""在线程中解析资源工厂,避免第三方导入阻塞事件循环。"""
|
||||
return await asyncio.to_thread(_load_entrypoint, spec)
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
spec: CapabilitySpec,
|
||||
implementation: Any,
|
||||
_generation: int,
|
||||
_previous: Any = None,
|
||||
) -> Any:
|
||||
"""创建尚未发布的异步资源候选。"""
|
||||
return _create_candidate(spec, implementation)
|
||||
|
||||
@staticmethod
|
||||
async def start(spec: CapabilitySpec, candidate: Any, _generation: int) -> None:
|
||||
"""等待异步候选完成启动。"""
|
||||
result = _resource_method(spec, candidate, "start")()
|
||||
if not inspect.isawaitable(result):
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint}.start() 必须返回 awaitable"
|
||||
)
|
||||
await result
|
||||
|
||||
@staticmethod
|
||||
async def stop(spec: CapabilitySpec, instance: Any, _generation: int) -> None:
|
||||
"""等待异步资源完成停止。"""
|
||||
result = _resource_method(spec, instance, "stop")()
|
||||
if not inspect.isawaitable(result):
|
||||
raise CapabilityAdapterContractError(
|
||||
f"{spec.entrypoint}.stop() 必须返回 awaitable"
|
||||
)
|
||||
await result
|
||||
|
||||
@staticmethod
|
||||
async def cleanup(
|
||||
spec: CapabilitySpec,
|
||||
candidate: Any,
|
||||
generation: int,
|
||||
_error: BaseException,
|
||||
) -> None:
|
||||
"""启动失败时等待同一 stop 合同清理候选。"""
|
||||
await AsyncManagedResourceAdapter.stop(spec, candidate, generation)
|
||||
|
||||
|
||||
def _validate_registry(registry: CapabilityRegistry) -> None:
|
||||
"""固定类别级声明合同,资源只能由显式首用触发。"""
|
||||
for spec in registry.list_specs():
|
||||
if set(spec.metadata) != {"name"}:
|
||||
raise ValueError(f"{spec.source}: Managed Resource metadata 只能包含 name")
|
||||
if spec.activation is not ActivationPolicy.ON_FIRST_USE:
|
||||
raise ValueError(f"{spec.source}: Managed Resource 必须使用 on_first_use")
|
||||
if spec.selector is not None or spec.watch:
|
||||
raise ValueError(f"{spec.source}: Managed Resource 不接受配置 selector 或 watch")
|
||||
|
||||
|
||||
def build_managed_resource_registry(
|
||||
roots: Iterable[Path | str] | None = None,
|
||||
) -> CapabilityRegistry:
|
||||
"""从 data-only manifest 构建不导入资源实现的注册表。"""
|
||||
registry = CapabilityRegistry.discover(
|
||||
tuple(roots) if roots is not None else (_DEFAULT_RESOURCE_ROOT,),
|
||||
kinds=_RESOURCE_KINDS,
|
||||
selector_schemas={},
|
||||
)
|
||||
_validate_registry(registry)
|
||||
return registry
|
||||
@@ -37,6 +37,7 @@ from app.schemas.types import EventType, SystemConfigKey
|
||||
|
||||
LegacyDiagnosticsConfigurator = Callable[..., None]
|
||||
LegacyImportScanner = Callable[..., None]
|
||||
LegacyPluginImportPreparer = Callable[..., None]
|
||||
PluginInstallReporter = Callable[..., None]
|
||||
SiteAuthLevelProvider = Callable[[], int]
|
||||
|
||||
@@ -45,6 +46,10 @@ def _ignore_legacy_diagnostics(**_kwargs) -> None:
|
||||
"""在启动组合根尚未注入兼容服务时保持插件加载可用。"""
|
||||
|
||||
|
||||
def _ignore_plugin_resource_imports(**_kwargs) -> None:
|
||||
"""未进入应用启动组合时不主动创建进程级宿主资源。"""
|
||||
|
||||
|
||||
def _unavailable_site_auth_level() -> int:
|
||||
"""站点能力尚未装配时返回未认证等级。"""
|
||||
return 0
|
||||
@@ -54,6 +59,9 @@ _legacy_diagnostics_configurator: LegacyDiagnosticsConfigurator = (
|
||||
_ignore_legacy_diagnostics
|
||||
)
|
||||
_legacy_import_scanner: LegacyImportScanner = _ignore_legacy_diagnostics
|
||||
_legacy_plugin_import_preparer: LegacyPluginImportPreparer = (
|
||||
_ignore_plugin_resource_imports
|
||||
)
|
||||
_plugin_install_reporter: PluginInstallReporter = _ignore_legacy_diagnostics
|
||||
_site_auth_level_provider: SiteAuthLevelProvider = _unavailable_site_auth_level
|
||||
|
||||
@@ -69,6 +77,14 @@ def configure_plugin_legacy_import_services(
|
||||
_legacy_import_scanner = import_scanner
|
||||
|
||||
|
||||
def configure_plugin_resource_import_preparer(
|
||||
preparer: LegacyPluginImportPreparer,
|
||||
) -> None:
|
||||
"""注入旧插件导入前的宿主资源准备器。"""
|
||||
global _legacy_plugin_import_preparer
|
||||
_legacy_plugin_import_preparer = preparer
|
||||
|
||||
|
||||
def configure_plugin_install_reporter(reporter: PluginInstallReporter) -> None:
|
||||
"""由启动组合根注入插件安装上报器,避免扩展层依赖远程服务。"""
|
||||
global _plugin_install_reporter
|
||||
@@ -318,6 +334,13 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
module_name = f"app.plugins.{plugin_dir.name}"
|
||||
logger.debug(f"正在导入插件模块:{module_name}")
|
||||
|
||||
# 旧插件可能直接导入带宿主资源前置条件的第三方包。资源必须在
|
||||
# Python 执行插件模块顶层代码前就绪,否则导入副作用无法安全回滚。
|
||||
_legacy_plugin_import_preparer(
|
||||
plugin_id=plugin_dir.name,
|
||||
plugin_dir=plugin_dir,
|
||||
)
|
||||
|
||||
_legacy_import_scanner(
|
||||
plugin_id=plugin_dir.name,
|
||||
plugin_dir=plugin_dir,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""进程级托管资源的轻量调用门面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
|
||||
MANAGED_RESOURCE_SYNC_KIND = "managed_resource.sync"
|
||||
MANAGED_RESOURCE_ASYNC_KIND = "managed_resource.async"
|
||||
|
||||
|
||||
class ManagedResourceRuntime(Protocol):
|
||||
"""Managed Resource 门面依赖的最小 Capability Runtime 合同。"""
|
||||
|
||||
@property
|
||||
def is_shutdown(self) -> bool:
|
||||
"""返回 Runtime 是否已进入不可逆关闭态。"""
|
||||
|
||||
def get_spec(self, capability_id: str) -> Any:
|
||||
"""返回资源声明。"""
|
||||
|
||||
def get_running(self, capability_id: str) -> Any:
|
||||
"""只查询已发布实例。"""
|
||||
|
||||
def snapshot(self, capability_id: str) -> Any:
|
||||
"""返回资源状态快照。"""
|
||||
|
||||
def observations(self, capability_id: Optional[str] = None) -> tuple[Any, ...]:
|
||||
"""返回资源转换观测。"""
|
||||
|
||||
def activate(self, capability_id: str, *, reason: str, retry: bool = False) -> Any:
|
||||
"""通过同步 adapter 激活资源。"""
|
||||
|
||||
async def activate_async(
|
||||
self,
|
||||
capability_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
retry: bool = False,
|
||||
) -> Any:
|
||||
"""通过异步 adapter 激活资源。"""
|
||||
|
||||
def stop(self, capability_id: str, *, reason: str) -> None:
|
||||
"""通过同步 adapter 停止资源。"""
|
||||
|
||||
async def stop_async(self, capability_id: str, *, reason: str) -> None:
|
||||
"""通过异步 adapter 停止资源。"""
|
||||
|
||||
async def shutdown_async(self, *, reason: str) -> None:
|
||||
"""关闭混合同步和异步 adapter 的 Runtime。"""
|
||||
|
||||
|
||||
_runtime_lock = threading.RLock()
|
||||
_managed_resource_runtime: Optional[ManagedResourceRuntime] = None
|
||||
|
||||
|
||||
def configure_managed_resource_runtime(runtime: ManagedResourceRuntime) -> None:
|
||||
"""由启动组合层注入唯一的 Managed Resource Runtime。"""
|
||||
if runtime is None:
|
||||
raise ValueError("Managed Resource Runtime 不能为空")
|
||||
global _managed_resource_runtime
|
||||
with _runtime_lock:
|
||||
_managed_resource_runtime = runtime
|
||||
|
||||
|
||||
def _runtime(*, required: bool) -> Optional[ManagedResourceRuntime]:
|
||||
"""读取当前 Runtime;资源使用路径要求启动组合已经完成装配。"""
|
||||
with _runtime_lock:
|
||||
runtime = _managed_resource_runtime
|
||||
if runtime is None and required:
|
||||
raise RuntimeError("Managed Resource Runtime 尚未初始化")
|
||||
return runtime
|
||||
|
||||
|
||||
def _resource_kind(runtime: ManagedResourceRuntime, capability_id: str) -> str:
|
||||
"""返回声明的执行模式;未知资源继续沿用 Runtime 的领域错误。"""
|
||||
spec = runtime.get_spec(capability_id)
|
||||
if spec is None:
|
||||
runtime.get_running(capability_id)
|
||||
raise RuntimeError(f"未知 Managed Resource:{capability_id}")
|
||||
return str(spec.kind)
|
||||
|
||||
|
||||
def acquire_managed_resource(
|
||||
capability_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
retry: bool = True,
|
||||
) -> Any:
|
||||
"""同步激活一个声明为同步模式的托管资源。"""
|
||||
runtime = _runtime(required=True)
|
||||
kind = _resource_kind(runtime, capability_id)
|
||||
if kind != MANAGED_RESOURCE_SYNC_KIND:
|
||||
raise RuntimeError(f"异步 Managed Resource 不能通过同步入口激活:{capability_id}")
|
||||
return runtime.activate(capability_id, reason=reason, retry=retry)
|
||||
|
||||
|
||||
async def acquire_managed_resource_async(
|
||||
capability_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
retry: bool = True,
|
||||
) -> Any:
|
||||
"""异步激活资源;同步资源移交工作线程,避免阻塞事件循环。"""
|
||||
runtime = _runtime(required=True)
|
||||
kind = _resource_kind(runtime, capability_id)
|
||||
if kind == MANAGED_RESOURCE_ASYNC_KIND:
|
||||
return await runtime.activate_async(
|
||||
capability_id,
|
||||
reason=reason,
|
||||
retry=retry,
|
||||
)
|
||||
if kind == MANAGED_RESOURCE_SYNC_KIND:
|
||||
return await asyncio.to_thread(
|
||||
runtime.activate,
|
||||
capability_id,
|
||||
reason=reason,
|
||||
retry=retry,
|
||||
)
|
||||
raise RuntimeError(f"未知 Managed Resource kind:{kind}")
|
||||
|
||||
|
||||
def get_running_managed_resource(capability_id: str) -> Any:
|
||||
"""只查询已发布资源;Runtime 未配置时返回 None,不触发初始化。"""
|
||||
runtime = _runtime(required=False)
|
||||
if runtime is None:
|
||||
return None
|
||||
return runtime.get_running(capability_id)
|
||||
|
||||
|
||||
def managed_resource_snapshot(capability_id: str) -> Any:
|
||||
"""返回资源状态快照;Runtime 未配置时返回 None。"""
|
||||
runtime = _runtime(required=False)
|
||||
if runtime is None:
|
||||
return None
|
||||
return runtime.snapshot(capability_id)
|
||||
|
||||
|
||||
def managed_resource_observations(
|
||||
capability_id: Optional[str] = None,
|
||||
) -> tuple[Any, ...]:
|
||||
"""返回资源转换观测;Runtime 未配置时返回空快照。"""
|
||||
runtime = _runtime(required=False)
|
||||
if runtime is None:
|
||||
return ()
|
||||
return runtime.observations(capability_id)
|
||||
|
||||
|
||||
def stop_managed_resource(capability_id: str, *, reason: str) -> None:
|
||||
"""同步停止资源;Runtime 未配置时保持幂等且不反向初始化。"""
|
||||
runtime = _runtime(required=False)
|
||||
if runtime is None:
|
||||
return
|
||||
kind = _resource_kind(runtime, capability_id)
|
||||
if kind != MANAGED_RESOURCE_SYNC_KIND:
|
||||
raise RuntimeError(f"异步 Managed Resource 不能通过同步入口停止:{capability_id}")
|
||||
runtime.stop(capability_id, reason=reason)
|
||||
|
||||
|
||||
async def stop_managed_resource_async(capability_id: str, *, reason: str) -> None:
|
||||
"""异步停止资源;同步资源移交工作线程。"""
|
||||
runtime = _runtime(required=False)
|
||||
if runtime is None:
|
||||
return
|
||||
kind = _resource_kind(runtime, capability_id)
|
||||
if kind == MANAGED_RESOURCE_ASYNC_KIND:
|
||||
await runtime.stop_async(capability_id, reason=reason)
|
||||
return
|
||||
if kind == MANAGED_RESOURCE_SYNC_KIND:
|
||||
await asyncio.to_thread(runtime.stop, capability_id, reason=reason)
|
||||
return
|
||||
raise RuntimeError(f"未知 Managed Resource kind:{kind}")
|
||||
|
||||
|
||||
async def shutdown_managed_resource_runtime(*, reason: str) -> None:
|
||||
"""关闭已配置 Runtime;未配置时直接返回,绝不因关闭而创建资源。"""
|
||||
runtime = _runtime(required=False)
|
||||
if runtime is None:
|
||||
return
|
||||
await runtime.shutdown_async(reason=reason)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""插件可依赖的轻量浏览器启动接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def launch_browser_context(headless: bool = True, **kwargs: Any) -> Any:
|
||||
"""
|
||||
启动同步浏览器上下文,并由宿主协调所需进程资源。
|
||||
|
||||
:param headless: 是否使用无头模式
|
||||
:param kwargs: 浏览器实现接受的其余启动参数
|
||||
:return: 浏览器上下文
|
||||
"""
|
||||
from app.adapters.network.browser import launch_browser_context as launch
|
||||
|
||||
return launch(headless=headless, **kwargs)
|
||||
|
||||
|
||||
async def launch_browser_context_async(headless: bool = True, **kwargs: Any) -> Any:
|
||||
"""
|
||||
启动异步浏览器上下文,并由宿主协调所需进程资源。
|
||||
|
||||
:param headless: 是否使用无头模式
|
||||
:param kwargs: 浏览器实现接受的其余启动参数
|
||||
:return: 浏览器上下文
|
||||
"""
|
||||
from app.adapters.network.browser import launch_browser_context_async as launch
|
||||
|
||||
return await launch(headless=headless, **kwargs)
|
||||
|
||||
|
||||
__all__ = ["launch_browser_context", "launch_browser_context_async"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Managed Resource 的启动组合与进程关闭入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from app.runtime.capabilities.runtime import CapabilityRuntime
|
||||
from app.runtime.extensions.managed_resource_adapter import (
|
||||
AsyncManagedResourceAdapter,
|
||||
SyncManagedResourceAdapter,
|
||||
build_managed_resource_registry,
|
||||
)
|
||||
from app.runtime.managed_resources import (
|
||||
MANAGED_RESOURCE_ASYNC_KIND,
|
||||
MANAGED_RESOURCE_SYNC_KIND,
|
||||
configure_managed_resource_runtime,
|
||||
)
|
||||
|
||||
|
||||
_runtime_lock = threading.RLock()
|
||||
_managed_resource_runtime: Optional[CapabilityRuntime] = None
|
||||
|
||||
|
||||
def init_managed_resources() -> CapabilityRuntime:
|
||||
"""构建并注入资源 Runtime;只发现声明,不物化或启动任何资源。"""
|
||||
global _managed_resource_runtime
|
||||
with _runtime_lock:
|
||||
if _managed_resource_runtime is None:
|
||||
_managed_resource_runtime = CapabilityRuntime(
|
||||
build_managed_resource_registry(),
|
||||
adapters={
|
||||
MANAGED_RESOURCE_SYNC_KIND: SyncManagedResourceAdapter(),
|
||||
MANAGED_RESOURCE_ASYNC_KIND: AsyncManagedResourceAdapter(),
|
||||
},
|
||||
)
|
||||
configure_managed_resource_runtime(_managed_resource_runtime)
|
||||
return _managed_resource_runtime
|
||||
|
||||
|
||||
async def stop_managed_resources() -> None:
|
||||
"""关闭已经初始化的资源 Runtime;未初始化时不执行发现或激活。"""
|
||||
with _runtime_lock:
|
||||
runtime = _managed_resource_runtime
|
||||
if runtime is None:
|
||||
return
|
||||
await runtime.shutdown_async(reason="application_shutdown")
|
||||
@@ -22,7 +22,6 @@ from app.runtime.extensions.module_manager import ModuleManager
|
||||
from app.runtime.events import EventManager
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.runtime.thread import ThreadHelper
|
||||
from app.adapters.system.display import DisplayHelper
|
||||
from app.adapters.network.doh import DohHelper
|
||||
from app.adapters.system.resource import (
|
||||
ResourceHelper,
|
||||
@@ -36,6 +35,10 @@ from app.command import CommandChain
|
||||
from app.schemas import Notification, NotificationType
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.startup.agent_initializer import init_agent, stop_agent
|
||||
from app.startup.managed_resources_initializer import (
|
||||
init_managed_resources,
|
||||
stop_managed_resources,
|
||||
)
|
||||
from app.application.security.access import set_superuser_token_payload_provider
|
||||
from app.application.security.auth import build_superuser_token_payload
|
||||
from app.application.image import configure_wallpaper_providers
|
||||
@@ -170,6 +173,13 @@ def update_resources() -> None:
|
||||
logger.error(f"资源更新完成但自动重启失败:{message}")
|
||||
|
||||
|
||||
def close_browser_sessions() -> None:
|
||||
"""在托管资源关闭前释放所有浏览器上下文及其工作线程。"""
|
||||
from app.adapters.network.browser import BrowserSessionHelper
|
||||
|
||||
BrowserSessionHelper.close_all_sessions()
|
||||
|
||||
|
||||
async def stop_modules():
|
||||
"""
|
||||
服务关闭
|
||||
@@ -186,7 +196,8 @@ async def stop_modules():
|
||||
await run_step("AI智能体", stop_agent)
|
||||
await run_step("模块", lambda: ModuleManager().shutdown())
|
||||
await run_step("事件消费", lambda: EventManager().stop())
|
||||
await run_step("虚拟显示", lambda: DisplayHelper().stop())
|
||||
await run_step("浏览器会话", close_browser_sessions)
|
||||
await run_step("托管资源", stop_managed_resources)
|
||||
await run_step("DoH服务", lambda: DohHelper().shutdown())
|
||||
await run_step("线程池", lambda: ThreadHelper().shutdown())
|
||||
await run_step("消息服务", stop_message)
|
||||
@@ -201,12 +212,12 @@ async def init_modules():
|
||||
"""
|
||||
启动模块
|
||||
"""
|
||||
# 托管资源只在这里装配声明与 adapter,具体资源仍由首个消费者显式激活。
|
||||
init_managed_resources()
|
||||
# 应用服务不反向依赖 Chain,由启动组合层注入壁纸来源。
|
||||
configure_wallpaper_services()
|
||||
# 认证访问层不反向依赖数据库实现,由启动组合层注入载荷提供器。
|
||||
set_superuser_token_payload_provider(build_superuser_token_payload)
|
||||
# 虚拟显示
|
||||
DisplayHelper()
|
||||
# DoH
|
||||
DohHelper()
|
||||
# 站点管理
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
from pathlib import Path
|
||||
|
||||
from app.runtime.compat.diagnostics import (
|
||||
configure_legacy_import_diagnostics,
|
||||
scan_plugin_legacy_imports,
|
||||
)
|
||||
from app.runtime.compat.resource_imports import scan_plugin_resource_imports
|
||||
from app.runtime.config import global_vars
|
||||
from app.runtime.extensions.plugin_manager import (
|
||||
PluginManager,
|
||||
configure_plugin_install_reporter,
|
||||
configure_plugin_legacy_import_services,
|
||||
configure_plugin_resource_import_preparer,
|
||||
configure_site_auth_level_provider,
|
||||
)
|
||||
from app.runtime.managed_resources import acquire_managed_resource
|
||||
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.runtime.log import logger
|
||||
|
||||
|
||||
def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None:
|
||||
"""在执行旧插件顶层代码前准备其静态导入所需的宿主资源。"""
|
||||
for capability_id in scan_plugin_resource_imports(plugin_id, plugin_dir):
|
||||
acquire_managed_resource(
|
||||
capability_id,
|
||||
reason="legacy_plugin_import",
|
||||
)
|
||||
|
||||
|
||||
def _configure_plugin_services() -> None:
|
||||
"""把兼容诊断、远程上报和站点认证等级装配到插件管理器。"""
|
||||
configure_plugin_legacy_import_services(
|
||||
diagnostics_configurator=configure_legacy_import_diagnostics,
|
||||
import_scanner=scan_plugin_legacy_imports,
|
||||
)
|
||||
configure_plugin_resource_import_preparer(_prepare_legacy_plugin_import)
|
||||
configure_plugin_install_reporter(MoviePilotServerHelper.install_plugin_reg)
|
||||
configure_site_auth_level_provider(lambda: SitesHelper().auth_level)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user