fix(api): 修复音乐详情响应校验

This commit is contained in:
jxxghp
2026-09-05 16:42:15 +08:00
parent 0ed64d9380
commit 7251400237
5 changed files with 58 additions and 6 deletions
+3 -2
View File
@@ -25,6 +25,7 @@ from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo, MetaInfoPath from app.domain.metainfo import MetaInfo, MetaInfoPath
from app.schemas.category import CategoryConfig as _SchemaCategoryConfig from app.schemas.category import CategoryConfig as _SchemaCategoryConfig
from app.schemas.category import MediaCategoryMap as _SchemaMediaCategoryMap 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 MediaEpisodeGroup as _SchemaMediaEpisodeGroup
from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.schemas.context import MediaSearchResults as _SchemaMediaSearchResults from app.schemas.context import MediaSearchResults as _SchemaMediaSearchResults
@@ -624,7 +625,7 @@ async def seasons(
return [] return []
@router.get("/{media_id}", summary="查询媒体详情", response_model=_SchemaMediaInfo) @router.get("/{media_id}", summary="查询媒体详情", response_model=_SchemaMediaDetailResult)
async def detail( async def detail(
media_id: str, media_id: str,
media_source: MediaSource, media_source: MediaSource,
@@ -632,7 +633,7 @@ async def detail(
_: _SchemaTokenPayload = Depends(verify_token), _: _SchemaTokenPayload = Depends(verify_token),
) -> Any: ) -> Any:
""" """
根据媒体来源和原生 ID 查询媒体信息,type_name: 电影/电视剧 根据媒体来源和原生 ID 查询影视或音乐信息
""" """
mtype = MediaType(type_name) mtype = MediaType(type_name)
normalized_source, normalized_media_id = resolve_media_identity( normalized_source, normalized_media_id = resolve_media_identity(
+12 -3
View File
@@ -539,8 +539,8 @@ class MediaPerson(BaseModel):
relation: Optional[str] = None relation: Optional[str] = None
def _media_search_result_kind(value: Any) -> str: def _media_result_kind(value: Any) -> str:
"""按稳定字段区分音乐、人物与影视/合集搜索结果。""" """按稳定字段区分音乐、人物与影视/合集结果。"""
if isinstance(value, BaseModel): if isinstance(value, BaseModel):
value = value.model_dump() value = value.model_dump()
if isinstance(value, dict): if isinstance(value, dict):
@@ -551,13 +551,22 @@ def _media_search_result_kind(value: Any) -> str:
return "media" return "media"
MediaDetailResult = Annotated[
Union[
Annotated[MusicInfo, Tag("music")],
Annotated[MediaInfo, Tag("media")],
],
Discriminator(_media_result_kind),
]
MediaSearchResult = Annotated[ MediaSearchResult = Annotated[
Union[ Union[
Annotated[MusicInfo, Tag("music")], Annotated[MusicInfo, Tag("music")],
Annotated[MediaPerson, Tag("person")], Annotated[MediaPerson, Tag("person")],
Annotated[MediaInfo, Tag("media")], Annotated[MediaInfo, Tag("media")],
], ],
Discriminator(_media_search_result_kind), Discriminator(_media_result_kind),
] ]
+1
View File
@@ -242,6 +242,7 @@ SCHEMA_EXPORTS = {
'MediaCompany': ('app.schemas.context', 'MediaCompany'), 'MediaCompany': ('app.schemas.context', 'MediaCompany'),
'MediaCountry': ('app.schemas.context', 'MediaCountry'), 'MediaCountry': ('app.schemas.context', 'MediaCountry'),
'MediaCredit': ('app.schemas.context', 'MediaCredit'), 'MediaCredit': ('app.schemas.context', 'MediaCredit'),
'MediaDetailResult': ('app.schemas.context', 'MediaDetailResult'),
'MediaEpisode': ('app.schemas.context', 'MediaEpisode'), 'MediaEpisode': ('app.schemas.context', 'MediaEpisode'),
'MediaEpisodeGroup': ('app.schemas.context', 'MediaEpisodeGroup'), 'MediaEpisodeGroup': ('app.schemas.context', 'MediaEpisodeGroup'),
'MediaEpisodeGroupNetwork': ('app.schemas.context', 'MediaEpisodeGroupNetwork'), 'MediaEpisodeGroupNetwork': ('app.schemas.context', 'MediaEpisodeGroupNetwork'),
+1 -1
View File
@@ -274,7 +274,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
| GET | `/api/v1/media/search` | 按标题搜索媒体、合集、人物或音乐,参数:`title``type``page``count`,可重复传入可选 `media_source`;内置模块只处理自身支持的来源,插件模块可以处理其注册的扩展来源,旧客户端的逗号格式仅在输入边界兼容 | | 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` | 识别标题,参数:`title``subtitle``custom_words`,可选 `media_source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 |
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `media_source` | | 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/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source``media_id``type_name`(电影/电视剧/音乐)。音乐会按策略处理音频标签、封面和歌词 |
| POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 | | POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 |
| POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | | POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 |
+41
View File
@@ -1,11 +1,15 @@
from unittest.mock import AsyncMock, Mock
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app import schemas 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.endpoints import mediaserver as mediaserver_endpoint
from app.api.response import ResponseAPIRouter from app.api.response import ResponseAPIRouter
from app.domain.context import MediaInfo as CoreMediaInfo from app.domain.context import MediaInfo as CoreMediaInfo
from app.domain.context import MusicInfo as CoreMusicInfo
from app.schemas.types import MediaSource, MediaType 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"} 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 @pytest.mark.asyncio
async def test_media_response_accepts_legacy_source_key() -> None: async def test_media_response_accepts_legacy_source_key() -> None:
"""媒体身份重构前缓存的旧格式条目(source + media_id)应被归一化并正常响应。""" """媒体身份重构前缓存的旧格式条目(source + media_id)应被归一化并正常响应。"""