diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 2e7d18cf6..be47e8654 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -101,6 +101,24 @@ MediaSourceQuery = Annotated[ ] +def _get_search_result_source(obj: Union[_SchemaMediaInfo, _SchemaMediaPerson, dict]) -> Any: + """读取影视、人物或音乐搜索结果中的媒体来源标识。""" + if isinstance(obj, dict): + return obj.get("media_source") or obj.get("source") + return getattr(obj, "media_source", None) or getattr(obj, "source", None) + + +def _serialize_search_result(obj: Any) -> dict: + """将域对象或 Pydantic 人物对象转换为统一搜索响应字典。""" + if isinstance(obj, dict): + return obj + if hasattr(obj, "to_dict"): + return obj.to_dict() + if hasattr(obj, "model_dump"): + return obj.model_dump() + return {} + + def _is_valid_source_media_id( media_source: Optional[MediaSource], media_id: str, @@ -301,24 +319,6 @@ async def search( :return: 搜索结果列表 """ - def __get_source(obj: Union[_SchemaMediaInfo, _SchemaMediaPerson, dict]): - """ - 获取对象属性 - """ - if isinstance(obj, dict): - return obj.get("media_source") or obj.get("source") - return getattr(obj, "media_source", None) or getattr(obj, "source", None) - - def __serialize_search_result(obj: Any) -> dict: - """将域对象或 Pydantic 人物对象转换为统一搜索响应字典。""" - if isinstance(obj, dict): - return obj - if hasattr(obj, "to_dict"): - return obj.to_dict() - if hasattr(obj, "model_dump"): - return obj.model_dump() - return {} - # 直接函数调用也可能绕过 FastAPI/Pydantic,仅在该测试与内部兼容边界补一次规范化。 selected_sources = ( media_source @@ -332,7 +332,7 @@ async def search( is_music = type == "music" or any(is_music_media_source(source) for source in selected_sources) if type == "person": persons = await media_chain.async_search_persons(name=title, media_source=source_selection) - result = [__serialize_search_result(person) for person in persons or []] + result = [_serialize_search_result(person) for person in persons or []] elif is_music: music_types = (music_type,) if music_type else (MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM) filtered_music_results = await media_chain.async_search_music( @@ -350,7 +350,7 @@ async def search( result = [collection.to_dict() for collection in collections] if collections else [] else: # person persons = await media_chain.async_search_persons(name=title, media_source=source_selection) - result = [__serialize_search_result(person) for person in persons or []] + result = [_serialize_search_result(person) for person in persons or []] if not result: return [] @@ -360,7 +360,7 @@ async def search( setting_order = search_source.split(",") if search_source else [] sort_order = {source: index for index, source in enumerate(setting_order)} - sorted_result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4)) + sorted_result = sorted(result, key=lambda x: sort_order.get(_get_search_result_source(x), 4)) return sorted_result[(page - 1) * count : page * count] diff --git a/docs/architecture/agent-api-surface-audit.json b/docs/architecture/agent-api-surface-audit.json index d1c23e961..68e262779 100644 --- a/docs/architecture/agent-api-surface-audit.json +++ b/docs/architecture/agent-api-surface-audit.json @@ -1245,7 +1245,7 @@ "owner": "moviepilot-api", "path": "/api/v1/media/search", "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", - "summary": "搜索媒体/人物信息", + "summary": "搜索媒体/人物/艺术家信息", "tags": [ "media" ] diff --git a/docs/architecture/agent-api-surface-audit.md b/docs/architecture/agent-api-surface-audit.md index 35982d7a7..a4eaa1e42 100644 --- a/docs/architecture/agent-api-surface-audit.md +++ b/docs/architecture/agent-api-surface-audit.md @@ -131,7 +131,7 @@ | `GET` | `/api/v1/media/recognize_file` | media | `gateway` | media.recognize_file | 识别媒体信息(文件) | | `GET` | `/api/v1/media/recognize_file2` | media | `alternate-auth-duplicate` | moviepilot-api | 识别文件媒体信息(API_TOKEN) | | `POST` | `/api/v1/media/scrape/{storage}` | media | `gateway` | media.scrape | 刮削媒体信息 | -| `GET` | `/api/v1/media/search` | media | `gateway` | media.person.search, media.search | 搜索媒体/人物信息 | +| `GET` | `/api/v1/media/search` | media | `gateway` | media.person.search, media.search | 搜索媒体/人物/艺术家信息 | | `GET` | `/api/v1/media/seasons` | media | `gateway` | media.seasons | 查询媒体季信息 | | `GET` | `/api/v1/media/source` | media | `gateway` | media.sources | 获取媒体数据源 | | `GET` | `/api/v1/media/{media_id}` | media | `gateway` | media.detail | 查询媒体详情 | diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py index 128af59e7..1a2f01b14 100644 --- a/tests/test_music_endpoint.py +++ b/tests/test_music_endpoint.py @@ -18,7 +18,7 @@ from app.api.endpoints.music import ( from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease from app.schemas.mediaserver import ExistMediaInfo from app.schemas.music import MusicLibraryStatusRequest, MusicRecognizeRequest -from app.schemas.types import MediaSource, MediaType +from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType def test_music_routes_are_registered(): @@ -68,11 +68,11 @@ def test_music_routes_are_registered(): def test_media_search_routes_music_through_common_catalog_entry(): - """音乐与影视应调用同一媒体搜索入口,仅传入不同的媒体类型。""" + """音乐搜索应通过统一媒体链路调用音乐搜索入口。""" chain = Mock() - chain.async_search = AsyncMock( - return_value=(None, [ + chain.async_search_music = AsyncMock( + return_value=[ MusicInfo( media_source="musicbrainz", media_id="recording-1", @@ -82,7 +82,7 @@ def test_media_search_routes_music_through_common_catalog_entry(): release_date="2003-07-31", category="Album / Studio", ) - ]) + ] ) with ( @@ -101,14 +101,19 @@ def test_media_search_routes_music_through_common_catalog_entry(): assert result[0]["media_id"] == "recording-1" assert result[0]["music_type"] == "recording" assert result[0]["title"] == "晴天" - chain.async_search.assert_awaited_once_with(title="晴天", limit=30, mtype=MediaType.MUSIC, media_source=None) + chain.async_search_music.assert_awaited_once_with( + query="晴天", + limit=30, + media_source=None, + music_types=(MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM), + ) media_chain.assert_called_once() def test_media_search_forwards_explicit_music_source(): """统一音乐搜索应把显式选择的可扩展音乐源转发给 MediaChain。""" chain = Mock() - chain.async_search = AsyncMock(return_value=(None, [])) + chain.async_search_music = AsyncMock(return_value=[]) with patch.object(media_endpoints, "MediaChain", return_value=chain): result = asyncio.run( @@ -122,11 +127,11 @@ def test_media_search_forwards_explicit_music_source(): ) assert result == [] - chain.async_search.assert_awaited_once_with( - title="Coldplay", - mtype=MediaType.MUSIC, + chain.async_search_music.assert_awaited_once_with( + query="Coldplay", limit=20, media_source=(MediaSource.TheAudioDB,), + music_types=(MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM), )