From ae393456bdd44304dc51f648d26b50e9110987a5 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 11 Aug 2026 10:24:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(music):=20=E7=BC=A9=E5=86=99=E7=82=B9?= =?UTF-8?q?=E5=8F=B7=E8=BF=98=E5=8E=9F=E4=B8=8E=E5=8C=85=E5=90=AB/?= =?UTF-8?q?=E6=BC=94=E5=87=BA=E5=90=8E=E7=BC=80=E5=BC=B1=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=B1=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - metamusic: 连续单字母空格序列还原为点号缩写(S H E -> S.H.E), 在场景点分之后执行避免点号再被压平 - metamusic: 规格段残留判定收紧,发布组短词仅在格式词无空格连字符 形态(FLAC-HHWEB)放行,修复自然语言曲名(Ashielf Alpen)被误剥 - musicbrainz: 专辑候选新增包含关系弱匹配(原声带条目带额外前缀) 与演出后缀弱匹配(S.H.E十七音乐会 -> 条目「十七」) - 补充 metamusic 缩写还原与 musicbrainz 弱匹配测试案例 --- app/core/meta/metamusic.py | 39 +++++++++++++++++++++++---- app/modules/musicbrainz/__init__.py | 29 ++++++++++++++++++++ tests/test_metamusic.py | 14 ++++++++-- tests/test_musicbrainz_module.py | 41 +++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 7 deletions(-) diff --git a/app/core/meta/metamusic.py b/app/core/meta/metamusic.py index 5b5e0690f..92ae2e18f 100644 --- a/app/core/meta/metamusic.py +++ b/app/core/meta/metamusic.py @@ -208,14 +208,14 @@ _MUSIC_QUALITY_TOKEN_RE = re.compile( # 演唱会/音乐视频种子的视频编码标记:分辨率、编码、容器与声道描述, # 不是音乐文本信息,不剔除会污染曲名并阻断艺术家/曲名拆分 _MUSIC_VIDEO_TOKEN_RE = re.compile(rf"\b(?:{_MUSIC_VIDEO_TOKEN_ALT})\b", re.IGNORECASE) -# 尾部规格段判定:整段仅由格式词、视频标记、位深采样、无损声明词与发布组标签组成, -# 曲名含任何自然语言文本时判定失败,保证「曲名 (注释) - WEB-DL」不被误剥 +# 尾部规格段判定:整段仅由格式词、视频标记、位深采样与无损声明词组成, +# 曲名含任何自然语言文本时判定失败,保证「曲名 (注释) - WEB-DL」不被误剥; +# 发布组标签(HHWEB/FHDMv)不是固定词表,由 _strip_spec_segments 的短词规则另行放行 _MUSIC_SPEC_SEGMENT_RE = re.compile( rf"^(?:\b(?:{_MUSIC_FORMAT_TOKEN_ALT}|{_MUSIC_VIDEO_TOKEN_ALT}|single|ep|album)\b" r"|\d{1,3}\s*-?\s*bits?|\d{2,4}(?:(?:[..]|\s)\d)?\s*k(?:hz|bps?)" r"|lossless|无损音质|无损|分轨|整轨|合集|精选" - r"|[\s\-–—−-/+]+(?![\s\-–—−-/+])" - r"|(?:(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{3,})" + r"|[\s\-–—−-/+]" r")+$", re.IGNORECASE, ) @@ -364,6 +364,18 @@ def _normalize_scene_dots(value: str) -> str: return _SCENE_DOT_RE.sub(" ", value) +# 连续单字母空格序列是缩写点号被全角归一/场景点分压平的结果(S.H.E -> S H E), +# 还原为点号缩写(S.H.E)才能与 MusicBrainz 条目署名比对 +_LETTER_RUN_RE = re.compile(r"(? str: + """把连续单字母空格序列还原为点号缩写(S H E -> S.H.E)。""" + return _LETTER_RUN_RE.sub( + lambda m: ".".join(m.group(1).split(" ")), str(value or "") + ) + + class MetaMusic(MetaBase): """音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。""" @@ -598,6 +610,8 @@ class MetaMusic(MetaBase): text = _to_halfwidth(str(value or "")).replace("_", " ") # 场景命名用点号分隔单词(Shan.Ge.Liao.Zai.2023),归一为空格便于拆分检索 text = _normalize_scene_dots(text) + # 缩写点号被全角归一/场景点分压平成单字母空格序列,在点分之后还原(S H E -> S.H.E) + text = _restore_letter_abbrev(text) return _MUSIC_SPACES_RE.sub(" ", text).strip() @classmethod @@ -645,7 +659,7 @@ class MetaMusic(MetaBase): # 加号是 APE+CUE 类格式联合写法的分隔符,占位为空格后再判定 probe = _MUSIC_VIDEO_TOKEN_RE.sub(" ", _MUSIC_QUALITY_TOKEN_RE.sub(" ", probe)) residue = probe.replace("+", " ").strip() - if residue and not _MUSIC_SPEC_SEGMENT_RE.fullmatch(residue): + if residue and not cls._is_spec_residue(residue, bool(prefix)): return text # 不含任何规格词的纯标签段,仅在紧跟格式词的无空格形态下才是发布组标签; # 「艺术家 - 单词曲名」的曲名段不含规格词,必须保留 @@ -653,6 +667,21 @@ class MetaMusic(MetaBase): return text text = text[: segment_match.start()].rstrip() + @classmethod + def _is_spec_residue(cls, residue: str, has_prefix: bool) -> bool: + """判定规格词替换后的残留是否为发布组标签而非自然语言曲名。 + + 发布组标签只出现在格式词无空格连字符形态(FLAC-HHWEB、AAC-FHDMv), + 此时短字母数字组合可放行;空白连字符分隔的段(艺术家 - 曲名 FLAC) + 残留可能是自然语言曲名,仅严格规格词表可过。 + """ + if _MUSIC_SPEC_SEGMENT_RE.fullmatch(residue): + return True + tokens = residue.split() + return has_prefix and bool(tokens) and all( + len(token) <= 8 and re.fullmatch(r"[A-Za-z0-9]+", token) for token in tokens + ) + @classmethod def _strip_quality_tokens(cls, value: str) -> str: """剥离音频格式、视频编码、规格参数与年份括号,保留有效的艺术家与曲名文本。""" diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index a10fa043c..836ddf016 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -192,6 +192,15 @@ class MusicBrainzModule(_ModuleBase): text = str(value or "").strip() return cls._normalize_text(text.split(" ", 1)[0]) if text else "" + # 演唱会资源的标题常带演出后缀(S.H.E十七音乐会),条目仅保留演出名本体 + _PERFORMANCE_SUFFIX_RE = re.compile( + r"\s*(?:音乐会|音樂會|演唱会|演唱會|巡回|巡演|Live|Tour)$", re.IGNORECASE) + + @classmethod + def _performance_title(cls, value: Optional[str]) -> str: + """剔除标题尾部的演出形态后缀,返回演出名本体。""" + return cls._normalize_text(cls._PERFORMANCE_SUFFIX_RE.sub("", str(value or ""))) + @classmethod def _strip_artist_prefix(cls, title: Optional[str], artists: Optional[list[str]]) -> str: """剥离曲名开头的艺术家署名前缀(「许茹芸的爱情电影主题曲」)。 @@ -635,6 +644,16 @@ class MusicBrainzModule(_ModuleBase): len(cls._match_text(bare_title)) >= 3 and cls._same_text(bare_title, cls._lead_token(candidate.title)) ) + # 条目带额外前缀/后缀完整包含资源主体名(好莱坞原声带类),长文本包含视为弱匹配 + or ( + len(cls._match_text(bare_title)) >= 6 + and cls._match_text(bare_title) in cls._match_text(candidate.title) + ) + # 资源标题带演出后缀(S.H.E十七音乐会),条目本体一致视为弱匹配 + or ( + cls._performance_title(bare_title) + and cls._same_text(cls._performance_title(bare_title), candidate.title) + ) ) ): score += 2 @@ -701,6 +720,16 @@ class MusicBrainzModule(_ModuleBase): len(cls._match_text(bare_title)) >= 3 and cls._same_text(bare_title, cls._lead_token(album_title)) ) + # 条目带额外前缀/后缀完整包含资源主体名(好莱坞原声带类),长文本包含视为弱匹配 + or ( + len(cls._match_text(bare_title)) >= 6 + and cls._match_text(bare_title) in cls._match_text(album_title) + ) + # 资源标题带演出后缀(S.H.E十七音乐会),条目本体一致视为弱匹配 + or ( + cls._performance_title(bare_title) + and cls._same_text(cls._performance_title(bare_title), album_title) + ) ) ): # 「我爱夜 (新歌+精选)」对位条目「我爱夜」这类注释差异 diff --git a/tests/test_metamusic.py b/tests/test_metamusic.py index 0a6276110..4d2dcc9ce 100644 --- a/tests/test_metamusic.py +++ b/tests/test_metamusic.py @@ -164,12 +164,22 @@ def test_apply_title_strips_video_tokens(): """演唱会视频种子的分辨率与编码标记不应进入曲名。""" meta = parse_title("S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv") - assert meta.artists == ["S H E"] - assert meta.title == "S H E十七音乐会" + # 连续单字母空格序列是缩写点号被压平的结果,还原为 S.H.E 才能与条目署名比对 + assert meta.artists == ["S.H.E"] + assert meta.title == "S.H.E十七音乐会" # 尾部年份提取为发行年份线索 assert meta.year == 2018 +def test_apply_title_restores_letter_abbrev(): + """单字母点号缩写还原不误伤合法缩写与单词。""" + meta = parse_title("E.S.Posthumus - Ashielf Alpen FLAC") + + # 点分少于 3 处的合法缩写不被场景点分/还原逻辑破坏 + assert meta.artists == ["E.S.Posthumus"] + assert meta.title == "Ashielf Alpen" + + def test_apply_title_strips_release_group_tag(): """格式标记后的发布组标签(大小写混合)应整体剔除。""" meta = parse_title("某某乐队 - 星空 FLAC-FHDMv") diff --git a/tests/test_musicbrainz_module.py b/tests/test_musicbrainz_module.py index 98cbfc801..227492655 100644 --- a/tests/test_musicbrainz_module.py +++ b/tests/test_musicbrainz_module.py @@ -661,6 +661,47 @@ def test_select_album_candidate_matches_lead_token_structure(): 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_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_select_candidate_rejects_wrong_artist_same_title(): """已知艺术家时,同名异曲的候选不能因标题相等被采信。""" meta = MetaMusic(title="因为有你", artists=["毛阿敏"])