Files
MoviePilot/app/runtime/extensions/plugin/runtime.py
Aqr-K d44480d739 Merge pull request #6666 from Aqr-K/feat/plugin-instance-target-enablement
feat(plugin): 插件实例默认调用目标与启停,未指定实例时不再兜底取第一个
2026-09-13 16:37:27 +08:00

386 lines
15 KiB
Python

"""插件宿主运行时依赖聚合与构造。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Protocol
from app.foundation.crypto import RSAUtils
from app.foundation.version import compare_version
from app.runtime.events import eventmanager
from app.runtime.extensions.plugin.access import PluginAccessPolicy
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
from app.runtime.extensions.plugin.catalog import PluginCatalogFacade
from app.runtime.extensions.plugin.classification import PluginClassificationRegistry
from app.runtime.extensions.plugin.clone import PluginCloneService
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
from app.runtime.extensions.plugin.database import PluginDatabase
from app.runtime.extensions.plugin.dependency import PluginDependencyService
from app.runtime.extensions.plugin.lifecycle import PluginLifecycle
from app.runtime.extensions.plugin.loader import PluginLoader
from app.runtime.extensions.plugin.loglevel import PluginLogLevelControl
from app.runtime.extensions.plugin.metadata import PluginMetadataMapper
from app.runtime.extensions.plugin.monitor import PluginMonitorController
from app.runtime.extensions.plugin.paths import PluginPathResolver
from app.runtime.extensions.plugin.projection import PluginProjection
from app.runtime.extensions.plugin.registry import PluginRegistry
from app.runtime.extensions.plugin.storage import (
PluginConfigStore,
PluginInstanceDirectory,
PluginInstanceStore,
PluginStorage,
)
from app.runtime.extensions.plugin.sync import (
LocalPluginSyncService,
PluginSyncService,
)
from app.runtime.extensions.plugin.system import PluginSystemServices
from app.runtime.extensions.plugin.target import PluginDefaultTargetControl
from app.runtime.extensions.plugin.tools import PluginToolCatalog
from app.schemas.types import SystemConfigKey
class PluginRuntimeHost(Protocol):
"""声明运行时 owner 回调宿主生命周期门面的最小合同。"""
def reload_plugin(self, plugin_id: str) -> Any:
"""重载指定插件。"""
...
def remove_plugin(self, plugin_id: str) -> Any:
"""移除指定插件运行实例。"""
...
@staticmethod
def get_plugin_remote_entry(plugin_id: str, page: str) -> str:
"""构造插件远程页面入口。"""
...
def _run_file_watcher(self) -> None:
"""运行插件文件监控循环。"""
...
def get_plugins_from_market(
self,
market: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[list[Any]]:
"""读取指定市场目录。"""
...
async def async_get_plugins_from_market(
self,
market: str,
package_version: Optional[str] = None,
force: bool = False,
) -> Optional[list[Any]]:
"""异步读取指定市场目录。"""
...
PluginCatalogFactory = Callable[[Callable[..., Any]], Any]
PluginImportService = Callable[..., None]
PluginRemoteEntryBuilder = Callable[[str, str], str]
@dataclass(frozen=True, slots=True)
class PluginRuntimeEnvironment:
"""保存由组合根提供的插件运行时外部端口。"""
plugins_root: Path
storage: Callable[[], PluginStorage]
instance_directory: Callable[[], PluginInstanceDirectory]
system: Callable[[], PluginSystemServices]
database: Callable[[], PluginDatabase]
catalog_factory: PluginCatalogFactory
import_preparer: PluginImportService
import_scanner: PluginImportService
auth_level: Callable[[], int]
remote_entry: PluginRemoteEntryBuilder
development: Callable[[], bool]
logger: Any
# 默认调用目标的置位与清除必须在库层一个事务内清旧置新,因而由组合根直接给出
# 原子写入端口,不经过按实例逐行读写的实例表端口
set_default_target: Callable[[str, str], bool]
clear_default_target: Callable[[str], None]
@dataclass(frozen=True, slots=True)
class PluginRuntime:
"""聚合一个 PluginManager 生命周期内唯一的职责 owner。"""
registry: PluginRegistry
instances: PluginInstanceStore
configs: PluginConfigStore
access: PluginAccessPolicy
catalog: PluginCatalogFacade
paths: PluginPathResolver
local_sync: LocalPluginSyncService
monitor: PluginMonitorController
admission: PluginMutationAdmission
dependencies: PluginDependencyService
loader: PluginLoader
tools: PluginToolCatalog
lifecycle: PluginLifecycle
metadata: PluginMetadataMapper
sync: PluginSyncService
clone: PluginCloneService
log_level: PluginLogLevelControl
default_target: PluginDefaultTargetControl
projection: PluginProjection
classification: PluginClassificationRegistry
recent_local_sync: dict[str, float]
system: Callable[[], PluginSystemServices]
def build_plugin_runtime(
host: PluginRuntimeHost,
environment: PluginRuntimeEnvironment,
*,
tool_build_max_attempts: int,
) -> PluginRuntime:
"""按依赖顺序构造唯一插件运行时,各业务能力仍由对应 owner 实现。"""
registry = PluginRegistry()
instances = PluginInstanceStore(
storage=environment.storage,
directory=environment.instance_directory,
)
configs = PluginConfigStore(
storage=environment.storage,
database=environment.database,
plugin_exists=lambda plugin_id: bool(registry.classes.get(plugin_id)),
)
access = PluginAccessPolicy(
auth_level=environment.auth_level,
verify_keys=RSAUtils.verify_rsa_keys,
log=environment.logger,
)
loader = PluginLoader(
plugins_root=environment.plugins_root,
import_preparer=environment.import_preparer,
import_scanner=environment.import_scanner,
log=environment.logger,
)
tools = PluginToolCatalog(max_attempts=tool_build_max_attempts)
classification = PluginClassificationRegistry(environment.logger)
def refresh_classification(plugin_id: str, instance: Any) -> None:
"""读取插件当前媒体来源声明并替换其分类扩展注册。"""
classification.remove(plugin_id)
declarations = (
instance.get_media_source() or []
if supports_plugin_hook(instance, "get_media_source")
else []
)
classification.replace(plugin_id, declarations)
def load_plugins(
plugin_id: Optional[str],
loadable_plugins: list[str],
validator: Callable[[Any], bool],
) -> list[Any]:
"""加载物理插件或虚拟实例,并保持持久化实例顺序。"""
if plugin_id:
instance = instances.get(plugin_id)
if instance:
return loader.load_instance(instance, validator)
return loader.load(plugin_id, loadable_plugins, validator)
plugins = loader.load(None, loadable_plugins, validator)
# 只装载启用的配置:停用的分身仍登记在册、卡片可见,但不该被实例化
for instance in instances.enabled().values():
plugins.extend(loader.load_instance(instance, validator))
return plugins
lifecycle = PluginLifecycle(
classes=registry.classes,
running=registry.running,
load_plugins=load_plugins,
# 本体的装载判据归口到实例表的启用位;安装清单只回答「包在不在磁盘上」,
# 它同时兼任运行开关时,「装着但先不跑」根本没有地方可以表达
loadable_plugins=lambda: list(instances.enabled_hosts()),
plugin_config=configs.read,
auth_checker=lambda plugin: access.check(plugin),
clear_modules=loader.clear_modules,
clear_tools=tools.clear,
enable_events=eventmanager.enable_event_handler,
disable_events=eventmanager.disable_event_handler,
runtime_status_writer=registry.set_runtime_status,
database=environment.database,
log=environment.logger,
event_sender=eventmanager.send_event,
refresh_classification=refresh_classification,
remove_classification=classification.remove,
)
metadata = PluginMetadataMapper(
plugin_instance=registry.instance,
plugin_class=registry.plugin_class,
annotate_system_version=lambda info: environment.system().annotate_system_version(
info
),
is_package_compatible=lambda info, version: environment.system().is_package_compatible(
info,
version,
),
auth_checker=lambda plugin, source: access.check(plugin, source),
version_compare=lambda source, comparison, target: (
compare_version(source, comparison, target) is True
),
log=environment.logger,
)
catalog = PluginCatalogFacade(
classes=lambda: registry.classes,
running=lambda: registry.running,
storage=environment.storage,
system=environment.system,
market_catalog=lambda: environment.catalog_factory(metadata.map),
market_loader=lambda market, package_version=None, force=False: (
host.get_plugins_from_market(market, package_version, force)
),
async_market_loader=lambda market, package_version=None, force=False: (
host.async_get_plugins_from_market(
market,
package_version,
force,
)
),
map_plugin=lambda **kwargs: metadata.map(
plugin_id=kwargs["pid"],
plugin_info=kwargs["plugin_info"],
market=kwargs["market"],
installed_plugins=kwargs["installed_apps"],
add_time=kwargs["add_time"],
package_version=kwargs.get("package_version"),
),
auth_checker=lambda **kwargs: access.check(**kwargs),
plugin_attr=lambda plugin_id, attribute: getattr(
registry.instance(plugin_id),
attribute,
None,
),
plugin_instance=instances.get,
plugin_instances=instances.all,
host_instances=instances.all_hosts,
runtime_status=registry.runtime_status,
log=environment.logger,
)
paths = PluginPathResolver(
runtime_root=environment.plugins_root,
running=lambda: registry.running,
system=environment.system,
strict_system_version=lambda: not environment.development(),
log=environment.logger,
)
recent_local_sync: dict[str, float] = {}
local_sync = LocalPluginSyncService(
installed_plugins=lambda: environment.storage().read(
SystemConfigKey.UserInstalledPlugins
) or [],
candidate=lambda plugin_id: environment.system().local_candidate(plugin_id),
system=environment.system,
recent_sync=recent_local_sync,
log=environment.logger,
)
dependencies = PluginDependencyService(
system=environment.system,
# 分类结果会被逐个 start(),因此两层都只能给出应当装载的那一部分
instances=instances.enabled,
loadable_hosts=lambda: set(instances.enabled_hosts()),
registry=registry,
log=environment.logger,
)
sync = PluginSyncService(
frozen=lambda: environment.system().is_frozen(),
installed_plugins=lambda: environment.storage().read(
SystemConfigKey.UserInstalledPlugins
) or [],
online_plugins=catalog.online,
# 启动恢复必须保留本地仓库扫描失败,不能把异常降级为空候选后
# 再从在线市场下载覆盖当前载荷。
local_plugins=lambda: catalog.local_repository(raise_errors=True),
merge_plugins=lambda higher, base, _markets: catalog.merge(higher, base),
plugin_exists=catalog.exists,
install=lambda plugin_id, repo_url, force, startup_token: environment.system().install_plugin(
plugin_id=plugin_id,
repo_url=repo_url,
force=force,
startup_token=startup_token,
),
runtime_status_writer=registry.set_runtime_status,
log=environment.logger,
)
def source_plugin_id(plugin_id: str) -> str:
"""把虚拟实例归一到持久化的物理源码插件。"""
instance = instances.get(plugin_id)
return instance.source_plugin_id if instance else plugin_id
clone = PluginCloneService(
plugin_class=registry.plugin_class,
plugin_exists=catalog.exists,
source_plugin_id=source_plugin_id,
save_instance=instances.save,
delete_instance=instances.delete,
read_config=configs.read,
save_config=lambda plugin_id, config: configs.write(
plugin_id,
config,
force=True,
),
delete_config=lambda plugin_id: configs.delete(plugin_id, force=True),
reload_plugin=host.reload_plugin,
remove_plugin=host.remove_plugin,
log=environment.logger,
)
log_level = PluginLogLevelControl(
plugin_exists=lambda plugin_id: registry.plugin_class(plugin_id) is not None,
get_instance=instances.get,
instances_for_source=instances.for_source,
read_log_level=configs.read_log_level,
write_log_level=configs.write_log_level,
)
default_target = PluginDefaultTargetControl(
plugin_exists=lambda plugin_id: registry.plugin_class(plugin_id) is not None,
get_instance=instances.get,
instances_for_source=instances.for_source,
get_host_instance=instances.get_host,
save_host_instance=instances.save_host,
running=lambda: registry.running,
set_default_target=environment.set_default_target,
clear_default_target=environment.clear_default_target,
)
projection = PluginProjection(
registry.running,
environment.logger,
environment.remote_entry,
)
return PluginRuntime(
registry=registry,
instances=instances,
configs=configs,
access=access,
catalog=catalog,
paths=paths,
local_sync=local_sync,
monitor=PluginMonitorController(
runner=host._run_file_watcher,
log=environment.logger,
),
admission=PluginMutationAdmission(),
dependencies=dependencies,
loader=loader,
tools=tools,
lifecycle=lifecycle,
metadata=metadata,
sync=sync,
clone=clone,
log_level=log_level,
default_target=default_target,
projection=projection,
classification=classification,
recent_local_sync=recent_local_sync,
system=environment.system,
)