diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index ff46b6e71..a3f514098 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -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( diff --git a/app/schemas/history.py b/app/schemas/history.py index 0426e6d13..a74a8ca9c 100644 --- a/app/schemas/history.py +++ b/app/schemas/history.py @@ -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 # 目的目录 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index c5788636a..ca89da74a 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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 中显式声明对应的流、文件或协议模型。 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index ae12905aa..cdf751f0b 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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` | diff --git a/tests/test_explicit_response_models.py b/tests/test_explicit_response_models.py index bb4c32e8b..219f83fed 100644 --- a/tests/test_explicit_response_models.py +++ b/tests/test_explicit_response_models.py @@ -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" diff --git a/tests/test_media_response_models.py b/tests/test_media_response_models.py index 664cdab19..cdc9c5f9e 100644 --- a/tests/test_media_response_models.py +++ b/tests/test_media_response_models.py @@ -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": {}}