mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
fix(api): 修复音乐详情响应校验
This commit is contained in:
@@ -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
@@ -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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 |
|
||||||
|
|||||||
@@ -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)应被归一化并正常响应。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user