Files
MoviePilot/app/runtime/extensions/plugin/projection.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

369 lines
16 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.
"""插件公开能力投影。"""
import inspect
from typing import Any, Callable, Dict, List, Mapping, Optional
from app.runtime.extensions.plugin.contracts import (
PluginDashboardError,
PluginNotFoundError,
supports_plugin_hook,
)
from app.runtime.log import logger as default_logger
from app.runtime.log import wrap_for_plugin_instance
from app.schemas.plugin import PluginDashboard
class PluginProjection:
"""把运行态插件投影为宿主命令、API、服务、模块和动作清单。"""
def __init__(
self,
running_plugins: Mapping[str, Any],
log: Any = default_logger,
remote_entry_factory: Optional[Callable[[str, str], str]] = None,
) -> None:
"""保存运行态插件映射和错误日志端口。"""
self._running_plugins = running_plugins
self._logger = log
self._remote_entry_factory = remote_entry_factory
def _items(self, pid: Optional[str]) -> list[tuple[str, Any]]:
"""返回指定插件或运行态插件的稳定快照。"""
snapshot = dict(self._running_plugins)
if pid:
plugin = snapshot.get(pid)
return [(pid, plugin)] if plugin is not None else []
return list(snapshot.items())
def commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""聚合插件命令并补充插件 ID。"""
commands: list[dict] = []
for plugin_id, plugin in self._items(pid):
if not supports_plugin_hook(plugin, "get_command"):
continue
try:
if not plugin.get_state():
continue
for command in plugin.get_command() or []:
command["pid"] = plugin_id
commands.append(command)
except Exception as error:
self._logger.error(f"获取插件命令出错:{str(error)}")
return commands
def apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""聚合插件 API 并补充宿主路径和默认认证方式,端点绑定发起实例的日志上下文。"""
apis: list[dict] = []
for plugin_id, plugin in self._items(pid):
if not supports_plugin_hook(plugin, "get_api"):
continue
try:
for source_api in plugin.get_api() or []:
api = dict(source_api)
api["path"] = f"/{plugin_id}{api['path']}"
if not api.get("auth"):
api["auth"] = "apikey"
endpoint = api.get("endpoint")
if callable(endpoint):
api["endpoint"] = wrap_for_plugin_instance(endpoint, plugin_id)
apis.append(api)
except Exception as error:
self._logger.error(f"获取插件 {plugin_id} API出错:{str(error)}")
return apis
def services(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""聚合启用插件的定时服务。"""
services: list[dict] = []
for plugin_id, plugin in self._items(pid):
if not supports_plugin_hook(plugin, "get_service"):
continue
try:
if plugin.get_state():
services.extend(plugin.get_service() or [])
except Exception as error:
self._logger.error(f"获取插件 {plugin_id} 服务出错:{str(error)}")
return services
def modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]:
"""聚合启用插件的模块方法清单。"""
modules: dict[tuple, dict] = {}
for plugin_id, plugin in self._items(pid):
if not supports_plugin_hook(plugin, "get_module"):
continue
try:
if plugin.get_state():
declared = plugin.get_module()
# 基类默认实现返回 None;只接受映射,防止把 list 当成方法表传入调度器
if declared is None:
continue
if not isinstance(declared, Mapping):
self._logger.error(
f"插件 {plugin_id} 的 get_module() 返回值必须是字典,实际是 {type(declared).__name__}"
)
continue
modules[(plugin_id, plugin.get_name())] = declared
except Exception as error:
self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}")
return modules
def media_sources(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""聚合启用插件声明的媒体数据源。"""
sources: list[dict] = []
for plugin_id, plugin in self._items(pid):
if not supports_plugin_hook(plugin, "get_media_source"):
continue
try:
if not plugin.get_state():
continue
for source in plugin.get_media_source() or []:
item = self._media_source_mapping(source)
if item is None:
continue
item.setdefault("plugin_id", plugin_id)
sources.append(item)
except Exception as error:
self._logger.error(f"获取插件 {plugin_id} 媒体数据源出错:{str(error)}")
return sources
@staticmethod
def _media_source_mapping(source: Any) -> dict[str, Any] | None:
"""把旧字典或新 SDK 模型转换为隔离的 JSON 字典。"""
if isinstance(source, Mapping):
return dict(source)
model_dump = getattr(source, "model_dump", None)
if not callable(model_dump):
return None
payload = model_dump(mode="json")
return dict(payload) if isinstance(payload, Mapping) else None
def actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""聚合启用插件的工作流动作。"""
actions: list[dict] = []
for plugin_id, plugin in self._items(pid):
if not supports_plugin_hook(plugin, "get_actions"):
continue
try:
if not plugin.get_state():
continue
plugin_actions = plugin.get_actions()
if plugin_actions:
actions.append({
"plugin_id": plugin_id,
"plugin_name": plugin.plugin_name,
"actions": plugin_actions,
})
except Exception as error:
self._logger.error(f"获取插件 {plugin_id} 动作出错:{str(error)}")
return actions
def remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""投影插件联邦远程入口,并保持旧渲染模式筛选语义。"""
remotes = []
for plugin_id, plugin in self._items(pid):
if not supports_plugin_hook(plugin, "get_render_mode"):
continue
render_mode, dist_path = plugin.get_render_mode()
if render_mode != "vue":
continue
if not self._remote_entry_factory:
raise RuntimeError("插件联邦入口生成器尚未配置")
remote = {
"id": plugin_id,
"url": self._remote_entry_factory(plugin_id, dist_path),
"name": plugin.plugin_name,
}
source_plugin_id = getattr(plugin, "plugin_source_id", None)
if source_plugin_id:
remote["source_plugin_id"] = source_plugin_id
remotes.append(remote)
return remotes
def auth_providers(self) -> List[Dict[str, Any]]:
"""投影启用插件声明的登录认证提供方。"""
providers = []
for plugin_id, plugin in self._items(None):
if not plugin.get_state() or not supports_plugin_hook(
plugin, "get_auth_providers"
):
continue
try:
plugin_providers = plugin.get_auth_providers() or []
except Exception as error:
self._logger.error(
f"获取插件 {plugin_id} 登录认证提供方出错:{str(error)}"
)
continue
render_mode = None
dist_path = None
if supports_plugin_hook(plugin, "get_render_mode"):
render_mode, dist_path = plugin.get_render_mode()
for raw_provider in plugin_providers:
if not raw_provider or not isinstance(raw_provider, dict):
continue
provider = raw_provider.copy()
provider["type"] = "plugin"
provider["plugin_id"] = plugin_id
provider.setdefault("id", f"plugin:{plugin_id}")
provider.setdefault("name", plugin.plugin_name)
provider.setdefault("enabled", True)
if render_mode == "vue" and dist_path:
if not self._remote_entry_factory:
raise RuntimeError("插件联邦入口生成器尚未配置")
provider.setdefault("component", "AuthPage")
remote = {
"id": plugin_id,
"url": self._remote_entry_factory(plugin_id, dist_path),
"name": plugin.plugin_name,
}
source_plugin_id = getattr(plugin, "plugin_source_id", None)
if source_plugin_id:
remote["source_plugin_id"] = source_plugin_id
provider["remote"] = remote
providers.append(provider)
return providers
def sidebar(self) -> List[Dict[str, Any]]:
"""投影启用 Vue 插件的侧栏导航,并规整权限、分区和顺序。"""
valid_sections = {"start", "discovery", "subscribe", "organize", "system"}
valid_permissions = {"subscribe", "discovery", "search", "manage", "admin"}
items = []
for plugin_id, plugin in self._items(None):
if not plugin.get_state() or not supports_plugin_hook(
plugin, "get_sidebar_nav"
):
continue
if not supports_plugin_hook(plugin, "get_render_mode"):
continue
render_mode, _ = plugin.get_render_mode()
if render_mode != "vue":
continue
try:
nav_list = plugin.get_sidebar_nav()
if not nav_list:
continue
for raw in nav_list:
if not raw or not isinstance(raw, dict):
continue
nav_key = str(
raw.get("nav_key") or raw.get("key") or "main"
).strip()
if not nav_key or any(
character in nav_key for character in ["/", "?", "#", " "]
):
self._logger.warning(
f"插件[{plugin_id}]侧栏项 nav_key 无效,已跳过: "
f"{nav_key!r}"
)
continue
section = str(raw.get("section") or "system").lower()
if section not in valid_sections:
section = "system"
permission = raw.get("permission")
if permission is not None and str(permission) not in valid_permissions:
permission = None
elif permission is not None:
permission = str(permission)
try:
order = int(raw.get("order", 0))
except (TypeError, ValueError):
order = 0
items.append({
"plugin_id": plugin_id,
"nav_key": nav_key,
"title": raw.get("title") or plugin.plugin_name,
"icon": raw.get("icon") or "mdi-puzzle",
"section": section,
"permission": permission,
"order": order,
})
except Exception as error:
self._logger.error(
f"获取插件[{plugin_id}]侧栏导航出错:{str(error)}"
)
items.sort(
key=lambda item: (
item["section"],
item["order"],
item["plugin_id"],
item["nav_key"],
)
)
return items
def dashboard_metadata(self) -> List[Dict[str, str]]:
"""投影启用插件的单仪表板或多仪表板元信息。"""
metadata = []
for plugin_id, plugin in self._items(None):
if not supports_plugin_hook(plugin, "get_dashboard"):
continue
try:
if not plugin.get_state():
continue
if supports_plugin_hook(plugin, "get_dashboard_meta"):
plugin_metadata = plugin.get_dashboard_meta()
if plugin_metadata:
metadata.extend({
"id": plugin_id,
"name": item.get("name"),
"key": item.get("key"),
} for item in plugin_metadata if item)
else:
metadata.append({
"id": plugin_id,
"name": plugin.plugin_name,
"key": "",
})
except Exception as error:
self._logger.error(
f"获取插件[{plugin_id}]仪表盘元数据出错:{str(error)}"
)
return metadata
def dashboard(
self,
plugin_id: str,
key: str,
user_agent: Optional[str] = None,
) -> Optional[PluginDashboard]:
"""调用插件仪表板钩子并返回稳定投影,不依赖 HTTP 异常。"""
plugin = self._running_plugins.get(plugin_id)
if not plugin:
raise PluginNotFoundError(f"插件 {plugin_id} 不存在或未加载")
try:
render_mode, _ = plugin.get_render_mode()
method = plugin.get_dashboard
count = len(inspect.signature(method).parameters)
if count > 1:
dashboard = method(key=key, user_agent=user_agent)
elif count > 0:
dashboard = method(user_agent=user_agent)
else:
dashboard = method()
except Exception as error: # noqa: BLE001
self._logger.error(f"插件 {plugin_id} 调用方法 get_dashboard 出错: {error}")
raise PluginDashboardError(
f"插件 {plugin_id} 调用方法 get_dashboard 出错: {error}"
) from error
if dashboard is None:
return None
if not isinstance(dashboard, (tuple, list)) or len(dashboard) != 3:
self._logger.error(f"插件 {plugin_id} 返回的仪表盘数据格式错误")
raise PluginDashboardError(
f"插件 {plugin_id} 返回的仪表盘数据格式错误"
)
cols, attrs, elements = dashboard
return PluginDashboard(
id=plugin_id,
name=plugin.plugin_name,
key=key,
render_mode=render_mode,
cols=cols or {},
attrs=attrs or {},
elements=elements,
source_plugin_id=getattr(plugin, "plugin_source_id", None),
is_instance=bool(getattr(plugin, "plugin_source_id", None)),
instance_mode=(
"virtual" if getattr(plugin, "plugin_source_id", None) else None
),
)