Files
MoviePilot/tests/test_musicbrainz_module.py
jxxghp 778d185c9d fix(music): 系列专辑弱匹配要求分卷一致避免跨卷误配
- 资源带卷号(Ibiza Lounge Moments, Vol. 1)时候选卷号为其他分卷
  直接排除,修复 Vol.1/Vol.2 被误配到 Vol.3 条目;
  候选无卷号(条目本体不含分卷标记)时仍允许弱匹配
- 补充分卷一致性测试案例
2026-08-11 11:22:57 +08:00

883 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from app.core.config import settings
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.modules.musicbrainz import MusicBrainzModule
def test_musicbrainz_cover_domains_are_allowed_by_image_proxy():
"""MusicBrainz 封面及其归档重定向域名应进入图片代理安全列表。"""
assert "coverartarchive.org" in settings.SECURITY_IMAGE_DOMAINS
assert "archive.org" in settings.SECURITY_IMAGE_DOMAINS
def test_build_query_uses_structured_music_fields():
"""MusicBrainz 查询应同时使用歌曲、艺术家和专辑条件。"""
query = MusicBrainzModule._build_query(
MetaMusic(
title='Love "Story"',
artists=["Taylor Swift"],
album="Fearless",
)
)
assert query == (
'recording:"Love \\"Story\\"" AND '
'artist:"Taylor Swift" AND release:"Fearless"'
)
def test_build_query_strips_audio_quality_tokens():
"""资源标题中的格式规格与年份后缀不应污染检索式,繁体统一转简体检索。"""
query = MusicBrainzModule._build_query(
MetaMusic(
title="永遠是朋友(2000) - ALAC [16B-44.1kHz]",
artists=["毛阿敏"],
)
)
# CJK 短语在 Lucene 索引中是单一词元,检索式拆为逐字 OROR 组带括号避免 AND 优先级歧义
assert query == 'recording:("" OR "" OR "" OR "" OR "") AND artist:"毛阿敏"'
def test_select_candidate_matches_traditional_chinese_title():
"""繁体资源标题应能命中 MusicBrainz 简体条目,不因写法差异失配。"""
meta = MetaMusic(title="永遠是朋友", artists=["毛阿敏"])
candidates = [
MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="永远是朋友",
artists=["毛阿敏"],
),
]
selected = MusicBrainzModule._select_candidate(meta, candidates, source="musicbrainz")
assert selected is candidates[0]
def test_recording_to_info_maps_musicbrainz_payload():
"""MusicBrainz Recording 应映射为统一 MusicInfo。"""
info = MusicBrainzModule._recording_to_info(
{
"id": "recording-1",
"title": "Get Lucky",
"length": 369000,
"first-release-date": "2013-04-19",
"isrcs": ["USQX91300105"],
"artist-credit": [
{"artist": {"name": "Daft Punk"}},
{"artist": {"name": "Pharrell Williams"}},
],
"releases": [
{
"title": "Random Access Memories",
"status": "Official",
"date": "2013-05-17",
"artist-credit": [{"artist": {"name": "Daft Punk"}}],
"release-group": {
"id": "release-group-1",
"primary-type": "Album",
"secondary-types": [],
},
}
],
}
)
assert info is not None
assert info.source == "musicbrainz"
assert info.media_id == "recording-1"
assert info.artists == ["Daft Punk", "Pharrell Williams"]
assert info.album == "Random Access Memories"
assert info.album_artist == "Daft Punk"
assert info.year == 2013
assert info.duration == 369
assert info.cover_url.endswith("/release-group-1/front-500")
def test_search_music_normalizes_candidates(monkeypatch):
"""搜索接口应把 MusicBrainz 列表转换为 MusicInfo 候选。"""
module = MusicBrainzModule()
monkeypatch.setattr(
module,
"_request_json",
lambda *_args, **_kwargs: {
"recordings": [{"id": "recording-1", "title": "晴天"}]
},
)
results = module.search_music(MetaMusic(title="晴天"), limit=5)
assert len(results) == 1
assert results[0].title == "晴天"
def test_search_music_interleaves_recordings_albums_and_artists(monkeypatch):
"""全局音乐搜索应交错返回三类实体,避免单曲结果挤掉整专和艺术家入口。"""
module = MusicBrainzModule()
requested = []
def fake_request(path, params=None):
"""按 MusicBrainz 实体路径返回可区分的搜索结果。"""
requested.append((path, params))
if path == "/recording":
return {
"recordings": [
{"id": "recording-1", "title": "晴天"},
{"id": "recording-2", "title": "轨迹"},
]
}
if path == "/release-group":
return {
"release-groups": [
{
"id": "album-1",
"title": "叶惠美",
"primary-type": "Album",
"artist-credit": [{"artist": {"id": "artist-1", "name": "周杰伦"}}],
},
{"id": "album-2", "title": "七里香", "primary-type": "Album"},
]
}
if path == "/artist":
return {
"artists": [
{"id": "artist-1", "name": "周杰伦", "type": "Person"},
{"id": "artist-2", "name": "Jay Chou", "type": "Person"},
]
}
return None
monkeypatch.setattr(module, "_request_json", fake_request)
results = module.search_music(
MetaMusic(title="晴天", artists=["周杰伦"]),
limit=5,
)
assert [item.music_type for item in results] == [
"recording",
"album",
"artist",
"recording",
"album",
]
assert results[1].album == "叶惠美"
assert results[2].title == "周杰伦"
assert results[2].artists == []
assert requested[1][1]["query"] == 'releasegroup:("" OR "") AND artist:"周杰伦"'
assert requested[2][1]["query"] == 'artist:("" OR "" OR "")'
def test_file_recognition_searches_recordings_only(monkeypatch):
"""本地音轨识别不得把同名专辑或艺术家候选当成 Recording。"""
module = MusicBrainzModule()
requested_paths = []
def fake_request(path, params=None):
"""记录文件识别实际访问的 MusicBrainz 实体。"""
requested_paths.append(path)
return {"recordings": [{"id": "recording-1", "title": "晴天"}]}
monkeypatch.setattr(module, "_request_json", fake_request)
result = module.recognize_media(meta=MetaMusic(title="晴天"))
assert result is not None
assert result.music_type == "recording"
assert requested_paths == ["/recording"]
def test_recognize_music_ignores_other_sources(monkeypatch):
"""MusicBrainz 模块不应处理其他元数据源的详情请求。"""
module = MusicBrainzModule()
called = False
def fake_request(*_args, **_kwargs):
"""记录测试中是否发生了不应出现的网络调用。"""
nonlocal called
called = True
return None
monkeypatch.setattr(module, "_request_json", fake_request)
assert module.recognize_music("netease", "song-1") is None
assert called is False
def test_recognize_music_fetches_recording_detail(monkeypatch):
"""MusicBrainz 详情请求应按 Recording ID 返回标准音乐信息。"""
module = MusicBrainzModule()
monkeypatch.setattr(
module,
"_request_json",
lambda path, params=None: {
"id": path.rsplit("/", 1)[-1],
"title": "晴天",
},
)
result = module.recognize_music("musicbrainz", "recording-1")
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"
class _FakeMusicBrainzResponse:
"""模拟 MusicBrainz HTTP 响应,便于缓存回归测试统计网络调用次数。"""
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
self.text = ""
def json(self):
"""返回预设的 JSON 负载。"""
return self._payload
def __bool__(self):
"""模拟 requests.ResponseHTTP 错误状态在布尔判断中为 False。"""
return self.status_code < 400
def close(self):
"""无需释放的资源。"""
def test_request_json_caches_repeated_calls(monkeypatch):
"""相同路径与参数的 MusicBrainz 请求应命中缓存,避免重复发起网络调用。"""
import app.modules.musicbrainz as musicbrainz_module
monkeypatch.setattr(
MusicBrainzModule, "_wait_for_rate_limit", classmethod(lambda cls: None)
)
network_calls = {"count": 0}
def fake_get_res(_self, url, params=None):
"""记录网络调用次数并返回固定的录音详情。"""
network_calls["count"] += 1
return _FakeMusicBrainzResponse({"id": "recording-cache", "title": "晴天"})
monkeypatch.setattr(musicbrainz_module.RequestUtils, "get_res", fake_get_res)
# 清理缓存区,排除其他用例残留
MusicBrainzModule._request_json.cache_clear()
first = MusicBrainzModule._request_json("/recording/recording-cache", params={"fmt": "json"})
second = MusicBrainzModule._request_json("/recording/recording-cache", params={"fmt": "json"})
assert first == second
assert network_calls["count"] == 1
def test_request_json_caches_not_found(monkeypatch):
"""MusicBrainz 稳定 404 应进入有界缓存,避免重复探测单曲与专辑入口。"""
import app.modules.musicbrainz as musicbrainz_module
monkeypatch.setattr(
MusicBrainzModule, "_wait_for_rate_limit", classmethod(lambda cls: None)
)
network_calls = {"count": 0}
def fake_get_res(_self, url, params=None):
"""始终返回 404用于验证稳定不存在结果会被缓存。"""
network_calls["count"] += 1
return _FakeMusicBrainzResponse(None, status_code=404)
monkeypatch.setattr(musicbrainz_module.RequestUtils, "get_res", fake_get_res)
MusicBrainzModule._request_json.cache_clear()
first = MusicBrainzModule._request_json("/recording/missing", params={"fmt": "json"})
second = MusicBrainzModule._request_json("/recording/missing", params={"fmt": "json"})
assert first == second == {}
assert network_calls["count"] == 1
def test_request_json_retries_on_server_busy(monkeypatch):
"""服务端繁忙429/5xx属于瞬时错误应退避重试而不是直接放弃。"""
import time
import app.modules.musicbrainz as musicbrainz_module
monkeypatch.setattr(
MusicBrainzModule, "_wait_for_rate_limit", classmethod(lambda cls: None)
)
monkeypatch.setattr(time, "sleep", lambda _seconds: None)
responses = [
_FakeMusicBrainzResponse(None, status_code=503),
_FakeMusicBrainzResponse({"id": "recording-busy", "title": "晴天"}),
]
def fake_get_res(_self, url, params=None):
"""依次返回繁忙与成功响应,验证重试后拿到结果。"""
return responses.pop(0)
monkeypatch.setattr(musicbrainz_module.RequestUtils, "get_res", fake_get_res)
MusicBrainzModule._request_json.cache_clear()
result = MusicBrainzModule._request_json("/recording/search-busy", params={"fmt": "json"})
assert result == {"id": "recording-busy", "title": "晴天"}
assert not responses
def test_same_text_normalizes_traditional_chinese():
"""候选比对应归一繁简差异,条目繁体写法不与简体资源标题失配。"""
assert MusicBrainzModule._same_text("神的遊戲", "神的游戏")
assert MusicBrainzModule._same_text("張懸", "张悬")
assert not MusicBrainzModule._same_text("神的遊戲", "神的冒险")
def test_search_title_replaces_sanitized_underscores():
"""流媒体文件名消毒产生的下划线应转空格,不破坏检索短语。"""
assert MusicBrainzModule._search_title("百年經典7_愛的奉獻") == "百年经典7 爱的奉献"
def test_select_candidate_matches_traditional_chinese_recording():
"""条目为繁体写法时,简体资源标题仍应命中候选。"""
meta = MetaMusic(title="芸开了", artists=["许茹芸"])
candidate = MusicInfo(
source="musicbrainz",
music_type="recording",
media_id="recording-1",
title="芸開了",
artists=["許茹芸"],
)
matched = MusicBrainzModule._select_candidate(meta, [candidate], source="musicbrainz")
assert matched is not None
assert matched.media_id == "recording-1"
def test_recording_queries_ladder_relaxes_to_bare_title_last():
"""检索阶梯由严到宽放宽,裸标题兜底只能出现在最后一级。"""
queries = MusicBrainzModule._recording_queries(
MetaMusic(title="晴天 (电影版)", artists=["周杰伦"])
)
full = '("" OR "") OR ("" OR "" OR "")'
assert queries[0] == f'recording:({full}) AND artist:"周杰伦"'
assert queries[1] == f'recording:({full})'
assert queries[2] == 'recording:("" OR "") AND artist:"周杰伦"'
# 署名变体兜底放在最后,由候选挑选的艺术家要求收紧
assert queries[-1] == 'recording:("" OR "")'
def test_query_phrase_latin_unchanged_and_cjk_char_or():
"""拉丁文本保持短语检索CJK 文本拆为逐字 OR混合文本按词元拆分。"""
assert MusicBrainzModule._query_phrase("Fearless") == '"Fearless"'
assert MusicBrainzModule._query_phrase("晴天") == '("" OR "")'
assert MusicBrainzModule._query_phrase("好歌茹芸 Vol. 3") == (
'(("" OR "" OR "" OR "") OR "Vol." OR "3")'
)
assert MusicBrainzModule._query_phrase("") is None
def test_same_text_normalizes_cjk_numerals():
"""汉字数字与阿拉伯数字写法差异不应阻断候选比对。"""
assert MusicBrainzModule._same_text("茹此精彩十三首", "茹此精彩13首")
assert MusicBrainzModule._same_text("二十周年演唱会", "20周年演唱会")
assert not MusicBrainzModule._same_text("茹此精彩十三首", "茹此精彩14首")
def test_strip_artist_prefix_removes_signature_prefix():
"""曲名开头的艺术家署名前缀是命名习惯,检索与比对应使用主体名。"""
assert MusicBrainzModule._strip_artist_prefix(
"许茹芸的爱情电影主题曲", ["许茹芸"]) == "爱情电影主题曲"
# 剥离后无剩余时保留原标题,短标题不受影响
assert MusicBrainzModule._strip_artist_prefix("许茹芸", ["许茹芸"]) == "许茹芸"
assert MusicBrainzModule._strip_artist_prefix("晴天", ["周杰伦"]) == "晴天"
def test_select_album_candidate_matches_lead_token_structure():
"""条目「主体名 补充说明」结构与资源主体名首段一致时应弱匹配命中。"""
meta = MetaMusic(title="许茹芸的爱情电影主题曲", artists=["许茹芸"], year=2003)
album = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-1",
title="愛情電影主題曲 雲且留住",
artists=["許茹芸"],
year=2003,
)
matched = MusicBrainzModule._select_album_candidate(meta, [album])
assert matched is not None
assert matched.media_id == "album-1"
def test_select_album_candidate_matches_performance_suffix():
"""资源标题带演出后缀S.H.E十七音乐会条目本体一致应弱匹配命中。"""
meta = MetaMusic(title="S.H.E十七音乐会", artists=["S.H.E"], year=2018)
album = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-1",
title="十七",
artists=["S.H.E"],
year=2018,
)
matched = MusicBrainzModule._select_album_candidate(meta, [album])
assert matched is not None
assert matched.media_id == "album-1"
def test_select_album_candidate_strips_volume_suffix():
"""系列专辑卷号后缀Vol. 3是发行分卷标记本体名一致应弱匹配命中。"""
meta = MetaMusic(title="好歌茹芸, Vol. 3", artists=["许茹芸"], year=2011)
album = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-1",
title="好歌, 茹芸: Valen Hsu Greatest Hits",
artists=["許茹芸"],
year=2011,
)
matched = MusicBrainzModule._select_album_candidate(meta, [album])
assert matched is not None
assert matched.media_id == "album-1"
def test_select_album_candidate_rejects_wrong_volume():
"""资源带卷号时其他分卷候选不能被弱匹配采信。"""
meta = MetaMusic(title="Ibiza Lounge Moments, Vol. 1", artists=["Various Artists"], year=2022)
wrong_volume = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-wrong",
title="Ibiza Lounge Moments, Vol. 3",
artists=["Various Artists"],
year=2023,
)
assert MusicBrainzModule._select_album_candidate(meta, [wrong_volume]) is None
def test_select_album_candidate_matches_contained_title():
"""条目带额外前缀完整包含资源主体名(原声带类)时应弱匹配命中。"""
meta = MetaMusic(
title="Once Upon a Time in Hollywood Original Motion Picture Soundtrack",
artists=["Various Artists"],
year=2019,
)
album = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-1",
title="Quentin Tarantino's Once Upon a Time in Hollywood: "
"Original Motion Picture Soundtrack",
artists=["Various Artists"],
year=2019,
)
matched = MusicBrainzModule._select_album_candidate(meta, [album])
assert matched is not None
assert matched.media_id == "album-1"
def test_soundtrack_body_extracts_movie_name():
"""原声带描述词尾部剔除返回电影名本体,非原声带标题与词内后缀不受影响。"""
body = MusicBrainzModule._soundtrack_body
assert body("Pulp Fiction Original Motion Picture Soundtrack") == "Pulp Fiction"
assert body("Reply 1988 OST") == "Reply 1988"
# Ghost 尾部的 ost 是单词组成部分,不能剔除
assert body("Ghost") == ""
assert body("晴天") == ""
def test_search_title_strips_repeated_trailing_year():
"""场景命名重复携带的尾部年份应全部剥离,避免年份文本阻断精确匹配。"""
assert MusicBrainzModule._search_title("Live At Montreux 2011 2011") == "Live At Montreux"
# 纯年份标题无前导空白不受影响
assert MusicBrainzModule._search_title("1999") == "1999"
def test_album_queries_include_soundtrack_body():
"""原声带专辑检索阶梯应包含电影名本体变体。"""
queries = MusicBrainzModule._album_queries(
MetaMusic(title="Pulp Fiction Original Motion Picture Soundtrack",
artists=["Various Artists"], year=1994)
)
assert 'releasegroup:"Pulp Fiction" AND artist:"Various Artists"' in queries
def test_select_album_candidate_matches_soundtrack_body():
"""条目以冒号副标题保留描述词时,电影名本体一致应弱匹配命中。"""
meta = MetaMusic(
title="Pulp Fiction Original Motion Picture Soundtrack",
artists=["Various Artists"],
year=1994,
)
album = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-1",
title="Pulp Fiction: Music From the Motion Picture",
artists=["Various Artists"],
year=1994,
)
matched = MusicBrainzModule._select_album_candidate(meta, [album])
assert matched is not None
assert matched.media_id == "album-1"
def test_select_candidate_rejects_wrong_artist_same_title():
"""已知艺术家时,同名异曲的候选不能因标题相等被采信。"""
meta = MetaMusic(title="因为有你", artists=["毛阿敏"])
wrong_artist = MusicInfo(
source="musicbrainz",
music_type="recording",
media_id="recording-wrong",
title="因为有你",
artists=["张蔷"],
)
assert MusicBrainzModule._select_candidate(meta, [wrong_artist], source="musicbrainz") is None
def test_select_candidate_rejects_artist_only_match():
"""CJK 逐字 OR 检索召回宽,标题未命中的候选不能仅凭艺术家署名被采信。"""
meta = MetaMusic(title="茹此精彩十三首", artists=["许茹芸"])
same_artist_other_song = MusicInfo(
source="musicbrainz",
music_type="recording",
media_id="recording-wrong",
title="半首歌",
artists=["许茹芸"],
)
assert MusicBrainzModule._select_candidate(
meta, [same_artist_other_song], source="musicbrainz") is None
def test_select_album_candidate_requires_title_and_artist():
"""专辑候选需标题(含去括号弱匹配)与艺术家同时命中才采信。"""
meta = MetaMusic(title="我爱夜 (新歌+精选)", artists=["许茹芸"], year=2003)
album_hit = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-1",
title="我爱夜",
artists=["许茹芸"],
year=2003,
)
matched = MusicBrainzModule._select_album_candidate(meta, [album_hit])
assert matched is not None
assert matched.media_id == "album-1"
album_wrong_artist = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-2",
title="我爱夜",
artists=["其他歌手"],
)
assert MusicBrainzModule._select_album_candidate(meta, [album_wrong_artist]) is None
def test_select_album_candidate_matches_colon_subtitle():
"""条目「主标题:副标题」结构应与资源主标题弱匹配命中。"""
meta = MetaMusic(title="天国的情人", artists=["邓丽君"])
album = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-colon",
title="天國的情人:鄧麗君逝世十周年紀念聲影存集",
artists=["鄧麗君"],
)
matched = MusicBrainzModule._select_album_candidate(meta, [album])
assert matched is not None
assert matched.media_id == "album-colon"
def test_select_album_candidate_matches_head_title():
"""条目「曲名-歌手《巡演名》」连字符前置命名应与资源曲名弱匹配命中。"""
meta = MetaMusic(title="为你盛开", artists=["许巍"])
album = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-head",
title="为你盛开-许巍《无尽光芒》巡回演唱会现场纪念",
artists=["许巍"],
)
matched = MusicBrainzModule._select_album_candidate(meta, [album])
assert matched is not None
assert matched.media_id == "album-head"
def test_search_title_strips_trailing_year():
"""检索式应剥离曲名尾部独立年份,避免年份文本造成精确短语零命中。"""
assert MusicBrainzModule._search_title("Funky Jazz Saxophone 2024") == "Funky Jazz Saxophone"
# 非尾部年份属于曲名内容,不应剥离
assert MusicBrainzModule._search_title("2002年的第一场雪") == "2002年的第一场雪"