fix(music): 署名前缀剥离与候选首段弱匹配修复失配

- 曲名开头的艺术家署名前缀(许茹芸的爱情电影主题曲)是命名习惯,
  检索式与候选比对统一用剥离后的主体名,署名身份由艺术家要求保证
- 条目「主体名 补充说明」结构(愛情電影主題曲 雲且留住)首段一致视为弱匹配
- 采样率支持空格写法(44 1khz),合集/精选纳入发行形态标记剔除
- 规格剔除后仅剩悬空分隔符时仍拆出艺术家(周杰伦 - 合集 2000-2022)
- 修正合集修饰词正则中的杂空格
This commit is contained in:
jxxghp
2026-08-11 09:55:22 +08:00
parent 62019d3d23
commit 19937b210b
4 changed files with 98 additions and 8 deletions

View File

@@ -199,9 +199,10 @@ _MUSIC_VIDEO_TOKEN_ALT = (
_MUSIC_QUALITY_TOKEN_RE = re.compile(
r"\[[^\]]*\]|\((?:19|20)\d{2}\)|"
rf"\b(?:{_MUSIC_FORMAT_TOKEN_ALT})\b|"
r"\b\d{1,3}\s*-?\s*bits?\b|\b\d{2,4}(?:\.\d)?\s*k(?:hz|bps?)\b|"
# 无损声明词、 ripping 方式与流媒体发行实体标记(- Single / - EP不是曲名的一部分
r"\b(?:single|ep|album)\b|(?<![A-Za-z0-9])(?:lossless|无损音质|无损|分轨|整轨)(?![A-Za-z0-9])",
r"\b\d{1,3}\s*-?\s*bits?\b|\b\d{2,4}(?:(?:[.]|\s)\d)?\s*k(?:hz|bps?)\b|"
# 无损声明词、 ripping 方式与流媒体发行实体标记(- Single / - EP不是曲名的一部分
# 合集/精选是发行形态标记CJK 词用非字母数字定界(\b 对中文不可靠)
r"\b(?:single|ep|album)\b|(?<![A-Za-z0-9])(?:lossless|无损音质|无损|分轨|整轨|合集|精选)(?![A-Za-z0-9])",
re.IGNORECASE,
)
# 演唱会/音乐视频种子的视频编码标记:分辨率、编码、容器与声道描述,
@@ -211,8 +212,8 @@ _MUSIC_VIDEO_TOKEN_RE = re.compile(rf"\b(?:{_MUSIC_VIDEO_TOKEN_ALT})\b", re.IGNO
# 曲名含任何自然语言文本时判定失败,保证「曲名 (注释) - WEB-DL」不被误剥
_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}(?:\.\d)?\s*k(?:hz|bps?)"
r"|lossless|无损音质|无损|分轨|整轨"
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")+$",
@@ -498,6 +499,14 @@ class MetaMusic(MetaBase):
if comment_match:
comment = comment_match.group("comment").strip()
cleaned = cleaned[: comment_match.start()].strip()
# 规格剔除后曲名侧无剩余文本时,尾部悬空分隔符仍是艺术家署名结构
# (「周杰伦 - 合集 2000-2022 - FLAC 16bit 44 1khz」剔除后仅剩「周杰伦 -」)
dangling = re.fullmatch(r"(?P<artist>.+?)\s+[\-–—−-]+", cleaned) if cleaned else None
if dangling and not self.artists:
self.artists = self._split_artists(dangling.group("artist"))
self.title = None
self._apply_track_prefix()
return
# CJK「歌手《专辑名》」书名号命名优先于连字符拆分提取艺术家与专辑实体
marker = None if self.artists else _MUSIC_ALBUM_MARKER_RE.match(cleaned)
if marker:

View File

@@ -151,6 +151,9 @@ class MusicBrainzModule(_ModuleBase):
artist = meta.artists[0] if meta.artists else None
# 括号内的影视 tie-in、版本说明多为半角与条目全角写法不一致准备去注释曲名兜底
bare_title = cls._strip_parenthetical(title)
# 曲名开头的艺术家署名前缀是命名习惯不是曲名内容,用主体名检索
title = cls._strip_artist_prefix(title, meta.artists)
bare_title = cls._strip_artist_prefix(bare_title, meta.artists)
queries: list[str] = []
for query in [
cls._build_query(meta),
@@ -183,6 +186,31 @@ class MusicBrainzModule(_ModuleBase):
text = re.split(r"\s*[-–—−-::]\s*|\s*《", str(value or ""), maxsplit=1)[0]
return cls._normalize_text(text)
@classmethod
def _lead_token(cls, value: Optional[str]) -> str:
"""提取候选标题首个空白分隔段(「愛情電影主題曲 雲且留住」的主体名)。"""
text = str(value or "").strip()
return cls._normalize_text(text.split(" ", 1)[0]) if text else ""
@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 remainder:
return remainder
return text
def _search_albums(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按标题和可选艺术家搜索 Release Group 专辑候选,检索式同样逐级放宽。"""
for query in self._album_queries(meta):
@@ -214,6 +242,9 @@ class MusicBrainzModule(_ModuleBase):
bare_title = cls._strip_parenthetical(title)
bare_title = re.sub(r",?\s*vol\.?\s*\d+$", "", bare_title, flags=re.IGNORECASE)
bare_title = cls._normalize_text(bare_title)
# 专辑名开头的艺术家署名前缀同样是命名习惯,用主体名检索
title = cls._strip_artist_prefix(title, meta.artists)
bare_title = cls._strip_artist_prefix(bare_title, meta.artists)
queries: list[str] = []
for query in [
f'releasegroup:{cls._query_phrase(title)} AND artist:"{cls._escape_query(artist)}"'
@@ -569,8 +600,9 @@ class MusicBrainzModule(_ModuleBase):
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], source: str) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的搜索候选。"""
normalized_source = cls._normalize_text(source).casefold()
# 资源标题携带的音质标记先剥离,再与候选曲名比对
clean_title = cls._search_title(meta.title)
# 资源标题携带的音质标记先剥离,再与候选曲名比对
# 曲名开头的艺术家署名前缀是命名习惯,用主体名比对
clean_title = cls._strip_artist_prefix(cls._search_title(meta.title), meta.artists)
# 条目的影视 tie-in 注释多为全角括号,与资源半角注释无法精确相等,
# 去括号后的主体曲名一致视为弱匹配,且需艺术家同时命中才采信
bare_title = cls._strip_parenthetical(clean_title)
@@ -598,6 +630,11 @@ class MusicBrainzModule(_ModuleBase):
or cls._same_text(bare_title, cls._main_title(candidate.title))
# 条目「为你盛开-许巍《无尽光芒》…」这类连字符前置命名,首段曲名一致视为弱匹配
or cls._same_text(bare_title, cls._head_title(candidate.title))
# 条目「愛情電影主題曲 雲且留住」这类「主体名 补充说明」结构,首段一致视为弱匹配
or (
len(cls._match_text(bare_title)) >= 3
and cls._same_text(bare_title, cls._lead_token(candidate.title))
)
)
):
score += 2
@@ -632,7 +669,8 @@ class MusicBrainzModule(_ModuleBase):
专辑重名多,要求标题(含去括号弱匹配)与艺术家同时命中才返回,
避免把音轨身份安到错误专辑上。
"""
clean_title = cls._search_title(meta.album or meta.title)
clean_title = cls._strip_artist_prefix(
cls._search_title(meta.album or meta.title), meta.artists)
if not clean_title:
return None
bare_title = cls._strip_parenthetical(clean_title)
@@ -658,6 +696,11 @@ class MusicBrainzModule(_ModuleBase):
or cls._same_text(bare_title, cls._main_title(album_title))
# 条目「为你盛开-许巍《无尽光芒》…」这类连字符前置命名,首段曲名一致视为弱匹配
or cls._same_text(bare_title, cls._head_title(album_title))
# 条目「愛情電影主題曲 雲且留住」这类「主体名 补充说明」结构,首段一致视为弱匹配
or (
len(cls._match_text(bare_title)) >= 3
and cls._same_text(bare_title, cls._lead_token(album_title))
)
)
):
# 「我爱夜 (新歌+精选)」对位条目「我爱夜」这类注释差异

View File

@@ -390,3 +390,14 @@ def test_apply_title_keeps_single_paren_disambiguation():
assert meta.artists == ["周杰伦"]
assert meta.title == "晴天 (电影版)"
def test_apply_title_collection_with_space_sample_rate():
"""合集/精选是发行形态标记空格写法的采样率44 1khz也应剔除
剔除后仅剩悬空分隔符时艺术家仍需拆出。"""
meta = parse_title("周杰伦 - 合集 2000-2022 - FLAC 16bit 44 1khz")
assert meta.artists == ["周杰伦"]
assert meta.title is None
assert meta.year == 2022
assert meta.audio_format == "FLAC"

View File

@@ -634,6 +634,33 @@ def test_same_text_normalizes_cjk_numerals():
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_candidate_rejects_wrong_artist_same_title():
"""已知艺术家时,同名异曲的候选不能因标题相等被采信。"""
meta = MetaMusic(title="因为有你", artists=["毛阿敏"])