diff --git a/app/domain/music.py b/app/domain/music.py index 6dd061d0c..50e06d4b5 100644 --- a/app/domain/music.py +++ b/app/domain/music.py @@ -134,11 +134,23 @@ def music_base_title(value: Optional[str], *, preserve_editions: bool = False) - return _BARE_VERSION_SUFFIX.sub(strip_version, _VERSION_SUFFIX.sub(strip_version, text)).strip() +def _music_title_key(value: Optional[str], *, preserve_editions: bool = False) -> str: + """生成音乐标题比较键,纯符号名称在普通归一化为空时保留其符号身份。""" + base = music_base_title( + normalize("NFKC", str(value or "")), + preserve_editions=preserve_editions, + ) + key = music_text_key(base) + if key: + return key + return re.sub(r"\s+", "", base).casefold() + + def music_title_matches(music: MusicInfo, title: Optional[str], *, preserve_editions: bool = False) -> bool: - """统一全半角后比较完整名称及别名,允许数据源保持既有发行版本边界。""" - expected = music_text_key(music_base_title(normalize("NFKC", str(title or "")), preserve_editions=preserve_editions)) + """统一全半角后比较完整名称及别名,纯符号名称也保留其身份。""" + expected = _music_title_key(title, preserve_editions=preserve_editions) return bool(expected and any( - expected == music_text_key(music_base_title(normalize("NFKC", name), preserve_editions=preserve_editions)) + expected == _music_title_key(name, preserve_editions=preserve_editions) for name in music_titles(music) )) @@ -229,7 +241,7 @@ def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = Fal variants.append(parts[0]) for part in variants: name = music_base_title(_TITLE_LABEL.sub("", part.strip())) - key = music_text_key(name) + key = _music_title_key(name) if not key: continue names.append(key) @@ -323,7 +335,7 @@ def match_music_resource( else any(_contains_artist(content, artist) for artist in artists) if not title_matched: if music.music_type != MUSIC_ENTITY_ALBUM and artist_matched and any( - music_text_key(music_base_title(item)) in names + _music_title_key(item) in names for item in music_titles(music, album=True) ): return MusicMatch("album", "related_album") @@ -338,7 +350,7 @@ def match_music_resource( # 所属专辑只说明单曲归属,不能代替资源主标题证明整专范围。 primary_names = _resource_names(resource, artists) if resource.track_number or (resource.album and not any( - music_text_key(music_base_title(item)) in primary_names for item in titles + _music_title_key(item) in primary_names for item in titles )): return MusicMatch("candidate", "partial_album") if music.year and resource.year and str(music.year) != str(resource.year): diff --git a/tests/test_music_matching.py b/tests/test_music_matching.py index a00946875..5c4378cc3 100644 --- a/tests/test_music_matching.py +++ b/tests/test_music_matching.py @@ -156,6 +156,39 @@ def test_soundtrack_credit_does_not_change_recording_title(title): assert music_title_matches(MusicInfo(title=expected), title) +@pytest.mark.parametrize("symbol", ["÷", "+", "=", "×", "−"]) +def test_symbol_only_music_title_matches_itself_and_alias(symbol): + """纯符号作品名不能因文本归一化为空而失去自身和别名匹配。""" + music = MusicInfo(title=symbol, title_aliases=["Readable Alias"]) + + assert music_title_matches(music, symbol) + assert music_title_matches(music, "Readable Alias") + assert not music_title_matches(music, "Different Title") + + +@pytest.mark.parametrize("symbol,alias,year", [ + ("÷", "Divide", 2017), + ("+", "Plus", 2011), + ("=", "Equals", 2021), + ("×", "Multiply", 2017), + ("−", "Subtract", 2023), +]) +def test_symbol_only_album_matches_parsed_resource(symbol, alias, year): + """纯符号专辑经资源解析后仍应按原名或可信别名精确命中。""" + music = MusicInfo( + music_type="album", + title=symbol, + album=symbol, + title_aliases=[alias], + album_aliases=[alias], + artists=["Ed Sheeran"], + year=year, + ) + + resource = f"Ed Sheeran - {symbol} - {year} - FLAC 分轨" + assert match_music_resource(music, resource).status == "exact" + + def test_music_album_field_cannot_match_target_recording(): """结构化资源中的所属专辑不能冒充同名目标单曲。""" music = MusicInfo(title="Album", artists=["Artist"])