From ee1a37a2ac3996a8358739c0002c5b57a5fb26fa Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Wed, 9 Sep 2026 16:25:16 +0800 Subject: [PATCH 1/4] feat: add music library status endpoint --- app/api/endpoints/music.py | 33 ++++++++++++++ app/schemas/exports.py | 2 + app/schemas/music.py | 25 +++++++++++ docs/mcp-api.md | 1 + tests/test_music_endpoint.py | 83 +++++++++++++++++++++++++++++++++++- 5 files changed, 143 insertions(+), 1 deletion(-) diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index 65d36ccee..1634eebe5 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -16,6 +16,8 @@ from app.chain.recommend import RecommendChain from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo from app.schemas.music import MusicAlbumInfo as _SchemaMusicAlbumInfo from app.schemas.music import MusicArtistInfo as _SchemaMusicArtistInfo +from app.schemas.music import MusicLibraryStatus as _SchemaMusicLibraryStatus +from app.schemas.music import MusicLibraryStatusRequest as _SchemaMusicLibraryStatusRequest from app.schemas.music import MusicRecognitionCacheData as _SchemaMusicRecognitionCacheData from app.schemas.music import MusicRecognizeRequest as _SchemaMusicRecognizeRequest from app.schemas.response import Response as _SchemaResponse @@ -283,6 +285,37 @@ async def music_artist_albums( return [_serialize_music(info) for info in results] +@router.post( # type: ignore[misc] + "/library/status", + summary="批量查询音乐专辑入库状态", + response_model=list[_SchemaMusicLibraryStatus], +) +def music_library_status( + request: _SchemaMusicLibraryStatusRequest, + _: _SchemaTokenPayload = Depends(verify_token), +) -> list[_SchemaMusicLibraryStatus]: + """按稳定音乐身份批量判断专辑是否已入库。""" + statuses: list[_SchemaMusicLibraryStatus] = [] + media_chain = MediaChain() + for item in request.items: + info = MusicInfo.from_dict(item.model_dump()) # type: ignore[misc] + # 艺术家作品列表来自 Release Group,本身不包含曲数。此查询的语义 + # 是“已存在/已入库”,无曲数时一首匹配曲目即足以阻止重复下载。 + expected_tracks = info.total_tracks or 1 + info.total_tracks = expected_tracks + exists = bool(media_chain.media_exists(mediainfo=info)) # type: ignore[arg-type] + assert item.media_source is not None + assert item.media_id is not None + statuses.append( + _SchemaMusicLibraryStatus( + media_source=item.media_source, + media_id=item.media_id, + exists=exists, + ) + ) + return statuses + + @router.get( "/artist/{artist_id}/related", summary="查询关联艺术家", diff --git a/app/schemas/exports.py b/app/schemas/exports.py index b6380ac2d..8339bc368 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -291,6 +291,8 @@ SCHEMA_EXPORTS = { 'MusicArtistInfo': ('app.schemas.music', 'MusicArtistInfo'), 'MusicEntityType': ('app.schemas.music', 'MusicEntityType'), 'MusicInfo': ('app.schemas.transfer', 'MusicInfo'), + 'MusicLibraryStatus': ('app.schemas.music', 'MusicLibraryStatus'), + 'MusicLibraryStatusRequest': ('app.schemas.music', 'MusicLibraryStatusRequest'), 'MusicMediaRecognizeEventData': ('app.schemas.event', 'MusicMediaRecognizeEventData'), 'MusicMeta': ('app.schemas.transfer', 'MusicMeta'), 'MusicNameRecognizeEventData': ('app.schemas.event', 'MusicNameRecognizeEventData'), diff --git a/app/schemas/music.py b/app/schemas/music.py index 0cae3dcee..a6fac1ff8 100644 --- a/app/schemas/music.py +++ b/app/schemas/music.py @@ -132,6 +132,31 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel): return None +class MusicLibraryStatusRequest(BaseModel): # type: ignore[misc] + """批量查询音乐媒体库状态的请求。""" + + items: list[MusicInfo] = Field(min_length=1, max_length=500) + + @field_validator("items") # type: ignore[misc] + @classmethod + def _validate_album_identities(cls, items: list[MusicInfo]) -> list[MusicInfo]: + """只接受带稳定来源身份的专辑,避免按模糊标题误报已入库。""" + if any( + item.music_type != "album" or not item.media_source or not item.media_id + for item in items + ): + raise ValueError("仅支持带媒体来源和媒体 ID 的专辑") + return items + + +class MusicLibraryStatus(BaseModel): # type: ignore[misc] + """音乐专辑在媒体库中的存在状态。""" + + media_source: MediaSource + media_id: str + exists: bool + + class MusicRelease(BaseModel): """音乐专辑下的单个发行版本。""" diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 1e1d9084e..e67912477 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -354,6 +354,7 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身 | GET | `/api/v1/media/search` | 当 `type=music` 或指定音乐 `media_source` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title`、`type`、`count`、可重复的 `media_source` 枚举,以及可选的 `music_type` 实体过滤 | | POST | `/api/v1/music/recognize` | 按 `media_source` + `media_id` 识别音乐详情,请求体:`MusicRecognizeRequest` | | GET | `/api/v1/music/explore` | 按来源浏览音乐;`media_source=musicbrainz` 支持 `mode=chart|fresh` 榜单与新发行,`media_source=doubanmusic` 固定按官方标签分类浏览,使用 `tags` 和 `douban_sort=U|S|R|O` 筛选。其它参数:`entity=recording|album`、`range_name`、`sort_by`、`sort`、`days`、`past`、`future`、`min_listen_count`、`with_cover`、`page`、`count` | +| POST | `/api/v1/music/library/status` | 按稳定的音乐来源和专辑 ID 批量查询媒体库是否已存在;请求体为 `items` 专辑列表,返回对应的 `exists` 状态,供艺人作品资源矩阵默认排除已入库项目 | | GET | `/api/v1/music/album/{album_id}` | 按来源专辑 ID 查询专辑详情、完整曲目和发行版本,参数:`media_source` | | GET | `/api/v1/music/album/{album_id}/related` | 按来源查询关联专辑,参数:`media_source`、`count` | | GET | `/api/v1/music/artist/{artist_id}` | 查询艺术家详情;艺术家为只读浏览实体,参数:`media_source` | diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py index 17115203d..74e939a45 100644 --- a/tests/test_music_endpoint.py +++ b/tests/test_music_endpoint.py @@ -12,10 +12,11 @@ from app.api.endpoints.music import ( music_artist, music_artist_albums, music_artist_related, + music_library_status, recognize_music, ) from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease -from app.schemas.music import MusicRecognizeRequest +from app.schemas.music import MusicLibraryStatusRequest, MusicRecognizeRequest from app.schemas.types import MediaSource, MediaType @@ -43,6 +44,10 @@ def test_music_routes_are_registered(): path == "/music/artist/{artist_id}/albums" and "GET" in methods for path, methods in routes ) + assert any( + path == "/music/library/status" and "POST" in methods + for path, methods in routes + ) assert any( path == "/music/artist/{artist_id}/related" and "GET" in methods for path, methods in routes @@ -457,6 +462,82 @@ def test_music_artist_albums_forwards_pagination_and_type(): ) +def test_music_library_status_marks_existing_albums(): + """批量状态接口应保留专辑身份,并根据入库检查返回状态。""" + media_chain = Mock() + media_chain.media_exists.side_effect = [object(), None] + request = MusicLibraryStatusRequest( + items=[ + { + "media_source": "musicbrainz", + "media_id": "album-1", + "music_type": "album", + "title": "First Album", + "artists": ["Artist"], + "total_tracks": 10, + }, + { + "media_source": "musicbrainz", + "media_id": "album-2", + "music_type": "album", + "title": "Second Album", + "artists": ["Artist"], + "total_tracks": 8, + }, + ] + ) + + with patch("app.api.endpoints.music.MediaChain", return_value=media_chain): + result = music_library_status(request=request, _=Mock()) + + assert [(item.media_id, item.exists) for item in result] == [ + ("album-1", True), + ("album-2", False), + ] + calls = media_chain.media_exists.call_args_list + assert calls[0].kwargs["mediainfo"].music_type == "album" + assert calls[0].kwargs["mediainfo"].media_id == "album-1" + + +def test_music_library_status_treats_release_group_without_track_count_as_existing(): + """Release Group 无曲数时应查询“是否存在”,而不是永久返回未入库。""" + media_chain = Mock() + media_chain.media_exists.return_value = object() + request = MusicLibraryStatusRequest( + items=[ + { + "media_source": "musicbrainz", + "media_id": "release-group-1", + "music_type": "album", + "title": "Catalog Album", + "artists": ["Artist"], + } + ] + ) + + with patch("app.api.endpoints.music.MediaChain", return_value=media_chain): + result = music_library_status(request=request, _=Mock()) + + assert result[0].exists is True + call = media_chain.media_exists.call_args + assert call.kwargs["mediainfo"].total_tracks == 1 + + +def test_music_library_status_rejects_recordings(): + """批量状态接口不得把单曲按专辑完整性规则查询。""" + with pytest.raises(ValueError, match="仅支持"): + MusicLibraryStatusRequest( + items=[ + { + "media_source": "musicbrainz", + "media_id": "recording-1", + "music_type": "recording", + "title": "Track", + } + ] + ) + + def test_music_artist_related_returns_relationship_text(): """关联艺术家接口应返回关系说明,供详情页展示。""" chain = Mock() From 395eb3616e1227b5e9f288398c107d48fd3be59f Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Wed, 9 Sep 2026 16:33:05 +0800 Subject: [PATCH 2/4] fix: validate music library status sources --- app/schemas/music.py | 11 +++++++++-- docs/mcp-api.md | 2 +- tests/test_music_endpoint.py | 19 +++++++++++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/schemas/music.py b/app/schemas/music.py index a6fac1ff8..11728baa5 100644 --- a/app/schemas/music.py +++ b/app/schemas/music.py @@ -141,11 +141,18 @@ class MusicLibraryStatusRequest(BaseModel): # type: ignore[misc] @classmethod def _validate_album_identities(cls, items: list[MusicInfo]) -> list[MusicInfo]: """只接受带稳定来源身份的专辑,避免按模糊标题误报已入库。""" + supported_sources = { + MediaSource.MusicBrainz, + MediaSource.TheAudioDB, + MediaSource.DoubanMusic, + } if any( - item.music_type != "album" or not item.media_source or not item.media_id + item.music_type != "album" + or item.media_source not in supported_sources + or not item.media_id for item in items ): - raise ValueError("仅支持带媒体来源和媒体 ID 的专辑") + raise ValueError("仅支持带稳定音乐来源和媒体 ID 的专辑") return items diff --git a/docs/mcp-api.md b/docs/mcp-api.md index e67912477..d912e5970 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -354,7 +354,7 @@ SSE 的 `candidate_items` 是站点原始返回数量,`match_counts` 记录身 | GET | `/api/v1/media/search` | 当 `type=music` 或指定音乐 `media_source` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title`、`type`、`count`、可重复的 `media_source` 枚举,以及可选的 `music_type` 实体过滤 | | POST | `/api/v1/music/recognize` | 按 `media_source` + `media_id` 识别音乐详情,请求体:`MusicRecognizeRequest` | | GET | `/api/v1/music/explore` | 按来源浏览音乐;`media_source=musicbrainz` 支持 `mode=chart|fresh` 榜单与新发行,`media_source=doubanmusic` 固定按官方标签分类浏览,使用 `tags` 和 `douban_sort=U|S|R|O` 筛选。其它参数:`entity=recording|album`、`range_name`、`sort_by`、`sort`、`days`、`past`、`future`、`min_listen_count`、`with_cover`、`page`、`count` | -| POST | `/api/v1/music/library/status` | 按稳定的音乐来源和专辑 ID 批量查询媒体库是否已存在;请求体为 `items` 专辑列表,返回对应的 `exists` 状态,供艺人作品资源矩阵默认排除已入库项目 | +| POST | `/api/v1/music/library/status` | 按 MusicBrainz、TheAudioDB 或豆瓣音乐的稳定专辑 ID 批量查询媒体库是否已存在;请求体为 `items` 专辑列表,返回对应的 `exists` 状态,供艺人作品资源矩阵默认排除已入库项目 | | GET | `/api/v1/music/album/{album_id}` | 按来源专辑 ID 查询专辑详情、完整曲目和发行版本,参数:`media_source` | | GET | `/api/v1/music/album/{album_id}/related` | 按来源查询关联专辑,参数:`media_source`、`count` | | GET | `/api/v1/music/artist/{artist_id}` | 查询艺术家详情;艺术家为只读浏览实体,参数:`media_source` | diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py index 74e939a45..e801d4660 100644 --- a/tests/test_music_endpoint.py +++ b/tests/test_music_endpoint.py @@ -131,8 +131,6 @@ def test_media_search_forwards_explicit_music_source(): def test_recognize_music_returns_detail(): """音乐识别接口应按来源和 ID 经统一识别入口返回详情。""" - from app.chain.media import MediaChain - chain = Mock() chain.async_recognize_media = AsyncMock( return_value=MusicInfo( @@ -165,8 +163,6 @@ def test_recognize_music_returns_detail(): def test_recognize_music_returns_404_for_unknown_item(): """音乐详情不存在时接口应返回 404。""" - from app.chain.media import MediaChain - chain = Mock() chain.async_recognize_media = AsyncMock(return_value=None) @@ -538,6 +534,21 @@ def test_music_library_status_rejects_recordings(): ) +def test_music_library_status_rejects_non_music_sources(): + """批量状态接口不得接受影视等非音乐媒体身份。""" + with pytest.raises(ValueError, match="稳定音乐来源"): + MusicLibraryStatusRequest( + items=[ + { + "media_source": "tmdb", + "media_id": "12345", + "music_type": "album", + "title": "Not a Music Album", + } + ] + ) + + def test_music_artist_related_returns_relationship_text(): """关联艺术家接口应返回关系说明,供详情页展示。""" chain = Mock() From 340bafbdaae09ff228c7f71ed0b1cd12186db2d8 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Wed, 9 Sep 2026 16:39:45 +0800 Subject: [PATCH 3/4] test: align music library status with media existence contract --- app/api/endpoints/music.py | 2 +- tests/test_music_endpoint.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index 1634eebe5..6ff432d67 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -303,7 +303,7 @@ def music_library_status( # 是“已存在/已入库”,无曲数时一首匹配曲目即足以阻止重复下载。 expected_tracks = info.total_tracks or 1 info.total_tracks = expected_tracks - exists = bool(media_chain.media_exists(mediainfo=info)) # type: ignore[arg-type] + exists = media_chain.media_exists(mediainfo=info) is not None # type: ignore[arg-type] assert item.media_source is not None assert item.media_id is not None statuses.append( diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py index e801d4660..e916f9bdb 100644 --- a/tests/test_music_endpoint.py +++ b/tests/test_music_endpoint.py @@ -16,6 +16,7 @@ from app.api.endpoints.music import ( recognize_music, ) 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 @@ -461,7 +462,10 @@ def test_music_artist_albums_forwards_pagination_and_type(): def test_music_library_status_marks_existing_albums(): """批量状态接口应保留专辑身份,并根据入库检查返回状态。""" media_chain = Mock() - media_chain.media_exists.side_effect = [object(), None] + media_chain.media_exists.side_effect = [ + ExistMediaInfo(type=MediaType.MUSIC, itemid="library-album-1"), + None, + ] request = MusicLibraryStatusRequest( items=[ { @@ -498,7 +502,10 @@ def test_music_library_status_marks_existing_albums(): def test_music_library_status_treats_release_group_without_track_count_as_existing(): """Release Group 无曲数时应查询“是否存在”,而不是永久返回未入库。""" media_chain = Mock() - media_chain.media_exists.return_value = object() + media_chain.media_exists.return_value = ExistMediaInfo( + type=MediaType.MUSIC, + itemid="library-release-group-1", + ) request = MusicLibraryStatusRequest( items=[ { From 89917a7baf8a56a111d84459841dc98f0b7435d6 Mon Sep 17 00:00:00 2001 From: yanjunpu <541726449@qq.com> Date: Wed, 9 Sep 2026 16:54:35 +0800 Subject: [PATCH 4/4] docs: classify music library status route --- docs/architecture/agent-api-surface-audit.json | 16 ++++++++++++++-- docs/architecture/agent-api-surface-audit.md | 5 +++-- scripts/generate_agent_api_surface_audit.py | 8 ++++++++ tests/test_music_endpoint.py | 4 ++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/architecture/agent-api-surface-audit.json b/docs/architecture/agent-api-surface-audit.json index 36ca0d94c..d1c23e961 100644 --- a/docs/architecture/agent-api-surface-audit.json +++ b/docs/architecture/agent-api-surface-audit.json @@ -6,7 +6,7 @@ "provider-skill": 12, "stream_or_binary": 10, "transport_or_identity": 66, - "ui_presentation": 22 + "ui_presentation": 23 }, "dynamic_gateway_routes": [ { @@ -21,7 +21,7 @@ "gateway_http_route_count": 203, "gateway_operation_count": 205, "matched_gateway_http_route_count": 202, - "openapi_operation_count": 395, + "openapi_operation_count": 396, "operations": [ { "disposition": "consolidated", @@ -1894,6 +1894,18 @@ "music" ] }, + { + "disposition": "ui_presentation", + "method": "POST", + "operation_ids": [], + "owner": "host-ui", + "path": "/api/v1/music/library/status", + "reason": "Batch music-library presence is a bounded projection for the authenticated artist resource matrix; it is not a standalone Agent business action.", + "summary": "批量查询音乐专辑入库状态", + "tags": [ + "music" + ] + }, { "disposition": "gateway", "method": "POST", diff --git a/docs/architecture/agent-api-surface-audit.md b/docs/architecture/agent-api-surface-audit.md index 22f6cd5f5..35982d7a7 100644 --- a/docs/architecture/agent-api-surface-audit.md +++ b/docs/architecture/agent-api-surface-audit.md @@ -5,7 +5,7 @@ ## Result -- OpenAPI HTTP operations: **395** +- OpenAPI HTTP operations: **396** - Stable `moviepilot_api` operations: **205** - Exact HTTP routes used by the gateway: **203** - OpenAPI routes matched directly by the gateway: **202** @@ -23,7 +23,7 @@ | `provider-skill` | 12 | Low-level downloader or media-server capability owned by a provider Skill. | | `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. | | `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. | -| `ui_presentation` | 22 | Frontend or plugin-rendered presentation contract. | +| `ui_presentation` | 23 | Frontend or plugin-rendered presentation contract. | ## Bounded Dynamic Routes @@ -183,6 +183,7 @@ | `GET` | `/api/v1/music/cache` | music | `gateway` | music.cache.get | 查询音乐识别缓存 | | `DELETE` | `/api/v1/music/cache/{cache_key}` | music | `gateway` | music.cache.delete | 删除指定音乐识别缓存 | | `GET` | `/api/v1/music/explore` | music | `gateway` | music.explore | 探索音乐 | +| `POST` | `/api/v1/music/library/status` | music | `ui_presentation` | host-ui | 批量查询音乐专辑入库状态 | | `POST` | `/api/v1/music/recognize` | music | `gateway` | music.recognize | 识别音乐元数据详情 | | `POST` | `/api/v1/notification/config` | notification | `transport_or_identity` | host-runtime | 保存通知渠道并同步登录缓存 | | `POST` | `/api/v1/notification/manage` | notification | `transport_or_identity` | host-runtime | 通知渠道统一管理 | diff --git a/scripts/generate_agent_api_surface_audit.py b/scripts/generate_agent_api_surface_audit.py index 390e9e723..c095256d9 100644 --- a/scripts/generate_agent_api_surface_audit.py +++ b/scripts/generate_agent_api_surface_audit.py @@ -112,6 +112,7 @@ EXPLICIT_TRANSPORT_PATHS = frozenset( ) SUBSCRIPTION_EXECUTION_UI_PREFIX = "/api/v1/subscribe/execution/" CLASSIFICATION_POLICY_UI_PREFIX = "/api/v1/media/classification/" +MUSIC_LIBRARY_STATUS_UI_PATH = "/api/v1/music/library/status" def _gateway_routes() -> dict[tuple[str, str], list[str]]: @@ -196,6 +197,13 @@ def _classify( "Classification policy authoring, validation, preview, impact analysis, and publication are owned by the authenticated frontend editor until a stable Agent governance contract is approved.", [], ) + if method == "POST" and path == MUSIC_LIBRARY_STATUS_UI_PATH: + return ( + "ui_presentation", + "host-ui", + "Batch music-library presence is a bounded projection for the authenticated artist resource matrix; it is not a standalone Agent business action.", + [], + ) if path in EXPLICIT_TRANSPORT_PATHS: return ( "transport_or_identity", diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py index e916f9bdb..128af59e7 100644 --- a/tests/test_music_endpoint.py +++ b/tests/test_music_endpoint.py @@ -132,6 +132,8 @@ def test_media_search_forwards_explicit_music_source(): def test_recognize_music_returns_detail(): """音乐识别接口应按来源和 ID 经统一识别入口返回详情。""" + from app.chain.media import MediaChain + chain = Mock() chain.async_recognize_media = AsyncMock( return_value=MusicInfo( @@ -164,6 +166,8 @@ def test_recognize_music_returns_detail(): def test_recognize_music_returns_404_for_unknown_item(): """音乐详情不存在时接口应返回 404。""" + from app.chain.media import MediaChain + chain = Mock() chain.async_recognize_media = AsyncMock(return_value=None)