From be415175208c2f7a3c1ebf556cc80c72cf322e10 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 6 Sep 2026 04:55:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=98=B2=E6=AD=A2=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E7=BD=B2=E5=90=8D=E6=B8=85=E7=90=86=E6=88=AA=E6=96=AD=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=9B=B2=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/domain/music.py | 42 +++++++++++++++----- app/modules/musicbrainz/__init__.py | 49 ++++++++++++------------ app/modules/musicbrainz/cache.py | 3 +- docs/architecture/media-search-design.md | 4 ++ docs/mcp-api.md | 1 + tests/test_media_search_pipeline.py | 10 +++-- tests/test_music_matching.py | 30 +++++++++++++++ tests/test_music_recognize_cache.py | 9 +++-- tests/test_musicbrainz_module.py | 46 ++++++++++++++++++++++ 9 files changed, 152 insertions(+), 42 deletions(-) diff --git a/app/domain/music.py b/app/domain/music.py index f396074cd..3c827d0aa 100644 --- a/app/domain/music.py +++ b/app/domain/music.py @@ -37,6 +37,7 @@ _COLLECTIVE_ARTISTS = ("Various Artists", "Various", "VA", "群星", "众艺人" _VERSION_YEAR = re.compile(r"(? bool: return bool(expected and expected == _isrc_key(music.isrc)) +def _artist_match_text(text: str) -> str: + """归一署名比较文本但保留分隔符,避免紧凑名称丢失单词边界。""" + normalized = str(zhconv_convert(normalize("NFKD", text).casefold(), "zh-hans")) + return "".join(char for char in normalized if not combining(char)) + + +def music_artist_affix_matches(title: str, artist: str, *, suffix: bool = False) -> bool: + """核验首尾完整署名;保留中日韩连写习惯,但不能截断其它文字的单词。""" + key = music_text_key(artist) + if not key: + return False + text = _artist_match_text(title) + pattern = r"[\W_]*".join(re.escape(char) for char in key) + match = re.search(rf"{pattern}[\W_]*$" if suffix else rf"^[\W_]*{pattern}", text) + if not match: + return False + neighbor = text[match.start() - 1:match.start()] if suffix else text[match.end():match.end() + 1] + edge = key[0] if suffix else key[-1] + return not (neighbor.isalnum() and edge.isalnum() and not _CJK.search(neighbor + edge)) + + def _contains_artist(text: str, artist: str) -> bool: """匹配完整署名,避免短拉丁艺名命中另一个人名的子串。""" - normalized = str(zhconv_convert(normalize("NFKD", text).casefold(), "zh-hans")) - normalized = "".join(char for char in normalized if not combining(char)) + normalized = _artist_match_text(text) key = music_text_key(artist) if not key: return False @@ -150,7 +171,7 @@ def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = Fal album_suffixes: Optional[list[str]] = None) -> list[str]: """复用音乐命名解析器提取作品片段,去掉已确认的首尾艺术家署名。""" names: list[str] = [] - artist_keys = [music_text_key(item) for item in artists if item] + artist_keys = [(item, music_text_key(item)) for item in artists if item] suffix_keys = {music_text_key(item) for item in album_suffixes or []} for value in (primary.title, primary.album if album else None): if not value: @@ -161,15 +182,18 @@ def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = Fal if len(parts) > 1 and all(music_text_key(part) in suffix_keys for part in parts[1:]): variants.append(parts[0]) for part in variants: - key = music_text_key(music_base_title(_TITLE_LABEL.sub("", part.strip()))) + name = music_base_title(_TITLE_LABEL.sub("", part.strip())) + key = music_text_key(name) if not key: continue names.append(key) - for artist in artist_keys: - if key.startswith(artist) and key != artist: - names.append(key[len(artist):]) - if key.endswith(artist) and key != artist: - names.append(key[:-len(artist)]) + for artist, artist_key in artist_keys: + if key.startswith(artist_key) and key != artist_key and music_artist_affix_matches(name, artist): + remainder = re.sub(r"^[的之]", "", key[len(artist_key):]) + if remainder: + names.append(remainder) + if key.endswith(artist_key) and key != artist_key and music_artist_affix_matches(name, artist, suffix=True): + names.append(key[:-len(artist_key)]) return names diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index 75c4dabf0..bb2aa7ad0 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -19,6 +19,7 @@ from app.domain.media import is_media_source_selected from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.music import ( + music_artist_affix_matches, music_artist_matches, music_base_title, music_isrc_matches, @@ -478,19 +479,15 @@ class MusicBrainzModule(_ModuleBase): @classmethod def _strip_artist_prefix(cls, title: Optional[str], artists: Optional[list[str]]) -> str: - """剥离曲名开头的艺术家署名前缀(「许茹芸的爱情电影主题曲」)。 - - 资源命名习惯把署名放在曲名前,条目不含该前缀;署名身份由 - 候选挑选阶段的艺术家要求保证,不会产生错误归属。前缀剥离后 - 无剩余文本时保留原标题(「合集 - 花开」类短标题保护)。 - """ + """剥离完整署名前缀,不截断单词;保留中文连写与“的/之”署名习惯。""" text = str(title or "").strip() for artist in artists or []: artist = str(artist or "").strip() if len(artist) < 2: continue - if text.startswith(artist): - remainder = re.sub(r"^[的之]\s*", "", text[len(artist):]).strip() + if text.startswith(artist) and music_artist_affix_matches(text, artist): + remainder = text[len(artist):].lstrip(" \t-–—−-/|::;;") + remainder = re.sub(r"^[的之]\s*", "", remainder).strip() if remainder: return remainder return text @@ -1219,11 +1216,11 @@ class MusicBrainzModule(_ModuleBase): ) -> Optional[MusicInfo]: """优先采用同一 ISRC,其他候选须满足完整名称、已有署名和录音版本约束。""" normalized_source = cls._normalize_text(media_source).casefold() - # 资源标题携带的音质标记先剥离,再与候选曲名比对; - # 曲名开头的艺术家署名前缀是命名习惯,用主体名比对 - clean_title = cls._strip_artist_prefix(cls._search_title(meta.title), meta.artists) + # 完整名称优先,去署名只产生回退名称,不能覆盖实际包含艺名的曲名。 + original_title = cls._search_title(meta.title) + clean_title = cls._strip_artist_prefix(original_title, meta.artists) bare_title = music_base_title(clean_title) - ranked: list[tuple[int, MusicInfo]] = [] + ranked: list[tuple[bool, int, MusicInfo]] = [] for candidate in candidates: if normalized_source and str(candidate.media_source or "").casefold() != normalized_source: continue @@ -1235,7 +1232,8 @@ class MusicBrainzModule(_ModuleBase): # 多艺术家资源任一命中即可,联名候选不会因主艺术家顺序失配 artist_match = music_artist_matches(candidate, meta.artists) titles = music_titles(candidate) - if clean_title and any(cls._same_text(clean_title, title) for title in titles): + exact_title = bool(original_title and any(cls._same_text(original_title, title) for title in titles)) + if exact_title: score += 4 title_match = True elif ( @@ -1256,12 +1254,12 @@ class MusicBrainzModule(_ModuleBase): if ( (meta.artists and not artist_match) or not title_match or not music_version_matches(candidate, meta) ): - score = 0 - ranked.append((score, candidate)) + continue + ranked.append((exact_title, score, candidate)) if not ranked: return None - ranked.sort(key=lambda item: item[0], reverse=True) - return ranked[0][1] if ranked[0][0] > 0 else None + ranked.sort(key=lambda item: (item[0], item[1]), reverse=True) + return ranked[0][2] @classmethod def _select_album_candidate(cls, meta: MetaMusic, albums: Iterable[MusicInfo]) -> Optional[MusicInfo]: @@ -1270,26 +1268,28 @@ class MusicBrainzModule(_ModuleBase): 专辑重名多,要求标题(含去括号弱匹配)与艺术家同时命中才返回, 避免把音轨身份安到错误专辑上。 """ - clean_title = cls._strip_artist_prefix( - cls._search_title(meta.album or meta.title), meta.artists) + original_title = cls._search_title(meta.album or meta.title) + clean_title = cls._strip_artist_prefix(original_title, meta.artists) if not clean_title: return None # 去括号与卷号后缀后的本体名用于弱匹配(好歌茹芸, Vol. 3 -> 好歌茹芸); # 资源带卷号时弱匹配要求候选卷号一致,避免 Ibiza Vol.1 误配 Vol.3 bare_title = cls._strip_volume_suffix(cls._strip_parenthetical(clean_title)) meta_volume = cls._volume_number(clean_title) - ranked: list[tuple[int, MusicInfo]] = [] + ranked: list[tuple[bool, int, MusicInfo]] = [] for album in albums: score = 0 album_title = album.title or album.album artist_match = music_artist_matches(album, meta.artists) title_match = False + exact_title = False # 资源带卷号时候选卷号不一致(含其他分卷)直接排除,避免 Vol.1 误配 Vol.3 if meta_volume and cls._volume_number(album_title) not in (None, meta_volume): pass - elif any(cls._same_text(clean_title, title) for title in music_titles(album, album=True)): + elif any(cls._same_text(original_title, title) for title in music_titles(album, album=True)): score += 4 title_match = True + exact_title = True elif ( artist_match and bare_title @@ -1333,11 +1333,12 @@ class MusicBrainzModule(_ModuleBase): if meta.year and album.year and int(meta.year) == int(album.year): score += 1 # 标题与艺术家缺一不可,仅有标题相似不能采信 - ranked.append((score if title_match and artist_match and music_version_matches(album, meta) else 0, album)) + if title_match and artist_match and music_version_matches(album, meta): + ranked.append((exact_title, score, album)) if not ranked: return None - ranked.sort(key=lambda item: item[0], reverse=True) - return ranked[0][1] if ranked[0][0] > 0 else None + ranked.sort(key=lambda item: (item[0], item[1]), reverse=True) + return ranked[0][2] @classmethod def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo: diff --git a/app/modules/musicbrainz/cache.py b/app/modules/musicbrainz/cache.py index a2d620618..6457f48b6 100644 --- a/app/modules/musicbrainz/cache.py +++ b/app/modules/musicbrainz/cache.py @@ -16,7 +16,8 @@ from app.runtime.settings import get_runtime_setting from app.schemas.types import MUSIC_ENTITY_RECORDING lock = RLock() -PERSISTENCE_VERSION = 2 +# 旧确认规则可能把署名重叠的曲名截断,不能继续复用其派生身份。 +PERSISTENCE_VERSION = 3 PERSISTENCE_REGION = "recognize" PERSISTENCE_KEY = "musicbrainz" diff --git a/docs/architecture/media-search-design.md b/docs/architecture/media-search-design.md index 46801f9c6..1f8907be4 100644 --- a/docs/architecture/media-search-design.md +++ b/docs/architecture/media-search-design.md @@ -75,6 +75,10 @@ “晴天”、艺术家“周杰倫”,证明中文完整短语不是零命中。 查询字段与短语语义参考 [MusicBrainz 官方检索语法](https://musicbrainz.org/doc/Indexed_Search_Syntax)。 - 不根据展示文本臆造 MusicBrainz ID,不更改默认 MusicBrainz 或显式单来源行为。 +- 署名清理保留原始分隔信息,不能从非中日韩文字的单词内部剥离艺名,避免把 `Leeway` + 截为 `way`、把 `Surprise` 截为 `Surp`。中日韩连写署名及“的/之”形式保持兼容。 + MusicBrainz 确认先采用完整名称,去署名结果只作回退;不符合艺术家、版本约束的 + 完整名称不能抢占有效回退。旧名称确认规则的派生缓存升级后重新建立。 - MusicBrainz 的候选确认与资源匹配复用繁简、变音符、可信别名、完整署名和录音版本规则。 单曲不凭首词、包含关系或任意括号剥离认定同一作品;已返回的同一 ISRC 优先于名称打分。 专辑的 `secondary_types` 可提供整专版本证据,但不能反向覆盖其中单曲的录音版本。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index d781636de..43cf97ea3 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -420,6 +420,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized` 缓存键为不透明值,管理调用必须使用查询返回的原始键,不自行拼接。识别缓存按请求的 单曲、专辑或未限定实体范围隔离,版本及 ISRC 不同的文本识别请求也不会共用结果; 旧版未包含这些证据的派生缓存在升级后重新建立,不影响下载历史或订阅数据。 +名称确认规则更新时同样重建旧派生缓存,避免艺术家前后缀误截断的旧结果继续命中。 ### 插件补充接口 diff --git a/tests/test_media_search_pipeline.py b/tests/test_media_search_pipeline.py index 5f7ae7591..8af6fb885 100644 --- a/tests/test_media_search_pipeline.py +++ b/tests/test_media_search_pipeline.py @@ -86,24 +86,26 @@ def test_all_media_use_filtered_results_to_stop_keyword_search(monkeypatch, mtyp @pytest.mark.parametrize("mode", ["sync", "async", "stream"]) -@pytest.mark.parametrize("case", ["partial_album", "subtitle_version", "recognition_words"]) +@pytest.mark.parametrize("case", ["partial_album", "subtitle_version", "recognition_words", "artist_affix"]) def test_music_resource_evidence_controls_shared_search_stop(monkeypatch, mode, case): """三种搜索入口均在完成识别词、版本与专辑范围验证后才允许停止换词。""" chain = SearchChain() chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False) album_target = case == "partial_album" + artist = "Lee" if case == "artist_affix" else "周杰伦" target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="target", music_type="album" if album_target else "recording", - title="叶惠美" if album_target else "晴天", artists=["周杰伦"]) + title="叶惠美" if album_target else "way" if case == "artist_affix" else "晴天", artists=[artist]) calls = [] def search(**kwargs): """先返回待核验或需改名的资源,再提供正确身份的资源。""" calls.append(kwargs["keyword"]) if len(calls) > 1: - return [TorrentInfo(title=f"周杰伦 - {target.title} FLAC", category=MediaType.MUSIC.value)] + return [TorrentInfo(title=f"{artist} - {target.title} FLAC", category=MediaType.MUSIC.value)] + first_title = "错误曲名" if case == "recognition_words" else "Leeway" if case == "artist_affix" else "晴天" return [TorrentInfo( - title="周杰伦 - 错误曲名 FLAC" if case == "recognition_words" else "周杰伦 - 晴天 FLAC", + title=f"{artist} - {first_title} FLAC", description="专辑:叶惠美" if album_target else "版本:Live" if case == "subtitle_version" else None, category=MediaType.MUSIC.value, )] diff --git a/tests/test_music_matching.py b/tests/test_music_matching.py index d4545678b..3b9ef46bd 100644 --- a/tests/test_music_matching.py +++ b/tests/test_music_matching.py @@ -24,6 +24,36 @@ def test_music_match_rejects_other_titles(title): assert match_music_resource(MusicInfo(title="One", artists=["U2"]), title).status == "rejected" +@pytest.mark.parametrize("artist,title,wrong_title", [ + ("Lee", "Leeway", "way"), + ("Rise", "Surprise", "Surp"), + ("Lee", "HappyLee", "Happy"), + ("Élan", "Élansong", "song"), + ("Мир", "Мирный", "ный"), +]) +def test_artist_affix_cannot_cut_into_a_title_word(artist, title, wrong_title): + """署名恰好是单词的一部分时,不得据此构造另一首歌的名称。""" + resource = f"{artist} - {title} FLAC" + assert match_music_resource(MusicInfo(title=title, artists=[artist]), resource).status == "exact" + assert match_music_resource(MusicInfo(title=wrong_title, artists=[artist]), resource).status == "rejected" + + +@pytest.mark.parametrize("title", ["Lee - Song", "Song - Lee", "LEE: Song"]) +def test_artist_affix_preserves_explicit_signature_boundaries(title): + """已解析字段中的完整署名有分隔符时,仍可去掉署名参与匹配。""" + target = MusicInfo(title="Song", artists=["Lee"]) + meta = MetaMusic(title=title, artists=["Lee"]) + assert match_music_resource(target, title, meta=meta).status == "exact" + + +def test_artist_affix_supports_existing_cjk_signature_style(): + """中文连写及“的”字署名形式仍参与同一套资源名称比较。""" + target = MusicInfo(title="爱情电影主题曲", artists=["许茹芸"]) + for title in ("许茹芸爱情电影主题曲", "许茹芸的爱情电影主题曲"): + meta = MetaMusic(title=title, artists=["许茹芸"]) + assert match_music_resource(target, title, meta=meta).status == "exact" + + @pytest.mark.parametrize("artist", ["Jay Chou", "周杰倫"]) def test_music_match_accepts_source_artist_aliases(artist): """同一艺术家来源别名和繁简署名均可命中。""" diff --git a/tests/test_music_recognize_cache.py b/tests/test_music_recognize_cache.py index 9aa8cb4eb..63be2f6cb 100644 --- a/tests/test_music_recognize_cache.py +++ b/tests/test_music_recognize_cache.py @@ -155,15 +155,16 @@ def test_music_cache_key_prefers_media_id(): cache.update(meta, _music_info()) - assert next(iter(cache._cache.data)).startswith("[音乐:v2]") + assert next(iter(cache._cache.data)).startswith(f"[音乐:v{music_cache_module.PERSISTENCE_VERSION}]") renamed = MetaMusic(title="不同展示名", artists=["不同署名"], media_source="musicbrainz", media_id="rec-1") assert cache.get(renamed).media_id == "rec-1" -def test_music_cache_rebuilds_legacy_identity_keys(monkeypatch): - """旧缓存未区分版本和实体范围,升级后不恢复其中可能串用的身份。""" +@pytest.mark.parametrize("version", [1, 2]) +def test_music_cache_rebuilds_legacy_identity_keys(monkeypatch, version): + """旧缓存可能串用实体或保存了截断名称的错误身份,升级后不再恢复。""" file_cache = _FileCacheStub(pickle.dumps({ - "version": 1, "items": {"[音乐]legacy": {"expires_at": 2000, "value": _music_info().to_dict()}}, + "version": version, "items": {"[音乐]legacy": {"expires_at": 2000, "value": _music_info().to_dict()}}, })) runtime_cache = _TTLCacheStub() _build_initialized_music_cache(monkeypatch, file_cache, runtime_cache) diff --git a/tests/test_musicbrainz_module.py b/tests/test_musicbrainz_module.py index eee8f7402..fddf161c8 100644 --- a/tests/test_musicbrainz_module.py +++ b/tests/test_musicbrainz_module.py @@ -870,6 +870,52 @@ def test_strip_artist_prefix_removes_signature_prefix(): assert MusicBrainzModule._strip_artist_prefix("晴天", ["周杰伦"]) == "晴天" +@pytest.mark.parametrize("artist,title", [("Lee", "Leeway"), ("Élan", "Élansong"), ("Мир", "Мирный")]) +def test_artist_prefix_cleanup_keeps_whole_words(artist, title): + """候选确认和后续检索式都不能从实际曲名的单词内部剥离艺术家。""" + assert MusicBrainzModule._strip_artist_prefix(title, [artist]) == title + meta = MetaMusic(title=title, artists=[artist]) + candidate = MusicInfo(media_source="musicbrainz", media_id="correct", title=title, artists=[artist]) + assert MusicBrainzModule._select_candidate(meta, [candidate], "musicbrainz") is candidate + + +def test_recording_queries_do_not_shorten_title_words(): + """首轮精确查询之后也必须保留完整曲名,不能换成错误的缩短词。""" + queries = MusicBrainzModule._recording_queries(MetaMusic(title="Leeway", artists=["Lee"])) + assert queries == ['recording:"Leeway" AND artist:"Lee"', 'recording:"Leeway"'] + + +@pytest.mark.parametrize("music_type", ["recording", "album"]) +def test_exact_title_precedes_derived_artist_signature(music_type): + """曲名确实包含艺名时,完整名称命中优先于去署名后的回退名称。""" + meta = MetaMusic(title="Lee Loves You", artists=["Lee"]) + shortened = MusicInfo(media_source="musicbrainz", media_id="short", title="Loves You", + music_type=music_type, artists=["Lee"]) + original = MusicInfo(media_source="musicbrainz", media_id="original", title="Lee Loves You", + music_type=music_type, artists=["Lee"]) + if music_type == "recording": + meta.album = shortened.album = "Example Collection" + meta.year = shortened.year = 2001 + if music_type == "album": + assert MusicBrainzModule._select_album_candidate(meta, [shortened, original]) is original + else: + assert MusicBrainzModule._select_candidate(meta, [shortened, original], "musicbrainz") is original + + +@pytest.mark.parametrize("music_type", ["recording", "album"]) +def test_rejected_exact_title_does_not_hide_valid_signature_fallback(music_type): + """完整名称优先不能放过署名不符的候选,仍应采用有效的去署名回退。""" + meta = MetaMusic(title="Lee Loves You", artists=["Lee"]) + invalid = MusicInfo(media_source="musicbrainz", media_id="wrong", music_type=music_type, + title=meta.title, artists=["Other Artist"]) + valid = MusicInfo(media_source="musicbrainz", media_id="right", music_type=music_type, + title="Loves You", artists=["Lee"]) + if music_type == "album": + assert MusicBrainzModule._select_album_candidate(meta, [invalid, valid]) is valid + else: + assert MusicBrainzModule._select_candidate(meta, [invalid, valid], "musicbrainz") is valid + + def test_select_album_candidate_matches_lead_token_structure(): """条目「主体名 补充说明」结构与资源主体名首段一致时应弱匹配命中。""" meta = MetaMusic(title="许茹芸的爱情电影主题曲", artists=["许茹芸"], year=2003)