fix(music): 尾部规格段剥离与书名号结构归因修复

- 新增 _strip_spec_segments 尾部纯规格段整段剥离,避免规格后缀把
  艺术家/曲名拆分点推到连字符上(许茹芸 - 等得到 ... ALAC-HHWEB)
- 段定位限定段内禁连字符,匹配落在最尾片段;发布组标签仅在
  紧跟格式词的无空格形态或段内含规格词时剥离,保护单词曲名
- APE+CUE 联合写法的加号纳入规格段判定与悬空分隔符修剪
- 含书名号的括号注释(电影《xxx》主题曲)在结构解析前提取并拼回曲名,
  单层括号消歧后缀(电影版)保留
- 书名号分支支持「曲名-歌手」艺术家段反向拆分与尾部碟号/年份提取
- MusicBrainz 候选弱匹配扩展冒号副标题与首段连字符,检索式剥离尾部年份
- 补充规格段/书名号/VA/场景点分等真实种子测试案例
This commit is contained in:
jxxghp
2026-08-11 08:57:43 +08:00
parent ef73c79078
commit 779e10c6ae
4 changed files with 228 additions and 29 deletions

View File

@@ -183,32 +183,52 @@ def _optional_int(value: Any) -> Optional[int]:
# 资源标题中的音质规格与发行标记(格式、位深采样、年份括号、发行实体标记),
# 拆分艺术家/曲名前需先剔除,否则「曲名 - FLAC [16B-44.1kHz]」会被误拆成艺术家与曲名
_MUSIC_FORMAT_TOKEN_ALT = (
r"DSD(?:64|128|256|512)?|DSF|DFF|FLAC|ALAC|APE|CUE|WAV|WAVE|AIFF?|PCM|"
r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA|WEB-?DL|WEBRip|WEB"
)
_MUSIC_VIDEO_TOKEN_ALT = (
r"1080[pi]|720p|2160[pi]|480[pi]|4k|8k|uhd|"
r"bluray|blu-ray|bdrip|uhd\s*bd|hddvd|hdvd|hdtv|webrip|remux|"
r"avc|hevc|x26[45]|h\.?26[45]|mpeg-?2|vc-?1|prores|av1|"
r"dts(?:-hd\s*(?:ma|hra)?)?(?:\s*[257]\.1)?|"
r"truehd|atmos|ddp?(?:\+[\w.]*)?|eac3|ac3|lpcm|flac\s*[257]\.1|"
r"[257]\.1(?:\s*ch(?:annels?)?)?|stereo|mono|"
r"hdr10\+?|dovi|dolby\s*vision|sub(?:title)?s?|chs&cht"
)
_MUSIC_QUALITY_TOKEN_RE = re.compile(
r"\[[^\]]*\]|\((?:19|20)\d{2}\)|"
r"\b(?:DSD(?:64|128|256|512)?|DSF|DFF|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|"
r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA|WEB-?DL|WEBRip|WEB)\b|"
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|"
# 无损声明词与流媒体发行实体标记(- Single / - EP不是曲名的一部分
r"\b(?:single|ep|album)\b|(?<![A-Za-z0-9])(?:lossless|无损音质|无损)(?![A-Za-z0-9])",
# 无损声明词、 ripping 方式与流媒体发行实体标记(- Single / - EP不是曲名的一部分
r"\b(?:single|ep|album)\b|(?<![A-Za-z0-9])(?:lossless|无损音质|无损|分轨|整轨)(?![A-Za-z0-9])",
re.IGNORECASE,
)
# 演唱会/音乐视频种子的视频编码标记:分辨率、编码、容器与声道描述,
# 不是音乐文本信息,不剔除会污染曲名并阻断艺术家/曲名拆分
_MUSIC_VIDEO_TOKEN_RE = re.compile(
r"\b(?:1080[pi]|720p|2160[pi]|480[pi]|4k|8k|uhd)\b|"
r"\b(?:bluray|blu-ray|bdrip|uhd\s*bd|hddvd|hdvd|hdtv|webrip|remux|"
r"avc|hevc|x26[45]|h\.?26[45]|mpeg-?2|vc-?1|prores|av1)\b|"
r"\b(?:dts(?:-hd\s*(?:ma|hra)?)?(?:\s*[257]\.1)?|"
r"truehd|atmos|ddp?(?:\+[\w.]*)?|eac3|ac3|lpcm|flac\s*[257]\.1|"
r"[257]\.1(?:\s*ch(?:annels?)?)?|stereo|mono)\b|"
r"\b(?:hdr10\+?|dovi|dolby\s*vision|sub(?:title)?s?|chs&cht)\b",
_MUSIC_VIDEO_TOKEN_RE = re.compile(rf"\b(?:{_MUSIC_VIDEO_TOKEN_ALT})\b", re.IGNORECASE)
# 尾部规格段判定:整段仅由格式词、视频标记、位深采样、无损声明词与发布组标签组成,
# 曲名含任何自然语言文本时判定失败,保证「曲名 (注释) - 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"|[\s\-–—−-/+]+(?![\s\-–—−-/+])"
r"|(?:(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{3,})"
r")+$",
re.IGNORECASE,
)
# 尾部规格段定位:最后一个「空白+连字符」或「格式词连字符」分隔的片段;
# 片段内不允许连字符,保证匹配落在最尾部片段而不是从左吞掉整个尾巴
_MUSIC_TRAILING_SEGMENT_RE = re.compile(
r"(?:\s+[\-–—−-]+\s*|(?P<prefix>[A-Za-z0-9]+)[\-–—−-]+)"
r"(?P<segment>[^\s\-–—−-]+(?:\s+[^\s\-–—−-]+)*)\s*$"
)
# 年份括号:(2000)2000【2000】形式的发行年份作为候选消歧线索
_MUSIC_YEAR_RE = re.compile(r"[\((【]((?:19|20)\d{2})[\))】]")
# 标题尾部独立年份「xxx音乐会 2018」「Funky Jazz Saxophone 2024」
# 标题尾部独立年份「xxx音乐会 2018」「Funky Jazz Saxophone 2024」「系列-2007」
# 提取为发行年份线索并从曲名剥离,避免年份文本进入检索式造成零命中
_MUSIC_TRAILING_YEAR_RE = re.compile(r"(?<!\d)\s+((?:19|20)\d{2})\s*$")
_MUSIC_TRAILING_YEAR_RE = re.compile(r"(?<!\d)[\s\-–—]+((?:19|20)\d{2})\s*$")
# 无括号年份区间:全集/精选标题尾部的 1967-1995、2015-16取结束年作为发行年份线索
# CJK 字符属于 \w不能用 \b 定界,改用数字负向断言;
# 短年右侧禁止再跟数字,避免把 2024-01-27 这类日期的 2024-01 误当区间;
@@ -240,6 +260,15 @@ _MUSIC_ALIAS_PREFIX_RE = re.compile(
_MUSIC_ARTIST_TITLE_RE = re.compile(
r"^\s*(?P<artist>.+?)\s+[\-–—−-]+\s+(?P<title>.+?)\s*$"
)
# 曲名后含书名号的括号注释(影视原声说明等):「等得到 (电影《如影随心》主题曲 独唱版)」,
# 注释内的《》会抢先触发专辑书名号判定,需在结构解析前提取;
# 单层括号注释(电影版/Live是 MusicBrainz 条目的消歧后缀,不提取
_MUSIC_TITLE_COMMENT_RE = re.compile(r"\s*[\(](?P<comment>[^)]*[《》][^)]*)[\)]\s*$")
# CJK「歌手《专辑名》」书名号命名提取艺术家与专辑专辑内尾部 -CD2 为碟号
_MUSIC_ALBUM_MARKER_RE = re.compile(
r"^\s*(?P<artist>[^《》]+?)\s*《(?P<album>[^《》]+)》\s*(?P<rest>.*)$"
)
_MUSIC_ALBUM_DISC_RE = re.compile(r"[\s\-–—−-]*(?:cd|disc)\s*(\d{1,2})$", re.IGNORECASE)
# 曲名尾部重复的艺术家署名(如「名人名曲-毛阿敏」「xxx - 许茹芸」)
_MUSIC_ARTIST_SUFFIX_RE = re.compile(r"[\-–—−-]\s*(?P<suffix>[^\-–—−-]+?)\s*$")
# 艺术家段尾部的合集修饰词:「邓丽君作品全集」需剥离为「邓丽君」才能命中条目署名
@@ -280,7 +309,7 @@ _FULLWIDTH_MAP = {
0x300C: "[", 0x300D: "]", # 「」
0x300E: "[", 0x300F: "]", # 『』
0x3010: "[", 0x3011: "]", # 【】
0x300A: "[", 0x300B: "]", # 《》
# 《》多为专辑书名号(歌手《专辑名》),不属于格式注释,保留原样由专辑结构规则处理
0xFF08: "(", 0xFF09: ")", #
0xFF3B: "[", 0xFF3D: "]", #
0xFF5B: "{", 0xFF5D: "}", #
@@ -454,12 +483,58 @@ class MetaMusic(MetaBase):
if years and not self.year:
# 多个年份括号时取最后一个,资源标题中年份通常位于末位
self.year = int(years[-1])
# 音质标记先剔除再拆分,避免规格文本独占曲名位置
cleaned = self._strip_quality_tokens(normalized)
# 音质标记先剔除再拆分,避免规格文本独占曲名位置
# 尾部纯规格段(含发布组标签)先整段剔除,保护「曲名 (注释)」不被拆散
cleaned = self._strip_spec_segments(normalized)
cleaned = self._strip_quality_tokens(cleaned)
# 广播/发行日期前缀与年份区间在规格剔除后处理,避免误伤曲名中的短数字序列
cleaned, range_year = self._strip_date_prefix(cleaned)
if range_year and not self.year:
self.year = range_year
# 曲名尾部括号注释先提取:注释内嵌套《》会干扰专辑书名号判定;
# 注释是曲名的版本说明,最终拼回曲名供展示与弱匹配
comment = None
comment_match = _MUSIC_TITLE_COMMENT_RE.search(cleaned)
if comment_match:
comment = comment_match.group("comment").strip()
cleaned = cleaned[: comment_match.start()].strip()
# CJK「歌手《专辑名》」书名号命名优先于连字符拆分提取艺术家与专辑实体
marker = None if self.artists else _MUSIC_ALBUM_MARKER_RE.match(cleaned)
if marker:
# 书名号前的艺术家段可能是「曲名-歌手」无空格连字符写法(为你盛开-许巍),
# 反向拆分后首段是曲名线索,比专辑名更接近单曲检索目标
song_hint: Optional[str] = None
hyphen_artists, head_title = self._split_cjk_hyphen(marker.group("artist"))
if hyphen_artists:
self.artists = hyphen_artists
song_hint = self._clean_tail(head_title)
else:
self.artists = self._split_artists(marker.group("artist"))
album = self._normalize_text(marker.group("album"))
disc_match = _MUSIC_ALBUM_DISC_RE.search(album)
if disc_match:
# 专辑名尾部 -CD2 是碟号线索不是专辑名内容
self.disc_number = self.disc_number or int(disc_match.group(1))
album = album[: disc_match.start()].strip()
self.album = album
rest = marker.group("rest").strip(" \t-_《》.")
# 书名号后仅剩年份时作为发行年份线索
if rest and re.fullmatch(r"(19|20)\d{2}", rest):
if not self.year:
self.year = int(rest)
rest = ""
# 书名号后仅剩碟号CD2/Disc1时提取为碟号线索
rest_disc = re.fullmatch(r"(?:cd|disc|disk)\s*(\d{1,2})", rest, re.IGNORECASE) if rest else None
if rest_disc:
self.disc_number = self.disc_number or int(rest_disc.group(1))
rest = ""
# 曲名回退依次用剩余文本、曲名线索与专辑名供检索
self._finalize_title(rest or song_hint or album)
if comment:
# 书名号结构下注释属于专辑后的补充说明,拼回曲名
self.title = f"{self.title} ({comment})" if self.title else comment
self._apply_track_prefix()
return
match = _MUSIC_ARTIST_TITLE_RE.match(cleaned)
if match and not self.artists:
self.artists = self._split_artists(match.group("artist"))
@@ -484,6 +559,9 @@ class MetaMusic(MetaBase):
self._finalize_title(self._strip_cjk_artist_suffix(title))
else:
self.title = None
# 括号注释属于曲名的版本/出处说明,剥离的规格文本之后拼回展示
if comment and self.title:
self.title = f"{self.title} ({comment})"
self._apply_track_prefix()
@classmethod
@@ -530,25 +608,50 @@ class MetaMusic(MetaBase):
@staticmethod
def _clean_tail(value: str) -> str:
"""修剪曲名尾部残留:流媒体文件名消毒产生的下划线与悬空分隔符。"""
return re.sub(r"[\s_\-–—−-/]+$", "", str(value or "")).strip()
return re.sub(r"[\s_\-–—−-/.+]+$", "", str(value or "")).strip()
@classmethod
def _strip_spec_segments(cls, value: str) -> str:
"""从尾部反复剥离纯规格段(- WEB-DL - 16bit ALAC-HHWEB
规格段在艺术家/曲名拆分前整段剔除,避免规格后缀把主拆分位置推到
「艺术家 - 曲名」的连字符上;曲名含自然语言文本的段永远不会被误剥。
"""
text = str(value or "").strip()
while True:
segment_match = _MUSIC_TRAILING_SEGMENT_RE.search(text)
if not segment_match:
return text
prefix = segment_match.group("prefix")
segment = segment_match.group("segment")
# 无空格连字符段FLAC-HHWEB只有紧跟格式词时才是发布组标签
# 否则是「曲名-歌手」类连字符命名,交由后续拆分规则处理
if prefix and not re.fullmatch(rf"{_MUSIC_FORMAT_TOKEN_ALT}", prefix, re.IGNORECASE):
return text
# 年份括号是发行线索不是规格词,占位后再判定,避免「曲名 (2000)」被误当规格段
probe = re.sub(r"\((?:19|20)\d{2}\)", " ", segment)
has_spec_token = bool(
_MUSIC_QUALITY_TOKEN_RE.search(probe) or _MUSIC_VIDEO_TOKEN_RE.search(probe)
)
# 加号是 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):
return text
# 不含任何规格词的纯标签段,仅在紧跟格式词的无空格形态下才是发布组标签;
# 「艺术家 - 单词曲名」的曲名段不含规格词,必须保留
if not has_spec_token and not prefix:
return text
text = text[: segment_match.start()].rstrip()
@classmethod
def _strip_quality_tokens(cls, value: str) -> str:
"""剥离音频格式、视频编码、规格参数与年份括号,保留有效的艺术家与曲名文本。"""
raw = str(value or "")
has_quality_token = bool(_MUSIC_QUALITY_TOKEN_RE.search(raw) or _MUSIC_VIDEO_TOKEN_RE.search(raw))
text = _MUSIC_QUALITY_TOKEN_RE.sub(" ", raw)
text = _MUSIC_VIDEO_TOKEN_RE.sub(" ", text)
# 规格剥离后可能残留悬空分隔符,统一修剪
text = cls._normalize_text(re.sub(r"^[\s\-–—−-/]+|[\s\-–—−-/]+$", "", text))
if not has_quality_token:
return text
# 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB、AAC-FHDMv整体剔除避免污染曲名
# 仅在存在音质标记时剔除,否则「艺术家 - 单词曲名」会被误当标签吞掉;
# 要求标签至少含一个字母,避免误吞年份区间尾巴(-1995
return re.sub(
r"[-–—−-]\s*(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{3,}\s*$", "", text, flags=re.IGNORECASE
).strip()
# 规格剥离后可能残留悬空分隔符(含 APE+CUE 类格式联合写法残留的加号),统一修剪
return cls._normalize_text(re.sub(r"^[\s\-–—−-/+]+|[\s\-–—−-/+]+$", "", text))
@classmethod
def _strip_artist_suffix(cls, value: str, artists: list[str]) -> str:

View File

@@ -177,6 +177,12 @@ class MusicBrainzModule(_ModuleBase):
text = re.split(r"\s*[:]\s*", str(value or ""), maxsplit=1)[0]
return cls._normalize_text(text)
@classmethod
def _head_title(cls, value: Optional[str]) -> str:
"""提取候选「曲名-歌手」「曲名《巡演名》…」命名的首段曲名。"""
text = re.split(r"\s*[-–—−-::]\s*|\s*《", str(value or ""), maxsplit=1)[0]
return cls._normalize_text(text)
def _search_albums(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按标题和可选艺术家搜索 Release Group 专辑候选,检索式同样逐级放宽。"""
for query in self._album_queries(meta):
@@ -591,6 +597,8 @@ class MusicBrainzModule(_ModuleBase):
cls._same_text(bare_title, cls._strip_parenthetical(candidate.title))
# 条目「天國的情人:鄧麗君逝世十周年…」这类冒号副标题,主标题一致视为弱匹配
or cls._same_text(bare_title, cls._main_title(candidate.title))
# 条目「为你盛开-许巍《无尽光芒》…」这类连字符前置命名,首段曲名一致视为弱匹配
or cls._same_text(bare_title, cls._head_title(candidate.title))
)
):
score += 2
@@ -645,6 +653,8 @@ class MusicBrainzModule(_ModuleBase):
cls._same_text(bare_title, cls._strip_parenthetical(album_title))
# 条目「天國的情人:鄧麗君逝世十周年…」这类冒号副标题,主标题一致视为弱匹配
or cls._same_text(bare_title, cls._main_title(album_title))
# 条目「为你盛开-许巍《无尽光芒》…」这类连字符前置命名,首段曲名一致视为弱匹配
or cls._same_text(bare_title, cls._head_title(album_title))
)
):
# 「我爱夜 (新歌+精选)」对位条目「我爱夜」这类注释差异

View File

@@ -274,6 +274,35 @@ def test_apply_title_keeps_single_word_title():
assert meta.title == "Maraboot"
def test_apply_title_keeps_title_with_quality_tokens():
"""存在音质标记时,空格连字符后的单词曲名也不应被当发布组标签剔除。"""
meta = parse_title("Yes - Aurora [Bonus Tracks Edition, 24-bit Hi-Res] (2026) [FLAC]")
assert meta.artists == ["Yes"]
assert meta.title == "Aurora"
assert meta.year == 2026
def test_apply_title_album_marker():
"""「歌手《专辑名》」书名号命名应提取艺术家、专辑与碟号。"""
meta = parse_title("李宗盛《理性与感性作品音乐会-CD2》2006-FLAC-分轨")
assert meta.artists == ["李宗盛"]
assert meta.album == "理性与感性作品音乐会"
assert meta.title == "理性与感性作品音乐会"
assert meta.disc_number == 2
assert meta.year == 2006
def test_apply_title_strips_cue_and_plus():
"""APE+CUE 类格式联合写法应剔除,残留加号不阻断标题提取。"""
meta = parse_title("世界著名古典大师名版收藏15RCA发烧古典系列-2007-FLAC-APE+CUE")
assert meta.artists == []
assert meta.title == "世界著名古典大师名版收藏(15)RCA发烧古典系列"
assert meta.year == 2007
def test_apply_title_cjk_hyphen_artist_suffix():
"""CJK「曲名-歌手」无空格连字符写法应反向拆分艺术家。"""
meta = parse_title("因为有你-毛阿敏")
@@ -321,3 +350,43 @@ def test_apply_title_bracket_date_prefix():
assert meta.artists == ["某歌手"]
assert meta.title == "某曲名"
def test_apply_title_spec_segments_do_not_shift_split():
"""尾部规格段WEB-DL/位深/发布组)不应把拆分点推到艺术家与曲名的连字符上。"""
meta = parse_title(
"许茹芸 - 等得到 (电影《如影随心》主题曲 独唱版) (2019) - WEB-DL - 24bit ALAC-HHWEB")
assert meta.artists == ["许茹芸"]
# 含书名号的括号注释是版本说明,应拼回曲名而不触发专辑结构
assert meta.title == "等得到 (电影《如影随心》主题曲 独唱版)"
assert meta.year == 2019
def test_apply_title_album_marker_with_song_artist_prefix():
"""书名号前的「曲名-歌手」连字符段应反向拆分,首段作为曲名线索。"""
meta = parse_title("为你盛开-许巍《无尽光芒》2019.FLAC")
assert meta.artists == ["许巍"]
assert meta.album == "无尽光芒"
assert meta.title == "为你盛开"
assert meta.year == 2019
def test_apply_title_album_marker_rest_disc_number():
"""书名号后仅剩碟号时应提取为碟号,曲名回退用专辑名。"""
meta = parse_title("李宗盛《理性与感性作品音乐会》 CD2 (2006)")
assert meta.artists == ["李宗盛"]
assert meta.album == "理性与感性作品音乐会"
assert meta.disc_number == 2
assert meta.title == "理性与感性作品音乐会"
assert meta.year == 2006
def test_apply_title_keeps_single_paren_disambiguation():
"""单层括号注释是条目消歧后缀,应保留在曲名中。"""
meta = parse_title("周杰伦 - 晴天 (电影版)")
assert meta.artists == ["周杰伦"]
assert meta.title == "晴天 (电影版)"

View File

@@ -674,6 +674,23 @@ def test_select_album_candidate_matches_colon_subtitle():
assert matched.media_id == "album-colon"
def test_select_album_candidate_matches_head_title():
"""条目「曲名-歌手《巡演名》」连字符前置命名应与资源曲名弱匹配命中。"""
meta = MetaMusic(title="为你盛开", artists=["许巍"])
album = MusicInfo(
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"