From 7251400237bf5079319c8b743410f096fc1ce549 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 5 Sep 2026 16:42:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E4=BF=AE=E5=A4=8D=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E8=AF=A6=E6=83=85=E5=93=8D=E5=BA=94=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/endpoints/media.py | 5 ++-- app/schemas/context.py | 15 ++++++++--- app/schemas/exports.py | 1 + docs/mcp-api.md | 2 +- tests/test_media_response_models.py | 41 +++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 978dcbae7..45837e280 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -25,6 +25,7 @@ from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath from app.schemas.category import CategoryConfig as _SchemaCategoryConfig from app.schemas.category import MediaCategoryMap as _SchemaMediaCategoryMap +from app.schemas.context import MediaDetailResult as _SchemaMediaDetailResult from app.schemas.context import MediaEpisodeGroup as _SchemaMediaEpisodeGroup from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.schemas.context import MediaSearchResults as _SchemaMediaSearchResults @@ -624,7 +625,7 @@ async def seasons( return [] -@router.get("/{media_id}", summary="查询媒体详情", response_model=_SchemaMediaInfo) +@router.get("/{media_id}", summary="查询媒体详情", response_model=_SchemaMediaDetailResult) async def detail( media_id: str, media_source: MediaSource, @@ -632,7 +633,7 @@ async def detail( _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ - 根据媒体来源和原生 ID 查询媒体信息,type_name: 电影/电视剧 + 根据媒体来源和原生 ID 查询影视或音乐信息 """ mtype = MediaType(type_name) normalized_source, normalized_media_id = resolve_media_identity( diff --git a/app/schemas/context.py b/app/schemas/context.py index b272b9abc..f1c5cbfcc 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -539,8 +539,8 @@ class MediaPerson(BaseModel): relation: Optional[str] = None -def _media_search_result_kind(value: Any) -> str: - """按稳定字段区分音乐、人物与影视/合集搜索结果。""" +def _media_result_kind(value: Any) -> str: + """按稳定字段区分音乐、人物与影视/合集结果。""" if isinstance(value, BaseModel): value = value.model_dump() if isinstance(value, dict): @@ -551,13 +551,22 @@ def _media_search_result_kind(value: Any) -> str: return "media" +MediaDetailResult = Annotated[ + Union[ + Annotated[MusicInfo, Tag("music")], + Annotated[MediaInfo, Tag("media")], + ], + Discriminator(_media_result_kind), +] + + MediaSearchResult = Annotated[ Union[ Annotated[MusicInfo, Tag("music")], Annotated[MediaPerson, Tag("person")], Annotated[MediaInfo, Tag("media")], ], - Discriminator(_media_search_result_kind), + Discriminator(_media_result_kind), ] diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 42eface1a..b2300ec44 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -242,6 +242,7 @@ SCHEMA_EXPORTS = { 'MediaCompany': ('app.schemas.context', 'MediaCompany'), 'MediaCountry': ('app.schemas.context', 'MediaCountry'), 'MediaCredit': ('app.schemas.context', 'MediaCredit'), + 'MediaDetailResult': ('app.schemas.context', 'MediaDetailResult'), 'MediaEpisode': ('app.schemas.context', 'MediaEpisode'), 'MediaEpisodeGroup': ('app.schemas.context', 'MediaEpisodeGroup'), 'MediaEpisodeGroupNetwork': ('app.schemas.context', 'MediaEpisodeGroupNetwork'), diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 0944dd897..d33e2b2f9 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -274,7 +274,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 | GET | `/api/v1/media/search` | 按标题搜索媒体、合集、人物或音乐,参数:`title`、`type`、`page`、`count`,可重复传入可选 `media_source`;内置模块只处理自身支持的来源,插件模块可以处理其注册的扩展来源,旧客户端的逗号格式仅在输入边界兼容 | | GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `media_source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 | | GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `media_source` | -| GET | `/api/v1/media/{media_id}` | 按原生 ID 查询媒体详情;必填参数:`media_source`、`type_name`,其中 `media_source` 与路径中的 `media_id` 组成统一媒体身份 | +| 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`(电影/电视剧/音乐)。音乐会按策略处理音频标签、封面和歌词 | | POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 | | POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | diff --git a/tests/test_media_response_models.py b/tests/test_media_response_models.py index 336284aaa..110c3b8e0 100644 --- a/tests/test_media_response_models.py +++ b/tests/test_media_response_models.py @@ -1,11 +1,15 @@ +from unittest.mock import AsyncMock, Mock + import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from app import schemas +from app.api.endpoints import media as media_endpoint from app.api.endpoints import mediaserver as mediaserver_endpoint from app.api.response import ResponseAPIRouter from app.domain.context import MediaInfo as CoreMediaInfo +from app.domain.context import MusicInfo as CoreMusicInfo from app.schemas.types import MediaSource, MediaType @@ -121,6 +125,43 @@ async def test_douban_media_response_filters_unknown_season_years() -> None: assert response.json()["data"][0]["season_years"] == {"2": "2025"} +@pytest.mark.asyncio +async def test_media_detail_response_accepts_music_numeric_year(monkeypatch) -> None: + """通用详情接口应按音乐模型序列化 MusicBrainz 的整数年份。""" + media_id = "b79e2ffd-7e44-4dbf-91f9-167c05d1fc91" + music = CoreMusicInfo( + media_source=MediaSource.MusicBrainz, + media_id=media_id, + title="示例单曲", + artists=["示例歌手"], + album="示例专辑", + year=2004, + ) + media_chain = Mock() + media_chain.async_recognize_media = AsyncMock(return_value=music) + media_chain.async_obtain_images = AsyncMock(return_value=None) + monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain)) + + app = FastAPI() + app.dependency_overrides[media_endpoint.verify_token] = lambda: None + app.include_router(media_endpoint.router, prefix="/api/v1/media") + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get( + f"/api/v1/media/{media_id}", + params={"media_source": "musicbrainz", "type_name": "音乐"}, + ) + + assert response.status_code == 200 + result = response.json()["data"] + assert result["type"] == MediaType.MUSIC.value + assert result["music_type"] == "recording" + assert result["artists"] == ["示例歌手"] + assert result["year"] == 2004 + + @pytest.mark.asyncio async def test_media_response_accepts_legacy_source_key() -> None: """媒体身份重构前缓存的旧格式条目(source + media_id)应被归一化并正常响应。"""