Files
MoviePilot/app/application/plugin/management.py
Aqr-K 6610a8c008 fix: retain plugin lifecycle ownership and recover installation state (#7146)
* fix: retain plugin lifecycle ownership and recover installation state

* test: cover plugin rollback and uninstall lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:44:07 +08:00

383 lines
14 KiB
Python
Raw Permalink 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 typing import Any, Iterable, Optional, cast
from anyio import to_thread
from app.application.configuration import get_configured_system_config
from app.application.plugin.gateway import get_plugin_install_service
from app.application.plugin.runtime import get_plugin_manager
from app.domain.plugin import is_local_plugin_source
from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
DEFAULT_PLUGIN_CANDIDATE_LIMIT = 50
MAX_PLUGIN_CANDIDATE_LIMIT = 200
def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]:
"""
获取已安装插件的基础信息快照。
"""
plugin_manager = get_plugin_manager()
for plugin in plugin_manager.get_local_plugins():
if plugin.id == plugin_id:
return {
"plugin_id": plugin.id,
"plugin_name": plugin.plugin_name,
"plugin_version": plugin.plugin_version,
"state": plugin.state,
}
return None
def refresh_plugin_registrations(plugin_id: str) -> None:
"""
重新注册插件的定时任务、命令和动态 API 路由。
"""
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
from app.application.commands import init_commands
from app.application.plugin.routes import register_plugin_api
from app.application.scheduling import update_plugin_job
update_plugin_job(plugin_id)
init_commands(plugin_id)
register_plugin_api(plugin_id)
def reload_plugin_runtime(plugin_id: str) -> PluginRuntimeStatus:
"""
重载插件实例树并重新注册其命令、定时任务和 API。
分身与本体共用同一份源码,只重载本体会让分身继续持有旧模块与旧类对象,改完源码
点重载后新旧代码在同一进程内并存;分身的 API、调度与命令注册也不会刷新。文件监听
那条重载路径本来就是按实例树走的,手工重载必须与它一致。
"""
plugin_manager = get_plugin_manager()
with plugin_manager.mutation(f"重载插件 {plugin_id}"):
runtime_status = plugin_manager.reload_plugin_tree(plugin_id)
for reload_target in plugin_manager.get_plugin_reload_targets(plugin_id):
refresh_plugin_registrations(reload_target)
return cast(PluginRuntimeStatus, runtime_status)
def summarize_plugin(plugin: Any) -> dict[str, Any]:
"""
提取插件对象中对 Agent 有价值的摘要字段。
"""
repo_url = getattr(plugin, "repo_url", None)
return {
"id": getattr(plugin, "id", None),
"plugin_name": getattr(plugin, "plugin_name", None),
"plugin_desc": getattr(plugin, "plugin_desc", None),
"plugin_version": getattr(plugin, "plugin_version", None),
"plugin_author": getattr(plugin, "plugin_author", None),
"installed": bool(getattr(plugin, "installed", False)),
"has_update": bool(getattr(plugin, "has_update", False)),
"system_version_compatible": getattr(plugin, "system_version_compatible", True) is not False,
"system_version": getattr(plugin, "system_version", None),
"system_version_message": getattr(plugin, "system_version_message", None),
"state": bool(getattr(plugin, "state", False)),
"repo_url": repo_url,
"source": "local_repo" if is_local_plugin_source(repo_url) else "market",
}
def _merge_plugin_source_metadata(plugin: Any, source_plugin: Any) -> Any:
"""
将插件市场或本地仓库中的来源元数据合并到已安装插件对象。
"""
repo_url = getattr(source_plugin, "repo_url", None)
if repo_url:
setattr(plugin, "repo_url", repo_url)
# 运行时兼容状态与版本兼容状态同属安装准入依据,已安装卡片据此禁用更新入口
for attr in (
"has_update",
"release",
"system_version",
"system_version_compatible",
"system_version_message",
"runtime_compatible",
"runtime_message",
):
value = getattr(source_plugin, attr, None)
if value is not None:
setattr(plugin, attr, value)
return plugin
def _map_plugins_by_id(plugins: Iterable[object]) -> dict[object, object]:
"""
按插件 ID 建立稳定映射,保留同 ID 首个候选来源。
"""
plugin_map: dict[object, object] = {}
for plugin in plugins:
plugin_id = getattr(plugin, "id", None)
if plugin_id and plugin_id not in plugin_map:
plugin_map[plugin_id] = plugin
return plugin_map
async def enrich_installed_plugin_sources(
installed_plugins: list[Any],
force_refresh: bool = False,
) -> list[Any]:
"""
为已安装插件补齐安装来源仓库地址。
本地插件对象只包含运行目录中的静态元数据,通常没有 repo_url。这里按需从
本地插件仓库和插件市场补齐来源,保证 Agent 后续安装、升级判断可以拿到仓库地址。
"""
missing_source_plugins = [plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)]
if not missing_source_plugins:
return installed_plugins
plugin_manager = get_plugin_manager()
local_repo_map = _map_plugins_by_id(plugin_manager.get_local_repo_plugins())
for plugin in missing_source_plugins:
source_plugin = local_repo_map.get(getattr(plugin, "id", None))
if source_plugin:
_merge_plugin_source_metadata(plugin, source_plugin)
missing_source_plugins = [plugin for plugin in installed_plugins if not getattr(plugin, "repo_url", None)]
if not missing_source_plugins:
return installed_plugins
market_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh)
market_map = _map_plugins_by_id(market_plugins or [])
for plugin in missing_source_plugins:
source_plugin = market_map.get(getattr(plugin, "id", None))
if source_plugin:
_merge_plugin_source_metadata(plugin, source_plugin)
return installed_plugins
async def load_market_plugins(force_refresh: bool = False) -> list[Any]:
"""
聚合插件市场与本地插件仓库中的候选插件。
"""
plugin_manager = get_plugin_manager()
online_plugins = await plugin_manager.async_get_online_plugins(force=force_refresh)
local_repo_plugins = plugin_manager.get_local_repo_plugins()
if not online_plugins and not local_repo_plugins:
return []
return cast(list[Any], plugin_manager.process_plugins_list(online_plugins + local_repo_plugins, []))
def list_installed_plugins() -> list[Any]:
"""
返回当前已安装插件列表。
"""
plugin_manager = get_plugin_manager()
return [plugin for plugin in plugin_manager.get_local_plugins() if plugin.installed]
def _normalize_text(value: Optional[str]) -> str:
"""
规范化插件检索文本,空配置按空字符串处理。
"""
return (value or "").strip().lower()
def is_exact_plugin_match(plugin: Any, query: str) -> bool:
"""
精确匹配插件 ID 或插件名称,用于安全地自动选择候选。
"""
normalized_query = _normalize_text(query)
return normalized_query in {
_normalize_text(getattr(plugin, "id", None)),
_normalize_text(getattr(plugin, "plugin_name", None)),
}
def search_plugin_candidates(query: str, plugins: list[Any]) -> list[dict[str, Any]]:
"""
按插件 ID、名称、描述和作者搜索候选,并返回打分结果。
"""
normalized_query = _normalize_text(query)
if not normalized_query:
return []
tokens = [token for token in normalized_query.replace("-", " ").split() if token]
matches: list[dict[str, Any]] = []
for plugin in plugins:
plugin_id = _normalize_text(getattr(plugin, "id", None))
plugin_name = _normalize_text(getattr(plugin, "plugin_name", None))
plugin_desc = _normalize_text(getattr(plugin, "plugin_desc", None))
plugin_author = _normalize_text(getattr(plugin, "plugin_author", None))
haystack = "\n".join([plugin_id, plugin_name, plugin_desc, plugin_author])
score = 0
if normalized_query == plugin_id:
score = 100
elif normalized_query == plugin_name:
score = 95
elif plugin_id.startswith(normalized_query):
score = 85
elif plugin_name.startswith(normalized_query):
score = 80
elif normalized_query in plugin_id:
score = 75
elif normalized_query in plugin_name:
score = 70
elif tokens and all(token in plugin_name for token in tokens):
score = 68
elif tokens and all(token in plugin_id for token in tokens):
score = 66
elif normalized_query in plugin_desc:
score = 45
elif normalized_query in plugin_author:
score = 40
elif tokens and all(token in haystack for token in tokens):
score = 35
if score <= 0:
continue
matches.append(
{
"plugin": plugin,
"score": score,
"exact": is_exact_plugin_match(plugin, normalized_query),
}
)
return sorted(
matches,
key=lambda item: (
-item["score"],
not item["exact"],
-int(bool(getattr(item["plugin"], "has_update", False))),
-int(bool(getattr(item["plugin"], "installed", False))),
-int(getattr(item["plugin"], "add_time", 0) or 0),
),
)
def summarize_candidates(
matches: list[dict[str, Any]], limit: int = DEFAULT_PLUGIN_CANDIDATE_LIMIT
) -> list[dict[str, Any]]:
"""
压缩候选列表,避免一次性把完整市场数据返回给 Agent。
"""
return [
{
**summarize_plugin(item["plugin"]),
"score": item["score"],
"exact": item["exact"],
}
for item in matches[:limit]
]
async def install_plugin_runtime(
plugin_id: str,
repo_url: Optional[str],
force: bool = False,
*,
explicit_source: bool = False,
) -> tuple[bool, str, bool]:
"""
按现有插件接口的行为安装插件,并刷新运行态注册信息。
"""
result = await get_plugin_install_service().install(
plugin_id=plugin_id,
repo_url=repo_url or None,
force=force,
explicit_source=explicit_source,
)
return result.success, result.message, result.refreshed_only
async def inspect_plugin_sources(
plugin_id: str,
*,
force: bool = False,
) -> dict[str, Any]:
"""
返回 Agent 可展示的脱敏来源候选与当前准入状态。
"""
inspection = await get_plugin_install_service().inspect_source(
plugin_id=plugin_id,
force=force,
)
candidates = [candidate.public_dict() for candidate in inspection.online_candidates]
if inspection.local_candidate is not None:
candidates.append(inspection.local_candidate.public_dict())
return {
"selection_status": inspection.selection.status.value,
"selection_reason": inspection.selection.reason,
"inventory_complete": inspection.inventory_complete,
"candidates": candidates,
}
async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
"""
按现有卸载逻辑移除插件,并清理运行态注册与分组信息。
"""
from app.application.plugin.folders import remove_plugin_from_folders
from app.application.plugin.routes import remove_plugin_api
from app.application.scheduling import remove_plugin_job
plugin_manager = get_plugin_manager()
with plugin_manager.mutation(f"卸载插件 {plugin_id}"):
virtual_instance = plugin_manager.get_plugin_instance(plugin_id)
source_instances = plugin_manager.get_plugin_source_instances(plugin_id)
if not virtual_instance and source_instances:
instance_ids = "、".join(item.instance_id for item in source_instances)
raise ValueError(f"请先卸载该插件的分身:{instance_ids}")
config_oper = get_configured_system_config()
remove_plugin_api(plugin_id)
remove_plugin_job(plugin_id)
plugin_class = plugin_manager.plugins.get(plugin_id)
was_clone = bool(getattr(plugin_class, "is_clone", False))
clone_files_removed = False
# 删除数据必须发生在插件停止之后:插件的停机钩子只要取一次自有库句柄,就会把刚
# 删除的数据重新建出来。停止同时注销插件类,故其后的删除一律按 force 执行
if plugin_manager.stop(plugin_id) is False:
raise RuntimeError("插件后台服务尚未停止,已保留实例和数据供重试")
await config_oper.async_update_atomically(
SystemConfigKey.UserInstalledPlugins,
lambda current: (None, [item for item in (current or []) if item != plugin_id]),
)
if virtual_instance:
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
plugin_manager.delete_plugin_instance(plugin_id)
else:
# 本体的装载判据在实例表的启用位上:不停用这一行,卸载后重启仍会
# 按已删除的包去加载它。业务参数保留,重装后用户的配置应当还在
plugin_manager.disable_plugin_host(plugin_id)
if not virtual_instance and was_clone:
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
try:
clone_files_removed = await to_thread.run_sync(
plugin_manager.remove_plugin_package,
plugin_id,
)
if clone_files_removed:
plugin_manager.plugins.pop(plugin_id, None)
except Exception:
clone_files_removed = False
remove_plugin_from_folders(plugin_id)
plugin_manager.remove_plugin(plugin_id)
return {
"was_clone": was_clone,
"clone_files_removed": clone_files_removed,
}