Files
MoviePilot/tests/test_music_chain.py
2026-08-09 17:05:36 +08:00

325 lines
11 KiB
Python

from unittest.mock import AsyncMock
from app.chain.music import MusicChain
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper
from app.modules.musicbrainz import MusicBrainzModule
def test_parse_query_supports_artist_title_format():
"""艺术家与标题格式应拆分为结构化搜索条件。"""
meta = MusicChain.parse_query(" 周杰伦 - 晴天 ")
assert meta.artists == ["周杰伦"]
assert meta.title == "晴天"
assert meta.org_string == " 周杰伦 - 晴天 "
def test_parse_query_keeps_plain_title():
"""普通文本应保留为歌曲或专辑标题。"""
meta = MusicChain.parse_query(" Random Access Memories ")
assert meta.artists == []
assert meta.title == "Random Access Memories"
def test_build_site_keywords_prefers_artist_album():
"""站点关键词应优先使用艺术家和专辑组合。"""
info = MusicInfo(
title="Get Lucky",
artists=["Daft Punk"],
album="Random Access Memories",
)
assert MusicChain.build_site_keywords(info) == [
"Daft Punk Random Access Memories",
"Daft Punk Get Lucky",
"Random Access Memories",
"Get Lucky",
]
def test_normalize_candidates_deduplicates_source_identity():
"""同一来源和媒体 ID 的音乐候选应只保留一次。"""
results = MusicChain.normalize_candidates(
[
MusicInfo(source="musicbrainz", media_id="recording-1", title="A"),
{
"type": "音乐",
"source": "musicbrainz",
"media_id": "recording-1",
"title": "A duplicate",
},
]
)
assert len(results) == 1
assert results[0].title == "A"
def test_normalize_candidates_deduplicates_metadata_without_id():
"""缺少来源 ID 时应按标题、艺术家和专辑去重。"""
results = MusicChain.normalize_candidates(
[
MusicInfo(title="One More Time", artists=["Daft Punk"], album="Discovery"),
MusicInfo(title=" one more time ", artists=["daft punk"], album="DISCOVERY"),
]
)
assert len(results) == 1
def test_to_meta_preserves_selected_identity():
"""候选转换后应保留下载和整理所需的标准身份。"""
info = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
track_number=3,
)
meta = MusicChain.to_meta(info)
assert meta.media_source == "musicbrainz"
assert meta.media_id == "recording-1"
assert meta.artists == ["周杰伦"]
assert meta.album == "叶惠美"
assert meta.track_number == 3
def test_chart_converts_page_to_listenbrainz_offset(monkeypatch):
"""音乐榜单处理链应将页码转换为模块需要的偏移量。"""
chain = MusicChain()
requested = {}
def fake_run_module(method, **kwargs):
"""记录榜单模块调用并返回重复候选。"""
requested.update(method=method, **kwargs)
return [
MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天"),
MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天"),
]
monkeypatch.setattr(chain, "run_module", fake_run_module)
results = chain.chart(range_name="this_week", page=2, count=30)
assert requested == {
"method": "music_chart",
"range_name": "this_week",
"offset": 30,
"count": 30,
}
assert len(results) == 1
def test_async_chart_applies_music_explore_filters(monkeypatch):
"""音乐探索应按收听次数、封面条件和升序设置筛选榜单。"""
chain = MusicChain()
async def fake_async_run_module(method, **kwargs):
"""返回包含不同热度和封面状态的榜单候选。"""
assert method == "music_chart"
return [
MusicInfo(media_id="1", source="musicbrainz", title="A", listen_count=300),
MusicInfo(
media_id="2",
source="musicbrainz",
title="B",
listen_count=120,
cover_url="https://coverartarchive.org/release/2/front-500",
),
MusicInfo(
media_id="3",
source="musicbrainz",
title="C",
listen_count=240,
cover_url="https://coverartarchive.org/release/3/front-500",
),
]
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
import asyncio
results = asyncio.run(
chain.async_chart(
range_name="this_month",
count=30,
sort_by="listen_count.asc",
min_listen_count=100,
with_cover=True,
)
)
assert [item.title for item in results] == ["B", "C"]
def test_musicbrainz_module_select_candidate_prefers_matching_audio_tags():
"""文件识别应优先选择标题、艺术家和专辑均匹配的 MusicBrainz 候选。"""
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
candidates = [
MusicInfo(source="musicbrainz", media_id="1", title="晴天", artists=["其他歌手"]),
MusicInfo(
source="musicbrainz",
media_id="2",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
),
]
selected = MusicBrainzModule._select_candidate(meta, candidates, source="musicbrainz")
assert selected is candidates[1]
def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch):
"""本地音频识别应使用内嵌标签补全艺术家、专辑并提高封面候选命中率。"""
audio_path = tmp_path / "02. 眼泪成诗.m4a"
audio_path.write_bytes(b"audio")
meta = MetaMusic(
title="眼泪成诗",
artists=["孙燕姿"],
album="完美的一天",
track_number=2,
duration=221,
)
info = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="眼泪成诗",
artists=["孙燕姿"],
album="完美的一天",
cover_url="https://coverartarchive.org/release-group/album-1/front-500",
)
chain = MusicChain()
recognize = AsyncMock(return_value=info)
monkeypatch.setattr(AudioMetadataHelper, "read", lambda path: meta)
monkeypatch.setattr(chain, "async_recognize_media", recognize)
import asyncio
recognized_meta, recognized_info = asyncio.run(
chain.async_recognize_by_path(audio_path)
)
assert recognized_meta is meta
assert recognized_info is info
recognize.assert_awaited_once_with(meta=meta, source="musicbrainz")
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"]