feat(v3): enhance music server integration with Navidrome support

This commit is contained in:
jxxghp
2026-08-08 12:57:09 +08:00
parent c356d47807
commit 415ccda6a2
35 changed files with 2676 additions and 90 deletions

View File

@@ -24,13 +24,13 @@ def test_recording_to_info_maps_listenbrainz_payload():
def test_music_chart_requests_requested_page(monkeypatch):
"""音乐榜单模块应传递周期、偏移量和数量并过滤无身份记录。"""
"""音乐榜单模块应传递实体、周期、偏移量和数量并过滤无身份记录。"""
module = ListenBrainzModule()
requested = {}
def fake_request(range_name, offset, count):
def fake_request(entity, range_name, offset, count):
"""记录榜单请求参数并返回一条有效录音。"""
requested.update(range_name=range_name, offset=offset, count=count)
requested.update(entity=entity, range_name=range_name, offset=offset, count=count)
return {
"payload": {
"recordings": [
@@ -48,5 +48,100 @@ def test_music_chart_requests_requested_page(monkeypatch):
results = module.music_chart(range_name="this_month", offset=30, count=30)
assert requested == {"range_name": "this_month", "offset": 30, "count": 30}
assert requested == {
"entity": "recordings",
"range_name": "this_month",
"offset": 30,
"count": 30,
}
assert [item.media_id for item in results] == ["recording-1"]
def test_music_chart_supports_album_entity(monkeypatch):
"""热门专辑榜单应请求官方 release-groups 接口并返回专辑实体。"""
module = ListenBrainzModule()
requested = {}
def fake_request(entity, range_name, offset, count):
"""记录请求实体并返回一条热门专辑。"""
requested.update(entity=entity, range_name=range_name)
return {
"payload": {
"release_groups": [
{
"artist_name": "BTS",
"artist_mbids": ["artist-1"],
"listen_count": 999,
"release_group_mbid": "release-group-1",
"release_group_name": "ARIRANG",
}
]
}
}
monkeypatch.setattr(module, "_request_chart", fake_request)
results = module.music_chart(range_name="week", offset=0, count=10, entity="album")
assert requested == {"entity": "release-groups", "range_name": "week"}
assert [item.media_id for item in results] == ["release-group-1"]
assert results[0].music_type == "album"
assert results[0].album_id == "release-group-1"
assert results[0].artist_ids == ["artist-1"]
def test_music_chart_falls_back_to_supported_range(monkeypatch):
"""非官方周期应回退到默认周期,避免请求被官方接口拒绝。"""
module = ListenBrainzModule()
requested = {}
def fake_request(entity, range_name, offset, count):
"""仅记录周期取值。"""
requested.update(range_name=range_name)
return {"payload": {"recordings": []}}
monkeypatch.setattr(module, "_request_chart", fake_request)
module.music_chart(range_name="last_decade")
assert requested == {"range_name": "this_month"}
def test_music_fresh_releases_pages_official_window(monkeypatch):
"""新发行探索应按官方排序请求并在结果集上分页。"""
module = ListenBrainzModule()
requested = {}
def fake_releases(days, sort, past, future):
"""记录官方新发行请求参数并返回两条发行。"""
requested.update(days=days, sort=sort, past=past, future=future)
return [
{
"artist_credit_name": "Artist A",
"artist_mbids": ["artist-1"],
"release_date": "2026-08-01",
"release_group_mbid": "release-group-1",
"release_group_primary_type": "Album",
"release_name": "First",
},
{
"artist_credit_name": "Artist B",
"release_date": "2026-08-02",
"release_group_mbid": "release-group-2",
"release_name": "Second",
},
]
monkeypatch.setattr(module, "_fresh_releases", fake_releases)
results = module.music_fresh_releases(
days=200,
sort="unsupported",
past=True,
future=False,
offset=1,
count=1,
)
assert requested == {"days": 90, "sort": "release_date", "past": True, "future": False}
assert [item.media_id for item in results] == ["release-group-2"]
assert results[0].music_type == "album"

View File

@@ -1,5 +1,5 @@
from app.chain.music import MusicChain
from app.core.music import MusicInfo, MusicMeta
from app.core.music import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicMeta
def test_parse_query_supports_artist_title_format():
@@ -171,3 +171,114 @@ def test_select_path_candidate_prefers_matching_audio_tags():
selected = MusicChain._select_path_candidate(meta, candidates, source="musicbrainz")
assert selected is candidates[1]
def test_async_chart_forwards_album_entity(monkeypatch):
"""热门专辑探索应把实体类型透传给 ListenBrainz 榜单模块。"""
chain = MusicChain()
requested = {}
async def fake_async_run_module(method, **kwargs):
"""记录榜单请求参数并返回一个专辑候选。"""
requested.update(method=method, **kwargs)
return [
MusicInfo(
media_id="release-group-1",
source="musicbrainz",
music_type="album",
title="ARIRANG",
listen_count=10,
)
]
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
import asyncio
results = asyncio.run(chain.async_chart(range_name="week", page=3, count=20, entity="album"))
assert requested["method"] == "music_chart"
assert requested["entity"] == "album"
assert requested["offset"] == 40
assert results[0].music_type == "album"
def test_async_fresh_releases_keeps_official_order(monkeypatch):
"""新发行探索应保留官方排序,只按封面条件过滤。"""
chain = MusicChain()
requested = {}
async def fake_async_run_module(method, **kwargs):
"""记录新发行请求参数并返回带封面与不带封面的候选。"""
requested.update(method=method, **kwargs)
return [
MusicInfo(media_id="b", source="musicbrainz", music_type="album", title="B"),
MusicInfo(
media_id="a",
source="musicbrainz",
music_type="album",
title="A",
cover_url="https://coverartarchive.org/release/a/front-500",
),
]
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
import asyncio
results = asyncio.run(
chain.async_fresh_releases(days=30, sort="release_name", page=2, count=10, with_cover=True)
)
assert requested["method"] == "music_fresh_releases"
assert requested["offset"] == 10
assert requested["sort"] == "release_name"
assert [item.title for item in results] == ["A"]
def test_async_artist_related_deduplicates_artists(monkeypatch):
"""关联艺术家应按标准 ID 去重,避免同一成员重复出现。"""
chain = MusicChain()
async def fake_async_run_module(method, **kwargs):
"""返回重复的关联艺术家候选。"""
assert method == "music_artist_related"
return [
MusicArtistInfo(source="musicbrainz", media_id="artist-1", name="Brian May"),
MusicArtistInfo(source="musicbrainz", media_id="artist-1", name="Brian May"),
MusicArtistInfo(source="musicbrainz", media_id="artist-2", name="John Deacon"),
]
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
import asyncio
results = asyncio.run(chain.async_artist_related(source="musicbrainz", media_id="artist-0"))
assert [item.media_id for item in results] == ["artist-1", "artist-2"]
def test_async_album_restores_dataclass_from_plugin_dict(monkeypatch):
"""插件返回字典时专辑链应恢复为标准专辑对象。"""
chain = MusicChain()
async def fake_async_run_module(method, **kwargs):
"""模拟插件模块以字典形式返回专辑详情。"""
assert method == "music_album"
return MusicAlbumInfo(
source="musicbrainz",
media_id="release-group-1",
title="A Night at the Opera",
artists=["Queen"],
release_date="1975-11-21",
).to_dict()
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
import asyncio
album = asyncio.run(chain.async_album(source="musicbrainz", media_id="release-group-1"))
assert album is not None
assert album.year == 1975
assert album.artists == ["Queen"]

View File

@@ -5,18 +5,36 @@ import pytest
from fastapi import HTTPException
from app.api.apiv1 import api_router
from app.api.endpoints.music import explore_music, recognize_music, search_music
from app.core.music import MusicInfo
from app.api.endpoints.music import (
explore_music,
music_album,
music_artist,
music_artist_albums,
music_artist_related,
recognize_music,
search_music,
)
from app.core.music import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease
from app.schemas.music import MusicRecognizeRequest
def test_music_routes_are_registered():
"""V1 API 应注册音乐搜索详情识别路由。"""
"""V1 API 应注册音乐搜索详情识别、探索及艺术家专辑浏览路由。"""
routes = {(route.path, tuple(route.methods or [])) for route in api_router.routes}
assert any(path == "/music/search" and "GET" in methods for path, methods in routes)
assert any(path == "/music/recognize" and "POST" in methods for path, methods in routes)
assert any(path == "/music/explore" and "GET" in methods for path, methods in routes)
assert any(path == "/music/album/{album_id}" and "GET" in methods for path, methods in routes)
assert any(path == "/music/artist/{artist_id}" and "GET" in methods for path, methods in routes)
assert any(
path == "/music/artist/{artist_id}/albums" and "GET" in methods
for path, methods in routes
)
assert any(
path == "/music/artist/{artist_id}/related" and "GET" in methods
for path, methods in routes
)
def test_search_music_serializes_chain_results():
@@ -91,7 +109,7 @@ def test_recognize_music_returns_404_for_unknown_item():
def test_explore_music_forwards_filters_and_serializes_chart():
"""音乐探索接口应传递周期、排序、热度和封面筛选条件。"""
"""音乐探索接口应传递实体、周期、排序、热度和封面筛选条件。"""
chain = Mock()
chain.async_chart = AsyncMock(
return_value=[
@@ -126,4 +144,165 @@ def test_explore_music_forwards_filters_and_serializes_chart():
sort_by="listen_count.asc",
min_listen_count=100,
with_cover=True,
entity="recording",
)
def test_explore_music_supports_official_fresh_release_mode():
"""新发行模式应按 ListenBrainz 官方排序和时间窗口请求探索数据。"""
chain = Mock()
chain.async_fresh_releases = AsyncMock(
return_value=[
MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="ARIRANG",
artists=["BTS"],
)
]
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(
explore_music(
page=1,
count=30,
mode="fresh",
sort="artist_credit_name",
days=30,
past=True,
future=False,
with_cover=True,
_=Mock(),
)
)
assert result[0].music_type == "album"
chain.async_fresh_releases.assert_awaited_once_with(
days=30,
sort="artist_credit_name",
past=True,
future=False,
page=1,
count=30,
with_cover=True,
)
def test_music_album_returns_tracks_and_releases():
"""专辑接口应返回专辑详情、曲目和发行版本。"""
chain = Mock()
chain.async_album = AsyncMock(
return_value=MusicAlbumInfo(
source="musicbrainz",
media_id="release-group-1",
title="A Night at the Opera",
artists=["Queen"],
artist_ids=["artist-1"],
album_type="Album",
release_date="1975-11-21",
tracks=[MusicInfo(source="musicbrainz", media_id="recording-1", title="Love of My Life")],
releases=[MusicRelease(media_id="release-1", title="A Night at the Opera", date="1975")],
)
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(music_album(album_id="release-group-1", _=Mock()))
assert result.music_type == "album"
assert result.year == 1975
assert result.total_tracks == 1
assert result.tracks[0].media_id == "recording-1"
assert result.releases[0].media_id == "release-1"
chain.async_album.assert_awaited_once_with(source="musicbrainz", media_id="release-group-1")
def test_music_album_returns_404_for_unknown_album():
"""专辑不存在时接口应返回 404。"""
chain = Mock()
chain.async_album = AsyncMock(return_value=None)
with (
patch("app.api.endpoints.music.MusicChain", return_value=chain),
pytest.raises(HTTPException) as error,
):
asyncio.run(music_album(album_id="missing", _=Mock()))
assert error.value.status_code == 404
def test_music_artist_returns_detail():
"""艺术家接口应返回名称、类型和活跃时间。"""
chain = Mock()
chain.async_artist = AsyncMock(
return_value=MusicArtistInfo(
source="musicbrainz",
media_id="artist-1",
name="Queen",
artist_type="Group",
begin_date="1970-06-27",
)
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(music_artist(artist_id="artist-1", _=Mock()))
assert result.name == "Queen"
assert result.title == "Queen"
assert result.music_type == "artist"
chain.async_artist.assert_awaited_once_with(source="musicbrainz", media_id="artist-1")
def test_music_artist_albums_forwards_pagination_and_type():
"""艺术家专辑接口应传递分页和专辑类型筛选。"""
chain = Mock()
chain.async_artist_albums = AsyncMock(
return_value=[
MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="News of the World",
)
]
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(
music_artist_albums(artist_id="artist-1", page=2, count=10, album_type="ep", _=Mock())
)
assert result[0].media_id == "release-group-1"
chain.async_artist_albums.assert_awaited_once_with(
source="musicbrainz",
media_id="artist-1",
page=2,
count=10,
album_type="ep",
)
def test_music_artist_related_returns_relationship_text():
"""关联艺术家接口应返回关系说明,供详情页展示。"""
chain = Mock()
chain.async_artist_related = AsyncMock(
return_value=[
MusicArtistInfo(
source="musicbrainz",
media_id="artist-2",
name="Freddie Mercury",
relation="member of band",
)
]
)
with patch("app.api.endpoints.music.MusicChain", return_value=chain):
result = asyncio.run(music_artist_related(artist_id="artist-1", count=5, _=Mock()))
assert result[0].relation == "member of band"
chain.async_artist_related.assert_awaited_once_with(
source="musicbrainz",
media_id="artist-1",
count=5,
)

View File

@@ -62,6 +62,7 @@ def test_build_subscribe_meta_returns_music_meta():
assert isinstance(meta, MusicMeta)
assert meta.type == MediaType.MUSIC
assert meta.media_id == "recording-1"
assert meta.original_name == "晴天"
def test_music_subscribe_reuses_search_download_and_finish_flow():

View File

@@ -116,3 +116,255 @@ def test_recognize_music_fetches_recording_detail(monkeypatch):
assert result is not None
assert result.media_id == "recording-1"
assert result.title == "晴天"
def test_recognize_music_falls_back_to_album(monkeypatch):
"""单曲 ID 不存在时应按专辑再识别一次,保证专辑订阅可恢复目标。"""
module = MusicBrainzModule()
requested = []
def fake_request(path, params=None):
"""单曲请求返回空,专辑请求返回最小 Release Group 数据。"""
requested.append(path)
if path.startswith("/recording/"):
return None
if path.startswith("/release-group/"):
return {
"id": "release-group-1",
"title": "A Night at the Opera",
"primary-type": "Album",
"first-release-date": "1975-11-21",
"artist-credit": [{"artist": {"id": "artist-1", "name": "Queen"}}],
"releases": [],
}
return None
monkeypatch.setattr(module, "_request_json", fake_request)
monkeypatch.setattr(MusicBrainzModule, "_request_json", staticmethod(fake_request))
info = module.recognize_music("musicbrainz", "release-group-1")
assert info is not None
assert info.music_type == "album"
assert info.album_id == "release-group-1"
assert info.artists == ["Queen"]
assert info.artist_ids == ["artist-1"]
assert requested[0].startswith("/recording/")
def test_music_album_builds_tracks_and_release_variants(monkeypatch):
"""专辑详情应带上曲目列表、发行版本和 10 分制评分。"""
module = MusicBrainzModule()
def fake_request(path, params=None):
"""按路径分别返回 Release Group 与代表性 Release 数据。"""
if path == "/release-group/release-group-1":
return {
"id": "release-group-1",
"title": "A Night at the Opera",
"primary-type": "Album",
"secondary-types": ["Live"],
"first-release-date": "1975-11-21",
"rating": {"value": 4.25, "votes-count": 44},
"genres": [{"name": "rock", "count": 13}, {"name": "art rock", "count": 4}],
"tags": [{"name": "british", "count": 2}],
"artist-credit": [{"artist": {"id": "artist-1", "name": "Queen"}}],
"releases": [
{
"id": "release-early",
"title": "A Night at the Opera",
"status": "Official",
"date": "1975-11-21",
"country": "GB",
"packaging": "Gatefold Cover",
"media": [{"format": "12\" Vinyl", "track-count": 2}],
},
{
"id": "release-late",
"title": "A Night at the Opera",
"status": "Official",
"date": "1991",
"media": [{"format": "CD", "track-count": 2}],
},
],
}
if path == "/release/release-early":
return {
"media": [
{
"position": 1,
"track-count": 2,
"tracks": [
{
"position": 1,
"title": "Death on Two Legs",
"length": 223733,
"recording": {"id": "recording-1", "title": "Death on Two Legs"},
},
{
"position": 2,
"title": "Lazing on a Sunday Afternoon",
"recording": {"id": "recording-2", "length": 68000},
},
],
}
]
}
return None
monkeypatch.setattr(MusicBrainzModule, "_request_json", staticmethod(fake_request))
album = module.music_album("musicbrainz", "release-group-1")
assert album is not None
assert album.category == "Album / Live"
assert album.rating == 8.5
assert album.genres[:2] == ["rock", "art rock"]
assert [track.media_id for track in album.tracks] == ["recording-1", "recording-2"]
assert album.tracks[0].duration == 224
assert album.tracks[0].track_number == 1
assert album.tracks[0].disc_number == 1
assert album.tracks[0].album_id == "release-group-1"
assert album.tracks[1].title == "Lazing on a Sunday Afternoon"
assert [release.media_id for release in album.releases] == ["release-early", "release-late"]
assert album.releases[0].formats == ['12" Vinyl']
assert album.track_count == 2
def test_music_artist_maps_profile_links_and_image(monkeypatch):
"""艺术家详情应整理活跃时间、别名、外链并把维基共享页转为图片直链。"""
module = MusicBrainzModule()
monkeypatch.setattr(
MusicBrainzModule,
"_request_json",
staticmethod(
lambda path, params=None: {
"id": "artist-1",
"name": "Queen",
"sort-name": "Queen",
"type": "Group",
"disambiguation": "UK rock group",
"country": "GB",
"area": {"name": "United Kingdom"},
"life-span": {"begin": "1970-06-27", "ended": True},
"genres": [{"name": "rock", "count": 20}, {"name": "glam rock", "count": 9}],
"tags": [{"name": "british", "count": 15}],
"aliases": [{"name": "皇后乐队", "count": 0}],
"relations": [
{
"type": "image",
"target-type": "url",
"url": {"resource": "https://commons.wikimedia.org/wiki/File:Queen.jpg"},
},
{
"type": "official homepage",
"target-type": "url",
"url": {"resource": "http://www.queenonline.com/"},
},
{
"type": "wikidata",
"target-type": "url",
"url": {"resource": "https://www.wikidata.org/wiki/Q15862"},
},
{
"type": "creative commons licensed download",
"target-type": "url",
"url": {"resource": "https://example.com/ignored"},
},
],
}
),
)
artist = module.music_artist("musicbrainz", "artist-1")
assert artist is not None
assert artist.artist_type == "Group"
assert artist.area == "United Kingdom"
assert artist.life_span == "1970-06-27"
assert artist.genres == ["rock", "glam rock"]
assert artist.aliases == ["皇后乐队"]
assert artist.image_url == (
"https://commons.wikimedia.org/wiki/Special:FilePath/Queen.jpg?width=500"
)
assert set(artist.external_links) == {"official homepage", "wikidata"}
def test_music_artist_albums_sorts_page_by_release_date(monkeypatch):
"""艺术家专辑列表应按发行日期倒序,并带上专辑类型筛选参数。"""
module = MusicBrainzModule()
requested = {}
def fake_request(path, params=None):
"""记录浏览请求参数并返回两个乱序专辑。"""
requested.update(path=path, params=params)
return {
"release-groups": [
{
"id": "release-group-old",
"title": "Queen",
"primary-type": "Album",
"first-release-date": "1973-07-13",
"artist-credit": [{"artist": {"id": "artist-1", "name": "Queen"}}],
},
{
"id": "release-group-new",
"title": "News of the World",
"primary-type": "Album",
"first-release-date": "1977-10-28",
"artist-credit": [{"artist": {"id": "artist-1", "name": "Queen"}}],
},
]
}
monkeypatch.setattr(MusicBrainzModule, "_request_json", staticmethod(fake_request))
albums = module.music_artist_albums("musicbrainz", "artist-1", page=2, count=10, album_type="album")
assert requested["path"] == "/release-group"
assert requested["params"]["artist"] == "artist-1"
assert requested["params"]["offset"] == 10
assert requested["params"]["type"] == "album"
assert [album.media_id for album in albums] == ["release-group-new", "release-group-old"]
assert albums[0].music_type == "album"
def test_music_artist_related_prefers_meaningful_relations(monkeypatch):
"""关联艺术家应优先返回成员与子团体关系,致敬乐队排在最后。"""
module = MusicBrainzModule()
monkeypatch.setattr(
MusicBrainzModule,
"_request_json",
staticmethod(
lambda path, params=None: {
"relations": [
{
"type": "tribute",
"target-type": "artist",
"artist": {"id": "artist-tribute", "name": "Queen Tribute"},
},
{
"type": "member of band",
"target-type": "artist",
"artist": {"id": "artist-member", "name": "Brian May"},
},
{
"type": "member of band",
"target-type": "artist",
"artist": {"id": "artist-member", "name": "Brian May"},
},
{
"type": "allmusic",
"target-type": "url",
"url": {"resource": "https://example.com"},
},
]
}
),
)
related = module.music_artist_related("musicbrainz", "artist-1", count=5)
assert [item.media_id for item in related] == ["artist-member", "artist-tribute"]
assert related[0].relation == "member of band"
assert related[0].music_type == "artist"

View File

@@ -0,0 +1,32 @@
"""Navidrome 媒体服务器模块接入测试。"""
from app.core.module import ModuleManager
from app.modules.navidrome import NavidromeModule
from app.schemas.types import MediaServerType, ModuleType
def test_navidrome_module_declares_media_server_identity():
"""Navidrome 应以媒体服务器身份注册,供统一媒体服务器链调用。"""
assert NavidromeModule.get_name() == "Navidrome"
assert NavidromeModule.get_type() == ModuleType.MediaServer
assert NavidromeModule.get_subtype() == MediaServerType.Navidrome
def test_navidrome_module_has_no_system_switch():
"""Navidrome 由服务配置控制启用,不能返回无效的系统开关名。"""
assert NavidromeModule().init_setting() is None
def test_navidrome_module_is_loaded_by_module_manager():
"""模块管理器应能加载 Navidrome否则媒体服务器列表里不会出现该类型。"""
assert "NavidromeModule" in ModuleManager()._running_modules
def test_navidrome_module_ignores_non_music_media():
"""Navidrome 只管理音乐,影视存在性检查应交给其它媒体服务器。"""
from app.core.context import MediaInfo
from app.schemas.types import MediaType
mediainfo = MediaInfo()
mediainfo.type = MediaType.MOVIE
assert NavidromeModule().media_exists(mediainfo) is None