mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-09 15:34:31 +08:00
feat(plugin): add centralized rating APIs
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
127
tests/test_plugin_rating.py
Normal file
127
tests/test_plugin_rating.py
Normal 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())
|
||||
Reference in New Issue
Block a user