mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-10 15:50:24 +08:00
* fix(plugin): bound market metadata inputs * test(architecture): sync plugin metadata baseline * fix(plugin): narrow validated metadata identifiers * test(architecture): sync coverage baseline
295 lines
12 KiB
Python
295 lines
12 KiB
Python
"""插件本地运行态和远程市场目录投影。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
from collections.abc import Callable, Mapping
|
||
from typing import Any, Optional
|
||
|
||
from app.foundation.version import compare_version
|
||
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
|
||
from app.runtime.extensions.plugin.storage import PluginStorage
|
||
from app.runtime.extensions.plugin.system import PluginSystemServices
|
||
from app.runtime.settings import get_runtime_setting
|
||
from app.schemas.plugin import Plugin, PluginInstance, PluginRuntimeStatus
|
||
from app.schemas.types import SystemConfigKey
|
||
|
||
|
||
class PluginCatalogFacade:
|
||
"""把插件目录应用服务与运行态注册表连接起来。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
classes: Callable[[], Mapping[str, Any]],
|
||
running: Callable[[], Mapping[str, Any]],
|
||
storage: Callable[[], PluginStorage],
|
||
system: Callable[[], PluginSystemServices],
|
||
market_catalog: Callable[[], Any],
|
||
market_loader: Callable[..., Any],
|
||
async_market_loader: Callable[..., Any],
|
||
map_plugin: Callable[..., Optional[Plugin]],
|
||
auth_checker: Callable[..., bool],
|
||
plugin_attr: Callable[[str, str], Any],
|
||
plugin_instance: Callable[[str], Optional[PluginInstance]],
|
||
plugin_instances: Callable[[], dict[str, PluginInstance]],
|
||
runtime_status: Callable[[str], Optional[PluginRuntimeStatus]],
|
||
log: Any,
|
||
) -> None:
|
||
"""保存注册表、目录服务和插件外部系统端口。"""
|
||
self._classes = classes
|
||
self._running = running
|
||
self._storage = storage
|
||
self._system = system
|
||
self._market_catalog = market_catalog
|
||
self._market_loader = market_loader
|
||
self._async_market_loader = async_market_loader
|
||
self._map_plugin = map_plugin
|
||
self._auth_checker = auth_checker
|
||
self._plugin_attr = plugin_attr
|
||
self._plugin_instance = plugin_instance
|
||
self._plugin_instances = plugin_instances
|
||
self._runtime_status = runtime_status
|
||
self._logger = log
|
||
|
||
def online(self, force: bool = False) -> list[Plugin]:
|
||
"""读取所有兼容代际的在线插件目录。"""
|
||
plugin_market = get_runtime_setting('PLUGIN_MARKET')
|
||
if not plugin_market:
|
||
return []
|
||
markets = [item for item in plugin_market.split(",") if item]
|
||
result = self._market_catalog().collect(
|
||
markets=markets,
|
||
compatible_flags=self._system().compatible_flags(
|
||
get_runtime_setting('VERSION_FLAG')
|
||
),
|
||
force=force,
|
||
loader=self._market_loader,
|
||
)
|
||
self._logger.info(f"获取到 {len(result)} 个线上插件")
|
||
return result
|
||
|
||
def local(self) -> list[Plugin]:
|
||
"""把已加载插件投影为本地插件目录 DTO。"""
|
||
installed = self._installed_ids()
|
||
plugins: list[Plugin] = []
|
||
for plugin_id, plugin_class in self._classes().items():
|
||
plugin_instance = self._running().get(plugin_id)
|
||
instance = self._plugin_instance(plugin_id)
|
||
plugin = Plugin(
|
||
id=plugin_id,
|
||
installed=plugin_id in installed,
|
||
state=self._safe_state(plugin_id, plugin_instance),
|
||
runtime_status=self._runtime_status(plugin_id),
|
||
has_page=supports_plugin_hook(plugin_class, "get_page"),
|
||
plugin_public_key=getattr(plugin_class, "plugin_public_key", None),
|
||
plugin_name=getattr(plugin_class, "plugin_name", None),
|
||
plugin_desc=getattr(plugin_class, "plugin_desc", None),
|
||
plugin_version=getattr(plugin_class, "plugin_version", None),
|
||
plugin_icon=getattr(plugin_class, "plugin_icon", None),
|
||
plugin_author=getattr(plugin_class, "plugin_author", None),
|
||
author_url=getattr(plugin_class, "author_url", None),
|
||
plugin_order=getattr(plugin_class, "plugin_order", 0),
|
||
has_update=False,
|
||
is_local=True,
|
||
source_plugin_id=getattr(plugin_class, "plugin_source_id", None),
|
||
is_instance=instance is not None,
|
||
instance_mode=instance.mode if instance else None,
|
||
)
|
||
if not self._auth_checker(plugin=plugin, source=plugin_class):
|
||
continue
|
||
plugins.append(plugin)
|
||
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
|
||
return plugins
|
||
|
||
def installed(self) -> list[Plugin]:
|
||
"""按安装清单投影插件,未加载项目仍返回可观察占位卡片。"""
|
||
installed_ids = self._installed_ids()
|
||
local_by_id = {
|
||
plugin.id: plugin
|
||
for plugin in self.local()
|
||
if plugin.installed and plugin.id
|
||
}
|
||
result = []
|
||
for plugin_id in installed_ids:
|
||
plugin = local_by_id.get(plugin_id)
|
||
if plugin:
|
||
result.append(plugin)
|
||
continue
|
||
instance = self._plugin_instance(plugin_id)
|
||
result.append(Plugin(
|
||
id=plugin_id,
|
||
plugin_name=plugin_id,
|
||
installed=True,
|
||
state=False,
|
||
runtime_status=self._runtime_status(plugin_id),
|
||
is_local=True,
|
||
source_plugin_id=(
|
||
instance.source_plugin_id if instance else None
|
||
),
|
||
is_instance=instance is not None,
|
||
instance_mode=instance.mode if instance else None,
|
||
))
|
||
# 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。
|
||
# 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。
|
||
return result
|
||
|
||
def local_version(self, plugin_id: str) -> Optional[str]:
|
||
"""读取指定已安装插件版本,不触发全量目录投影。"""
|
||
installed = self._installed_ids()
|
||
if plugin_id not in installed:
|
||
return None
|
||
plugin_class = self._classes().get(plugin_id)
|
||
return getattr(plugin_class, "plugin_version", None)
|
||
|
||
def local_repository(self) -> list[Plugin]:
|
||
"""读取本地插件仓候选并映射为目录 DTO。"""
|
||
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||
try:
|
||
candidates = self._system().local_candidates()
|
||
except Exception as error: # noqa: BLE001 - 展示失败不能阻断整个插件目录
|
||
self._logger.warning(f"读取本地插件仓候选失败,已跳过本地目录展示:{error}")
|
||
return []
|
||
plugins: list[Plugin] = []
|
||
for plugin_id, info in candidates.items():
|
||
package_version = info.get("package_version")
|
||
plugin = self._map_plugin(
|
||
pid=plugin_id,
|
||
plugin_info=info,
|
||
market=self._system().local_repo_url(
|
||
plugin_id,
|
||
None,
|
||
package_version,
|
||
),
|
||
installed_apps=installed,
|
||
add_time=0,
|
||
package_version=package_version,
|
||
)
|
||
if plugin:
|
||
plugin.is_local = True
|
||
plugins.append(plugin)
|
||
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
|
||
self._logger.info(f"获取到 {len(plugins)} 个本地插件")
|
||
return plugins
|
||
|
||
def exists(self, plugin_id: str, version: Optional[str] = None) -> bool:
|
||
"""判断插件包和已加载版本是否满足安装前置条件。"""
|
||
if not plugin_id:
|
||
return False
|
||
try:
|
||
instance = self._plugin_instance(plugin_id)
|
||
source_plugin_id = (
|
||
instance.source_plugin_id if instance else plugin_id
|
||
)
|
||
package_name = f"app.plugins.{source_plugin_id.lower()}"
|
||
spec = importlib.util.find_spec(package_name)
|
||
if spec is None or spec.origin is None:
|
||
return False
|
||
local_version = self._plugin_attr(plugin_id, "plugin_version")
|
||
if not local_version and instance:
|
||
local_version = self._plugin_attr(
|
||
instance.source_plugin_id,
|
||
"plugin_version",
|
||
)
|
||
if not local_version:
|
||
return False
|
||
if version and not compare_version(local_version, ">=", version):
|
||
self._logger.warning(
|
||
f"Plugin {plugin_id} version: {local_version} "
|
||
f"(older than version: {version})"
|
||
)
|
||
return False
|
||
return True
|
||
except Exception as error:
|
||
self._logger.debug(f"获取插件是否在本地包中存在失败,{error}")
|
||
return False
|
||
|
||
def _installed_ids(self) -> list[str]:
|
||
"""合并物理安装清单和虚拟实例清单并保持各自持久化顺序。"""
|
||
installed = list(
|
||
self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
|
||
)
|
||
for instance_id in self._plugin_instances():
|
||
if instance_id not in installed:
|
||
installed.append(instance_id)
|
||
return installed
|
||
|
||
def get_from_market(
|
||
self,
|
||
market: str,
|
||
package_version: Optional[str] = None,
|
||
force: bool = False,
|
||
) -> list[Plugin]:
|
||
"""读取并映射指定插件市场。"""
|
||
return self._market_catalog().load(market, package_version, force)
|
||
|
||
async def async_online(
|
||
self,
|
||
force: bool = False,
|
||
progress_callback: Optional[Callable[..., None]] = None,
|
||
) -> list[Plugin]:
|
||
"""异步读取所有兼容代际的在线插件目录。"""
|
||
plugin_market = get_runtime_setting('PLUGIN_MARKET')
|
||
if not plugin_market:
|
||
if progress_callback:
|
||
progress_callback(value=100, text="未配置插件市场,跳过刷新")
|
||
return []
|
||
markets = [item for item in plugin_market.split(",") if item]
|
||
result = await self._market_catalog().async_collect(
|
||
markets=markets,
|
||
compatible_flags=self._system().compatible_flags(
|
||
get_runtime_setting('VERSION_FLAG')
|
||
),
|
||
force=force,
|
||
loader=self._async_market_loader,
|
||
progress_callback=progress_callback,
|
||
)
|
||
self._logger.info(f"获取到 {len(result)} 个线上插件")
|
||
return result
|
||
|
||
async def async_online_candidates(self, force: bool = False) -> list[Plugin]:
|
||
"""读取在线目录并保留每个仓库的最高候选,供来源准入使用。"""
|
||
plugin_market = get_runtime_setting('PLUGIN_MARKET')
|
||
if not plugin_market:
|
||
return []
|
||
markets = [item for item in plugin_market.split(",") if item]
|
||
result: list[Plugin] = await self._market_catalog().async_collect(
|
||
markets=markets,
|
||
compatible_flags=self._system().compatible_flags(
|
||
get_runtime_setting('VERSION_FLAG')
|
||
),
|
||
force=force,
|
||
loader=self._async_market_loader,
|
||
preserve_sources=True,
|
||
)
|
||
return result
|
||
|
||
async def async_get_from_market(
|
||
self,
|
||
market: str,
|
||
package_version: Optional[str] = None,
|
||
force: bool = False,
|
||
) -> list[Plugin]:
|
||
"""异步读取并映射指定插件市场。"""
|
||
return await self._market_catalog().async_load(
|
||
market,
|
||
package_version,
|
||
force,
|
||
)
|
||
|
||
def merge(self, higher: list[Plugin], base: list[Plugin]) -> list[Plugin]:
|
||
"""合并不同代际插件目录并保留市场优先级。"""
|
||
plugin_market = get_runtime_setting('PLUGIN_MARKET')
|
||
markets = [item for item in plugin_market.split(",") if item]
|
||
return self._market_catalog().merge(higher, base, markets)
|
||
|
||
def _safe_state(self, plugin_id: str, plugin: Any) -> bool:
|
||
"""读取插件状态,单个插件异常不阻断整个本地目录。"""
|
||
if not plugin or not hasattr(plugin, "get_state"):
|
||
return False
|
||
try:
|
||
return bool(plugin.get_state())
|
||
except Exception as error:
|
||
self._logger.error(f"获取插件 {plugin_id} 状态出错:{error}")
|
||
return False
|