Files
MoviePilot/app/runtime/extensions/plugin/runtime.py

460 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""插件宿主运行时依赖聚合与构造。"""
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]:
"""加载物理插件或虚拟实例,并保持持久化实例顺序。
带具体实例 ID 的定向装载同样认启用位。实例存储的读取口刻意返回全部在册行
(含停用的),加载器在收到具体插件 ID 时也只按这个 ID 找目录、不看可装载
清单;两处叠在一起,源码变更触发的实例树重载、按 ID 发起的重载就会绕过启用
判据,把用户停用的实例又拉起来跑到下次重启。
"""
if plugin_id:
instance = instances.get(plugin_id)
if instance:
if not instance.is_enabled:
return []
return loader.load_instance(instance, validator)
if not any(
loadable.casefold() == plugin_id.casefold()
for loadable in loadable_plugins
):
return []
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
def plugin_registered(plugin_id: str) -> bool:
"""判断插件是否在册:装过(安装清单里有)或留有持久化的实例行。
默认调用目标与实例日志等级这两个管理接口问的都是「这个插件还在不在册、能不能
被管理」,因此共用这一个判据,而不能绑在运行期类注册表上:启动只把启用中的
本体与分身装进注册表,某插件的全部实例停用后重启,注册表里就没有它的类了,
但它的安装记录与实例行都还在。绑在注册表上等于说「停用即不存在」,而停用不是
卸载——在册的实例必须仍然可见、可管理,否则用户再也无法把它重新指回默认调用
目标,也调不出它的日志等级设置,而那份设置正是排查它为什么被停用时要看的。
「当前是否装载」是另一个问题,由各自的端口回答:插件配置读写看类注册表,
分身建号与安装前置看包在不在磁盘上,都不走这里。
:param plugin_id: 插件 ID
:return: 该插件是否在册
"""
if instances.get_host(plugin_id) is not None:
return True
if instances.for_source(plugin_id):
return True
installed = environment.storage().read(
SystemConfigKey.UserInstalledPlugins
) or []
return plugin_id in installed
def instance_id_taken(instance_id: str) -> bool:
"""判断一个候选实例 ID 是否已被占用。
创建分身的判存与自动分配后缀共用这一个判据,自动分配因此不可能挑中一个手填
时会被拒绝的 ID。四条依据各自覆盖一类占用者缺一条就会让新分身顶掉一个真实
存在的插件身份:
* 类注册表——当前已装载的本体与分身;
* 实例行——含已停用的分身与本体,它们的配置还留在行上,不是空位;
* 安装清单——装过但此刻未装载的物理插件;
* 插件包目录——卸载不删源码,磁盘上因此会留下不在前三者里的插件包,占了它的
号会让那个插件以后再也装不回来(实例行的归属列对不上,写入直接被拒)。
判存不能只看运行态:源插件本次加载失败时,已有的同名分身会被判成「不存在」
而放行,随后它的描述符被覆盖,再在回滚里连同配置一起删掉。``catalog.exists``
不足以充当磁盘判据——它要从**运行中**的实例上取版本号,未装载的插件包一律
报告不存在,因而这里直接看包目录。
:param instance_id: 候选实例 ID
:return: 该 ID 是否已被占用
"""
if registry.plugin_class(instance_id) is not None:
return True
if instances.get(instance_id) is not None:
return True
if instances.get_host(instance_id) is not None:
return True
installed = environment.storage().read(
SystemConfigKey.UserInstalledPlugins
) or []
if instance_id in installed:
return True
return (environment.plugins_root / instance_id.lower()).is_dir()
clone = PluginCloneService(
plugin_class=registry.plugin_class,
instance_id_taken=instance_id_taken,
get_instance=instances.get,
source_plugin_id=source_plugin_id,
save_instance=instances.save,
delete_instance=instances.delete,
disable_instance=instances.disable,
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=plugin_registered,
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=plugin_registered,
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,
)