mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
* fix: retain plugin lifecycle ownership and recover installation state * test: cover plugin rollback and uninstall lifecycle boundaries --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
383 lines
14 KiB
Python
383 lines
14 KiB
Python
"""
|
||
插件目录、生命周期和数据投影应用能力。
|
||
"""
|
||
|
||
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,
|
||
}
|