diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index d7e939a1..e1ef2b51 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -146,6 +146,62 @@ def register_plugin(plugin_id: str): register_plugin_api(plugin_id) +def _merge_plugin_market_metadata( + plugin: schemas.Plugin, market_plugin: schemas.Plugin +) -> schemas.Plugin: + """ + 合并插件市场中的远端元数据,供已安装插件按需展示更新说明。 + """ + plugin.repo_url = market_plugin.repo_url or plugin.repo_url + plugin.history = market_plugin.history or {} + plugin.has_update = market_plugin.has_update + plugin.system_version = market_plugin.system_version or plugin.system_version + plugin.system_version_compatible = market_plugin.system_version_compatible + plugin.system_version_message = ( + market_plugin.system_version_message or plugin.system_version_message + ) + return plugin + + +async def _get_plugin_history_detail( + plugin_id: str, force: bool = True +) -> Optional[schemas.Plugin]: + """ + 按需获取插件远端元数据,避免插件列表加载时批量访问网络。 + """ + plugin_manager = PluginManager() + installed_plugin = next( + ( + plugin + for plugin in plugin_manager.get_local_plugins() + if plugin.id == plugin_id and plugin.installed + ), + None, + ) + if not installed_plugin: + return None + + local_repo_plugin = next( + (plugin for plugin in plugin_manager.get_local_repo_plugins() if plugin.id == plugin_id), + None, + ) + if local_repo_plugin: + return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin) + + market_plugin = next( + ( + plugin + for plugin in await plugin_manager.async_get_online_plugins(force=force) + if plugin.id == plugin_id + ), + None, + ) + if not market_plugin: + return installed_plugin + + return _merge_plugin_market_metadata(installed_plugin, market_plugin) + + @router.get("/", summary="所有插件", response_model=List[schemas.Plugin]) async def all_plugins( _: User = Depends(get_current_active_superuser_async), @@ -213,6 +269,24 @@ async def installed(_: User = Depends(get_current_active_superuser_async)) -> An return SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] +@router.get("/history/{plugin_id}", summary="获取插件更新说明", response_model=schemas.Plugin) +async def plugin_history( + plugin_id: str, + _: User = Depends(get_current_active_superuser_async), + force: bool = True, +) -> schemas.Plugin: + """ + 按需获取指定插件的更新说明。 + """ + plugin = await _get_plugin_history_detail(plugin_id=plugin_id, force=force) + if not plugin: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"插件 {plugin_id} 不存在或未安装", + ) + return plugin + + @router.get("/statistic", summary="插件安装统计", response_model=dict) async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 04cbaaaa..c3cb996b 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -74,6 +74,12 @@ MoviePilot 实现了标准的 **Model Context Protocol (MCP)**,允许 AI 智 ## 6. RESTful API 所有工具相关的API端点都在 `/api/v1/mcp` 路径下(保持向后兼容)。 +### 插件补充接口 + +**GET** `/api/v1/plugin/history/{plugin_id}` + +按需读取指定已安装插件的最新远端更新说明。该接口用于前端在用户点击“查看更新说明”时再实时访问插件仓库,避免加载已安装插件列表时批量请求网络。 + ### 1. 列出所有工具 **GET** `/api/v1/mcp/tools` diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py new file mode 100644 index 00000000..15b10736 --- /dev/null +++ b/tests/test_plugin_endpoint.py @@ -0,0 +1,71 @@ +import asyncio +from unittest import TestCase +from unittest.mock import AsyncMock, MagicMock, patch + + +class PluginEndpointTest(TestCase): + + def test_plugin_history_merges_remote_metadata(self): + """ + 已安装插件点击更新说明时,接口会按需合并远端仓库中的更新记录。 + """ + try: + from app import schemas + from app.api.endpoints.plugin import plugin_history + except ModuleNotFoundError as exc: + self.skipTest(f"missing dependency: {exc}") + + installed_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_name="Demo Plugin", + plugin_version="1.0.0", + installed=True, + history={}, + ) + market_plugin = schemas.Plugin( + id="DemoPlugin", + repo_url="https://github.com/demo/plugins", + history={"v1.1.0": "- 新增更新说明"}, + system_version=">=2.0.0", + system_version_compatible=True, + has_update=True, + ) + plugin_manager = MagicMock() + plugin_manager.get_local_plugins.return_value = [installed_plugin] + plugin_manager.get_local_repo_plugins.return_value = [] + plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin]) + + with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager): + result = asyncio.run(plugin_history("DemoPlugin", None, True)) + + self.assertEqual("https://github.com/demo/plugins", result.repo_url) + self.assertEqual({"v1.1.0": "- 新增更新说明"}, result.history) + self.assertEqual(">=2.0.0", result.system_version) + self.assertTrue(result.has_update) + + def test_plugin_history_returns_installed_plugin_when_remote_missing(self): + """ + 远端仓库不可用时,接口仍返回本地已安装插件信息,前端可继续展示兜底状态。 + """ + try: + from app import schemas + from app.api.endpoints.plugin import plugin_history + except ModuleNotFoundError as exc: + self.skipTest(f"missing dependency: {exc}") + + installed_plugin = schemas.Plugin( + id="DemoPlugin", + plugin_name="Demo Plugin", + plugin_version="1.0.0", + installed=True, + ) + plugin_manager = MagicMock() + plugin_manager.get_local_plugins.return_value = [installed_plugin] + plugin_manager.get_local_repo_plugins.return_value = [] + plugin_manager.async_get_online_plugins = AsyncMock(return_value=[]) + + with patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager): + result = asyncio.run(plugin_history("DemoPlugin", None, True)) + + self.assertEqual("DemoPlugin", result.id) + self.assertEqual({}, result.history)