from app.core.config import settings from app.core.context import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, 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 索引中是单一词元,检索式拆为逐字 OR,OR 组带括号避免 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( media_source="musicbrainz", media_id="recording-1", title="永远是朋友", artists=["毛阿敏"], ), ] selected = MusicBrainzModule._select_candidate(meta, candidates, media_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.media_source.value == "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): """旧调用未给实体类型时保留先单曲后专辑的兼容探测。""" 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_recognize_music_recording_does_not_probe_album(monkeypatch): """显式 Recording ID 未命中时不得继续请求同 ID 的专辑实体。""" module = MusicBrainzModule() requested = [] monkeypatch.setattr( module, "_request_json", lambda path, params=None: requested.append(path), ) result = module.recognize_music( "musicbrainz", "recording-missing", music_type=MUSIC_ENTITY_RECORDING, ) assert result is None assert requested == ["/recording/recording-missing"] def test_recognize_music_album_skips_recording_namespace(monkeypatch): """显式 Album ID 应直接读取 Release Group,不先探测 Recording。""" module = MusicBrainzModule() requested = [] def fake_request(path, params=None): """记录请求路径并返回最小专辑详情。""" requested.append(path) if path.startswith("/release-group/"): return { "id": "release-group-1", "title": "叶惠美", "artist-credit": [], "releases": [], } return None monkeypatch.setattr(module, "_request_json", fake_request) result = module.recognize_music( "musicbrainz", "release-group-1", music_type=MUSIC_ENTITY_ALBUM, ) assert result and result.music_type == MUSIC_ENTITY_ALBUM assert requested[0] == "/release-group/release-group-1" assert not any(path.startswith("/recording/") for path in requested) 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.Response:HTTP 错误状态在布尔判断中为 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( media_source="musicbrainz", music_type="recording", media_id="recording-1", title="芸開了", artists=["許茹芸"], ) matched = MusicBrainzModule._select_candidate(meta, [candidate], media_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( media_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( media_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( media_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( media_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( media_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( media_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( media_source="musicbrainz", music_type="recording", media_id="recording-wrong", title="因为有你", artists=["张蔷"], ) assert MusicBrainzModule._select_candidate(meta, [wrong_artist], media_source="musicbrainz") is None def test_select_candidate_rejects_artist_only_match(): """CJK 逐字 OR 检索召回宽,标题未命中的候选不能仅凭艺术家署名被采信。""" meta = MetaMusic(title="茹此精彩十三首", artists=["许茹芸"]) same_artist_other_song = MusicInfo( media_source="musicbrainz", music_type="recording", media_id="recording-wrong", title="半首歌", artists=["许茹芸"], ) assert MusicBrainzModule._select_candidate( meta, [same_artist_other_song], media_source="musicbrainz") is None def test_select_album_candidate_requires_title_and_artist(): """专辑候选需标题(含去括号弱匹配)与艺术家同时命中才采信。""" meta = MetaMusic(title="我爱夜 (新歌+精选)", artists=["许茹芸"], year=2003) album_hit = MusicInfo( media_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( media_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( media_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( media_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年的第一场雪"