mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
fix: 防止音乐署名清理截断真实曲名
This commit is contained in:
+33
-9
@@ -37,6 +37,7 @@ _COLLECTIVE_ARTISTS = ("Various Artists", "Various", "VA", "群星", "众艺人"
|
|||||||
_VERSION_YEAR = re.compile(r"(?<!\d)(?:19|20)\d{2}(?!\d)")
|
_VERSION_YEAR = re.compile(r"(?<!\d)(?:19|20)\d{2}(?!\d)")
|
||||||
_VERSION_DATE = re.compile(r"(?<!\d)((?:19|20)\d{2})(?:[-./]|年)\s*(\d{1,2})(?:[-./]|月)\s*(\d{1,2})日?(?!\d)")
|
_VERSION_DATE = re.compile(r"(?<!\d)((?:19|20)\d{2})(?:[-./]|年)\s*(\d{1,2})(?:[-./]|月)\s*(\d{1,2})日?(?!\d)")
|
||||||
_ISRC = re.compile(r"[A-Z]{2}[A-Z0-9]{3}[0-9]{7}", re.IGNORECASE | re.ASCII)
|
_ISRC = re.compile(r"[A-Z]{2}[A-Z0-9]{3}[0-9]{7}", re.IGNORECASE | re.ASCII)
|
||||||
|
_CJK = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -135,10 +136,30 @@ def music_isrc_matches(music: MusicInfo, meta: MetaMusic) -> bool:
|
|||||||
return bool(expected and expected == _isrc_key(music.isrc))
|
return bool(expected and expected == _isrc_key(music.isrc))
|
||||||
|
|
||||||
|
|
||||||
|
def _artist_match_text(text: str) -> str:
|
||||||
|
"""归一署名比较文本但保留分隔符,避免紧凑名称丢失单词边界。"""
|
||||||
|
normalized = str(zhconv_convert(normalize("NFKD", text).casefold(), "zh-hans"))
|
||||||
|
return "".join(char for char in normalized if not combining(char))
|
||||||
|
|
||||||
|
|
||||||
|
def music_artist_affix_matches(title: str, artist: str, *, suffix: bool = False) -> bool:
|
||||||
|
"""核验首尾完整署名;保留中日韩连写习惯,但不能截断其它文字的单词。"""
|
||||||
|
key = music_text_key(artist)
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
text = _artist_match_text(title)
|
||||||
|
pattern = r"[\W_]*".join(re.escape(char) for char in key)
|
||||||
|
match = re.search(rf"{pattern}[\W_]*$" if suffix else rf"^[\W_]*{pattern}", text)
|
||||||
|
if not match:
|
||||||
|
return False
|
||||||
|
neighbor = text[match.start() - 1:match.start()] if suffix else text[match.end():match.end() + 1]
|
||||||
|
edge = key[0] if suffix else key[-1]
|
||||||
|
return not (neighbor.isalnum() and edge.isalnum() and not _CJK.search(neighbor + edge))
|
||||||
|
|
||||||
|
|
||||||
def _contains_artist(text: str, artist: str) -> bool:
|
def _contains_artist(text: str, artist: str) -> bool:
|
||||||
"""匹配完整署名,避免短拉丁艺名命中另一个人名的子串。"""
|
"""匹配完整署名,避免短拉丁艺名命中另一个人名的子串。"""
|
||||||
normalized = str(zhconv_convert(normalize("NFKD", text).casefold(), "zh-hans"))
|
normalized = _artist_match_text(text)
|
||||||
normalized = "".join(char for char in normalized if not combining(char))
|
|
||||||
key = music_text_key(artist)
|
key = music_text_key(artist)
|
||||||
if not key:
|
if not key:
|
||||||
return False
|
return False
|
||||||
@@ -150,7 +171,7 @@ def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = Fal
|
|||||||
album_suffixes: Optional[list[str]] = None) -> list[str]:
|
album_suffixes: Optional[list[str]] = None) -> list[str]:
|
||||||
"""复用音乐命名解析器提取作品片段,去掉已确认的首尾艺术家署名。"""
|
"""复用音乐命名解析器提取作品片段,去掉已确认的首尾艺术家署名。"""
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
artist_keys = [music_text_key(item) for item in artists if item]
|
artist_keys = [(item, music_text_key(item)) for item in artists if item]
|
||||||
suffix_keys = {music_text_key(item) for item in album_suffixes or []}
|
suffix_keys = {music_text_key(item) for item in album_suffixes or []}
|
||||||
for value in (primary.title, primary.album if album else None):
|
for value in (primary.title, primary.album if album else None):
|
||||||
if not value:
|
if not value:
|
||||||
@@ -161,15 +182,18 @@ def _resource_names(primary: MetaMusic, artists: list[str], *, album: bool = Fal
|
|||||||
if len(parts) > 1 and all(music_text_key(part) in suffix_keys for part in parts[1:]):
|
if len(parts) > 1 and all(music_text_key(part) in suffix_keys for part in parts[1:]):
|
||||||
variants.append(parts[0])
|
variants.append(parts[0])
|
||||||
for part in variants:
|
for part in variants:
|
||||||
key = music_text_key(music_base_title(_TITLE_LABEL.sub("", part.strip())))
|
name = music_base_title(_TITLE_LABEL.sub("", part.strip()))
|
||||||
|
key = music_text_key(name)
|
||||||
if not key:
|
if not key:
|
||||||
continue
|
continue
|
||||||
names.append(key)
|
names.append(key)
|
||||||
for artist in artist_keys:
|
for artist, artist_key in artist_keys:
|
||||||
if key.startswith(artist) and key != artist:
|
if key.startswith(artist_key) and key != artist_key and music_artist_affix_matches(name, artist):
|
||||||
names.append(key[len(artist):])
|
remainder = re.sub(r"^[的之]", "", key[len(artist_key):])
|
||||||
if key.endswith(artist) and key != artist:
|
if remainder:
|
||||||
names.append(key[:-len(artist)])
|
names.append(remainder)
|
||||||
|
if key.endswith(artist_key) and key != artist_key and music_artist_affix_matches(name, artist, suffix=True):
|
||||||
|
names.append(key[:-len(artist_key)])
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.domain.media import is_media_source_selected
|
|||||||
from app.domain.meta.metabase import MetaBase
|
from app.domain.meta.metabase import MetaBase
|
||||||
from app.domain.meta.metamusic import MetaMusic
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
from app.domain.music import (
|
from app.domain.music import (
|
||||||
|
music_artist_affix_matches,
|
||||||
music_artist_matches,
|
music_artist_matches,
|
||||||
music_base_title,
|
music_base_title,
|
||||||
music_isrc_matches,
|
music_isrc_matches,
|
||||||
@@ -478,19 +479,15 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _strip_artist_prefix(cls, title: Optional[str], artists: Optional[list[str]]) -> str:
|
def _strip_artist_prefix(cls, title: Optional[str], artists: Optional[list[str]]) -> str:
|
||||||
"""剥离曲名开头的艺术家署名前缀(「许茹芸的爱情电影主题曲」)。
|
"""剥离完整署名前缀,不截断单词;保留中文连写与“的/之”署名习惯。"""
|
||||||
|
|
||||||
资源命名习惯把署名放在曲名前,条目不含该前缀;署名身份由
|
|
||||||
候选挑选阶段的艺术家要求保证,不会产生错误归属。前缀剥离后
|
|
||||||
无剩余文本时保留原标题(「合集 - 花开」类短标题保护)。
|
|
||||||
"""
|
|
||||||
text = str(title or "").strip()
|
text = str(title or "").strip()
|
||||||
for artist in artists or []:
|
for artist in artists or []:
|
||||||
artist = str(artist or "").strip()
|
artist = str(artist or "").strip()
|
||||||
if len(artist) < 2:
|
if len(artist) < 2:
|
||||||
continue
|
continue
|
||||||
if text.startswith(artist):
|
if text.startswith(artist) and music_artist_affix_matches(text, artist):
|
||||||
remainder = re.sub(r"^[的之]\s*", "", text[len(artist):]).strip()
|
remainder = text[len(artist):].lstrip(" \t-–—−-/|::;;")
|
||||||
|
remainder = re.sub(r"^[的之]\s*", "", remainder).strip()
|
||||||
if remainder:
|
if remainder:
|
||||||
return remainder
|
return remainder
|
||||||
return text
|
return text
|
||||||
@@ -1219,11 +1216,11 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
) -> Optional[MusicInfo]:
|
) -> Optional[MusicInfo]:
|
||||||
"""优先采用同一 ISRC,其他候选须满足完整名称、已有署名和录音版本约束。"""
|
"""优先采用同一 ISRC,其他候选须满足完整名称、已有署名和录音版本约束。"""
|
||||||
normalized_source = cls._normalize_text(media_source).casefold()
|
normalized_source = cls._normalize_text(media_source).casefold()
|
||||||
# 资源标题携带的音质标记先剥离,再与候选曲名比对;
|
# 完整名称优先,去署名只产生回退名称,不能覆盖实际包含艺名的曲名。
|
||||||
# 曲名开头的艺术家署名前缀是命名习惯,用主体名比对
|
original_title = cls._search_title(meta.title)
|
||||||
clean_title = cls._strip_artist_prefix(cls._search_title(meta.title), meta.artists)
|
clean_title = cls._strip_artist_prefix(original_title, meta.artists)
|
||||||
bare_title = music_base_title(clean_title)
|
bare_title = music_base_title(clean_title)
|
||||||
ranked: list[tuple[int, MusicInfo]] = []
|
ranked: list[tuple[bool, int, MusicInfo]] = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if normalized_source and str(candidate.media_source or "").casefold() != normalized_source:
|
if normalized_source and str(candidate.media_source or "").casefold() != normalized_source:
|
||||||
continue
|
continue
|
||||||
@@ -1235,7 +1232,8 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
# 多艺术家资源任一命中即可,联名候选不会因主艺术家顺序失配
|
# 多艺术家资源任一命中即可,联名候选不会因主艺术家顺序失配
|
||||||
artist_match = music_artist_matches(candidate, meta.artists)
|
artist_match = music_artist_matches(candidate, meta.artists)
|
||||||
titles = music_titles(candidate)
|
titles = music_titles(candidate)
|
||||||
if clean_title and any(cls._same_text(clean_title, title) for title in titles):
|
exact_title = bool(original_title and any(cls._same_text(original_title, title) for title in titles))
|
||||||
|
if exact_title:
|
||||||
score += 4
|
score += 4
|
||||||
title_match = True
|
title_match = True
|
||||||
elif (
|
elif (
|
||||||
@@ -1256,12 +1254,12 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
if (
|
if (
|
||||||
(meta.artists and not artist_match) or not title_match or not music_version_matches(candidate, meta)
|
(meta.artists and not artist_match) or not title_match or not music_version_matches(candidate, meta)
|
||||||
):
|
):
|
||||||
score = 0
|
continue
|
||||||
ranked.append((score, candidate))
|
ranked.append((exact_title, score, candidate))
|
||||||
if not ranked:
|
if not ranked:
|
||||||
return None
|
return None
|
||||||
ranked.sort(key=lambda item: item[0], reverse=True)
|
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||||
return ranked[0][1] if ranked[0][0] > 0 else None
|
return ranked[0][2]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _select_album_candidate(cls, meta: MetaMusic, albums: Iterable[MusicInfo]) -> Optional[MusicInfo]:
|
def _select_album_candidate(cls, meta: MetaMusic, albums: Iterable[MusicInfo]) -> Optional[MusicInfo]:
|
||||||
@@ -1270,26 +1268,28 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
专辑重名多,要求标题(含去括号弱匹配)与艺术家同时命中才返回,
|
专辑重名多,要求标题(含去括号弱匹配)与艺术家同时命中才返回,
|
||||||
避免把音轨身份安到错误专辑上。
|
避免把音轨身份安到错误专辑上。
|
||||||
"""
|
"""
|
||||||
clean_title = cls._strip_artist_prefix(
|
original_title = cls._search_title(meta.album or meta.title)
|
||||||
cls._search_title(meta.album or meta.title), meta.artists)
|
clean_title = cls._strip_artist_prefix(original_title, meta.artists)
|
||||||
if not clean_title:
|
if not clean_title:
|
||||||
return None
|
return None
|
||||||
# 去括号与卷号后缀后的本体名用于弱匹配(好歌茹芸, Vol. 3 -> 好歌茹芸);
|
# 去括号与卷号后缀后的本体名用于弱匹配(好歌茹芸, Vol. 3 -> 好歌茹芸);
|
||||||
# 资源带卷号时弱匹配要求候选卷号一致,避免 Ibiza Vol.1 误配 Vol.3
|
# 资源带卷号时弱匹配要求候选卷号一致,避免 Ibiza Vol.1 误配 Vol.3
|
||||||
bare_title = cls._strip_volume_suffix(cls._strip_parenthetical(clean_title))
|
bare_title = cls._strip_volume_suffix(cls._strip_parenthetical(clean_title))
|
||||||
meta_volume = cls._volume_number(clean_title)
|
meta_volume = cls._volume_number(clean_title)
|
||||||
ranked: list[tuple[int, MusicInfo]] = []
|
ranked: list[tuple[bool, int, MusicInfo]] = []
|
||||||
for album in albums:
|
for album in albums:
|
||||||
score = 0
|
score = 0
|
||||||
album_title = album.title or album.album
|
album_title = album.title or album.album
|
||||||
artist_match = music_artist_matches(album, meta.artists)
|
artist_match = music_artist_matches(album, meta.artists)
|
||||||
title_match = False
|
title_match = False
|
||||||
|
exact_title = False
|
||||||
# 资源带卷号时候选卷号不一致(含其他分卷)直接排除,避免 Vol.1 误配 Vol.3
|
# 资源带卷号时候选卷号不一致(含其他分卷)直接排除,避免 Vol.1 误配 Vol.3
|
||||||
if meta_volume and cls._volume_number(album_title) not in (None, meta_volume):
|
if meta_volume and cls._volume_number(album_title) not in (None, meta_volume):
|
||||||
pass
|
pass
|
||||||
elif any(cls._same_text(clean_title, title) for title in music_titles(album, album=True)):
|
elif any(cls._same_text(original_title, title) for title in music_titles(album, album=True)):
|
||||||
score += 4
|
score += 4
|
||||||
title_match = True
|
title_match = True
|
||||||
|
exact_title = True
|
||||||
elif (
|
elif (
|
||||||
artist_match
|
artist_match
|
||||||
and bare_title
|
and bare_title
|
||||||
@@ -1333,11 +1333,12 @@ class MusicBrainzModule(_ModuleBase):
|
|||||||
if meta.year and album.year and int(meta.year) == int(album.year):
|
if meta.year and album.year and int(meta.year) == int(album.year):
|
||||||
score += 1
|
score += 1
|
||||||
# 标题与艺术家缺一不可,仅有标题相似不能采信
|
# 标题与艺术家缺一不可,仅有标题相似不能采信
|
||||||
ranked.append((score if title_match and artist_match and music_version_matches(album, meta) else 0, album))
|
if title_match and artist_match and music_version_matches(album, meta):
|
||||||
|
ranked.append((exact_title, score, album))
|
||||||
if not ranked:
|
if not ranked:
|
||||||
return None
|
return None
|
||||||
ranked.sort(key=lambda item: item[0], reverse=True)
|
ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||||
return ranked[0][1] if ranked[0][0] > 0 else None
|
return ranked[0][2]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
|
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ from app.runtime.settings import get_runtime_setting
|
|||||||
from app.schemas.types import MUSIC_ENTITY_RECORDING
|
from app.schemas.types import MUSIC_ENTITY_RECORDING
|
||||||
|
|
||||||
lock = RLock()
|
lock = RLock()
|
||||||
PERSISTENCE_VERSION = 2
|
# 旧确认规则可能把署名重叠的曲名截断,不能继续复用其派生身份。
|
||||||
|
PERSISTENCE_VERSION = 3
|
||||||
PERSISTENCE_REGION = "recognize"
|
PERSISTENCE_REGION = "recognize"
|
||||||
PERSISTENCE_KEY = "musicbrainz"
|
PERSISTENCE_KEY = "musicbrainz"
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,10 @@
|
|||||||
“晴天”、艺术家“周杰倫”,证明中文完整短语不是零命中。
|
“晴天”、艺术家“周杰倫”,证明中文完整短语不是零命中。
|
||||||
查询字段与短语语义参考 [MusicBrainz 官方检索语法](https://musicbrainz.org/doc/Indexed_Search_Syntax)。
|
查询字段与短语语义参考 [MusicBrainz 官方检索语法](https://musicbrainz.org/doc/Indexed_Search_Syntax)。
|
||||||
- 不根据展示文本臆造 MusicBrainz ID,不更改默认 MusicBrainz 或显式单来源行为。
|
- 不根据展示文本臆造 MusicBrainz ID,不更改默认 MusicBrainz 或显式单来源行为。
|
||||||
|
- 署名清理保留原始分隔信息,不能从非中日韩文字的单词内部剥离艺名,避免把 `Leeway`
|
||||||
|
截为 `way`、把 `Surprise` 截为 `Surp`。中日韩连写署名及“的/之”形式保持兼容。
|
||||||
|
MusicBrainz 确认先采用完整名称,去署名结果只作回退;不符合艺术家、版本约束的
|
||||||
|
完整名称不能抢占有效回退。旧名称确认规则的派生缓存升级后重新建立。
|
||||||
- MusicBrainz 的候选确认与资源匹配复用繁简、变音符、可信别名、完整署名和录音版本规则。
|
- MusicBrainz 的候选确认与资源匹配复用繁简、变音符、可信别名、完整署名和录音版本规则。
|
||||||
单曲不凭首词、包含关系或任意括号剥离认定同一作品;已返回的同一 ISRC 优先于名称打分。
|
单曲不凭首词、包含关系或任意括号剥离认定同一作品;已返回的同一 ISRC 优先于名称打分。
|
||||||
专辑的 `secondary_types` 可提供整专版本证据,但不能反向覆盖其中单曲的录音版本。
|
专辑的 `secondary_types` 可提供整专版本证据,但不能反向覆盖其中单曲的录音版本。
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized`
|
|||||||
缓存键为不透明值,管理调用必须使用查询返回的原始键,不自行拼接。识别缓存按请求的
|
缓存键为不透明值,管理调用必须使用查询返回的原始键,不自行拼接。识别缓存按请求的
|
||||||
单曲、专辑或未限定实体范围隔离,版本及 ISRC 不同的文本识别请求也不会共用结果;
|
单曲、专辑或未限定实体范围隔离,版本及 ISRC 不同的文本识别请求也不会共用结果;
|
||||||
旧版未包含这些证据的派生缓存在升级后重新建立,不影响下载历史或订阅数据。
|
旧版未包含这些证据的派生缓存在升级后重新建立,不影响下载历史或订阅数据。
|
||||||
|
名称确认规则更新时同样重建旧派生缓存,避免艺术家前后缀误截断的旧结果继续命中。
|
||||||
|
|
||||||
### 插件补充接口
|
### 插件补充接口
|
||||||
|
|
||||||
|
|||||||
@@ -86,24 +86,26 @@ def test_all_media_use_filtered_results_to_stop_keyword_search(monkeypatch, mtyp
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("mode", ["sync", "async", "stream"])
|
@pytest.mark.parametrize("mode", ["sync", "async", "stream"])
|
||||||
@pytest.mark.parametrize("case", ["partial_album", "subtitle_version", "recognition_words"])
|
@pytest.mark.parametrize("case", ["partial_album", "subtitle_version", "recognition_words", "artist_affix"])
|
||||||
def test_music_resource_evidence_controls_shared_search_stop(monkeypatch, mode, case):
|
def test_music_resource_evidence_controls_shared_search_stop(monkeypatch, mode, case):
|
||||||
"""三种搜索入口均在完成识别词、版本与专辑范围验证后才允许停止换词。"""
|
"""三种搜索入口均在完成识别词、版本与专辑范围验证后才允许停止换词。"""
|
||||||
chain = SearchChain()
|
chain = SearchChain()
|
||||||
chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False)
|
chain.runtime_config = replace(chain.runtime_config, search_multiple_name=False)
|
||||||
album_target = case == "partial_album"
|
album_target = case == "partial_album"
|
||||||
|
artist = "Lee" if case == "artist_affix" else "周杰伦"
|
||||||
target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="target",
|
target = MusicInfo(media_source=MediaSource.MusicBrainz, media_id="target",
|
||||||
music_type="album" if album_target else "recording",
|
music_type="album" if album_target else "recording",
|
||||||
title="叶惠美" if album_target else "晴天", artists=["周杰伦"])
|
title="叶惠美" if album_target else "way" if case == "artist_affix" else "晴天", artists=[artist])
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def search(**kwargs):
|
def search(**kwargs):
|
||||||
"""先返回待核验或需改名的资源,再提供正确身份的资源。"""
|
"""先返回待核验或需改名的资源,再提供正确身份的资源。"""
|
||||||
calls.append(kwargs["keyword"])
|
calls.append(kwargs["keyword"])
|
||||||
if len(calls) > 1:
|
if len(calls) > 1:
|
||||||
return [TorrentInfo(title=f"周杰伦 - {target.title} FLAC", category=MediaType.MUSIC.value)]
|
return [TorrentInfo(title=f"{artist} - {target.title} FLAC", category=MediaType.MUSIC.value)]
|
||||||
|
first_title = "错误曲名" if case == "recognition_words" else "Leeway" if case == "artist_affix" else "晴天"
|
||||||
return [TorrentInfo(
|
return [TorrentInfo(
|
||||||
title="周杰伦 - 错误曲名 FLAC" if case == "recognition_words" else "周杰伦 - 晴天 FLAC",
|
title=f"{artist} - {first_title} FLAC",
|
||||||
description="专辑:叶惠美" if album_target else "版本:Live" if case == "subtitle_version" else None,
|
description="专辑:叶惠美" if album_target else "版本:Live" if case == "subtitle_version" else None,
|
||||||
category=MediaType.MUSIC.value,
|
category=MediaType.MUSIC.value,
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -24,6 +24,36 @@ def test_music_match_rejects_other_titles(title):
|
|||||||
assert match_music_resource(MusicInfo(title="One", artists=["U2"]), title).status == "rejected"
|
assert match_music_resource(MusicInfo(title="One", artists=["U2"]), title).status == "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artist,title,wrong_title", [
|
||||||
|
("Lee", "Leeway", "way"),
|
||||||
|
("Rise", "Surprise", "Surp"),
|
||||||
|
("Lee", "HappyLee", "Happy"),
|
||||||
|
("Élan", "Élansong", "song"),
|
||||||
|
("Мир", "Мирный", "ный"),
|
||||||
|
])
|
||||||
|
def test_artist_affix_cannot_cut_into_a_title_word(artist, title, wrong_title):
|
||||||
|
"""署名恰好是单词的一部分时,不得据此构造另一首歌的名称。"""
|
||||||
|
resource = f"{artist} - {title} FLAC"
|
||||||
|
assert match_music_resource(MusicInfo(title=title, artists=[artist]), resource).status == "exact"
|
||||||
|
assert match_music_resource(MusicInfo(title=wrong_title, artists=[artist]), resource).status == "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", ["Lee - Song", "Song - Lee", "LEE: Song"])
|
||||||
|
def test_artist_affix_preserves_explicit_signature_boundaries(title):
|
||||||
|
"""已解析字段中的完整署名有分隔符时,仍可去掉署名参与匹配。"""
|
||||||
|
target = MusicInfo(title="Song", artists=["Lee"])
|
||||||
|
meta = MetaMusic(title=title, artists=["Lee"])
|
||||||
|
assert match_music_resource(target, title, meta=meta).status == "exact"
|
||||||
|
|
||||||
|
|
||||||
|
def test_artist_affix_supports_existing_cjk_signature_style():
|
||||||
|
"""中文连写及“的”字署名形式仍参与同一套资源名称比较。"""
|
||||||
|
target = MusicInfo(title="爱情电影主题曲", artists=["许茹芸"])
|
||||||
|
for title in ("许茹芸爱情电影主题曲", "许茹芸的爱情电影主题曲"):
|
||||||
|
meta = MetaMusic(title=title, artists=["许茹芸"])
|
||||||
|
assert match_music_resource(target, title, meta=meta).status == "exact"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("artist", ["Jay Chou", "周杰倫"])
|
@pytest.mark.parametrize("artist", ["Jay Chou", "周杰倫"])
|
||||||
def test_music_match_accepts_source_artist_aliases(artist):
|
def test_music_match_accepts_source_artist_aliases(artist):
|
||||||
"""同一艺术家来源别名和繁简署名均可命中。"""
|
"""同一艺术家来源别名和繁简署名均可命中。"""
|
||||||
|
|||||||
@@ -155,15 +155,16 @@ def test_music_cache_key_prefers_media_id():
|
|||||||
|
|
||||||
cache.update(meta, _music_info())
|
cache.update(meta, _music_info())
|
||||||
|
|
||||||
assert next(iter(cache._cache.data)).startswith("[音乐:v2]")
|
assert next(iter(cache._cache.data)).startswith(f"[音乐:v{music_cache_module.PERSISTENCE_VERSION}]")
|
||||||
renamed = MetaMusic(title="不同展示名", artists=["不同署名"], media_source="musicbrainz", media_id="rec-1")
|
renamed = MetaMusic(title="不同展示名", artists=["不同署名"], media_source="musicbrainz", media_id="rec-1")
|
||||||
assert cache.get(renamed).media_id == "rec-1"
|
assert cache.get(renamed).media_id == "rec-1"
|
||||||
|
|
||||||
|
|
||||||
def test_music_cache_rebuilds_legacy_identity_keys(monkeypatch):
|
@pytest.mark.parametrize("version", [1, 2])
|
||||||
"""旧缓存未区分版本和实体范围,升级后不恢复其中可能串用的身份。"""
|
def test_music_cache_rebuilds_legacy_identity_keys(monkeypatch, version):
|
||||||
|
"""旧缓存可能串用实体或保存了截断名称的错误身份,升级后不再恢复。"""
|
||||||
file_cache = _FileCacheStub(pickle.dumps({
|
file_cache = _FileCacheStub(pickle.dumps({
|
||||||
"version": 1, "items": {"[音乐]legacy": {"expires_at": 2000, "value": _music_info().to_dict()}},
|
"version": version, "items": {"[音乐]legacy": {"expires_at": 2000, "value": _music_info().to_dict()}},
|
||||||
}))
|
}))
|
||||||
runtime_cache = _TTLCacheStub()
|
runtime_cache = _TTLCacheStub()
|
||||||
_build_initialized_music_cache(monkeypatch, file_cache, runtime_cache)
|
_build_initialized_music_cache(monkeypatch, file_cache, runtime_cache)
|
||||||
|
|||||||
@@ -870,6 +870,52 @@ def test_strip_artist_prefix_removes_signature_prefix():
|
|||||||
assert MusicBrainzModule._strip_artist_prefix("晴天", ["周杰伦"]) == "晴天"
|
assert MusicBrainzModule._strip_artist_prefix("晴天", ["周杰伦"]) == "晴天"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("artist,title", [("Lee", "Leeway"), ("Élan", "Élansong"), ("Мир", "Мирный")])
|
||||||
|
def test_artist_prefix_cleanup_keeps_whole_words(artist, title):
|
||||||
|
"""候选确认和后续检索式都不能从实际曲名的单词内部剥离艺术家。"""
|
||||||
|
assert MusicBrainzModule._strip_artist_prefix(title, [artist]) == title
|
||||||
|
meta = MetaMusic(title=title, artists=[artist])
|
||||||
|
candidate = MusicInfo(media_source="musicbrainz", media_id="correct", title=title, artists=[artist])
|
||||||
|
assert MusicBrainzModule._select_candidate(meta, [candidate], "musicbrainz") is candidate
|
||||||
|
|
||||||
|
|
||||||
|
def test_recording_queries_do_not_shorten_title_words():
|
||||||
|
"""首轮精确查询之后也必须保留完整曲名,不能换成错误的缩短词。"""
|
||||||
|
queries = MusicBrainzModule._recording_queries(MetaMusic(title="Leeway", artists=["Lee"]))
|
||||||
|
assert queries == ['recording:"Leeway" AND artist:"Lee"', 'recording:"Leeway"']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("music_type", ["recording", "album"])
|
||||||
|
def test_exact_title_precedes_derived_artist_signature(music_type):
|
||||||
|
"""曲名确实包含艺名时,完整名称命中优先于去署名后的回退名称。"""
|
||||||
|
meta = MetaMusic(title="Lee Loves You", artists=["Lee"])
|
||||||
|
shortened = MusicInfo(media_source="musicbrainz", media_id="short", title="Loves You",
|
||||||
|
music_type=music_type, artists=["Lee"])
|
||||||
|
original = MusicInfo(media_source="musicbrainz", media_id="original", title="Lee Loves You",
|
||||||
|
music_type=music_type, artists=["Lee"])
|
||||||
|
if music_type == "recording":
|
||||||
|
meta.album = shortened.album = "Example Collection"
|
||||||
|
meta.year = shortened.year = 2001
|
||||||
|
if music_type == "album":
|
||||||
|
assert MusicBrainzModule._select_album_candidate(meta, [shortened, original]) is original
|
||||||
|
else:
|
||||||
|
assert MusicBrainzModule._select_candidate(meta, [shortened, original], "musicbrainz") is original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("music_type", ["recording", "album"])
|
||||||
|
def test_rejected_exact_title_does_not_hide_valid_signature_fallback(music_type):
|
||||||
|
"""完整名称优先不能放过署名不符的候选,仍应采用有效的去署名回退。"""
|
||||||
|
meta = MetaMusic(title="Lee Loves You", artists=["Lee"])
|
||||||
|
invalid = MusicInfo(media_source="musicbrainz", media_id="wrong", music_type=music_type,
|
||||||
|
title=meta.title, artists=["Other Artist"])
|
||||||
|
valid = MusicInfo(media_source="musicbrainz", media_id="right", music_type=music_type,
|
||||||
|
title="Loves You", artists=["Lee"])
|
||||||
|
if music_type == "album":
|
||||||
|
assert MusicBrainzModule._select_album_candidate(meta, [invalid, valid]) is valid
|
||||||
|
else:
|
||||||
|
assert MusicBrainzModule._select_candidate(meta, [invalid, valid], "musicbrainz") is valid
|
||||||
|
|
||||||
|
|
||||||
def test_select_album_candidate_matches_lead_token_structure():
|
def test_select_album_candidate_matches_lead_token_structure():
|
||||||
"""条目「主体名 补充说明」结构与资源主体名首段一致时应弱匹配命中。"""
|
"""条目「主体名 补充说明」结构与资源主体名首段一致时应弱匹配命中。"""
|
||||||
meta = MetaMusic(title="许茹芸的爱情电影主题曲", artists=["许茹芸"], year=2003)
|
meta = MetaMusic(title="许茹芸的爱情电影主题曲", artists=["许茹芸"], year=2003)
|
||||||
|
|||||||
Reference in New Issue
Block a user