mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 16:13:35 +08:00
386 lines
15 KiB
Python
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,
|
|
)
|