mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 00:32:50 +08:00
feat(api): add unified v2 response layer
This commit is contained in:
7
app/api/apiv2.py
Normal file
7
app/api/apiv2.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.apiv1 import api_router
|
||||||
|
|
||||||
|
|
||||||
|
api_router_v2 = APIRouter()
|
||||||
|
api_router_v2.include_router(api_router)
|
||||||
224
app/api/apiv2_utils.py
Normal file
224
app/api/apiv2_utils.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import Response as StarletteResponse
|
||||||
|
|
||||||
|
from app.schemas.response import Response
|
||||||
|
|
||||||
|
|
||||||
|
API_V2_STR = "/api/v2"
|
||||||
|
OPENAPI_V2_PATH = f"{API_V2_STR}/openapi.json"
|
||||||
|
_PROTOCOL_PREFIXES = ("/openai", "/anthropic", "/mcp")
|
||||||
|
_JSON_CONTENT_TYPES = ("application/json", "+json")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_protocol_path(path: str) -> bool:
|
||||||
|
"""判断路径是否属于需要保留原始协议响应的接口。"""
|
||||||
|
relative_path = path.removeprefix(API_V2_STR)
|
||||||
|
return any(
|
||||||
|
relative_path == prefix or relative_path.startswith(f"{prefix}/")
|
||||||
|
for prefix in _PROTOCOL_PREFIXES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_json_response(response: StarletteResponse) -> bool:
|
||||||
|
"""判断响应是否为可安全解析的 JSON 响应。"""
|
||||||
|
content_type = response.headers.get("content-type", "").split(";", 1)[0]
|
||||||
|
return any(
|
||||||
|
content_type == accepted_type or content_type.endswith(accepted_type)
|
||||||
|
for accepted_type in _JSON_CONTENT_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_response_payload(payload: Any) -> bool:
|
||||||
|
"""判断响应内容是否已经符合通用 Response 结构。"""
|
||||||
|
return isinstance(payload, dict) and {
|
||||||
|
"success",
|
||||||
|
"message",
|
||||||
|
"data",
|
||||||
|
}.issubset(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_error_message(payload: Any) -> str:
|
||||||
|
"""从旧版错误响应中提取统一的错误消息。"""
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
detail = payload.get("detail")
|
||||||
|
if isinstance(detail, str) and detail:
|
||||||
|
return detail
|
||||||
|
if isinstance(detail, list):
|
||||||
|
messages = [
|
||||||
|
item.get("msg")
|
||||||
|
for item in detail
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("msg"), str)
|
||||||
|
]
|
||||||
|
if messages:
|
||||||
|
return "; ".join(messages)
|
||||||
|
if detail is not None:
|
||||||
|
return json.dumps(detail, ensure_ascii=False)
|
||||||
|
message = payload.get("message")
|
||||||
|
if isinstance(message, str) and message:
|
||||||
|
return message
|
||||||
|
if isinstance(payload, str) and payload:
|
||||||
|
return payload
|
||||||
|
return "请求失败"
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_response_headers(source: StarletteResponse, target: StarletteResponse) -> None:
|
||||||
|
"""复制适配前响应中仍然有效的头信息。"""
|
||||||
|
for key, value in source.raw_headers:
|
||||||
|
if key.lower() not in {b"content-length", b"content-type"}:
|
||||||
|
target.raw_headers.append((key, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_response_body(
|
||||||
|
source: StarletteResponse,
|
||||||
|
body: bytes,
|
||||||
|
) -> StarletteResponse:
|
||||||
|
"""在检查响应体后恢复原始响应内容和头信息。"""
|
||||||
|
restored_response = StarletteResponse(
|
||||||
|
content=body,
|
||||||
|
status_code=source.status_code,
|
||||||
|
background=source.background,
|
||||||
|
)
|
||||||
|
restored_response.raw_headers = list(source.raw_headers)
|
||||||
|
return restored_response
|
||||||
|
|
||||||
|
|
||||||
|
class V2ResponseMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""
|
||||||
|
为 v2 REST 接口适配统一的 Response 响应结构。
|
||||||
|
|
||||||
|
已经返回项目 Response 模型的成功响应保持原样,避免改变既有接口语义;
|
||||||
|
OpenAI、Anthropic 和 MCP 协议接口也保持原始协议响应。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
call_next: Callable[[Request], Awaitable[StarletteResponse]],
|
||||||
|
) -> StarletteResponse:
|
||||||
|
"""处理 v2 请求并在必要时封装 JSON 响应。"""
|
||||||
|
response = await call_next(request)
|
||||||
|
if not request.url.path.startswith(f"{API_V2_STR}/"):
|
||||||
|
return response
|
||||||
|
if request.url.path == OPENAPI_V2_PATH:
|
||||||
|
return response
|
||||||
|
if _is_protocol_path(request.url.path):
|
||||||
|
return response
|
||||||
|
if response.status_code in {204, 304} or not _is_json_response(response):
|
||||||
|
return response
|
||||||
|
if response.headers.get("content-encoding"):
|
||||||
|
return response
|
||||||
|
|
||||||
|
route = request.scope.get("route")
|
||||||
|
route_response_model = getattr(route, "response_model", None)
|
||||||
|
if response.status_code < 400 and route_response_model is Response:
|
||||||
|
return response
|
||||||
|
|
||||||
|
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||||
|
if not body:
|
||||||
|
return _restore_response_body(response, body)
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _restore_response_body(response, body)
|
||||||
|
|
||||||
|
if _is_response_payload(payload):
|
||||||
|
return _restore_response_body(response, body)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
content = {
|
||||||
|
"success": False,
|
||||||
|
"message": _get_error_message(payload),
|
||||||
|
"data": {},
|
||||||
|
}
|
||||||
|
if isinstance(payload, dict) and isinstance(payload.get("detail_i18n"), str):
|
||||||
|
content["message_i18n"] = payload["detail_i18n"]
|
||||||
|
else:
|
||||||
|
content = {
|
||||||
|
"success": True,
|
||||||
|
"message": "",
|
||||||
|
"data": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapped_response = JSONResponse(
|
||||||
|
content=content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
background=response.background,
|
||||||
|
)
|
||||||
|
_copy_response_headers(response, wrapped_response)
|
||||||
|
return wrapped_response
|
||||||
|
|
||||||
|
|
||||||
|
def configure_v2_openapi(app: FastAPI) -> None:
|
||||||
|
"""
|
||||||
|
将 v2 普通 JSON 接口的 OpenAPI 响应模型改为通用 Response。
|
||||||
|
|
||||||
|
:param app: 已完成 v1/v2 路由注册的 FastAPI 应用
|
||||||
|
"""
|
||||||
|
if getattr(app, "_v2_openapi_configured", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
original_openapi = app.openapi
|
||||||
|
|
||||||
|
def custom_openapi() -> dict[str, Any]:
|
||||||
|
"""生成包含 v2 通用响应模型的 OpenAPI 文档。"""
|
||||||
|
schema = original_openapi()
|
||||||
|
components = schema.setdefault("components", {}).setdefault("schemas", {})
|
||||||
|
components["Response"] = Response.model_json_schema(
|
||||||
|
ref_template="#/components/schemas/{model}"
|
||||||
|
)
|
||||||
|
|
||||||
|
route_map = {
|
||||||
|
(route.path, method.lower()): route
|
||||||
|
for route in app.routes
|
||||||
|
if isinstance(route, APIRoute)
|
||||||
|
for method in route.methods
|
||||||
|
}
|
||||||
|
response_ref = {"$ref": "#/components/schemas/Response"}
|
||||||
|
for path, path_item in schema.get("paths", {}).items():
|
||||||
|
if not path.startswith(f"{API_V2_STR}/"):
|
||||||
|
continue
|
||||||
|
for method, operation in path_item.items():
|
||||||
|
if method not in {
|
||||||
|
"get",
|
||||||
|
"post",
|
||||||
|
"put",
|
||||||
|
"patch",
|
||||||
|
"delete",
|
||||||
|
"options",
|
||||||
|
"head",
|
||||||
|
}:
|
||||||
|
continue
|
||||||
|
route = route_map.get((path, method))
|
||||||
|
if (
|
||||||
|
route is None
|
||||||
|
or route.response_model is None
|
||||||
|
or route.response_model is Any
|
||||||
|
or route.response_model is Response
|
||||||
|
or _is_protocol_path(path)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if route.status_code in {204, 304}:
|
||||||
|
continue
|
||||||
|
content_type = getattr(route.response_class, "media_type", None)
|
||||||
|
if content_type and not (
|
||||||
|
content_type == "application/json" or content_type.endswith("+json")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
status_code = str(route.status_code or 200)
|
||||||
|
response = operation.get("responses", {}).get(status_code)
|
||||||
|
if response and "content" in response:
|
||||||
|
json_content = response["content"].get("application/json")
|
||||||
|
if json_content is not None:
|
||||||
|
json_content["schema"] = response_ref
|
||||||
|
|
||||||
|
app.openapi_schema = schema
|
||||||
|
return schema
|
||||||
|
|
||||||
|
app.openapi = custom_openapi
|
||||||
|
app._v2_openapi_configured = True
|
||||||
@@ -90,7 +90,7 @@ def wallpaper() -> Any:
|
|||||||
"""
|
"""
|
||||||
url = WallpaperHelper().get_wallpaper()
|
url = WallpaperHelper().get_wallpaper()
|
||||||
if url:
|
if url:
|
||||||
return schemas.Response(success=True, message=url)
|
return schemas.Response(success=True, data=url)
|
||||||
return schemas.Response(success=False)
|
return schemas.Response(success=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from starlette import status
|
|||||||
from starlette.responses import StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.apiv2_utils import API_V2_STR, OPENAPI_V2_PATH
|
||||||
from app.command import Command
|
from app.command import Command
|
||||||
from app.core.cache import async_fresh
|
from app.core.cache import async_fresh
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -36,8 +37,15 @@ from app.scheduler import Scheduler
|
|||||||
from app.schemas.event import PluginDataResetEventData
|
from app.schemas.event import PluginDataResetEventData
|
||||||
from app.schemas.types import ChainEventType, SystemConfigKey
|
from app.schemas.types import ChainEventType, SystemConfigKey
|
||||||
|
|
||||||
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
PROTECTED_ROUTES = {
|
||||||
|
"/api/v1/openapi.json",
|
||||||
|
OPENAPI_V2_PATH,
|
||||||
|
"/docs",
|
||||||
|
"/docs/oauth2-redirect",
|
||||||
|
"/redoc",
|
||||||
|
}
|
||||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||||
|
PLUGIN_V2_PREFIX = f"{API_V2_STR}/plugin"
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
|
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
|
||||||
@@ -158,8 +166,11 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
|||||||
elif Depends(verify_apikey) not in dependencies:
|
elif Depends(verify_apikey) not in dependencies:
|
||||||
dependencies.append(Depends(verify_apikey))
|
dependencies.append(Depends(verify_apikey))
|
||||||
app.add_api_route(**api, tags=["plugin"])
|
app.add_api_route(**api, tags=["plugin"])
|
||||||
|
v2_api = api.copy()
|
||||||
|
v2_api["path"] = api_path.replace(PLUGIN_PREFIX, PLUGIN_V2_PREFIX, 1)
|
||||||
|
app.add_api_route(**v2_api, tags=["plugin"])
|
||||||
is_modified = True
|
is_modified = True
|
||||||
logger.debug(f"Added plugin route: {api_path}")
|
logger.debug(f"Added plugin routes: {api_path}, {v2_api['path']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
|
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
|
||||||
|
|
||||||
@@ -177,8 +188,13 @@ def _remove_routes(plugin_id: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
return False
|
return False
|
||||||
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
|
prefixes = {
|
||||||
routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)]
|
f"{PLUGIN_PREFIX}/{plugin_id}/",
|
||||||
|
f"{PLUGIN_V2_PREFIX}/{plugin_id}/",
|
||||||
|
}
|
||||||
|
routes_to_remove = [
|
||||||
|
route for route in app.routes if any(route.path.startswith(prefix) for prefix in prefixes)
|
||||||
|
]
|
||||||
removed = False
|
removed = False
|
||||||
for route in routes_to_remove:
|
for route in routes_to_remove:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
from typing import Awaitable, Callable
|
import json
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api.apiv2_utils import OPENAPI_V2_PATH, V2ResponseMiddleware
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.helper.locale import LocaleHelper
|
from app.helper.locale import LocaleHelper
|
||||||
from app.startup.lifecycle import lifespan
|
from app.startup.lifecycle import lifespan
|
||||||
|
from version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def _get_http_exception_message(detail: Any) -> str:
|
||||||
|
"""将 HTTPException 的 detail 转换为统一消息文本。"""
|
||||||
|
if isinstance(detail, str) and detail:
|
||||||
|
return detail
|
||||||
|
if detail is None:
|
||||||
|
return "请求失败"
|
||||||
|
try:
|
||||||
|
return json.dumps(detail, ensure_ascii=False)
|
||||||
|
except TypeError:
|
||||||
|
return str(detail)
|
||||||
|
|
||||||
|
|
||||||
async def localized_http_exception_handler(
|
async def localized_http_exception_handler(
|
||||||
@@ -14,18 +29,20 @@ async def localized_http_exception_handler(
|
|||||||
exc: HTTPException,
|
exc: HTTPException,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
为 HTTPException 响应补充多语言错误详情。
|
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
|
||||||
|
|
||||||
:param _request: 当前 HTTP 请求
|
:param _request: 当前 HTTP 请求
|
||||||
:param exc: FastAPI HTTP 异常
|
:param exc: FastAPI HTTP 异常
|
||||||
:return: 带 detail_i18n 的 JSON 错误响应
|
:return: 统一 JSON 错误响应
|
||||||
"""
|
"""
|
||||||
content = {"detail": exc.detail}
|
message = _get_http_exception_message(exc.detail)
|
||||||
if isinstance(exc.detail, str):
|
|
||||||
content["detail_i18n"] = LocaleHelper.translate_text(exc.detail)
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
content=content,
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": message,
|
||||||
|
"data": {},
|
||||||
|
},
|
||||||
headers=exc.headers,
|
headers=exc.headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,10 +53,16 @@ def create_app() -> FastAPI:
|
|||||||
"""
|
"""
|
||||||
_app = FastAPI(
|
_app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
version=APP_VERSION,
|
||||||
|
openapi_url=OPENAPI_V2_PATH,
|
||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@_app.get(f"{settings.API_V1_STR}/openapi.json", include_in_schema=False)
|
||||||
|
def get_v1_openapi_schema() -> dict[str, Any]:
|
||||||
|
"""保留旧版 OpenAPI 地址并返回当前完整接口文档。"""
|
||||||
|
return _app.openapi()
|
||||||
|
|
||||||
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||||
|
|
||||||
# 配置 CORS 中间件
|
# 配置 CORS 中间件
|
||||||
@@ -50,6 +73,7 @@ def create_app() -> FastAPI:
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
_app.add_middleware(V2ResponseMiddleware)
|
||||||
|
|
||||||
@_app.middleware("http")
|
@_app.middleware("http")
|
||||||
async def locale_context_middleware(
|
async def locale_context_middleware(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Optional, Union
|
from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ class Response(BaseModel):
|
|||||||
# 多语言消息文本
|
# 多语言消息文本
|
||||||
message_i18n: Optional[str] = None
|
message_i18n: Optional[str] = None
|
||||||
# 数据
|
# 数据
|
||||||
data: Optional[Union[dict, list]] = Field(default_factory=dict)
|
data: Optional[Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def fill_message_i18n(self) -> "Response":
|
def fill_message_i18n(self) -> "Response":
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ def init_routers(app: FastAPI):
|
|||||||
初始化路由
|
初始化路由
|
||||||
"""
|
"""
|
||||||
from app.api.apiv1 import api_router
|
from app.api.apiv1 import api_router
|
||||||
|
from app.api.apiv2 import api_router_v2
|
||||||
|
from app.api.apiv2_utils import API_V2_STR, configure_v2_openapi
|
||||||
from app.api.servarr import arr_router
|
from app.api.servarr import arr_router
|
||||||
from app.api.servcookie import cookie_router
|
from app.api.servcookie import cookie_router
|
||||||
# API路由
|
# API路由
|
||||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||||
|
# v2 API复用v1路由,仅在响应出口统一封装
|
||||||
|
app.include_router(api_router_v2, prefix=API_V2_STR)
|
||||||
|
configure_v2_openapi(app)
|
||||||
# Radarr、Sonarr路由
|
# Radarr、Sonarr路由
|
||||||
app.include_router(arr_router, prefix="/api/v3")
|
app.include_router(arr_router, prefix="/api/v3")
|
||||||
# CookieCloud路由
|
# CookieCloud路由
|
||||||
|
|||||||
@@ -112,9 +112,23 @@ MoviePilot 的内置 Agent 也可以作为 MCP Client 连接外部 MCP 服务器
|
|||||||
|
|
||||||
MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`。
|
MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`。
|
||||||
|
|
||||||
标准 REST 响应包含 `success`、`message`、`message_i18n`、`data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
|
#### REST API 版本
|
||||||
|
|
||||||
FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返回 `detail_i18n`;新版前端优先展示 `detail_i18n`,缺失时回退 `detail`。
|
- `/api/v1` 默认保持原有响应结构,已有客户端无需迁移;登录壁纸接口的 URL 已统一放入 `data`。
|
||||||
|
- `/api/v2` 复用 `/api/v1` 的同一套路由、请求参数、鉴权依赖和业务实现,只统一普通 JSON 响应结构。
|
||||||
|
- v1 中已经使用通用 `Response` 的接口在 v2 中保持原样;其他成功 JSON 响应转换为 `{"success": true, "message": "", "data": <原响应>}`。
|
||||||
|
- HTTP 错误保留原状态码,并统一返回 `{"success": false, "message": <错误详情>, "data": {}}`;非业务异常不做多语言翻译。
|
||||||
|
- SSE、文件、图片、空响应,以及 OpenAI、Anthropic、MCP 等标准协议接口保持原始响应格式,不进行通用封装。
|
||||||
|
|
||||||
|
因此,普通 REST 接口可将文档中的 `/api/v1/...` 路径直接替换为 `/api/v2/...`。例如 `/api/v1/download/` 对应 `/api/v2/download/`。
|
||||||
|
|
||||||
|
通用 REST 响应包含 `success`、`message`、`message_i18n`、`data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
|
||||||
|
|
||||||
|
`GET /api/v1/login/wallpaper` 及对应的 v2 路径会将壁纸 URL 放在 `data` 字段中,`message` 不再承载业务数据。
|
||||||
|
|
||||||
|
FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶层 `detail` / `detail_i18n`。
|
||||||
|
|
||||||
|
交互式接口文档 `/docs` 默认读取 `/api/v2/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。旧地址 `/api/v1/openapi.json` 继续保留并返回同一份完整接口文档。
|
||||||
|
|
||||||
#### 媒体识别 / 整理
|
#### 媒体识别 / 整理
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: moviepilot-api
|
name: moviepilot-api
|
||||||
version: 7
|
version: 8
|
||||||
description: >-
|
description: >-
|
||||||
Use this skill when you need to call MoviePilot REST API endpoints directly
|
Use this skill when you need to call MoviePilot REST API endpoints directly
|
||||||
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
||||||
@@ -65,6 +65,25 @@ python scripts/mp-api.py <METHOD> <PATH> [key=value ...] [--json '<body>']
|
|||||||
- Both methods validate against the same `API_TOKEN` value.
|
- Both methods validate against the same `API_TOKEN` value.
|
||||||
- Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available.
|
- Never print, summarize, or ask the user to paste the API key unless the script is being used outside the local project and no safer configuration source is available.
|
||||||
|
|
||||||
|
### API versions and response envelopes
|
||||||
|
|
||||||
|
- `/api/v1` preserves the existing endpoint-specific response shapes by
|
||||||
|
default; the login wallpaper URL is now returned in `data`.
|
||||||
|
- `/api/v2` reuses the same routes, parameters, authentication dependencies,
|
||||||
|
and business handlers, but wraps ordinary JSON responses in the shared
|
||||||
|
`Response` envelope.
|
||||||
|
- A successful raw v1 payload becomes
|
||||||
|
`{"success":true,"message":"","data":<original payload>}` in v2.
|
||||||
|
- Existing `Response` payloads are not wrapped again. HTTP errors on both v1
|
||||||
|
and v2 keep their original status code and expose the error text in
|
||||||
|
`message` with `data={}`. Non-business HTTP exceptions are not translated.
|
||||||
|
- SSE, files, images, empty responses, and OpenAI, Anthropic, or MCP protocol
|
||||||
|
endpoints keep their protocol-native response body.
|
||||||
|
|
||||||
|
Use `/api/v2` for app clients that require one JSON envelope. Any ordinary
|
||||||
|
REST path listed below can switch from `/api/v1/...` to `/api/v2/...` without
|
||||||
|
changing its method, parameters, request body, or authentication.
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -79,6 +98,9 @@ python scripts/mp-api.py DELETE /api/v1/subscribe/123
|
|||||||
|
|
||||||
# Endpoints that require ?token= auth
|
# Endpoints that require ?token= auth
|
||||||
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
|
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
|
||||||
|
|
||||||
|
# Uniform v2 JSON response envelope
|
||||||
|
python scripts/mp-api.py GET /api/v2/dashboard/cpu
|
||||||
```
|
```
|
||||||
|
|
||||||
## Complete API Reference
|
## Complete API Reference
|
||||||
@@ -488,7 +510,7 @@ The list endpoint returns local cache totals plus `shared_recognized` and
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) |
|
| POST | `/api/v1/login/access-token` | Get JWT access token. Body: form (username, password) |
|
||||||
| GET | `/api/v1/login/wallpaper` | Login page wallpaper |
|
| GET | `/api/v1/login/wallpaper` | Login page wallpaper; URL is returned in `data` |
|
||||||
| GET | `/api/v1/login/wallpapers` | Login page wallpaper list |
|
| GET | `/api/v1/login/wallpapers` | Login page wallpaper list |
|
||||||
|
|
||||||
### MCP Tools (6 endpoints)
|
### MCP Tools (6 endpoints)
|
||||||
|
|||||||
259
tests/test_api_v2.py
Normal file
259
tests/test_api_v2.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.api.apiv2_utils import (
|
||||||
|
OPENAPI_V2_PATH,
|
||||||
|
V2ResponseMiddleware,
|
||||||
|
configure_v2_openapi,
|
||||||
|
)
|
||||||
|
from app.schemas.response import Response
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def anyio_backend():
|
||||||
|
"""使用 asyncio 运行异步接口测试。"""
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def api_app() -> FastAPI:
|
||||||
|
"""构造同时包含 v1 和 v2 示例接口的测试应用。"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(V2ResponseMiddleware)
|
||||||
|
|
||||||
|
@app.get("/api/v1/items")
|
||||||
|
async def get_v1_items() -> list[dict]:
|
||||||
|
"""返回未封装的 v1 列表。"""
|
||||||
|
return [{"id": 1}]
|
||||||
|
|
||||||
|
@app.get("/api/v2/items")
|
||||||
|
async def get_v2_items() -> list[dict]:
|
||||||
|
"""返回供 v2 适配器封装的列表。"""
|
||||||
|
return [{"id": 1}]
|
||||||
|
|
||||||
|
@app.get("/api/v2/wrapped", response_model=Response)
|
||||||
|
async def get_wrapped_response() -> Response:
|
||||||
|
"""返回已经使用通用结构封装的响应。"""
|
||||||
|
return Response(success=True, message="操作成功", data={"id": 1})
|
||||||
|
|
||||||
|
@app.get("/api/v2/error")
|
||||||
|
async def get_error() -> None:
|
||||||
|
"""返回供 v2 适配器转换的 HTTP 错误。"""
|
||||||
|
raise HTTPException(status_code=400, detail="请求参数错误")
|
||||||
|
|
||||||
|
@app.get("/api/v2/validated/{item_id}")
|
||||||
|
async def get_validated_item(item_id: int) -> dict:
|
||||||
|
"""返回带路径参数校验的示例数据。"""
|
||||||
|
return {"id": item_id}
|
||||||
|
|
||||||
|
@app.get("/api/v2/openai/v1/models")
|
||||||
|
async def get_openai_models() -> dict:
|
||||||
|
"""返回需要保持原始协议结构的 OpenAI 模型列表。"""
|
||||||
|
return {"object": "list", "data": []}
|
||||||
|
|
||||||
|
@app.get("/api/v2/events")
|
||||||
|
async def get_events() -> None:
|
||||||
|
"""返回不应封装的 SSE 流。"""
|
||||||
|
async def event_source():
|
||||||
|
"""生成一条测试事件。"""
|
||||||
|
yield "data: ok\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(event_source(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
@app.get(OPENAPI_V2_PATH, include_in_schema=False)
|
||||||
|
async def get_v2_openapi_schema() -> dict:
|
||||||
|
"""返回不应被 v2 中间件封装的 OpenAPI 文档。"""
|
||||||
|
return {"openapi": "3.1.0", "info": {"title": "Test", "version": "1.0.0"}}
|
||||||
|
|
||||||
|
configure_v2_openapi(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def make_client(app: FastAPI) -> httpx.AsyncClient:
|
||||||
|
"""创建不访问真实网络的 ASGI 测试客户端。"""
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v2_wraps_raw_json_without_changing_v1(api_app: FastAPI):
|
||||||
|
"""v2 应封装原始 JSON 数据,同时保持 v1 返回结构不变。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
v1_response = await client.get("/api/v1/items")
|
||||||
|
v2_response = await client.get("/api/v2/items")
|
||||||
|
|
||||||
|
assert v1_response.json() == [{"id": 1}]
|
||||||
|
assert v2_response.json() == {
|
||||||
|
"success": True,
|
||||||
|
"message": "",
|
||||||
|
"data": [{"id": 1}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v2_keeps_existing_response_payload(api_app: FastAPI):
|
||||||
|
"""已经使用 Response 的接口不应被重复封装。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
response = await client.get("/api/v2/wrapped")
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["message"] == "操作成功"
|
||||||
|
assert payload["data"] == {"id": 1}
|
||||||
|
assert "success" not in payload["data"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v2_moves_http_error_detail_to_message(api_app: FastAPI):
|
||||||
|
"""v2 HTTP 错误应保留状态码并把 detail 转换到 message。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
response = await client.get("/api/v2/error")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"success": False,
|
||||||
|
"message": "请求参数错误",
|
||||||
|
"data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v2_moves_validation_error_to_message(api_app: FastAPI):
|
||||||
|
"""v2 参数校验错误也应返回可直接展示的 message。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
response = await client.get("/api/v2/validated/not-an-integer")
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert payload["message"]
|
||||||
|
assert payload["data"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v2_keeps_protocol_response_unwrapped(api_app: FastAPI):
|
||||||
|
"""OpenAI 等标准协议接口应保持原始响应结构。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
response = await client.get("/api/v2/openai/v1/models")
|
||||||
|
|
||||||
|
assert response.json() == {"object": "list", "data": []}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v2_keeps_streaming_response_unwrapped(api_app: FastAPI):
|
||||||
|
"""v2 SSE 等非 JSON 响应应保持原始内容。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
response = await client.get("/api/v2/events")
|
||||||
|
|
||||||
|
assert response.headers["content-type"].startswith("text/event-stream")
|
||||||
|
assert response.text == "data: ok\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_v2_keeps_openapi_schema_unwrapped(api_app: FastAPI):
|
||||||
|
"""v2 OpenAPI 文档应保持 Swagger UI 可读取的原始结构。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
response = await client.get(OPENAPI_V2_PATH)
|
||||||
|
|
||||||
|
assert response.json() == {
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {"title": "Test", "version": "1.0.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_openapi_uses_response_schema(api_app: FastAPI):
|
||||||
|
"""v2 普通 JSON 接口的 OpenAPI 应声明通用 Response 模型。"""
|
||||||
|
schema = api_app.openapi()
|
||||||
|
|
||||||
|
raw_schema = schema["paths"]["/api/v2/items"]["get"]["responses"]["200"]
|
||||||
|
protocol_schema = schema["paths"]["/api/v2/openai/v1/models"]["get"]
|
||||||
|
stream_schema = schema["paths"]["/api/v2/events"]["get"]
|
||||||
|
|
||||||
|
assert raw_schema["content"]["application/json"]["schema"] == {
|
||||||
|
"$ref": "#/components/schemas/Response"
|
||||||
|
}
|
||||||
|
assert protocol_schema["responses"]["200"]["content"]["application/json"][
|
||||||
|
"schema"
|
||||||
|
] != {"$ref": "#/components/schemas/Response"}
|
||||||
|
assert stream_schema["responses"]["200"]["content"]["application/json"].get(
|
||||||
|
"schema"
|
||||||
|
) != {"$ref": "#/components/schemas/Response"}
|
||||||
|
data_schema = schema["components"]["schemas"]["Response"]["properties"]["data"]
|
||||||
|
assert data_schema["title"] == "Data"
|
||||||
|
assert {} in data_schema["anyOf"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_accepts_scalar_data():
|
||||||
|
"""通用 Response 的 data 应支持标量接口数据。"""
|
||||||
|
response = Response(success=True, data=1)
|
||||||
|
|
||||||
|
assert response.data == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_router_reuses_v1_route_endpoints():
|
||||||
|
"""v2 路由应直接复用 v1 的端点函数,避免复制业务实现。"""
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from app.api.apiv1 import api_router
|
||||||
|
from app.api.apiv2 import api_router_v2
|
||||||
|
|
||||||
|
v1_routes = [route for route in api_router.routes if isinstance(route, APIRoute)]
|
||||||
|
v2_routes = [route for route in api_router_v2.routes if isinstance(route, APIRoute)]
|
||||||
|
|
||||||
|
assert len(v2_routes) == len(v1_routes)
|
||||||
|
assert all(
|
||||||
|
v2_route.path == v1_route.path
|
||||||
|
and v2_route.methods == v1_route.methods
|
||||||
|
and v2_route.endpoint is v1_route.endpoint
|
||||||
|
for v1_route, v2_route in zip(v1_routes, v2_routes)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_routes_are_mirrored_to_v2(monkeypatch):
|
||||||
|
"""插件动态路由注册和移除时应同步维护 v2 路径。"""
|
||||||
|
from app.api.endpoints import plugin as plugin_endpoint
|
||||||
|
|
||||||
|
class FakeApp:
|
||||||
|
"""记录动态注册路径的应用桩。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.routes = []
|
||||||
|
self.openapi_schema = None
|
||||||
|
|
||||||
|
def add_api_route(self, **kwargs):
|
||||||
|
"""记录新增的路由路径。"""
|
||||||
|
self.routes.append(SimpleNamespace(path=kwargs["path"]))
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
"""模拟 FastAPI 路由重建。"""
|
||||||
|
|
||||||
|
class FakePluginManager:
|
||||||
|
"""返回单个测试插件 API 的管理器桩。"""
|
||||||
|
|
||||||
|
def get_plugin_apis(self, plugin_id):
|
||||||
|
"""返回测试插件 API。"""
|
||||||
|
assert plugin_id == "DemoPlugin"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"path": "/DemoPlugin/health",
|
||||||
|
"endpoint": lambda: {"ok": True},
|
||||||
|
"methods": ["GET"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_app = FakeApp()
|
||||||
|
monkeypatch.setattr(plugin_endpoint, "app", fake_app)
|
||||||
|
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
|
||||||
|
|
||||||
|
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="add")
|
||||||
|
|
||||||
|
assert [route.path for route in fake_app.routes] == [
|
||||||
|
"/api/v1/plugin/DemoPlugin/health",
|
||||||
|
"/api/v2/plugin/DemoPlugin/health",
|
||||||
|
]
|
||||||
|
|
||||||
|
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove")
|
||||||
|
|
||||||
|
assert fake_app.routes == []
|
||||||
@@ -23,7 +23,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
|||||||
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
||||||
expected_versions = {
|
expected_versions = {
|
||||||
"database-operation": "3",
|
"database-operation": "3",
|
||||||
"moviepilot-api": "7",
|
"moviepilot-api": "8",
|
||||||
"moviepilot-cli": "6",
|
"moviepilot-cli": "6",
|
||||||
"moviepilot-update": "3",
|
"moviepilot-update": "3",
|
||||||
"transfer-failed-retry": "2",
|
"transfer-failed-retry": "2",
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.factory import localized_http_exception_handler
|
from app.factory import create_app, localized_http_exception_handler
|
||||||
from app.helper.locale import LocaleHelper
|
from app.helper.locale import LocaleHelper
|
||||||
from app.helper.progress import ProgressHelper
|
from app.helper.progress import ProgressHelper
|
||||||
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
|
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
|
||||||
from app.schemas.response import Response
|
from app.schemas.response import Response
|
||||||
|
from version import APP_VERSION
|
||||||
|
|
||||||
|
|
||||||
def _has_chinese(text: str) -> bool:
|
def _has_chinese(text: str) -> bool:
|
||||||
@@ -247,8 +248,8 @@ def test_response_auto_fills_message_i18n_from_locale_context():
|
|||||||
assert response.message_i18n == "Module does not support testing"
|
assert response.message_i18n == "Module does not support testing"
|
||||||
|
|
||||||
|
|
||||||
def test_http_exception_handler_adds_detail_i18n_from_locale_context():
|
def test_http_exception_handler_returns_untranslated_response_envelope():
|
||||||
"""HTTPException 响应应补充多语言 detail 字段。"""
|
"""HTTPException 响应应统一封装并保留原始错误文本。"""
|
||||||
token = LocaleHelper.set_current_locale("en-US")
|
token = LocaleHelper.set_current_locale("en-US")
|
||||||
try:
|
try:
|
||||||
response = asyncio.run(
|
response = asyncio.run(
|
||||||
@@ -260,9 +261,22 @@ def test_http_exception_handler_adds_detail_i18n_from_locale_context():
|
|||||||
finally:
|
finally:
|
||||||
LocaleHelper.reset_current_locale(token)
|
LocaleHelper.reset_current_locale(token)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
payload = json.loads(response.body)
|
payload = json.loads(response.body)
|
||||||
assert payload["detail"] == "用户名或密码错误"
|
assert payload == {
|
||||||
assert payload["detail_i18n"] == "Incorrect username or password"
|
"success": False,
|
||||||
|
"message": "用户名或密码错误",
|
||||||
|
"data": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_docs_use_v2_openapi_and_backend_version():
|
||||||
|
"""默认接口文档应展示 v2 地址并使用真实后端版本号。"""
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
assert app.openapi_url == "/api/v2/openapi.json"
|
||||||
|
assert app.version == APP_VERSION
|
||||||
|
assert "/api/v1/openapi.json" in {route.path for route in app.routes}
|
||||||
|
|
||||||
|
|
||||||
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
|
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
|
||||||
|
|||||||
@@ -95,3 +95,23 @@ def test_login_invalid_password_does_not_expose_mfa_methods(monkeypatch):
|
|||||||
assert exc_info.value.status_code == 401
|
assert exc_info.value.status_code == 401
|
||||||
assert exc_info.value.detail == "用户名或密码错误"
|
assert exc_info.value.detail == "用户名或密码错误"
|
||||||
assert "X-MFA-Required" not in (exc_info.value.headers or {})
|
assert "X-MFA-Required" not in (exc_info.value.headers or {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_wallpaper_returns_url_in_data(monkeypatch):
|
||||||
|
"""登录壁纸地址应放入 data,message 只保留消息文本。"""
|
||||||
|
|
||||||
|
class FakeWallpaperHelper:
|
||||||
|
"""返回固定登录壁纸地址。"""
|
||||||
|
|
||||||
|
def get_wallpaper(self):
|
||||||
|
"""返回测试壁纸地址。"""
|
||||||
|
return "https://images.example/wallpaper.jpg"
|
||||||
|
|
||||||
|
monkeypatch.setattr(login_endpoint, "WallpaperHelper", FakeWallpaperHelper)
|
||||||
|
|
||||||
|
response = login_endpoint.wallpaper()
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
assert response.data == "https://images.example/wallpaper.jpg"
|
||||||
|
assert response.message is None
|
||||||
|
assert response.message_i18n is None
|
||||||
|
|||||||
Reference in New Issue
Block a user