mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 00:46:57 +08:00
feat(plugin): add centralized rating APIs
This commit is contained in:
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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