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

View File

@@ -107,7 +107,7 @@ async def exists_local(
)
if exist:
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(

View File

@@ -69,6 +69,10 @@ class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
# ID
id: int
# 源存储类型
src_storage: Optional[str] = None
# 目标存储类型
dest_storage: Optional[str] = None
# 源目录
src: Optional[str] = None
# 目的目录

View File

@@ -117,6 +117,7 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
- 普通 JSON REST 接口统一使用 `/api/v1`,不再提供 `/api/v2` 套壳版本。
- 成功和失败响应都只包含 `success``message``data` 三个顶层字段;各接口只有 `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` 查询数据结构。
- SSE、文件、图片、HTML、空响应以及 OAuth2 登录、OpenAI、Anthropic、MCP JSON-RPC 等标准协议端点保持协议原生响应体;它们会在 OpenAPI 中显式声明对应的流、文件或协议模型。

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. |
| 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 |
| 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 |
|--------|------|-------------|
| 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/notexists` | Check missing episodes (remote). Body: MediaInfo JSON |
| GET | `/api/v1/mediaserver/latest` | Latest library items. Params: `server` (required), `count` |

View File

@@ -2,6 +2,7 @@
from app.schemas.event import DiscoverMediaSource
from app.schemas.file import FileItem, StorageTransType
from app.schemas.history import TransferHistory
from app.schemas.mediaserver import MediaServerLibrary, MediaServerPlayItem, NotExistMediaInfo
from app.schemas.plugin import Plugin, PluginDashboard
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"] == {
"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()["guest_stars"][0]["character"] == "Guest"

View File

@@ -1,7 +1,9 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app import schemas
from app.api.endpoints import mediaserver as mediaserver_endpoint
from app.api.response import ResponseAPIRouter
from app.core.context import MediaInfo as CoreMediaInfo
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"][1]["id"] == 3
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": {}}