From 48852350a0396a6ad1ed8393dc338912592a593c Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 31 Jul 2026 13:41:29 +0800 Subject: [PATCH] feat(plugin): add centralized rating APIs --- app/api/endpoints/plugin.py | 67 ++++++++++++++++- app/helper/server.py | 86 ++++++++++++++++++++++ app/schemas/plugin.py | 20 ++++++ docs/mcp-api.md | 12 ++++ skills/moviepilot-api/SKILL.md | 5 +- tests/test_plugin_rating.py | 127 +++++++++++++++++++++++++++++++++ 6 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 tests/test_plugin_rating.py diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index ad2b1cc5..856c07fd 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -1,7 +1,7 @@ import asyncio import mimetypes import shutil -from typing import Annotated, Any, List, Optional +from typing import Annotated, Any, Dict, List, Optional import aiofiles from anyio import Path as AsyncPath @@ -476,6 +476,71 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any: return await MoviePilotServerHelper.async_get_plugin_statistic() +@router.get( + "/rating", + summary="批量查询插件评分", + response_model=Dict[str, schemas.PluginRating], +) +async def plugin_ratings( + plugin_ids: Optional[str] = None, + _: User = Depends(get_current_active_superuser_async), +) -> Dict[str, schemas.PluginRating]: + """ + 批量查询插件平均分、评分人数和当前安装实例评分。 + """ + requested_ids = plugin_ids.split(",") if plugin_ids is not None else None + ratings = await MoviePilotServerHelper.async_get_plugin_ratings(requested_ids) + return { + plugin_id: schemas.PluginRating.model_validate(rating) + for plugin_id, rating in ratings.items() + } + + +@router.get( + "/rating/{plugin_id}", + summary="查询插件评分", + response_model=schemas.PluginRating, +) +async def plugin_rating( + plugin_id: str, + _: User = Depends(get_current_active_superuser_async), +) -> schemas.PluginRating: + """ + 查询单个插件平均分、评分人数和当前安装实例评分。 + """ + rating = await MoviePilotServerHelper.async_get_plugin_rating(plugin_id) + return schemas.PluginRating.model_validate(rating) + + +@router.post( + "/rating/{plugin_id}", + summary="提交插件评分", + response_model=schemas.Response, +) +async def rate_plugin( + plugin_id: str, + payload: schemas.PluginRatingRequest, + _: User = Depends(get_current_active_superuser_async), +) -> schemas.Response: + """ + 为已安装插件新增或更新当前安装实例评分。 + """ + installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] + if plugin_id not in installed_plugins: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"插件 {plugin_id} 未安装,无法评分", + ) + + rating = await MoviePilotServerHelper.async_submit_plugin_rating( + plugin_id, + payload.rating, + ) + if rating is None: + return schemas.Response(success=False, message="连接MoviePilot服务器失败") + return schemas.Response(success=True, data=rating) + + @router.get( "/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response ) diff --git a/app/helper/server.py b/app/helper/server.py index fe241a86..d2b95dfb 100644 --- a/app/helper/server.py +++ b/app/helper/server.py @@ -29,6 +29,7 @@ class MoviePilotServerHelper: _USAGE_REPORT_PATH = "/usage/report" _USAGE_STATISTIC_PATH = "/usage/statistic" _PLUGIN_INSTALL_PATH = "/plugin/install" + _PLUGIN_RATING_PATH = "/plugin/rating" _PLUGIN_STATISTIC_PATH = "/plugin/statistic" _SUBSCRIBE_ADD_PATH = "/subscribe/add" _SUBSCRIBE_DONE_PATH = "/subscribe/done" @@ -398,6 +399,39 @@ class MoviePilotServerHelper: """ return await cls._async_get(cls._server_url(cls._PLUGIN_STATISTIC_PATH), timeout=10) + @classmethod + async def async_plugin_ratings(cls, plugin_ids: Optional[List[str]] = None): + """ + 异步批量查询中心端插件评分。 + """ + params = {"plugin_ids": ",".join(plugin_ids)} if plugin_ids is not None else None + return await cls._async_get( + cls._server_url(cls._PLUGIN_RATING_PATH), + params=params, + timeout=10, + ) + + @classmethod + async def async_plugin_rating(cls, plugin_id: str): + """ + 异步查询中心端单个插件评分。 + """ + return await cls._async_get( + f"{cls._server_url(cls._PLUGIN_RATING_PATH)}/{quote(plugin_id, safe='')}", + timeout=10, + ) + + @classmethod + async def async_rate_plugin(cls, plugin_id: str, rating: float): + """ + 异步提交当前安装实例的插件评分。 + """ + return await cls._async_post_json( + f"{cls._server_url(cls._PLUGIN_RATING_PATH)}/{quote(plugin_id, safe='')}", + {"rating": rating}, + timeout=10, + ) + @classmethod def plugin_install(cls, plugin_id: str, payload: Dict[str, Any]): """ @@ -459,6 +493,58 @@ class MoviePilotServerHelper: return res.json() return {} + @classmethod + async def async_get_plugin_ratings( + cls, + plugin_ids: Optional[List[str]] = None, + ) -> Dict[str, Dict[str, Any]]: + """ + 批量获取插件评分,中心端不可用时返回空结果。 + """ + try: + res = await cls.async_plugin_ratings(plugin_ids) + if res is not None and res.status_code == 200: + return res.json() + except Exception as err: + logger.debug(f"批量获取插件评分失败:{str(err)}") + return {} + + @classmethod + async def async_get_plugin_rating(cls, plugin_id: str) -> Dict[str, Any]: + """ + 获取单个插件评分,中心端不可用时返回零评分。 + """ + empty_rating = { + "plugin_id": plugin_id, + "average_rating": 0.0, + "rating_count": 0, + "user_rating": None, + } + try: + res = await cls.async_plugin_rating(plugin_id) + if res is not None and res.status_code == 200: + return res.json() + except Exception as err: + logger.debug(f"获取插件 {plugin_id} 评分失败:{str(err)}") + return empty_rating + + @classmethod + async def async_submit_plugin_rating( + cls, + plugin_id: str, + rating: float, + ) -> Optional[Dict[str, Any]]: + """ + 提交插件评分,成功时返回最新评分结果。 + """ + try: + res = await cls.async_rate_plugin(plugin_id, rating) + if res is not None and res.status_code == 200: + return res.json() + except Exception as err: + logger.debug(f"提交插件 {plugin_id} 评分失败:{str(err)}") + return None + @classmethod def install_plugin_reg(cls, plugin_id: str, repo_url: Optional[str] = None) -> bool: """ diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 0bdeee49..213e6c70 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -95,6 +95,26 @@ class PluginSidebarNavItem(BaseModel): order: int = Field(default=0, description="同组内排序,越小越靠前") +class PluginRatingRequest(BaseModel): + """插件评分请求""" + + rating: float = Field( + ge=0.1, + le=5.0, + multiple_of=0.1, + description="评分,范围 0.1 至 5.0,精确到 0.1", + ) + + +class PluginRating(BaseModel): + """插件评分结果""" + + plugin_id: str = Field(description="插件 ID") + average_rating: float = Field(default=0.0, description="平均评分") + rating_count: int = Field(default=0, description="评分人数") + user_rating: Optional[float] = Field(default=None, description="当前安装实例评分") + + class PluginMemoryInfo(BaseModel): """插件内存信息""" plugin_id: str = Field(description="插件ID") diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 2d1fb4f6..e352ce32 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -213,6 +213,18 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch 按需读取指定已安装插件的最新远端更新说明。该接口用于前端在用户点击“查看更新说明”时再实时访问插件仓库,避免加载已安装插件列表时批量请求网络。 +**GET** `/api/v1/plugin/rating?plugin_ids={plugin_id,...}` + +批量查询插件平均分、评分人数和当前安装实例评分。`plugin_ids` 省略时查询中心端已有的全部插件评分。 + +**GET** `/api/v1/plugin/rating/{plugin_id}` + +查询单个插件平均分、评分人数和当前安装实例评分。中心端暂不可用时返回该插件的零评分结果。 + +**POST** `/api/v1/plugin/rating/{plugin_id}` + +为已安装插件提交当前安装实例评分,请求体为 `{"rating": 4.5}`。评分范围为 `0.1` 至 `5.0`,精确到 `0.1`;同一安装实例再次提交会更新原评分。 + ### 1. 列出所有工具 **GET** `/api/v1/mcp/tools` diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index bf191a07..e0f13253 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -324,13 +324,16 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | GET | `/api/v1/dashboard/network` | Network traffic | | GET | `/api/v1/dashboard/network2` | Network traffic (API_TOKEN) | -### Plugin (22 endpoints) +### Plugin (25 endpoints) | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/plugin/` | List plugins. Params: `state` (installed/market/all), `force` | | GET | `/api/v1/plugin/installed` | List installed plugins | | GET | `/api/v1/plugin/statistic` | Plugin install statistics | +| GET | `/api/v1/plugin/rating` | Batch plugin ratings. Params: comma-separated `plugin_ids` | +| GET | `/api/v1/plugin/rating/{plugin_id}` | Get average rating, rating count, and this installation's rating | +| POST | `/api/v1/plugin/rating/{plugin_id}` | Rate an installed plugin. Body: `{"rating": 4.5}`; range 0.1-5.0 | | GET | `/api/v1/plugin/install/{plugin_id}` | Install plugin. Params: `repo_url`, `force` | | GET | `/api/v1/plugin/reload/{plugin_id}` | Reload plugin | | GET | `/api/v1/plugin/reset/{plugin_id}` | Reset plugin config & data | diff --git a/tests/test_plugin_rating.py b/tests/test_plugin_rating.py new file mode 100644 index 00000000..0e7bb5eb --- /dev/null +++ b/tests/test_plugin_rating.py @@ -0,0 +1,127 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from app import schemas +from app.api.endpoints.plugin import plugin_rating, plugin_ratings, rate_plugin +from app.helper.server import MoviePilotServerHelper + + +def test_server_helper_uses_plugin_rating_endpoints() -> None: + """评分辅助方法应使用独立中心端路径并传递评分载荷。""" + + async def run_scenario() -> None: + with ( + patch("app.helper.server.settings.MP_SERVER_HOST", "https://movie-pilot.org"), + patch.object( + MoviePilotServerHelper, + "_async_get", + new=AsyncMock(), + ) as get_request, + patch.object( + MoviePilotServerHelper, + "_async_post_json", + new=AsyncMock(), + ) as post_request, + ): + await MoviePilotServerHelper.async_plugin_ratings(["DemoPlugin", "OtherPlugin"]) + await MoviePilotServerHelper.async_plugin_rating("Demo Plugin") + await MoviePilotServerHelper.async_rate_plugin("Demo Plugin", 4.5) + + assert get_request.await_args_list[0].args == ( + "https://movie-pilot.org/plugin/rating", + ) + assert get_request.await_args_list[0].kwargs == { + "params": {"plugin_ids": "DemoPlugin,OtherPlugin"}, + "timeout": 10, + } + assert get_request.await_args_list[1].args == ( + "https://movie-pilot.org/plugin/rating/Demo%20Plugin", + ) + assert post_request.await_args.args == ( + "https://movie-pilot.org/plugin/rating/Demo%20Plugin", + {"rating": 4.5}, + ) + + asyncio.run(run_scenario()) + + +def test_plugin_rating_endpoints_return_center_results() -> None: + """评分查询和提交接口应返回中心端结果并校验安装状态。""" + + async def run_scenario() -> None: + rating_result = { + "plugin_id": "DemoPlugin", + "average_rating": 4.3, + "rating_count": 12, + "user_rating": 4.5, + } + with patch.object( + MoviePilotServerHelper, + "async_get_plugin_ratings", + new=AsyncMock(return_value={"DemoPlugin": rating_result}), + ) as batch_query: + batch = await plugin_ratings("DemoPlugin", None) + + assert batch["DemoPlugin"].average_rating == 4.3 + batch_query.assert_awaited_once_with(["DemoPlugin"]) + + with patch.object( + MoviePilotServerHelper, + "async_get_plugin_rating", + new=AsyncMock(return_value=rating_result), + ): + single = await plugin_rating("DemoPlugin", None) + + assert single.user_rating == 4.5 + + system_config = MagicMock() + system_config.get.return_value = ["DemoPlugin"] + with ( + patch("app.api.endpoints.plugin.SystemConfigOper", return_value=system_config), + patch.object( + MoviePilotServerHelper, + "async_submit_plugin_rating", + new=AsyncMock(return_value=rating_result), + ) as submit_rating, + ): + response = await rate_plugin( + "DemoPlugin", + schemas.PluginRatingRequest(rating=4.5), + None, + ) + + assert response.success is True + assert response.data == rating_result + submit_rating.assert_awaited_once_with("DemoPlugin", 4.5) + + asyncio.run(run_scenario()) + + +def test_plugin_rating_rejects_uninstalled_plugin() -> None: + """未安装插件不能借助 MoviePilot 接口向中心端提交评分。""" + + async def run_scenario() -> None: + system_config = MagicMock() + system_config.get.return_value = [] + with ( + patch("app.api.endpoints.plugin.SystemConfigOper", return_value=system_config), + patch.object( + MoviePilotServerHelper, + "async_submit_plugin_rating", + new=AsyncMock(), + ) as submit_rating, + ): + with pytest.raises(HTTPException) as error: + await rate_plugin( + "DemoPlugin", + schemas.PluginRatingRequest(rating=4.5), + None, + ) + + assert error.value.status_code == 400 + submit_rating.assert_not_awaited() + + asyncio.run(run_scenario())