Merge pull request #6624 from dogodefi/codex/music-artist-discography

feat: 增加音乐专辑批量入库状态查询
This commit is contained in:
jxxghp
2026-09-09 17:22:21 +08:00
committed by GitHub
8 changed files with 197 additions and 5 deletions

View File

@@ -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 = 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(
_SchemaMusicLibraryStatus(
media_source=item.media_source,
media_id=item.media_id,
exists=exists,
)
)
return statuses
@router.get(
"/artist/{artist_id}/related",
summary="查询关联艺术家",

View File

@@ -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'),

View File

@@ -132,6 +132,38 @@ 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]:
"""只接受带稳定来源身份的专辑,避免按模糊标题误报已入库。"""
supported_sources = {
MediaSource.MusicBrainz,
MediaSource.TheAudioDB,
MediaSource.DoubanMusic,
}
if any(
item.music_type != "album"
or item.media_source not in supported_sources
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):
"""音乐专辑下的单个发行版本。"""

View File

@@ -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",

View File

@@ -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 | 通知渠道统一管理 |

View File

@@ -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` | 按 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` |

View File

@@ -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",

View File

@@ -12,10 +12,12 @@ 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.mediaserver import ExistMediaInfo
from app.schemas.music import MusicLibraryStatusRequest, MusicRecognizeRequest
from app.schemas.types import MediaSource, MediaType
@@ -43,6 +45,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 +463,103 @@ 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 = [
ExistMediaInfo(type=MediaType.MUSIC, itemid="library-album-1"),
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 = ExistMediaInfo(
type=MediaType.MUSIC,
itemid="library-release-group-1",
)
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_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()