From 613eb5313b0c329440b6ae1cf38169d913be5e69 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 15 Oct 2026 09:06:19 +0800 Subject: [PATCH] feat(media): return parsed meta info when recognition fails The recognize endpoint returned an empty context when no media matched, so the recognition shortcut could not show the applied custom words or the processed title, which made failures hard to diagnose. Always return the parsed meta_info (including apply_words) and leave media_info empty when the lookup fails. Update the REST API docs and the generated agent skill note accordingly. Refs #6997 --- app/api/endpoints/media.py | 5 ++--- docs/mcp-api.md | 2 +- scripts/generate_agent_skill_docs.py | 8 +++++--- skills/moviepilot-api/api/media.md | 2 +- tests/test_media_recognize_endpoint.py | 19 +++++++++++++++++++ 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index ef1e67c4f..3782eade0 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -271,9 +271,8 @@ async def recognize( metainfo, media_source=media_source, ) - if mediainfo: - return Context(meta_info=metainfo, media_info=mediainfo).to_dict() - return _SchemaContext() + # 未匹配到媒体时也返回解析元数据,便于识别测试查看识别词应用情况和解析结果 + return Context(meta_info=metainfo, media_info=mediainfo).to_dict() @router.get( diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 905479980..46540a131 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -443,7 +443,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | | GET | `/api/v1/media/search` | 按标题搜索媒体、合集、影视人物或音乐;人物结果可同时包含 MusicBrainz 艺术家,参数:`title`、`type`、`page`、`count`,可重复传入可选 `media_source`;未指定 `music_type` 时音乐搜索只返回单曲和专辑,显式 `music_type` 仍可用于专用实体选择;内置模块只处理自身支持的来源,插件模块可以处理其注册的扩展来源,旧客户端的逗号格式仅在输入边界兼容 | -| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `media_source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 | +| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `media_source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息;未匹配到媒体时仍返回解析元数据 `meta_info`(含实际应用的识别词 `apply_words`),`media_info` 为空 | | GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `media_source` | | GET | `/api/v1/media/{media_id}` | 按原生 ID 查询影视或音乐详情;必填参数:`media_source`、`type_name`,其中 `media_source` 与路径中的 `media_id` 组成统一媒体身份,`type_name` 支持电影、电视剧和音乐 | | POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)和 TMDB `episode_group`。剧集组用于指定电视剧的季集顺序;音乐会按策略处理音频标签、封面和歌词。会覆盖目标位置已有的图片、NFO 和音频标签,与文件管理、手动整理一样需要管理权限 | diff --git a/scripts/generate_agent_skill_docs.py b/scripts/generate_agent_skill_docs.py index 29644ad04..8b4b62460 100644 --- a/scripts/generate_agent_skill_docs.py +++ b/scripts/generate_agent_skill_docs.py @@ -299,9 +299,11 @@ _OPERATION_NOTES: dict[str, list[str]] = { ], "media.recognize": [ "- **Agent note:** Recognition returns a context containing `media_info`, not a candidate list. " - "Check its title and media type against the user's request. If recognition is missing or " - "ambiguous, use `media.search` and compare candidates by title, type, and year. Preserve " - "the selected result's exact `media_source` + `media_id` for `search.torrents`.", + "A failed match still returns parsed `meta_info` with the applied recognition words, so treat " + "recognition as successful only when `media_info` is present. Check its title and media type " + "against the user's request. If recognition is missing or ambiguous, use `media.search` and " + "compare candidates by title, type, and year. Preserve the selected result's exact " + "`media_source` + `media_id` for `search.torrents`.", ], "search.torrents": [ "- **Agent note:** `media_id` is a **path** parameter and MUST be nested under " diff --git a/skills/moviepilot-api/api/media.md b/skills/moviepilot-api/api/media.md index daf7b9262..15457e9ed 100644 --- a/skills/moviepilot-api/api/media.md +++ b/skills/moviepilot-api/api/media.md @@ -182,7 +182,7 @@ Identity scope: `bound_user`; required role: `user`. - `path_params`: none - `query`: `custom_words` (string|null): Custom recognition or rename words applied to this media workflow.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `subtitle` (string|null): Optional subtitle text used together with title during media recognition.; `title*` (string): Media, torrent, subscription, or history title used by the operation. - `body`: none -- **Agent note:** Recognition returns a context containing `media_info`, not a candidate list. Check its title and media type against the user's request. If recognition is missing or ambiguous, use `media.search` and compare candidates by title, type, and year. Preserve the selected result's exact `media_source` + `media_id` for `search.torrents`. +- **Agent note:** Recognition returns a context containing `media_info`, not a candidate list. A failed match still returns parsed `meta_info` with the applied recognition words, so treat recognition as successful only when `media_info` is present. Check its title and media type against the user's request. If recognition is missing or ambiguous, use `media.search` and compare candidates by title, type, and year. Preserve the selected result's exact `media_source` + `media_id` for `search.torrents`. ### `media.recognize_file` `GET /api/v1/media/recognize_file`; policy effect: `safe_read`. diff --git a/tests/test_media_recognize_endpoint.py b/tests/test_media_recognize_endpoint.py index c789f94a7..7d89f22cb 100644 --- a/tests/test_media_recognize_endpoint.py +++ b/tests/test_media_recognize_endpoint.py @@ -87,3 +87,22 @@ def test_recognize_audio_file_title_uses_music_meta_without_source() -> None: metainfo = chain.async_recognize_by_meta.await_args.args[0] assert isinstance(metainfo, MetaMusic) assert metainfo.audio_format + + +def test_recognize_returns_meta_info_when_media_lookup_fails() -> None: + """未匹配到媒体时也应返回解析元数据,供识别测试展示识别词应用情况和解析结果。""" + chain = Mock() + chain.async_recognize_by_meta = AsyncMock(return_value=None) + + with patch("app.api.endpoints.media.MediaChain", return_value=chain): + result = asyncio.run( + recognize( + title="Some.Release.2024", + custom_words="Some => Other", + _=Mock(), + ) + ) + + assert result["media_info"] is None + assert result["meta_info"]["org_string"] == "Other.Release.2024" + assert result["meta_info"]["apply_words"] == ["Some => Other"]