Files
MoviePilot/app/runtime/extensions/plugin/catalog.py
Aqr-K e47d7defe7 feat(plugin): 插件本体与分身可各自设置独立日志等级
全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行
日志会淹没在其他几十个插件与宿主自身的 DEBUG 输出里,找不出来;不下调又什么都
看不见。插件实例数量只会继续增长,这个矛盾不会自己消失。

因此把等级下放到实例:plugininstance 行加 log_level / log_expires_at 两列,本体与
分身共用同一张表、同一套语义。等级落在实例自己那一行而不是另起一张按实例 ID 索引
的表——它和业务参数一样是用户按实例设置的东西,同一个生命周期,实例被删时应当随行
一起消失。清除覆盖后若本体行只剩一对身份列,整行按 carries_only_identity 回收,与
清空业务参数同一规则。

覆盖带失效时间,因为它是调试设置不是长期配置:开了 DEBUG 忘记关,下次谁也想不起
某个插件为什么一直在刷日志。过期判定在读取时惰性执行并就地清理,不另起后台线程去
扫一张最多几十条的表。expires_at 在写入侧先归一到 UTC 再分发给缓存与落盘两处:客户端
提交的时间可能不带时区,两处各按进程时区折算一次,就会与读取口返回的 UTC 值差出一个
时区,表现为「提交的失效时刻和读回来的对不上」。

运行期用 ContextVar 在宿主自己控制的四类调用点绑定当前实例——构造与 init_plugin、
事件处理器、定时服务回调、插件声明的 HTTP 端点——而不是沿用 logger() 已有的栈帧内省。
内省认的是栈顶的模块与类,插件通过宿主公共方法转发调用时栈顶是宿主而不是发起调用的
插件,会把日志记到错误的来源上;分身更甚:分身共享源码,类的 __qualname__ 保持源类名
不变,内省根本分不出是哪一个分身在打日志。内省结果继续只用于文件路由,不参与等级判定。

等级过滤必须排在全局等级判定之前。官方现状是先按全局等级短路、之后才做内省,先短路
就会把实例调低的等级挡在门外,后面再怎么识别来源都看不到这条日志。

回调在注册时被捕获、稍后才由调度器或 FastAPI 调用,那时已经不在任何绑定作用域内,
故这两处改用 wrap_for_plugin_instance 把绑定包进回调自身;同一实例重复包装直接返回
原对象,避免插件缓存声明时包装链随重载次数无限增长。

进程内覆盖表按实例 ID 常驻,删分身时必须一并清掉:只删库里那一行的话,同一进程内用
相同后缀重建的分身会继承上一个分身的等级,界面显示「跟随全局」而实际仍按旧等级输出。
进程重启后反向从库预热缓存。

迁移链在 F1 之后追加:c4e1a7b9d2f6(3.0.35)-> 487f7e681955(3.0.36 加两列)。

顺带补了 agent 策略里缺失的 media.classification.policy.get 的 template 字段说明:
少了它,api_mcp_schema.json 与技能文档的两个生成脚本都跑不起来。
2026-09-12 19:05:05 -04:00

318 lines
14 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
import importlib.util
from collections.abc import Callable, Mapping
from typing import Any, Optional
from app.domain.plugin import split_plugin_market_repo_urls
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.log import get_plugin_instance_log_level_override
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 = split_plugin_market_repo_urls(plugin_market)
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,
**self._instance_overlay(plugin_id),
)
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,
**self._instance_overlay(plugin_id),
))
# 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。
# 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。
return result
@staticmethod
def _instance_overlay(instance_id: str) -> dict[str, Any]:
"""把该实例当前的日志等级覆盖投影为卡片列表的只读叠加字段。
直接按实例 ID 查进程内覆盖缓存,不回表:本体与分身在覆盖表里共用同一个
命名空间,而卡片的 ``plugin_id`` 本身就是运行实例的 ID,本体等于插件 ID、
分身等于分身实例 ID,再去查一次实例行只会为同一个键多走一趟数据库。
:param instance_id: 运行实例 ID
:return: 可直接展开进 ``Plugin(...)`` 构造参数的字段字典
"""
override = get_plugin_instance_log_level_override(instance_id)
return {"log_level_effective": override[0] if override is not None else None}
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, *, raise_errors: bool = False) -> 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}")
if raise_errors:
raise
return []
plugins: list[Plugin] = []
for plugin_id, info in candidates.items():
package_version = info.get("package_version")
repo_url = info.get("repo_url")
if not isinstance(repo_url, str) or not repo_url.startswith("local://"):
repo_url = self._system().local_repo_url(
plugin_id,
info.get("repo_path"),
package_version,
)
plugin = self._map_plugin(
pid=plugin_id,
plugin_info=info,
market=repo_url,
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 = split_plugin_market_repo_urls(plugin_market)
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 = split_plugin_market_repo_urls(plugin_market)
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 = split_plugin_market_repo_urls(plugin_market)
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