fix(music): 修复音乐资源命名变体导致的漏搜 (#6706)

This commit is contained in:
sylas
2026-09-15 17:14:33 +08:00
committed by GitHub
parent 598cbf5dae
commit 9aa06edcc4
2 changed files with 162 additions and 3 deletions

View File

@@ -288,6 +288,18 @@ _MUSIC_RESOURCE_CATALOG_TAIL_RE = re.compile(
r"\s+-\s+\{[^{}\r\n]+\}(?:\s+-\s+[A-Za-z0-9][A-Za-z0-9@._-]*)?\s*$",
re.IGNORECASE,
)
# 发行年份与独立规格尾段共同限定资源命名,不能仅凭无空格连字符猜测署名方向。
_MUSIC_RESOURCE_RELEASE_RE = re.compile(
r"^(?P<name>.+?)\s+(?P<year>(?:19|20)\d{2})\s+[-–—−-]+\s+(?P<spec>.+)$"
)
_MUSIC_RESOURCE_BRACKET_RE = re.compile(
r"^\s*\[(?P<name>[^\[\]]+)\]\s*\[(?P<year>(?:19|20)\d{2})\](?P<tail>.*)$"
)
_MUSIC_RESOURCE_GROUP_RE = re.compile(
r"^(?P<spec>.+?)(?:\s*[-–—−-]+\s*|\s+)"
r"(?!(?:DELUXE|EXPANDED|SPECIAL|LIMITED|ANNIVERSARY|REMASTER(?:ED)?|RE-?RECORD(?:ED|ING))$)"
r"(?P<group>[A-Z0-9][A-Z0-9@._-]{1,20})$"
)
# 年份及完整发行日期括号提供候选消歧线索,规格或目录号不作为日期。
_MUSIC_YEAR_RE = re.compile(
r"[\(\[(【]((?:19|20)\d{2})"
@@ -750,10 +762,11 @@ class MetaMusic(MetaBase):
def parse_resource(cls, title: str, subtitle: Optional[str] = None) -> "MetaMusic":
"""合并资源标题与副标题的独立证据,不使用搜索目标补写作品身份。
标题解析仍复用 Python/Rust 公共入口;副标题只补缺失字段,保留标题中
已有的署名。专辑、曲序、曲名的明确多段格式在资源层统一补充。
带年份及规格证据的发行命名先统一归一,再复用 Python/Rust 公共入口;
副标题只补缺失字段,保留标题中已有的署名。专辑、曲序、曲名的明确
多段格式在资源层统一补充。
"""
meta = cls.parse_query(_MUSIC_RESOURCE_CATALOG_TAIL_RE.sub("", title))
meta = cls.parse_query(cls._normalize_resource_release(title))
meta.org_string = title
meta.apply_audio_quality(title)
subtitle = (subtitle or "").replace("", "|")
@@ -812,6 +825,42 @@ class MetaMusic(MetaBase):
meta.version = cls._resource_version(title, subtitle)
return meta
@classmethod
def _normalize_resource_release(cls, title: str) -> str:
"""在 Python/Rust 解析前归一带年份及规格证据的站点发行命名。
方括号只展开首个作品段;独立规格尾段允许携带发布组。此类发行命名的
中文紧凑署名采用「艺人-作品」,普通「作品-艺人」查询仍保留原有规则。
"""
text = _MUSIC_RESOURCE_CATALOG_TAIL_RE.sub("", title)
bracket = _MUSIC_RESOURCE_BRACKET_RE.fullmatch(text)
if bracket:
name, year = bracket.group("name", "year")
tail = bracket.group("tail")
else:
release = _MUSIC_RESOURCE_RELEASE_RE.fullmatch(text)
if not release or not cls._is_resource_spec_tail(release.group("spec")):
return text
name, year = release.group("name", "year")
tail = ""
if not _MUSIC_ARTIST_TITLE_RE.match(name):
artist, separator, work = name.partition("-")
if separator and cls._contains_cjk(artist) and cls._contains_cjk(work):
name = f"{artist.strip()} - {work.strip()}"
# 保留尾部裸年份,让 Album/Single 等真实作品名不会提前被当作独立发行标签删除。
return f"{name} {year}{tail}"
@staticmethod
def _is_resource_spec_tail(value: str) -> bool:
"""只清理完整规格及大写发布组尾标,录音版本和发行版本词必须保留。"""
if _MUSIC_SPEC_SEGMENT_RE.fullmatch(value):
return True
group = _MUSIC_RESOURCE_GROUP_RE.fullmatch(value)
return bool(
group and _MUSIC_SPEC_SEGMENT_RE.fullmatch(group.group("spec"))
and not _MUSIC_RECORDING_VERSION_RE.search(f"({group.group('group')})")
)
@classmethod
def _native_resource_title(
cls, title: Optional[str], subtitle: str, secondary: "MetaMusic",

View File

@@ -0,0 +1,110 @@
"""音乐站点发行命名的解析和搜索匹配回归。"""
import pytest
from app.adapters.system import rust
from app.chain.search import SearchChain
from app.domain.context import MusicInfo, TorrentInfo
from app.domain.meta import runtime
from app.domain.meta.metamusic import MetaMusic
from app.domain.music import match_music_resource
from app.schemas.types import MediaType
RESOURCE_TITLES = [
"赵雷-署前街少年 2022 - WEB-DL - 24bit ALAC-HHWEB",
"赵雷 - 署前街少年 2022 - FLAC TJUPT",
"[赵雷-署前街少年][2022][Hi-Res 44 1kHz_24bit][FLAC]",
"赵雷 - 署前街少年 2022 - WEB-DL - 24bit ALAC - HHWEB",
]
@pytest.fixture(params=["python", "rust"])
def music_engine(request, monkeypatch):
"""分别验证纯 Python 和实际 Rust 解析器,恢复全局加速器配置。"""
if request.param == "rust":
assert rust.is_available(), "锁定环境应安装 moviepilot-rust"
monkeypatch.setattr(rust, "is_enabled", lambda: True)
def reject_python_fallback(*_args, **_kwargs):
raise AssertionError("Rust 资源名回归不能用 Python 回退冒充通过")
monkeypatch.setattr(MetaMusic, "_prepare_name_context", reject_python_fallback)
monkeypatch.setattr(runtime, "_metainfo_accelerator", rust if request.param == "rust" else None)
@pytest.mark.usefixtures("music_engine")
@pytest.mark.parametrize("title", RESOURCE_TITLES)
def test_album_release_names_match_without_target_assisted_parsing(title):
"""真实命名变体应独立解析身份,并通过专辑搜索的严格匹配。"""
meta = MetaMusic.parse_resource(title)
assert (meta.title, meta.artists, meta.year) == ("署前街少年", ["赵雷"], 2022)
assert meta.org_string == title
assert meta.audio_format == ("ALAC" if "ALAC" in title else "FLAC")
assert meta.audio_lossless is True
target = MusicInfo(music_type="album", title="署前街少年", artists=["赵雷"], year="2022")
assert match_music_resource(target, title).status == "exact"
@pytest.mark.usefixtures("music_engine")
def test_album_search_preserves_resources_from_different_sites():
"""同一专辑的站点命名差异不能隐藏有折扣的候选。"""
target = MusicInfo(music_type="album", title="署前街少年", artists=["赵雷"], year="2022")
torrents = [
TorrentInfo(title=title, category=MediaType.MUSIC.value, site_name=f"Site{index}",
size=599533814, downloadvolumefactor=0.5 if index == 0 else 1,
hit_and_run=index != 0)
for index, title in enumerate(RESOURCE_TITLES)
]
contexts = SearchChain()._build_music_contexts(torrents, target, rule_groups=[])
assert [context.torrent_info for context in contexts] == torrents
assert contexts[0].torrent_info.downloadvolumefactor == 0.5
assert contexts[0].torrent_info.hit_and_run is False
@pytest.mark.usefixtures("music_engine")
@pytest.mark.parametrize("title,reason", [
("赵雷-成都 2022 - FLAC TJUPT", "title_mismatch"),
("其他艺人-署前街少年 2022 - FLAC TJUPT", "artist_unverified"),
("赵雷-署前街少年 2021 - FLAC TJUPT", "year_mismatch"),
("赵雷-署前街少年 (Live) 2022 - FLAC TJUPT", "version_mismatch"),
("[赵雷-署前街少年 (Remix)][2022][FLAC]", "version_mismatch"),
])
def test_release_normalization_preserves_identity_constraints(title, reason):
"""清理发行标记不能放宽作品、艺人、年份和版本的原有匹配约束。"""
target = MusicInfo(music_type="album", title="署前街少年", artists=["赵雷"], year="2022")
result = match_music_resource(target, title)
assert result.status != "exact"
assert result.reason == reason
@pytest.mark.usefixtures("music_engine")
@pytest.mark.parametrize("title", [
"为你盛开——许巍",
"署前街少年-赵雷 FLAC",
"Artist - Best Album FLAC",
"Artist - Single Ladies FLAC",
"Artist - Live At Montreux 1999 2022 FLAC",
"Artist - Album 2022 - FLAC LIVE",
"Artist - Album 2022 - FLAC DELUXE",
"Artist - Album 2022 - FLAC REMASTERED",
"Artist - Album 2022 - FLAC RE-RECORDED",
"Artist - Album 2022 - FLAC Bonus Track",
])
def test_release_normalization_preserves_other_naming_styles(title):
"""缺少明确发行尾链时沿用原解析规则,不能把自然语言尾段当发布组。"""
assert MetaMusic._normalize_resource_release(title) == title
resource = MetaMusic.parse_resource(title)
query = MetaMusic.parse_query(title)
assert (resource.title, resource.artists, resource.year) == (query.title, query.artists, query.year)
@pytest.mark.usefixtures("music_engine")
@pytest.mark.parametrize("title,artists", [
("Artist - Album 2022 - FLAC", ["Artist"]),
("Album 2022 - FLAC", []),
("[Artist - Album][2022][FLAC]", ["Artist"]),
])
def test_release_names_without_group_or_cjk_credit(title, artists):
"""规格不带发布组也应保留年份,且不能为缺少署名的资源补写艺术家。"""
meta = MetaMusic.parse_resource(title)
assert (meta.title, meta.artists, meta.year, meta.audio_format) == ("Album", artists, 2022, "FLAC")