Files
MoviePilot/tests/test_music_workflows.py
2026-08-12 20:01:51 +08:00

594 lines
20 KiB
Python

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from app.chain.acoustid import AcoustIdChain
from app.chain.listenbrainz import ListenBrainzChain
from app.chain.media import MediaChain
from app.chain.musicbrainz import MusicBrainzChain
from app.chain.recommend import RecommendChain
from app.chain.search import SearchChain
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.meta import MetaMusic
from app.modules.musicbrainz import MusicBrainzModule
from app.schemas.types import MediaSource, MediaType
def test_parse_query_supports_artist_title_format():
"""艺术家与标题格式应拆分为结构化搜索条件。"""
meta = MetaMusic.parse_query(" 周杰伦 - 晴天 ")
assert meta.artists == ["周杰伦"]
assert meta.title == "晴天"
assert meta.org_string == " 周杰伦 - 晴天 "
def test_parse_query_keeps_plain_title():
"""普通文本应保留为歌曲或专辑标题。"""
meta = MetaMusic.parse_query(" Random Access Memories ")
assert meta.artists == []
assert meta.title == "Random Access Memories"
def test_parse_query_strips_quality_tokens_before_artist_split():
"""音质规格不应参与艺术家/曲名拆分,年份括号与格式后缀需剔除。"""
meta = MetaMusic.parse_query("毛阿敏 - 永遠是朋友(2000) - ALAC [16B-44.1kHz]")
assert meta.artists == ["毛阿敏"]
assert meta.title == "永遠是朋友"
assert meta.audio_format == "ALAC"
def test_parse_query_does_not_misplit_quality_only_tail():
"""无艺术家时格式规格段不能被误拆成曲名,曲名不能丢字。"""
meta = MetaMusic.parse_query("永遠是朋友(2000) - ALAC [16B-44.1kHz]")
assert meta.artists == []
assert meta.title == "永遠是朋友"
def test_parse_query_strips_trailing_artist_suffix():
"""曲名尾部重复的艺术家署名应被剥离,不作为曲名参与检索。"""
meta = MetaMusic.parse_query("毛阿敏 - 名人名曲-毛阿敏(2000)")
assert meta.artists == ["毛阿敏"]
assert meta.title == "名人名曲"
assert meta.year == 2000
meta = MetaMusic.parse_query("许茹芸 - 争奇斗艳演唱会实况 2 - 许茹芸 (1996)")
assert meta.artists == ["许茹芸"]
assert meta.title == "争奇斗艳演唱会实况 2"
def test_parse_query_keeps_non_artist_suffix():
"""曲名尾段与艺术家不一致时不应被误剥离。"""
meta = MetaMusic.parse_query("毛阿敏 - 思念 - 现场版")
assert meta.artists == ["毛阿敏"]
assert meta.title == "思念 - 现场版"
def test_build_site_keywords_prefers_artist_album():
"""专辑订阅只按艺术家与专辑名搜索,不混入其中某首单曲。"""
info = MusicInfo(
music_type="album",
title="Get Lucky",
artists=["Daft Punk"],
album="Random Access Memories",
)
assert SearchChain.music_site_keywords(info) == [
"Daft Punk Random Access Memories",
"Random Access Memories",
]
def test_build_site_keywords_keeps_recording_out_of_album_search():
"""单曲订阅只按艺术家与曲名搜索,不能优先命中所属整张专辑。"""
info = MusicInfo(
music_type="recording",
title="Get Lucky",
artists=["Daft Punk"],
album="Random Access Memories",
)
assert SearchChain.music_site_keywords(info) == [
"Daft Punk Get Lucky",
"Get Lucky",
]
def test_album_resource_match_requires_selected_album_title():
"""专辑订阅只接受包含目标专辑名的站点资源,忽略大小写、空格和标点差异。"""
album = MusicInfo(
music_type="album",
title="Random Access Memories",
album="Random Access Memories",
names=["Random-Access Memories"],
)
assert SearchChain.matches_music_resource(
album,
"Daft.Punk-Random.Access.Memories-2013-FLAC",
) is True
assert SearchChain.matches_music_resource(album, "Daft Punk - Discovery - FLAC") is False
def test_recording_resource_match_does_not_treat_album_name_as_track_alias():
"""单曲候选的兼容 names 即使包含专辑名,也不能让整专标题冒充目标单曲。"""
recording = MusicInfo(
music_type="recording",
title="Get Lucky",
album="Random Access Memories",
names=["Get Lucky", "Random Access Memories"],
)
assert SearchChain.matches_music_resource(recording, "Daft Punk - Get Lucky FLAC") is True
assert SearchChain.matches_music_resource(
recording,
"Daft Punk - Random Access Memories FLAC",
) is False
def test_resource_match_requires_artist_when_target_artist_is_known():
"""同名作品很多,目标已知艺术家时资源标题也必须包含该艺术家。"""
recording = MusicInfo(
music_type="recording",
title="晴天",
artists=["周杰伦"],
)
assert SearchChain.matches_music_resource(recording, "周杰伦 - 晴天 FLAC") is True
assert SearchChain.matches_music_resource(recording, "其他艺人 - 晴天 FLAC") is False
assert SearchChain.matches_music_resource(recording, "晴天 FLAC") is False
def test_normalize_candidates_deduplicates_source_identity():
"""同一来源和媒体 ID 的音乐候选应只保留一次。"""
results = MediaChain.normalize_music_candidates(
[
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="A"),
{
"type": "音乐",
"media_source": "musicbrainz",
"media_id": "recording-1",
"title": "A duplicate",
},
]
)
assert len(results) == 1
assert results[0].title == "A"
def test_normalize_candidates_keeps_different_entities_with_same_source_id():
"""同一来源 ID 在不同音乐实体命名空间下不能互相去重。"""
results = MediaChain.normalize_music_candidates(
[
MusicInfo(media_source="musicbrainz", media_id="shared-id", music_type="recording", title="Song"),
MusicInfo(media_source="musicbrainz", media_id="shared-id", music_type="album", title="Album"),
]
)
assert [item.music_type for item in results] == ["recording", "album"]
def test_normalize_candidates_deduplicates_metadata_without_id():
"""缺少来源 ID 时应按标题、艺术家和专辑去重。"""
results = MediaChain.normalize_music_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(
media_source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
track_number=3,
)
meta = MetaMusic.from_music_info(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 = ListenBrainzChain()
requested = {}
def fake_run_module(method, **kwargs):
"""记录榜单模块调用并返回重复候选。"""
requested.update(method=method, **kwargs)
return [
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天"),
MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天"),
]
monkeypatch.setattr(chain, "run_module", fake_run_module)
results = chain.music_chart(range_name="this_week", page=2, count=30)
assert requested == {
"method": "music_chart",
"range_name": "this_week",
"offset": 30,
"count": 30,
"entity": "recording",
}
assert len(results) == 2
def test_async_chart_applies_music_explore_filters(monkeypatch):
"""音乐探索应按收听次数、封面条件和升序设置筛选榜单。"""
chain = RecommendChain()
async def fake_music_chart(**kwargs):
"""返回包含不同热度和封面状态的榜单候选。"""
return [
MusicInfo(media_id="1", media_source="musicbrainz", title="A", listen_count=300),
MusicInfo(
media_id="2",
media_source="musicbrainz",
title="B",
listen_count=120,
cover_url="https://coverartarchive.org/release/2/front-500",
),
MusicInfo(
media_id="3",
media_source="musicbrainz",
title="C",
listen_count=240,
cover_url="https://coverartarchive.org/release/3/front-500",
),
]
source_chain = Mock()
source_chain.async_music_chart = AsyncMock(side_effect=fake_music_chart)
monkeypatch.setattr("app.chain.recommend.ListenBrainzChain", Mock(return_value=source_chain))
results = asyncio.run(
chain.async_music_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(media_source="musicbrainz", media_id="1", title="晴天", artists=["其他歌手"]),
MusicInfo(
media_source="musicbrainz",
media_id="2",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
),
]
selected = MusicBrainzModule._select_candidate(meta, candidates, media_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(
media_source="musicbrainz",
media_id="recording-1",
title="眼泪成诗",
artists=["孙燕姿"],
album="完美的一天",
cover_url="https://coverartarchive.org/release-group/album-1/front-500",
)
chain = MediaChain()
recognize = AsyncMock(return_value=info)
filename_meta = MetaMusic(title="02. 眼泪成诗")
monkeypatch.setattr(
"app.chain.media.AudioMetadataHelper.read_evidence",
Mock(return_value=(meta, meta, filename_meta)),
)
monkeypatch.setattr(
AcoustIdChain,
"async_identify_music_by_fingerprint",
AsyncMock(return_value=None),
)
monkeypatch.setattr(chain, "async_recognize_media", recognize)
recognized_meta, recognized_info = asyncio.run(
chain.async_recognize_music_by_path(audio_path)
)
assert recognized_meta is meta
assert recognized_info is info
recognize.assert_awaited_once_with(
meta=meta,
media_source=None,
music_type="recording",
)
def test_media_chain_default_recognition_only_queries_musicbrainz(monkeypatch):
"""自动音乐识别只应调用 MusicBrainz 主数据源。"""
chain = MediaChain()
meta = MetaMusic(
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
expected = MusicInfo(
media_source="musicbrainz",
media_id="mb-1",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
year=2003,
)
recognize_source = Mock(return_value=expected)
monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source)
with patch("app.chain.MoviePilotServerHelper.report_recognize_share"):
result = chain.recognize_media(meta=meta)
assert result is expected
recognize_source.assert_called_once_with(
media_source=MediaSource.MusicBrainz,
meta=meta,
cache=True,
music_type="recording",
)
def test_recognize_from_source_selects_only_declared_music_source_chain(monkeypatch):
"""单源识别只允许调用声明该音乐来源的模块,忽略同接口影视模块。"""
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
source_chain = Mock()
source_chain.recognize_music.return_value = expected
monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain))
result = chain.recognize_music_from_source(
media_source="musicbrainz",
meta=meta,
cache=True,
)
assert result is expected
source_chain.recognize_music.assert_called_once_with(
meta=meta,
media_id=None,
cache=True,
music_type=None,
)
def test_default_recognition_does_not_fallback_after_musicbrainz_miss(monkeypatch):
"""MusicBrainz 未命中时自动识别不得继续请求其它音乐来源。"""
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
recognize_source = Mock(return_value=None)
monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source)
with patch(
"app.chain.MoviePilotServerHelper.query_recognize_share",
return_value=None,
):
assert chain.recognize_media(meta=meta) is None
recognize_source.assert_called_once()
assert recognize_source.call_args.kwargs["media_source"] == MediaSource.MusicBrainz
def test_async_default_recognition_only_queries_musicbrainz(monkeypatch):
"""异步自动音乐识别也只应调用 MusicBrainz 主数据源。"""
chain = MediaChain()
meta = MetaMusic(title="晴天", artists=["周杰伦"])
expected = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="晴天",
artists=["周杰伦"],
)
recognize_source = AsyncMock(return_value=expected)
monkeypatch.setattr(chain, "async_recognize_music_from_source", recognize_source)
with patch(
"app.chain.MoviePilotServerHelper.async_report_recognize_share",
new=AsyncMock(),
):
result = asyncio.run(chain.async_recognize_media(meta=meta))
assert result is expected
recognize_source.assert_awaited_once_with(
media_source=MediaSource.MusicBrainz,
meta=meta,
cache=True,
music_type="recording",
)
def test_musicbrainz_source_chain_calls_module_async_method(monkeypatch):
"""单源异步识别必须直接等待模块异步入口。"""
chain = MusicBrainzChain()
expected = MusicInfo(
media_source="musicbrainz",
media_id="recording-1",
title="晴天",
)
async_run_module = AsyncMock(return_value=expected)
run_module = Mock(side_effect=AssertionError("异步识别不应调用同步模块方法"))
monkeypatch.setattr(chain, "async_run_module", async_run_module)
monkeypatch.setattr(chain, "run_module", run_module)
result = asyncio.run(chain.async_recognize_music(
meta=MetaMusic(title="晴天"), cache=True
))
assert result is expected
async_run_module.assert_awaited_once()
run_module.assert_not_called()
def test_async_identify_by_fingerprint_uses_async_module_contract(monkeypatch):
"""指纹异步链路应请求模块的异步方法名。"""
chain = AcoustIdChain()
async_run_module = AsyncMock(return_value="recording-1")
monkeypatch.setattr(chain, "async_run_module", async_run_module)
result = asyncio.run(chain.async_identify_music_by_fingerprint("/music/track.flac"))
assert result == "recording-1"
async_run_module.assert_awaited_once_with(
"async_identify_music_by_fingerprint",
path=Path("/music/track.flac"),
)
def test_async_chart_forwards_album_entity(monkeypatch):
"""热门专辑探索应把实体类型透传给 ListenBrainz 榜单模块。"""
chain = ListenBrainzChain()
requested = {}
async def fake_async_run_module(method, **kwargs):
"""记录榜单请求参数并返回一个专辑候选。"""
requested.update(method=method, **kwargs)
return [
MusicInfo(
media_id="release-group-1",
media_source="musicbrainz",
music_type="album",
title="ARIRANG",
listen_count=10,
)
]
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
results = asyncio.run(chain.async_music_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 = RecommendChain()
requested = {}
async def fake_fresh_releases(**kwargs):
"""记录新发行请求参数并返回带封面与不带封面的候选。"""
requested.update(**kwargs)
return [
MusicInfo(media_id="b", media_source="musicbrainz", music_type="album", title="B"),
MusicInfo(
media_id="a",
media_source="musicbrainz",
music_type="album",
title="A",
cover_url="https://coverartarchive.org/release/a/front-500",
),
]
source_chain = Mock()
source_chain.async_music_fresh_releases = AsyncMock(side_effect=fake_fresh_releases)
monkeypatch.setattr("app.chain.recommend.ListenBrainzChain", Mock(return_value=source_chain))
results = asyncio.run(
chain.async_music_fresh_releases(
days=30, sort="release_name", page=2, count=10, with_cover=True
)
)
assert requested["page"] == 2
assert requested["sort"] == "release_name"
assert [item.title for item in results] == ["A"]
def test_async_artist_related_preserves_source_results(monkeypatch):
"""关联艺术家来源链应保留来源返回顺序和实体。"""
chain = MusicBrainzChain()
async def fake_async_run_module(method, **kwargs):
"""返回重复的关联艺术家候选。"""
assert method == "music_artist_related"
return [
MusicArtistInfo(media_source="musicbrainz", media_id="artist-1", name="Brian May"),
MusicArtistInfo(media_source="musicbrainz", media_id="artist-1", name="Brian May"),
MusicArtistInfo(media_source="musicbrainz", media_id="artist-2", name="John Deacon"),
]
monkeypatch.setattr(chain, "async_run_module", fake_async_run_module)
results = asyncio.run(chain.async_get_music_artist_related(media_id="artist-0"))
assert [item.media_id for item in results] == ["artist-1", "artist-1", "artist-2"]
def test_async_album_returns_source_chain_result(monkeypatch):
"""媒体链应按来源和 ID 返回来源链提供的标准专辑对象。"""
chain = MediaChain()
async def fake_get_album(media_id):
"""模拟插件模块以字典形式返回专辑详情。"""
assert media_id == "release-group-1"
return MusicAlbumInfo(
media_source="musicbrainz",
media_id="release-group-1",
title="A Night at the Opera",
artists=["Queen"],
release_date="1975-11-21",
)
source_chain = Mock()
source_chain.async_get_music_album = AsyncMock(side_effect=fake_get_album)
monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain))
album = asyncio.run(chain.async_get_music_album(
media_source="musicbrainz", media_id="release-group-1"
))
assert album is not None
assert album.year == 1975
assert album.artists == ["Queen"]