mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-15 04:44:39 +08:00
全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行 日志会淹没在其他几十个插件与宿主自身的 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 与技能文档的两个生成脚本都跑不起来。
318 lines
14 KiB
Python
318 lines
14 KiB
Python
"""插件本地运行态和远程市场目录投影。"""
|
||
|
||
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
|