fix(music): 原声带电影名本体检索与合辑前缀拆分修复零命中

- musicbrainz: 原声带描述词(Original Motion Picture Soundtrack)剔除后
  用电影名本体补充检索阶梯,候选比对支持本体与主标题弱匹配;
  后缀定界防止 Ghost 类词内 ost 误剥,本体过短时不作独立检索目标
- musicbrainz: 尾部年份反复剥离,修复场景命名重复携带年份
  (Live At Montreux 2011 2011)阻断精确匹配
- metamusic: Various Artists-Title 全拼合辑前缀拆分,未收录别名变体
  统一归一为 Various Artists
- 补充原声带/重复年份/合辑前缀测试案例
This commit is contained in:
jxxghp
2026-08-11 11:05:36 +08:00
parent 44487f4343
commit 9ab326e056
4 changed files with 106 additions and 6 deletions

View File

@@ -251,10 +251,10 @@ _MUSIC_DATE_PREFIX_RE = re.compile(
_MUSIC_ARTIST_SEPARATOR_RE = re.compile(r"\s*(?:&|,||、|/)\s*")
# 合辑资源的 Various Artists 署名别名VA 是场景命名常用缩写,
# 归一为 MusicBrainz 规范署名 Various Artists 才能命中合辑条目
_MUSIC_ARTIST_ALIASES = {"va": "Various Artists"}
# VA-Title 无空格连字符前缀写法(场景命名),主拆分不适用需单独处理
_MUSIC_ARTIST_ALIASES = {"va": "Various Artists", "various artists": "Various Artists"}
# VA-Title / Various Artists-Title 无空格连字符前缀写法(场景命名),主拆分不适用需单独处理
_MUSIC_ALIAS_PREFIX_RE = re.compile(
r"^\s*(?P<alias>VA)\s*[-–—−-]\s*(?P<title>.+\S)\s*$",
r"^\s*(?P<alias>VA|Various\.?\s*Artists)\s*[-–—−-]\s*(?P<title>.+\S)\s*$",
re.IGNORECASE,
)
# 艺术家与曲名的主分隔:半角/全角空格包裹的连字符(-
@@ -566,7 +566,9 @@ class MetaMusic(MetaBase):
# 场景命名的 VA-Title 无空格连字符写法,主拆分不适用,按别名前缀单独拆分
alias_match = None if self.artists else _MUSIC_ALIAS_PREFIX_RE.match(cleaned)
if alias_match:
self.artists = [_MUSIC_ARTIST_ALIASES[alias_match.group("alias").casefold()]]
# 别名前缀均为合辑署名写法,未收录变体统一归一为 Various Artists
self.artists = [_MUSIC_ARTIST_ALIASES.get(
alias_match.group("alias").casefold(), "Various Artists")]
self._finalize_title(self._clean_tail(alias_match.group("title")))
else:
# CJK 标题常见「专辑名-歌手」无空格连字符写法,主拆分未命中时兑底反向拆分

View File

@@ -200,6 +200,23 @@ class MusicBrainzModule(_ModuleBase):
"""剔除标题尾部的卷号后缀,返回专辑本体名。"""
return cls._normalize_text(cls._VOLUME_SUFFIX_RE.sub("", str(value or "")))
# 原声带资源标题的通用描述词尾部(条目本体是电影名);
# 尾部描述词保留时作为原声带形态标记参与弱匹配判定
_SOUNDTRACK_SUFFIX_RE = re.compile(
r"\s*(?:original\s+motion\s+picture|motion\s+picture)?\s*"
r"(?<![A-Za-z0-9])(?:original\s+)?(?:soundtrack|score|ost)$",
re.IGNORECASE,
)
@classmethod
def _soundtrack_body(cls, value: Optional[str]) -> str:
"""剔除原声带标题尾部的通用描述词,返回电影名本体;无描述词返回空串。"""
text = str(value or "").strip()
body = cls._SOUNDTRACK_SUFFIX_RE.sub("", text)
if not body or body == text:
return ""
return cls._normalize_text(body)
# 演唱会资源的标题常带演出后缀S.H.E十七音乐会条目仅保留演出名本体
_PERFORMANCE_SUFFIX_RE = re.compile(
r"\s*(?:音乐会|音樂會|演唱会|演唱會|巡回|巡演|Live|Tour)$", re.IGNORECASE)
@@ -261,6 +278,12 @@ class MusicBrainzModule(_ModuleBase):
# 专辑名开头的艺术家署名前缀同样是命名习惯,用主体名检索
title = cls._strip_artist_prefix(title, meta.artists)
bare_title = cls._strip_artist_prefix(bare_title, meta.artists)
# 原声带标题的通用描述词Original Motion Picture Soundtrack在条目中常省略
# 用电影名本体补充一级检索The Hateful Eight / Pulp Fiction
# 本体过短The Score 类)时不作为独立检索目标避免噪声
soundtrack_body = cls._soundtrack_body(bare_title)
if len(cls._match_text(soundtrack_body)) < 4:
soundtrack_body = ""
queries: list[str] = []
for query in [
f'releasegroup:{cls._query_phrase(title)} AND artist:"{cls._escape_query(artist)}"'
@@ -268,6 +291,9 @@ class MusicBrainzModule(_ModuleBase):
f"releasegroup:{cls._query_phrase(title)}" if title else None,
f'releasegroup:{cls._query_phrase(bare_title)} AND artist:"{cls._escape_query(artist)}"'
if artist and bare_title and bare_title != title else None,
f'releasegroup:{cls._query_phrase(soundtrack_body)} AND artist:"{cls._escape_query(artist)}"'
if artist and soundtrack_body else None,
f"releasegroup:{cls._query_phrase(soundtrack_body)}" if soundtrack_body else None,
# 署名变体兜底:仅按去注释专辑名检索,挑选阶段要求艺术家同时命中
f"releasegroup:{cls._query_phrase(bare_title)}" if bare_title else None,
]:
@@ -739,6 +765,15 @@ class MusicBrainzModule(_ModuleBase):
cls._performance_title(bare_title)
and cls._same_text(cls._performance_title(bare_title), album_title)
)
# 原声带资源的描述词在条目中常省略Pulp Fiction: Music From the…
# 电影名本体与条目标题或主标题一致视为弱匹配
or (
len(cls._match_text(cls._soundtrack_body(bare_title))) >= 4
and (
cls._same_text(cls._soundtrack_body(bare_title), album_title)
or cls._same_text(cls._soundtrack_body(bare_title), cls._main_title(album_title))
)
)
)
):
# 「我爱夜 (新歌+精选)」对位条目「我爱夜」这类注释差异
@@ -844,8 +879,14 @@ class MusicBrainzModule(_ModuleBase):
# 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB整体剔除
text = re.sub(r"[-–—]\s*[A-Z0-9]{3,}\s*$", "", text)
# 曲名尾部独立年份是发行线索不是曲名一部分(解析阶段通常已提取),
# 检索时剥离避免年份文本造成精确短语零命中
text = re.sub(r"(?<!\d)\s+(?:19|20)\d{2}$", "", cls._normalize_text(text))
# 反复剥离尾部年份场景命名可能重复携带Live At Montreux 2011 2011
text = cls._normalize_text(text)
while True:
# 仅剔除空白分隔的尾部年份纯年份标题1999无前导空白不受影响
stripped = re.sub(r"\s+(?:19|20)\d{2}$", "", text)
if stripped == text:
break
text = stripped
return cls._normalize_text(text)
def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:

View File

@@ -171,6 +171,14 @@ def test_apply_title_strips_video_tokens():
assert meta.year == 2018
def test_apply_title_splits_various_artists_prefix():
"""场景命名的 Various Artists-Title 无空格连字符前缀应拆分为合辑署名。"""
meta = parse_title("Various.Artists-Reply.1988.OST")
assert meta.artists == ["Various Artists"]
assert meta.title == "Reply 1988 OST"
def test_apply_title_restores_letter_abbrev():
"""单字母点号缩写还原不误伤合法缩写与单词。"""
meta = parse_title("E.S.Posthumus - Ashielf Alpen FLAC")

View File

@@ -720,6 +720,55 @@ def test_select_album_candidate_matches_contained_title():
assert matched.media_id == "album-1"
def test_soundtrack_body_extracts_movie_name():
"""原声带描述词尾部剔除返回电影名本体,非原声带标题与词内后缀不受影响。"""
body = MusicBrainzModule._soundtrack_body
assert body("Pulp Fiction Original Motion Picture Soundtrack") == "Pulp Fiction"
assert body("Reply 1988 OST") == "Reply 1988"
# Ghost 尾部的 ost 是单词组成部分,不能剔除
assert body("Ghost") == ""
assert body("晴天") == ""
def test_search_title_strips_repeated_trailing_year():
"""场景命名重复携带的尾部年份应全部剥离,避免年份文本阻断精确匹配。"""
assert MusicBrainzModule._search_title("Live At Montreux 2011 2011") == "Live At Montreux"
# 纯年份标题无前导空白不受影响
assert MusicBrainzModule._search_title("1999") == "1999"
def test_album_queries_include_soundtrack_body():
"""原声带专辑检索阶梯应包含电影名本体变体。"""
queries = MusicBrainzModule._album_queries(
MetaMusic(title="Pulp Fiction Original Motion Picture Soundtrack",
artists=["Various Artists"], year=1994)
)
assert 'releasegroup:"Pulp Fiction" AND artist:"Various Artists"' in queries
def test_select_album_candidate_matches_soundtrack_body():
"""条目以冒号副标题保留描述词时,电影名本体一致应弱匹配命中。"""
meta = MetaMusic(
title="Pulp Fiction Original Motion Picture Soundtrack",
artists=["Various Artists"],
year=1994,
)
album = MusicInfo(
source="musicbrainz",
music_type="album",
media_id="album-1",
title="Pulp Fiction: Music From the Motion Picture",
artists=["Various Artists"],
year=1994,
)
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=["毛阿敏"])