mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-04 14:24:58 +08:00
Merge pull request #6624 from dogodefi/codex/music-artist-discography
feat: 增加音乐专辑批量入库状态查询
This commit is contained in:
@@ -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="查询关联艺术家",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
"""音乐专辑下的单个发行版本。"""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 | 通知渠道统一管理 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user