feat(plugin): add centralized rating APIs

This commit is contained in:
jxxghp
2026-07-31 13:41:29 +08:00
parent a23fce1491
commit 48852350a0
6 changed files with 315 additions and 2 deletions
+66 -1
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
import mimetypes import mimetypes
import shutil import shutil
from typing import Annotated, Any, List, Optional from typing import Annotated, Any, Dict, List, Optional
import aiofiles import aiofiles
from anyio import Path as AsyncPath 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() 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( @router.get(
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response "/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response
) )
+86
View File
@@ -29,6 +29,7 @@ class MoviePilotServerHelper:
_USAGE_REPORT_PATH = "/usage/report" _USAGE_REPORT_PATH = "/usage/report"
_USAGE_STATISTIC_PATH = "/usage/statistic" _USAGE_STATISTIC_PATH = "/usage/statistic"
_PLUGIN_INSTALL_PATH = "/plugin/install" _PLUGIN_INSTALL_PATH = "/plugin/install"
_PLUGIN_RATING_PATH = "/plugin/rating"
_PLUGIN_STATISTIC_PATH = "/plugin/statistic" _PLUGIN_STATISTIC_PATH = "/plugin/statistic"
_SUBSCRIBE_ADD_PATH = "/subscribe/add" _SUBSCRIBE_ADD_PATH = "/subscribe/add"
_SUBSCRIBE_DONE_PATH = "/subscribe/done" _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) 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 @classmethod
def plugin_install(cls, plugin_id: str, payload: Dict[str, Any]): def plugin_install(cls, plugin_id: str, payload: Dict[str, Any]):
""" """
@@ -459,6 +493,58 @@ class MoviePilotServerHelper:
return res.json() return res.json()
return {} 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 @classmethod
def install_plugin_reg(cls, plugin_id: str, repo_url: Optional[str] = None) -> bool: def install_plugin_reg(cls, plugin_id: str, repo_url: Optional[str] = None) -> bool:
""" """
+20
View File
@@ -95,6 +95,26 @@ class PluginSidebarNavItem(BaseModel):
order: int = Field(default=0, description="同组内排序,越小越靠前") 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): class PluginMemoryInfo(BaseModel):
"""插件内存信息""" """插件内存信息"""
plugin_id: str = Field(description="插件ID") plugin_id: str = Field(description="插件ID")
+12
View File
@@ -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. 列出所有工具 ### 1. 列出所有工具
**GET** `/api/v1/mcp/tools` **GET** `/api/v1/mcp/tools`
+4 -1
View File
@@ -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/network` | Network traffic |
| GET | `/api/v1/dashboard/network2` | Network traffic (API_TOKEN) | | GET | `/api/v1/dashboard/network2` | Network traffic (API_TOKEN) |
### Plugin (22 endpoints) ### Plugin (25 endpoints)
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/v1/plugin/` | List plugins. Params: `state` (installed/market/all), `force` | | GET | `/api/v1/plugin/` | List plugins. Params: `state` (installed/market/all), `force` |
| GET | `/api/v1/plugin/installed` | List installed plugins | | GET | `/api/v1/plugin/installed` | List installed plugins |
| GET | `/api/v1/plugin/statistic` | Plugin install statistics | | 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/install/{plugin_id}` | Install plugin. Params: `repo_url`, `force` |
| GET | `/api/v1/plugin/reload/{plugin_id}` | Reload plugin | | GET | `/api/v1/plugin/reload/{plugin_id}` | Reload plugin |
| GET | `/api/v1/plugin/reset/{plugin_id}` | Reset plugin config & data | | GET | `/api/v1/plugin/reset/{plugin_id}` | Reset plugin config & data |
+127
View File
@@ -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())