fix(music): CJK 标题适配 Lucene 分词的逐字 OR 检索

- MusicBrainz 索引将连续 CJK 视为单一词元,短语检索对中文标题永远零命中,
  新增 _query_phrase 将 CJK 文本拆为逐字 OR 检索式(OR 组带括号避免 AND 优先级歧义),
  拉丁文本保持短语检索
- 逐字 OR 召回宽,候选挑选收紧:标题未命中的候选不能仅凭艺术家署名得分
- 汉字数字与阿拉伯数字写法差异归一比对(茹此精彩十三首/茹此精彩13首)
- 补充逐字 OR 检索式、数字归一、艺术家独占候选拒绝等测试案例
This commit is contained in:
jxxghp
2026-08-11 09:28:40 +08:00
parent 779e10c6ae
commit 62019d3d23
2 changed files with 130 additions and 25 deletions

View File

@@ -154,12 +154,12 @@ class MusicBrainzModule(_ModuleBase):
queries: list[str] = []
for query in [
cls._build_query(meta),
f'recording:"{cls._escape_query(title)}"',
f'recording:"{cls._escape_query(bare_title)}" AND artist:"{cls._escape_query(artist)}"'
f"recording:{cls._query_phrase(title)}" if title else None,
f'recording:{cls._query_phrase(bare_title)} AND artist:"{cls._escape_query(artist)}"'
if artist and bare_title and bare_title != title else None,
# 艺术家署名变体(外文艺名等)导致 AND 条件零命中时,仅按主体曲名检索,
# 候选挑选阶段要求艺术家命中兜住同名异曲
f'recording:"{cls._escape_query(bare_title)}"' if bare_title else None,
f"recording:{cls._query_phrase(bare_title)}" if bare_title else None,
]:
if query and query not in queries:
queries.append(query)
@@ -216,13 +216,13 @@ class MusicBrainzModule(_ModuleBase):
bare_title = cls._normalize_text(bare_title)
queries: list[str] = []
for query in [
f'releasegroup:"{cls._escape_query(title)}" AND artist:"{cls._escape_query(artist)}"'
f'releasegroup:{cls._query_phrase(title)} AND artist:"{cls._escape_query(artist)}"'
if artist else None,
f'releasegroup:"{cls._escape_query(title)}"',
f'releasegroup:"{cls._escape_query(bare_title)}" AND artist:"{cls._escape_query(artist)}"'
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._escape_query(bare_title)}"' if bare_title else None,
f"releasegroup:{cls._query_phrase(bare_title)}" if bare_title else None,
]:
if query and query not in queries:
queries.append(query)
@@ -231,15 +231,12 @@ class MusicBrainzModule(_ModuleBase):
def _search_artists(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按用户输入中的艺术家部分搜索 Artist 浏览候选。"""
artist_name = meta.artists[0] if meta.artists else meta.title
if not artist_name:
phrase = self._query_phrase(artist_name)
if not phrase:
return []
payload = self._request_json(
"/artist",
params={
"query": f'artist:"{self._escape_query(artist_name)}"',
"limit": max(1, min(limit, 100)),
"fmt": "json",
},
params={"query": f"artist:{phrase}", "limit": max(1, min(limit, 100)), "fmt": "json"},
)
return [
artist.to_music_info()
@@ -582,6 +579,7 @@ class MusicBrainzModule(_ModuleBase):
if normalized_source and (candidate.source or "").casefold() != normalized_source:
continue
score = 0
title_match = False
# 多艺术家资源任一命中即可,联名候选不会因主艺术家顺序失配
artist_match = bool(meta.artists) and any(
cls._same_text(artist_name, candidate_artist)
@@ -590,6 +588,7 @@ class MusicBrainzModule(_ModuleBase):
)
if clean_title and cls._same_text(clean_title, candidate.title):
score += 4
title_match = True
elif (
bare_title
and artist_match
@@ -602,6 +601,7 @@ class MusicBrainzModule(_ModuleBase):
)
):
score += 2
title_match = True
if artist_match:
score += 3
if meta.album and cls._same_text(meta.album, candidate.album):
@@ -613,8 +613,11 @@ class MusicBrainzModule(_ModuleBase):
if meta.year and candidate.year and int(meta.year) == int(candidate.year):
score += 1
# 已知艺术家时艺术家未命中的候选不能采信ISRC 精确身份除外),
# 兜住宽检索阶梯下同名异曲的误配
if meta.artists and not artist_match and not isrc_match:
# 兜住宽检索阶梯下同名异曲的误配CJK 逐字 OR 检索召回宽,
# 标题未命中的候选同样不能仅凭艺术家署名得分ISRC 除外)
if (meta.artists and not artist_match and not isrc_match) or (
not title_match and not isrc_match
):
score = 0
ranked.append((score, candidate))
if not ranked:
@@ -696,8 +699,35 @@ class MusicBrainzModule(_ModuleBase):
"""忽略大小写、空白与标点差异比较两个音乐文本字段。"""
compact_left = cls._match_text(left)
compact_right = cls._match_text(right)
# 空文本不参与比对,避免缺失字段被误判为相等
return bool(compact_left) and compact_left == compact_right
if compact_left and compact_left == compact_right:
return True
# 条目与资源标题的汉字数字写法不一致(十三首/13首归一后再比对
return bool(compact_left) and cls._convert_cjk_numerals(
compact_left) == cls._convert_cjk_numerals(compact_right)
# 常见汉字数字归一为阿拉伯数字:十三首/13首、二十周年/20周年 在条目与资源标题间混用
_CJK_DIGIT_MAP = {"": 0, "": 1, "": 2, "": 2, "": 3, "": 4,
"": 5, "": 6, "": 7, "": 8, "": 9}
_CJK_NUMERAL_RUN_RE = re.compile(r"[零一二两三四五六七八九十百]+")
@classmethod
def _convert_cjk_numerals(cls, value: str) -> str:
"""将文本中连续的汉字数字转换为阿拉伯数字,支持十位与百位组合。"""
def _convert(run: str) -> str:
total, current = 0, 0
for char in run:
if char in cls._CJK_DIGIT_MAP:
current = cls._CJK_DIGIT_MAP[char]
elif char == "":
total += (current or 1) * 10
current = 0
elif char == "":
total += (current or 1) * 100
current = 0
return str(total + current)
return cls._CJK_NUMERAL_RUN_RE.sub(lambda m: _convert(m.group()), str(value or ""))
@classmethod
def _normalize_text(cls, value: Optional[str]) -> str:
@@ -839,7 +869,7 @@ class MusicBrainzModule(_ModuleBase):
# 资源标题先剥离音质标记,避免规格文本污染检索式导致零命中
title = cls._search_title(meta.title)
if title:
clauses.append(f'recording:"{cls._escape_query(title)}"')
clauses.append(f"recording:{cls._query_phrase(title)}")
if meta.artists:
clauses.append(f'artist:"{cls._escape_query(meta.artists[0])}"')
if meta.album:
@@ -853,6 +883,47 @@ class MusicBrainzModule(_ModuleBase):
"""转义 MusicBrainz 查询中的引号和反斜线。"""
return value.replace("\\", "\\\\").replace('"', '\\"').strip()
# 中日韩字符Lucene 标准分词器不会切分连续 CJK短语检索对中文标题永远零命中
_QUERY_CJK_RE = re.compile(
r"[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF]")
# 检索词元切分:按空白、标点与括号拆分,保留 CJK 串与拉丁词(括号对逐字检索无意义)
_QUERY_TOKEN_SPLIT_RE = re.compile(
r"[\s\-–—−-。,、;:!?·.…()()「」『』【】\[\]《》]+")
@classmethod
def _query_phrase(cls, value: Optional[str]) -> Optional[str]:
"""构造适配 Lucene 分词的检索表达式。
无 CJK 的普通文本返回带引号短语;含 CJK 的文本拆为词元后用 OR 交集检索,
MusicBrainz 索引中连续 CJK 是单一词元,逐字 OR 才能命中(「茹此精彩十三首」);
过宽的召回由候选挑选阶段的标题与艺术家比对收紧。
"""
text = str(value or "").strip()
if not text:
return None
if not cls._QUERY_CJK_RE.search(text):
return f'"{cls._escape_query(text)}"'
tokens = [
token for token in cls._QUERY_TOKEN_SPLIT_RE.split(text) if token.strip()
]
if not tokens:
return None
parts = [
f'"{cls._escape_query(token)}"'
if not cls._QUERY_CJK_RE.search(token) else cls._or_group(
[f'"{cls._escape_query(char)}"' for char in token]
)
for token in tokens
]
return cls._or_group(parts)
@staticmethod
def _or_group(parts: list[str]) -> str:
"""拼接 OR 检索表达式,多项时用括号包裹避免与外层 AND 产生优先级歧义。"""
if len(parts) == 1:
return parts[0]
return "(" + " OR ".join(parts) + ")"
@classmethod
def _recording_to_info(cls, recording: dict[str, Any]) -> Optional[MusicInfo]:
"""将 MusicBrainz Recording 响应转换为标准音乐信息。"""

View File

@@ -35,7 +35,8 @@ def test_build_query_strips_audio_quality_tokens():
)
)
assert query == 'recording:"永远是朋友" AND artist:"毛阿敏"'
# CJK 短语在 Lucene 索引中是单一词元,检索式拆为逐字 OROR 组带括号避免 AND 优先级歧义
assert query == 'recording:("" OR "" OR "" OR "" OR "") AND artist:"毛阿敏"'
def test_select_candidate_matches_traditional_chinese_title():
@@ -165,8 +166,8 @@ def test_search_music_interleaves_recordings_albums_and_artists(monkeypatch):
assert results[1].album == "叶惠美"
assert results[2].title == "周杰伦"
assert results[2].artists == []
assert requested[1][1]["query"] == 'releasegroup:"晴天" AND artist:"周杰伦"'
assert requested[2][1]["query"] == 'artist:""'
assert requested[1][1]["query"] == 'releasegroup:("" OR "") AND artist:"周杰伦"'
assert requested[2][1]["query"] == 'artist:("" OR "" OR "")'
def test_file_recognition_searches_recordings_only(monkeypatch):
@@ -608,11 +609,29 @@ def test_recording_queries_ladder_relaxes_to_bare_title_last():
MetaMusic(title="晴天 (电影版)", artists=["周杰伦"])
)
assert queries[0] == 'recording:"晴天 (电影版)" AND artist:"周杰伦"'
assert queries[1] == 'recording:"晴天 (电影版)"'
assert queries[2] == 'recording:"晴天" AND artist:"周杰伦"'
full = '("" OR "") OR ("" OR "" OR "")'
assert queries[0] == f'recording:({full}) AND artist:"周杰伦"'
assert queries[1] == f'recording:({full})'
assert queries[2] == 'recording:("" OR "") AND artist:"周杰伦"'
# 署名变体兜底放在最后,由候选挑选的艺术家要求收紧
assert queries[-1] == 'recording:"晴天"'
assert queries[-1] == 'recording:("" OR "")'
def test_query_phrase_latin_unchanged_and_cjk_char_or():
"""拉丁文本保持短语检索CJK 文本拆为逐字 OR混合文本按词元拆分。"""
assert MusicBrainzModule._query_phrase("Fearless") == '"Fearless"'
assert MusicBrainzModule._query_phrase("晴天") == '("" OR "")'
assert MusicBrainzModule._query_phrase("好歌茹芸 Vol. 3") == (
'(("" OR "" OR "" OR "") OR "Vol." OR "3")'
)
assert MusicBrainzModule._query_phrase("") is None
def test_same_text_normalizes_cjk_numerals():
"""汉字数字与阿拉伯数字写法差异不应阻断候选比对。"""
assert MusicBrainzModule._same_text("茹此精彩十三首", "茹此精彩13首")
assert MusicBrainzModule._same_text("二十周年演唱会", "20周年演唱会")
assert not MusicBrainzModule._same_text("茹此精彩十三首", "茹此精彩14首")
def test_select_candidate_rejects_wrong_artist_same_title():
@@ -629,6 +648,21 @@ def test_select_candidate_rejects_wrong_artist_same_title():
assert MusicBrainzModule._select_candidate(meta, [wrong_artist], source="musicbrainz") is None
def test_select_candidate_rejects_artist_only_match():
"""CJK 逐字 OR 检索召回宽,标题未命中的候选不能仅凭艺术家署名被采信。"""
meta = MetaMusic(title="茹此精彩十三首", artists=["许茹芸"])
same_artist_other_song = MusicInfo(
source="musicbrainz",
music_type="recording",
media_id="recording-wrong",
title="半首歌",
artists=["许茹芸"],
)
assert MusicBrainzModule._select_candidate(
meta, [same_artist_other_song], source="musicbrainz") is None
def test_select_album_candidate_requires_title_and_artist():
"""专辑候选需标题(含去括号弱匹配)与艺术家同时命中才采信。"""
meta = MetaMusic(title="我爱夜 (新歌+精选)", artists=["许茹芸"], year=2003)