fix(music): recognize real site titles and prefer native subtitle names

This commit is contained in:
jxxghp
2026-09-06 09:00:13 +08:00
parent 82c0b4d09a
commit d82bcc5b90
4 changed files with 466 additions and 21 deletions
+139 -20
View File
@@ -1,10 +1,13 @@
import logging
import re
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from threading import RLock
from typing import Any, Callable, Optional
from Pinyin2Hanzi import DefaultHmmParams, is_pinyin
from app.domain.meta.metabase import MetaBase
from app.domain.meta.runtime import get_metainfo_accelerator
from app.schemas.media import resolve_media_identity
@@ -15,9 +18,9 @@ _AUDIO_FORMAT_PATTERN = re.compile(
r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA)(?![A-Z])",
re.IGNORECASE,
)
_BIT_DEPTH_PATTERN = re.compile(r"(?<!\d)(?P<value>16|20|24|32)\s*(?:-?bit|bits?|位)(?!\w)", re.IGNORECASE)
_BIT_DEPTH_PATTERN = re.compile(r"(?<!\d)(?P<value>16|20|24|32)\s*(?:-?bit|bits?|位|B)(?!\w)", re.IGNORECASE)
_SAMPLE_RATE_PATTERN = re.compile(
r"(?<!\d)(?P<value>44(?:\.1)?|48|88(?:\.2)?|96|176(?:\.4)?|192|352(?:\.8)?|384|705(?:\.6)?|768)"
r"(?<!\d)(?P<value>44(?:[. ]1)?|48|88(?:[. ]2)?|96|176(?:[. ]4)?|192|352(?:[. ]8)?|384|705(?:[. ]6)?|768)"
r"\s*k(?:hz)?(?!\w)",
re.IGNORECASE,
)
@@ -78,7 +81,7 @@ def parse_audio_quality(value: Any) -> dict[str, Any]:
audio_format = normalize_audio_format(format_match.group("format")) if format_match else None
bit_depth = int(bit_depth_match.group("value")) if bit_depth_match else None
sample_rate = (
int(float(sample_rate_match.group("value")) * 1000)
int(float(sample_rate_match.group("value").replace(" ", ".")) * 1000)
if sample_rate_match
else None
)
@@ -277,11 +280,19 @@ _MUSIC_PAREN_SPEC_RE = re.compile(
_MUSIC_EMPTY_BRACKET_RE = re.compile(r"[\(\[]\s*(?:[/+,\-]\s*)*[\)\]]")
# 尾部花括号通常是唱片目录号或发布标记,仅在末尾剔除,保护正文中的花括号文本。
_MUSIC_TRAILING_CATALOG_RE = re.compile(r"\s*\{[A-Za-z0-9][^{}]{0,40}\}\s*$")
# 年份括号:(2000)(2000)【2000】形式的发行年份,作为候选消歧线索
_MUSIC_YEAR_RE = re.compile(r"[\(\[(【]((?:19|20)\d{2})[\)\])】]")
# 年份及完整发行日期括号提供候选消歧线索,规格或目录号不作为日期。
_MUSIC_YEAR_RE = re.compile(
r"[\(\[(【]((?:19|20)\d{2})"
r"(?:[. /-](?:0?[1-9]|1[0-2])[. /-](?:0?[1-9]|[12]\d|3[01]))?[\)\])】]"
)
# 标题尾部独立年份:「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"[\s\-–—]+((?:19|20)\d{2})\s*$")
# 仅清理独立发行类型尾段或紧随年份的类型,保留 Best Album 等自然标题。
_MUSIC_RELEASE_TYPE_RE = re.compile(
r"(?:\s+[-–—−-]+\s*(?:single|ep|album)|"
r"(?P<year>(?:19|20)\d{2})\s+(?:single|ep|album))\s*$", re.IGNORECASE,
)
# 无括号年份区间:全集/精选标题尾部的 1967-1995、2015-16,取结束年作为发行年份线索;
# CJK 字符属于 \w,不能用 \b 定界,改用数字负向断言;
# 短年右侧禁止再跟数字,避免把 2024-01-27 这类日期的 2024-01 误当区间;
@@ -313,6 +324,11 @@ _MUSIC_ALIAS_PREFIX_RE = re.compile(
_MUSIC_ARTIST_TITLE_RE = re.compile(
r"^\s*(?P<artist>.+?)\s+[\-–—−-]+\s+(?P<title>.+?)\s*$"
)
_MUSIC_ARTIST_TITLE_SEPARATOR_RE = re.compile(r"\s+[-–—−-]+(?:\s+|$)")
_MUSIC_RESOURCE_ARTIST_LABELS = r"艺术家|藝術家|藝人|歌手|演唱|专辑艺人|專輯藝人|artist|performer"
_MUSIC_FEATURED_ARTIST_RE = re.compile(
r"\((?:featuring\s+|(?:feat|ft)(?:\.\s*|\s+))(?P<artist>[^()]+)\)", re.IGNORECASE,
)
# 曲名后含书名号的括号注释(影视原声说明等):「等得到 (电影《如影随心》主题曲 独唱版)」,
# 注释内的《》会抢先触发专辑书名号判定,需在结构解析前提取;
# 单层括号注释(电影版/Live)是 MusicBrainz 条目的消歧后缀,不提取
@@ -651,6 +667,12 @@ class MusicNameRegistry:
)
@lru_cache(maxsize=1)
def _music_pinyin_parameters() -> DefaultHmmParams:
"""按需复用已有拼音库的字音模型,仅校验副标题原文,不生成猜测名称。"""
return DefaultHmmParams()
class MetaMusic(MetaBase):
"""音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。"""
@@ -733,29 +755,112 @@ class MetaMusic(MetaBase):
meta.album, number, meta.title = track.groups()
meta.track_number = int(number)
if subtitle:
secondary = cls.parse_query(subtitle)
artist = re.search(
r"(?:^|[;\n])\s*(?:艺术家|藝術家|藝人|歌手|演唱|专辑艺人|專輯藝人|artist|performer)"
r"\s*[:]\s*([^;\n]+)", subtitle, re.I,
# 管道分隔字段包含平台、规格与发行类型,不属于作品名。
secondary_text = re.split(r"[|]", subtitle, maxsplit=1)[0].strip()
secondary = cls.parse_query(secondary_text)
artist = cls._resource_label(
subtitle, _MUSIC_RESOURCE_ARTIST_LABELS,
)
if not meta.artists:
meta.artists = cls._split_artists(artist.group(1)) if artist else list(secondary.artists)
album = re.search(
r"(?:^|[;\n])\s*(?:专辑(?:名|名称)?|專輯(?:名|名稱)?|album)\s*[:]\s*([^;\n]+)",
subtitle, re.I,
if artist:
meta.artists = cls._split_artists(cls._strip_quality_tokens(cls._strip_spec_segments(artist)))
elif (
_MUSIC_ARTIST_TITLE_RE.match(cls._normalize_text(secondary_text))
or _MUSIC_ALBUM_MARKER_RE.match(cls._normalize_text(secondary_text))
):
# 不把厂牌目录号、营销文案中的无空格连字符当作艺术家署名。
if any(re.search(r"[^\W\d_]", item) for item in secondary.artists):
meta.artists = list(secondary.artists)
album = cls._resource_label(
subtitle, r"专辑(?:名|名称)?|專輯(?:名|名稱)?|album",
)
if album and not meta.album:
meta.album = album.group(1).strip()
elif not meta.album and secondary.artists and secondary.title != meta.title:
if {cls.compact_text(item) for item in meta.artists} & {cls.compact_text(item) for item in secondary.artists}:
meta.album = secondary.title
meta.album = album
elif not meta.album and secondary.album:
primary_artists = {cls.compact_text(item) for item in meta.artists}
if primary_artists & {cls.compact_text(item) for item in secondary.artists}:
meta.album = secondary.album
if not meta.year:
meta.year = secondary.year
if not meta.year and secondary_text != subtitle:
# 日期可能独立位于后续字段;仅补年份,不采用整段文本的作品名。
meta.year = cls.parse_query(subtitle).year
meta.apply_audio_quality(subtitle)
if not meta.album and meta.title and not meta.track_number and cls._resource_is_album(title, subtitle):
meta.album = meta.title
if subtitle:
native_title = cls._native_resource_title(meta.title, subtitle, secondary)
if native_title:
if meta.album == meta.title:
meta.album = native_title
meta.title = native_title
if not meta.version:
meta.version = cls._resource_version(title, subtitle)
return meta
@classmethod
def _native_resource_title(
cls, title: Optional[str], subtitle: str, secondary: "MetaMusic",
) -> Optional[str]:
"""拼音主标题与副标题中文字音逐字一致时优先原文,保留普通外文名称。"""
if not title or not re.fullmatch(r"[A-Za-z ._-]+", title):
return None
syllables = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", title).lower()
parts = re.split(r"[ ._-]+", syllables.strip())
if not parts or not all(is_pinyin(part) for part in parts):
return None
candidates = [
cls._resource_label(subtitle, r"曲名|歌曲(?:名|名称)?|标题|標題|专辑(?:名)?|專輯(?:名)?|title|album"),
secondary.title,
]
for field in re.split(r"[|;\n]", subtitle):
# 「山歌廖哉 - 歌手:刀郎」的字段前缀本身就是明确的作品名。
candidate = re.split(
rf"\s+[-–—−-]+\s*(?:{_MUSIC_RESOURCE_ARTIST_LABELS})\s*[:]",
field, maxsplit=1, flags=re.I,
)[0]
candidates.append(candidate)
for candidate in candidates:
if not candidate:
continue
candidate = cls._normalize_text(candidate).strip(" 《》「」『』[]")
characters = cls.compact_text(candidate)
if len(characters) != len(parts) or not re.fullmatch(r"[\u3400-\u9fff]+", characters):
continue
try:
if all(char in _music_pinyin_parameters().get_states(part) for char, part in zip(characters, parts)):
return candidate
except KeyError:
# 字音模型不覆盖的音节不提供名称替换证据。
continue
return None
@staticmethod
def _resource_label(subtitle: str, labels: str) -> Optional[str]:
"""读取明确的副标题字段,允许管道、中文逗号或独立连字符作为字段边界。"""
match = re.search(
rf"(?:^|[;\n||,]|\s+[-–—−-]+\s+)\s*(?:{labels})"
r"\s*[:]\s*([^;\n||,]+?)"
r"(?=\s+[-–—−-]+\s+[^:;\n||,]+[:]|[;\n||,]|$)", subtitle, re.I,
)
return match.group(1).strip() if match else None
@classmethod
def _resource_is_album(cls, title: str, subtitle: Optional[str]) -> bool:
"""只采信明确专辑标签,不由音频格式或不同语言的副标题推断所属专辑。"""
normalized = cls._normalize_text(title)
if re.search(r"\[\s*(?:album|专辑|專輯)\s*\]", normalized, re.I):
return True
if re.search(r"\b(?:19|20)\d{2}\s+album\s*$", normalized, re.I):
return True
return any(
field.strip().strip("[]【】()()").strip().casefold() in {
"album", "专辑", "專輯", "音乐专辑", "音樂專輯",
"录音室专辑", "錄音室專輯", "单曲专辑", "單曲專輯",
}
for field in re.split(r"[|;\n]", subtitle or "")
)
@staticmethod
def _resource_version(title: str, subtitle: Optional[str]) -> Optional[str]:
"""优先保留标题版本,副标题仅接受明确版本字段或独立版本标签,避免误读艺名。"""
@@ -984,6 +1089,14 @@ class MetaMusic(MetaBase):
self.title = f"{self.title} ({context.comment})"
if parsed.artists is not None:
self.artists = list(parsed.artists)
if self.artists and not context.artists:
# 客串署名是明确艺术家证据,曲名中的原始版本说明仍完整保留。
seen = {self.compact_text(artist) for artist in self.artists}
for featured in _MUSIC_FEATURED_ARTIST_RE.findall(context.text):
for artist in self._split_artists(featured):
if self.compact_text(artist) not in seen:
self.artists.append(artist)
seen.add(self.compact_text(artist))
if parsed.album is not None:
self.album = parsed.album
if self.year is None:
@@ -1240,7 +1353,8 @@ class MetaMusic(MetaBase):
text = _MUSIC_TRAILING_CATALOG_RE.sub(" ", text)
text = _MUSIC_EMPTY_BRACKET_RE.sub(" ", text)
# 规格剥离后可能残留悬空分隔符(含 APE+CUE 类格式联合写法残留的加号),统一修剪
return cls._normalize_text(re.sub(r"^[\s\-–—−-/+]+|[\s\-–—−-/+]+$", "", text))
text = cls._normalize_text(re.sub(r"^[\s\-–—−-/+]+|[\s\-–—−-/+]+$", "", text))
return _MUSIC_RELEASE_TYPE_RE.sub(lambda match: match.group("year") or "", text).strip()
@classmethod
def _strip_artist_suffix(cls, value: str, artists: list[str]) -> str:
@@ -1731,7 +1845,12 @@ def _match_album_marker(context: MusicNameContext) -> Optional[Any]:
"""匹配 CJK 书名号专辑命名。"""
if context.artists:
return None
return _MUSIC_ALBUM_MARKER_RE.match(context.text)
matched = _MUSIC_ALBUM_MARKER_RE.match(context.text)
if matched and _MUSIC_ARTIST_TITLE_RE.match(context.text):
# 标准 artist - title 的作品名可含《片名》;双语双分隔前缀仍走专辑模式。
if len(_MUSIC_ARTIST_TITLE_SEPARATOR_RE.findall(matched.group("artist"))) == 1:
return None
return matched
def _parse_album_marker(
+209
View File
@@ -0,0 +1,209 @@
{
"sampled_on": "2026-09-06",
"provenance": "Read-only authenticated music listing samples; only original titles and subtitles retained. Expected fields are manually annotated from naming evidence, not external catalog guesses.",
"samples": [
{
"id": "wintersakura-pinyin-prefers-native-title",
"site": "wintersakura",
"title": "Yisa Yu 2019 SanShiErLi FLAC",
"subtitle": "郁可唯 - 三十而慄 2019 - FLAC 分軌",
"expected": {"artists": ["Yisa Yu"], "title": "三十而慄", "album": null, "year": 2019}
},
{
"id": "ptsbao-explicit-subtitle-album-marker",
"site": "ptsbao",
"title": "Wagner: Das Rheingold 2010 1080i Blu-ray AVC DTS 5.1",
"subtitle": "瓦格纳《莱茵的黄金》 / 大都会歌剧院高清转播系列 / Wagner: Das Rheingold | 类别:音乐 歌剧",
"expected": {"artists": ["瓦格纳"], "title": "Wagner: Das Rheingold", "album": "莱茵的黄金", "year": 2010}
},
{
"id": "hdhome-catalog-year-is-not-artist",
"site": "HDHome",
"title": "Emiri Miyamoto-Renaissance",
"subtitle": "2012 - Sony Records Int'l / SICC 10115 / SACD - DSF / Lossless / 频谱图",
"expected": {"artists": [], "album": null}
},
{
"id": "hdhome-description-is-not-artist",
"site": "HDHome",
"title": "Headphone Acoustics Reference CD [金耳朵]录音监察员专用测试天碟 编号:STS digital 611143",
"subtitle": "欧洲权威电器硬件生产制造商\"PHILIPS\"授权荷兰发烧录音制作品牌STS-Digital命名及合作录制的首张专业测试音乐CD,立体动感..",
"expected": {"artists": [], "album": null, "year": null}
},
{
"id": "hhan-single-pipe-fields",
"site": "hhanclub",
"title": "李佳薇 - 词不达意 (2020) - WEB-DL - 24bit ALAC-HHWEB",
"subtitle": "李佳薇 - 词不达意 | ALAC | 苹果音乐 | 单曲专辑",
"expected": {"artists": ["李佳薇"], "title": "词不达意", "album": "词不达意", "year": 2020, "audio_format": "ALAC", "bit_depth": 24}
},
{
"id": "hhan-featured-artist",
"site": "hhanclub",
"title": "黄明志 - 一起飙高音 (feat. 李佳薇) (2018) - WEB-DL - 24bit ALAC-HHWEB",
"subtitle": "黄明志 - 一起飙高音 (feat. 李佳薇) | ALAC | 苹果音乐 | 单曲专辑",
"expected": {"artists": ["黄明志", "李佳薇"], "title": "一起飙高音 (feat. 李佳薇)", "album": "一起飙高音 (feat. 李佳薇)", "year": 2018}
},
{
"id": "hhan-featured-compact",
"site": "hhanclub",
"title": "李佳薇 - 追心者2.0 (feat.舒灏) (2023) - WEB-DL - 16bit ALAC-HHWEB",
"subtitle": "李佳薇 - 追心者2.0 (feat.舒灏) | ALAC | 苹果音乐 | 录音室专辑",
"expected": {"artists": ["李佳薇", "舒灏"], "title": "追心者2.0 (feat.舒灏)", "album": "追心者2.0 (feat.舒灏)", "year": 2023}
},
{
"id": "hhan-soundtrack-book-title",
"site": "hhanclub",
"title": "群星 - 电视剧《如果奔跑是我的人生》原声带 (2024) - WEB-DL - 24bit ALAC-HHWEB",
"subtitle": "周深& 李佳薇& 金玟岐 & PMP Music - 电视剧《如果奔跑是我的人生》原声带 | ALAC | 苹果音乐 | 录音室专辑",
"expected": {"artists": ["群星"], "title": "电视剧《如果奔跑是我的人生》原声带", "album": "电视剧《如果奔跑是我的人生》原声带", "year": 2024}
},
{
"id": "hhan-soundtrack-leading-book-title",
"site": "hhanclub",
"title": "群星 - 《破事精英2》影视剧原声带 (2023) - WEB-DL - 24bit ALAC-HHWEB",
"subtitle": "李佳薇& 王弦& 池约翰C.J & 常佳宁 - 《破事精英2》影视剧原声带 | ALAC | 苹果音乐 | 录音室专辑",
"expected": {"artists": ["群星"], "title": "《破事精英2》影视剧原声带", "album": "《破事精英2》影视剧原声带", "year": 2023}
},
{
"id": "hhan-release-type-bit-abbreviation",
"site": "hhanclub",
"title": "李佳薇 - 大火 (Reborn) - Single(2024) - ALAC [16B-44.1kHz]",
"subtitle": "李佳薇 - 大火 (Reborn) | ALAC | 苹果音乐 | 录音室专辑",
"expected": {"artists": ["李佳薇"], "title": "大火 (Reborn)", "album": "大火 (Reborn)", "year": 2024, "bit_depth": 16, "sample_rate": 44100}
},
{
"id": "hhan-book-title-in-comment",
"site": "hhanclub",
"title": "李佳薇 - 黎明所愿 (电视剧《暗夜与黎明》插曲) (2024) - WEB-DL - 24bit ALAC-HHWEB",
"subtitle": "李佳薇 - 黎明所愿 (电视剧《暗夜与黎明》插曲) | ALAC | 苹果音乐 | 录音室专辑",
"expected": {"artists": ["李佳薇"], "title": "黎明所愿 (电视剧《暗夜与黎明》插曲)", "album": "黎明所愿 (电视剧《暗夜与黎明》插曲)", "year": 2024}
},
{
"id": "hhan-primary-artist-preserved",
"site": "hhanclub",
"title": "群星 - 风过留痕 影视原声带 (2026) - WEB-DL - 16bit ALAC-HHWEB",
"subtitle": "李佳薇& 杨宝心& 刘宇宁 & 诗和远方 - 风过留痕 影视原声带 | ALAC | 苹果音乐 | 录音室专辑",
"expected": {"artists": ["群星"], "title": "风过留痕 影视原声带", "album": "风过留痕 影视原声带", "year": 2026}
},
{
"id": "ptsbao-album-full-date",
"site": "ptsbao",
"title": "[Album] Aoi Teshima - Tokyo [2017.11.22]",
"subtitle": "[Album] Aoi Teshima - Tokyo [2017.11.22]",
"expected": {"artists": ["Aoi Teshima"], "title": "Tokyo", "album": "Tokyo", "year": 2017}
},
{
"id": "ptsbao-spaced-release-date",
"site": "ptsbao",
"title": "[2021 10 20]手嶌葵(Aoi Teshima) - Highlights from Simple is best Vol 2 [24bit96kHz] (Flac)",
"subtitle": "手嶌葵(Aoi Teshima) - Highlights from Simple is best Vol 2",
"expected": {"artists": ["手嶌葵(Aoi Teshima)"], "title": "Highlights from Simple is best Vol 2", "album": null, "year": 2021, "bit_depth": 24, "sample_rate": 96000}
},
{
"id": "ssd-explicit-subtitle-artist",
"site": "ssd",
"title": "Shan.Ge.Liao.Zai.2023.WEB-DL.FLAC-CMCTA",
"subtitle": "音乐专辑 | 山歌廖哉 - 歌手:刀郎 - FLAC分轨[~1600kbps]",
"expected": {"artists": ["刀郎"], "title": "山歌廖哉", "album": "山歌廖哉", "year": 2023, "audio_format": "FLAC"}
},
{
"id": "ssd-various-artists-compact-alias",
"site": "ssd",
"title": "VariousArtists-Top.100.Classical.Music.1994.Flac.16bit.44.1khz",
"subtitle": "群星-古典音乐1685年-1928年10CD|Delta",
"expected": {"artists": ["Various Artists"], "title": "Top 100 Classical Music", "album": null, "year": 1994, "sample_rate": 44100}
},
{
"id": "ssd-various-artists-dotted-alias",
"site": "ssd",
"title": "Various.Artists-Sci-Trance.2014-Redacted",
"subtitle": "Smiling Corpse / SCAA001 / CD / Log (100%) / Cue",
"expected": {"artists": ["Various Artists"], "album": null}
},
{
"id": "wintersakura-year-album-suffix",
"site": "wintersakura",
"title": "Sophie Zelmani-Sophie Zelmani 1995 Album",
"subtitle": "FLAC / Lossless / Log (100%) / Cue",
"expected": {"artists": ["Sophie Zelmani"], "title": "Sophie Zelmani", "album": "Sophie Zelmani", "year": 1995, "audio_format": "FLAC"}
},
{
"id": "wintersakura-year-album-second",
"site": "wintersakura",
"title": "Sophie Zelmani-Sing and Dance 2002 Album",
"subtitle": "FLAC / Lossless / Log (100%) / Cue",
"expected": {"artists": ["Sophie Zelmani"], "title": "Sing and Dance", "album": "Sing and Dance", "year": 2002}
},
{
"id": "wintersakura-spaced-sample-rate",
"site": "wintersakura",
"title": "Gene Clark-White Light 1971 - FLAC 16bit 44 1khz",
"subtitle": "吉恩·克拉克-白光 | 专辑 | 转自RED",
"expected": {"artists": ["Gene Clark"], "title": "White Light", "album": "White Light", "year": 1971, "sample_rate": 44100}
},
{
"id": "wintersakura-collection-not-recording",
"site": "wintersakura",
"title": "周杰伦 - 合集 2000-2022 - FLAC 16bit 44 1khz",
"subtitle": "周杰伦 音乐作品合集 2000-2022 | 专辑合集",
"expected": {"artists": ["周杰伦"], "title": null, "album": null, "year": 2022, "sample_rate": 44100}
},
{
"id": "0ff-spaced-sample-rate",
"site": "0ff",
"title": "Aimer - A World Where the Sun Never Rises 2025-FLAC 16bit 44 1khz-Mmx",
"subtitle": "",
"expected": {"artists": ["Aimer"], "title": "A World Where the Sun Never Rises", "album": null, "year": 2025, "sample_rate": 44100}
},
{
"id": "0ff-multiple-artists",
"site": "0ff",
"title": "Eric W Brown & Yasunori Mitsuda - Sea of Stars: Original Soundtrack 2023-FLAC 16bit 44 1khz-Mmx",
"subtitle": "",
"expected": {"artists": ["Eric W Brown", "Yasunori Mitsuda"], "title": "Sea of Stars: Original Soundtrack", "album": null, "year": 2023, "sample_rate": 44100}
},
{
"id": "0ff-hyphenated-artist",
"site": "0ff",
"title": "A-Lin - 出道十周年情歌精选 2016 FLAC 16bit 44 1kHz",
"subtitle": "",
"expected": {"artists": ["A-Lin"], "title": "出道十周年情歌精选", "album": null, "year": 2016, "sample_rate": 44100}
},
{
"id": "hdfans-volume-before-year",
"site": "hdfans",
"title": "VA - Bar Groove Analog 17 2026 FLAC",
"subtitle": "",
"expected": {"artists": ["Various Artists"], "title": "Bar Groove Analog 17", "album": null, "year": 2026}
},
{
"id": "btschool-known-good-title",
"site": "btschool",
"title": "Bonnie Tyler - The Very Best Of 2001 FLAC",
"subtitle": "()",
"expected": {"artists": ["Bonnie Tyler"], "title": "The Very Best Of", "album": null, "year": 2001}
},
{
"id": "hdsky-hyphenated-artist",
"site": "hdsky",
"title": "Anti-Flag - American Reckoning - 2009 - FLAC分轨",
"subtitle": "",
"expected": {"artists": ["Anti-Flag"], "title": "American Reckoning", "album": null, "year": 2009}
},
{
"id": "audiences-classical-composer",
"site": "audiences",
"title": "德沃夏克 - 第三、第七交响曲【1995】【CD】【FLAC分轨】",
"subtitle": "德沃夏克:第三、第七交响曲 郑明勋(1995) [维也纳爱乐乐团175周年纪念套装cd_14]",
"expected": {"artists": ["德沃夏克"], "title": "第三、第七交响曲", "album": null, "year": 1995}
},
{
"id": "hdhome-subtitle-translation-not-album",
"site": "HDHome",
"title": "罗文 & 甄妮 射雕英雄传 Roman Tam & Jenny Tseng Legends of the Condor Heroes 1983 [APE整轨+CUE]",
"subtitle": "羅文 & 甄妮 射雕英雄傳 (粤语) [APE整轨+CUE]",
"expected": {"artists": ["罗文", "甄妮"], "title": "射雕英雄传 Roman Tam & Jenny Tseng Legends of the Condor Heroes", "album": null, "year": 1983}
}
]
}
+1 -1
View File
@@ -325,7 +325,7 @@ def test_metainfo_path_uses_rust_once_and_keeps_python_directory_context(
"[CD][FLAC+CUE+LOG+BK][KDSD-01049]",
["中恵光城"],
"SELENiTE -Mitsuki Nakae Works Best Album",
None,
2022,
"FLAC",
),
(
+117
View File
@@ -0,0 +1,117 @@
"""真实站点主副标题的离线音乐识别回归,不包含站点凭据或网络访问。"""
import json
from pathlib import Path
import pytest
from app.adapters.system import rust
from app.domain.meta import runtime
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.schemas.types import MediaType
SAMPLES = json.loads(
(Path(__file__).parent / "fixtures" / "music_metainfo_samples.json").read_text(encoding="utf-8")
)["samples"]
@pytest.mark.parametrize("engine", ["python", "rust"])
@pytest.mark.parametrize("sample", SAMPLES, ids=lambda sample: sample["id"])
def test_real_music_titles_and_subtitles(sample, engine, monkeypatch):
"""两条真实 MetaInfo 路径须提取相同的名称证据,不能用 Python 回退冒充 Rust。"""
if engine == "rust":
if not rust.is_available():
pytest.skip("moviepilot_rust 扩展未安装")
monkeypatch.setattr(rust, "is_enabled", lambda: True)
def reject_python_fallback(*_args, **_kwargs):
"""Rust 样本解析一旦回退 Python 就显式失败。"""
raise AssertionError("真实音乐样本不应从 Rust 回退 Python")
monkeypatch.setattr(MetaMusic, "_prepare_name_context", reject_python_fallback)
monkeypatch.setattr(runtime, "_metainfo_accelerator", rust if engine == "rust" else None)
meta = MetaInfo(sample["title"], sample["subtitle"], mtype=MediaType.MUSIC)
assert meta.type == MediaType.MUSIC
assert meta.org_string == sample["title"]
for field, expected in sample["expected"].items():
assert getattr(meta, field) == expected, field
@pytest.mark.parametrize("engine", ["python", "rust"])
@pytest.mark.parametrize("title, expected", [
("Artist - Best Album FLAC", "Best Album"),
("Artist - Single Ladies FLAC", "Single Ladies"),
("Artist - Live At Montreux 1999 2022 FLAC", "Live At Montreux 1999 2022"),
])
def test_release_cleanup_preserves_natural_titles(title, expected, engine, monkeypatch):
"""发行类型和年份规则不能删除作品名中的自然单词或连续年份。"""
if engine == "rust" and not rust.is_available():
pytest.skip("moviepilot_rust 扩展未安装")
monkeypatch.setattr(rust, "is_enabled", lambda: True)
monkeypatch.setattr(runtime, "_metainfo_accelerator", rust if engine == "rust" else None)
meta = MetaInfo(title, mtype=MediaType.MUSIC)
assert meta.title == expected
assert meta.album is None
@pytest.mark.parametrize("engine", ["python", "rust"])
@pytest.mark.parametrize("subtitle", [
"歌手:周杰伦 - 专辑:叶惠美 | FLAC | [2003]",
"歌手:周杰伦;专辑:叶惠美;[2003] FLAC",
"歌手:周杰伦,专辑:叶惠美 | [2003] FLAC",
])
def test_subtitle_labels_keep_album_and_year_boundaries(subtitle, engine, monkeypatch):
"""明确字段可补全缺失信息,平台分隔与后续字段不能污染署名或专辑名。"""
if engine == "rust" and not rust.is_available():
pytest.skip("moviepilot_rust 扩展未安装")
monkeypatch.setattr(rust, "is_enabled", lambda: True)
monkeypatch.setattr(runtime, "_metainfo_accelerator", rust if engine == "rust" else None)
meta = MetaInfo("晴天", subtitle, mtype=MediaType.MUSIC)
assert (meta.artists, meta.title, meta.album, meta.year) == (["周杰伦"], "晴天", "叶惠美", 2003)
@pytest.mark.parametrize("engine", ["python", "rust"])
@pytest.mark.parametrize("suffix, artists", [
("(featuring Guest)", ["Artist", "Guest"]),
("(feat. Artist & Guest)", ["Artist", "Guest"]),
("(feature presentation)", ["Artist"]),
])
def test_featured_credits_require_explicit_marker(suffix, artists, engine, monkeypatch):
"""仅提取明确的客串署名,保留原始曲名,去重且不误读普通注释。"""
if engine == "rust" and not rust.is_available():
pytest.skip("moviepilot_rust 扩展未安装")
monkeypatch.setattr(rust, "is_enabled", lambda: True)
monkeypatch.setattr(runtime, "_metainfo_accelerator", rust if engine == "rust" else None)
meta = MetaInfo(f"Artist - Song {suffix} FLAC", mtype=MediaType.MUSIC)
assert meta.artists == artists
assert meta.title == f"Song {suffix}"
@pytest.mark.parametrize("engine", ["python", "rust"])
@pytest.mark.parametrize("title, subtitle, expected", [
("Shan.Ge.Liao.Zai.2023.FLAC", "音乐专辑 | 山歌廖哉 - 歌手:刀郎", "山歌廖哉"),
("Yisa Yu 2019 SanShiErLi FLAC", "郁可唯 - 三十而慄 2019 - FLAC 分軌", "三十而慄"),
("Shan Ge Liao Zai FLAC", "曲名:山高水长", "Shan Ge Liao Zai"),
("Artist - White Light 1971 FLAC", "歌手:歌手;曲名:白光", "White Light"),
("山歌廖哉 FLAC", "曲名:其他专辑", "山歌廖哉"),
])
def test_pinyin_title_prefers_verified_native_subtitle(title, subtitle, expected, engine, monkeypatch):
"""中文原文须与拼音逐字对应;同字数无关文案及正常外文名不能触发替换。"""
if engine == "rust" and not rust.is_available():
pytest.skip("moviepilot_rust 扩展未安装")
monkeypatch.setattr(rust, "is_enabled", lambda: True)
monkeypatch.setattr(runtime, "_metainfo_accelerator", rust if engine == "rust" else None)
meta = MetaInfo(title, subtitle, mtype=MediaType.MUSIC)
assert meta.title == expected
assert meta.org_string == title