mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 16:13:35 +08:00
460 lines
18 KiB
Python
460 lines
18 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]:
|
||
"""加载物理插件或虚拟实例,并保持持久化实例顺序。
|
||
|
||
带具体实例 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,
|
||
)
|