fix(api): restore successful query response semantics

This commit is contained in:
jxxghp
2026-08-13 07:20:49 +08:00
parent 00607bf5b3
commit f17b0b1bb9
6 changed files with 52 additions and 3 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ async def exists_local(
) )
if exist: if exist:
ret_info = {"id": exist.item_id} ret_info = {"id": exist.item_id}
return schemas.Response(success=True if exist else False, data={"item": ret_info}) return schemas.Response(success=True, data={"item": ret_info})
@router.post( @router.post(
+4
View File
@@ -69,6 +69,10 @@ class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
# ID # ID
id: int id: int
# 源存储类型
src_storage: Optional[str] = None
# 目标存储类型
dest_storage: Optional[str] = None
# 源目录 # 源目录
src: Optional[str] = None src: Optional[str] = None
# 目的目录 # 目的目录
+1
View File
@@ -117,6 +117,7 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
- 普通 JSON REST 接口统一使用 `/api/v1`,不再提供 `/api/v2` 套壳版本。 - 普通 JSON REST 接口统一使用 `/api/v1`,不再提供 `/api/v2` 套壳版本。
- 成功和失败响应都只包含 `success``message``data` 三个顶层字段;各接口只有 `data` 的模型可以变化。 - 成功和失败响应都只包含 `success``message``data` 三个顶层字段;各接口只有 `data` 的模型可以变化。
- 成功响应为 `{"success": true, "message": "", "data": <接口数据>}`。HTTP 错误保留原状态码,返回 `{"success": false, "message": <错误原因>, "data": null}`;请求参数校验错误会在 `data` 中附带结构化错误列表。 - 成功响应为 `{"success": true, "message": "", "data": <接口数据>}`。HTTP 错误保留原状态码,返回 `{"success": false, "message": <错误原因>, "data": null}`;请求参数校验错误会在 `data` 中附带结构化错误列表。
- 查询接口未命中但请求已正常完成时仍返回 `success=true`,存在性等业务状态通过 `data` 表达。例如 `/mediaserver/exists` 未命中时返回空的 `data.item`
- 每个普通 JSON 端点都会在 OpenAPI 中声明具体的 `Response[DataModel]`,调用方可从 `/docs``/api/v1/openapi.json` 查询数据结构。 - 每个普通 JSON 端点都会在 OpenAPI 中声明具体的 `Response[DataModel]`,调用方可从 `/docs``/api/v1/openapi.json` 查询数据结构。
- SSE、文件、图片、HTML、空响应,以及 OAuth2 登录、OpenAI、Anthropic、MCP JSON-RPC 等标准协议端点保持协议原生响应体;它们会在 OpenAPI 中显式声明对应的流、文件或协议模型。 - SSE、文件、图片、HTML、空响应,以及 OAuth2 登录、OpenAI、Anthropic、MCP JSON-RPC 等标准协议端点保持协议原生响应体;它们会在 OpenAPI 中显式声明对应的流、文件或协议模型。
+2 -2
View File
@@ -312,7 +312,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/v1/history/download` | Download history, newest first. Params: `page`, `count`. `poster` is the poster image; legacy `image` is the backdrop image. | | GET | `/api/v1/history/download` | Download history, newest first. Params: `page`, `count`. `poster` is the poster image; legacy `image` is the backdrop image. |
| DELETE | `/api/v1/history/download` | Delete download history. Body: DownloadHistory JSON | | DELETE | `/api/v1/history/download` | Delete download history. Body: DownloadHistory JSON |
| GET | `/api/v1/history/transfer` | Transfer history. Params: `title`, `page`, `count`, `status` | | GET | `/api/v1/history/transfer` | Transfer history, including `src_storage` and `dest_storage` for path labels. Params: `title`, `page`, `count`, `status` |
| DELETE | `/api/v1/history/transfer` | Delete transfer history. Params: `deletesrc`, `deletedest`. Body: TransferHistory | | DELETE | `/api/v1/history/transfer` | Delete transfer history. Params: `deletesrc`, `deletedest`. Body: TransferHistory |
| GET | `/api/v1/history/empty/transfer` | Clear all transfer history | | GET | `/api/v1/history/empty/transfer` | Clear all transfer history |
@@ -321,7 +321,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/v1/mediaserver/play/{itemid}` | Play media online | | GET | `/api/v1/mediaserver/play/{itemid}` | Play media online |
| GET | `/api/v1/mediaserver/exists` | Check if media exists in the local library database. Params: `media_source` + `media_id`, or `title` discovery; optional `year`, `mtype`, `season` | | GET | `/api/v1/mediaserver/exists` | Check if media exists in the local library database. A completed miss is `success=true` with an empty `data.item`. Params: `media_source` + `media_id`, or `title` discovery; optional `year`, `mtype`, `season` |
| POST | `/api/v1/mediaserver/exists_remote` | Check existing episodes (remote). Body: MediaInfo JSON | | POST | `/api/v1/mediaserver/exists_remote` | Check existing episodes (remote). Body: MediaInfo JSON |
| POST | `/api/v1/mediaserver/notexists` | Check missing episodes (remote). Body: MediaInfo JSON | | POST | `/api/v1/mediaserver/notexists` | Check missing episodes (remote). Body: MediaInfo JSON |
| GET | `/api/v1/mediaserver/latest` | Latest library items. Params: `server` (required), `count` | | GET | `/api/v1/mediaserver/latest` | Latest library items. Params: `server` (required), `count` |
+10
View File
@@ -2,6 +2,7 @@
from app.schemas.event import DiscoverMediaSource from app.schemas.event import DiscoverMediaSource
from app.schemas.file import FileItem, StorageTransType from app.schemas.file import FileItem, StorageTransType
from app.schemas.history import TransferHistory
from app.schemas.mediaserver import MediaServerLibrary, MediaServerPlayItem, NotExistMediaInfo from app.schemas.mediaserver import MediaServerLibrary, MediaServerPlayItem, NotExistMediaInfo
from app.schemas.plugin import Plugin, PluginDashboard from app.schemas.plugin import Plugin, PluginDashboard
from app.schemas.site import SiteStatistic, SiteUserData from app.schemas.site import SiteStatistic, SiteUserData
@@ -43,6 +44,15 @@ def test_stable_collections_serialize_without_changing_payload_shape():
assert StorageTransType(transtype={"move": "移动"}).model_dump()["transtype"] == { assert StorageTransType(transtype={"move": "移动"}).model_dump()["transtype"] == {
"move": "移动" "move": "移动"
} }
history = TransferHistory(
id=1,
src_storage="local",
dest_storage="alist",
src="/downloads/demo.mkv",
dest="/media/demo.mkv",
).model_dump()
assert history["src_storage"] == "local"
assert history["dest_storage"] == "alist"
assert episode.model_dump()["crew"][0]["job"] == "Writer" assert episode.model_dump()["crew"][0]["job"] == "Writer"
assert episode.model_dump()["guest_stars"][0]["character"] == "Guest" assert episode.model_dump()["guest_stars"][0]["character"] == "Guest"
+34
View File
@@ -1,7 +1,9 @@
import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app import schemas from app import schemas
from app.api.endpoints import mediaserver as mediaserver_endpoint
from app.api.response import ResponseAPIRouter from app.api.response import ResponseAPIRouter
from app.core.context import MediaInfo as CoreMediaInfo from app.core.context import MediaInfo as CoreMediaInfo
from app.schemas.types import MediaSource, MediaType from app.schemas.types import MediaSource, MediaType
@@ -79,3 +81,35 @@ def test_media_response_accepts_cross_source_credit_shapes() -> None:
assert media["directors"][0] == "导演甲" assert media["directors"][0] == "导演甲"
assert media["directors"][1]["id"] == 3 assert media["directors"][1]["id"] == 3
assert media["directors"][1]["name"] == "导演乙" assert media["directors"][1]["name"] == "导演乙"
@pytest.mark.asyncio
async def test_media_exists_not_found_is_a_successful_query(monkeypatch) -> None:
"""媒体库未命中是查询结果,不应被统一客户端识别为接口失败。"""
class EmptyMediaServerOper:
"""返回未命中的媒体库查询桩。"""
async def async_exists(self, **_kwargs):
"""模拟媒体库中不存在目标媒体。"""
return None
monkeypatch.setattr(
mediaserver_endpoint,
"MediaServerOper",
lambda _db: EmptyMediaServerOper(),
)
response = await mediaserver_endpoint.exists_local(
title="未入库电影",
year="2026",
mtype="电影",
media_source=None,
media_id=None,
season=None,
db=object(),
_=None,
)
assert response.success is True
assert response.data == {"item": {}}