diff --git a/app/domain/meta/metamusic.py b/app/domain/meta/metamusic.py index 06de1684e..e699bd3f1 100644 --- a/app/domain/meta/metamusic.py +++ b/app/domain/meta/metamusic.py @@ -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"(?16|20|24|32)\s*(?:-?bit|bits?|位)(?!\w)", re.IGNORECASE) +_BIT_DEPTH_PATTERN = re.compile(r"(?16|20|24|32)\s*(?:-?bit|bits?|位|B)(?!\w)", re.IGNORECASE) _SAMPLE_RATE_PATTERN = re.compile( - r"(?44(?:\.1)?|48|88(?:\.2)?|96|176(?:\.4)?|192|352(?:\.8)?|384|705(?:\.6)?|768)" + r"(?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"(?(?: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.+?)\s+[\-–—−-]+\s+(?P.+?)\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( diff --git a/tests/fixtures/music_metainfo_samples.json b/tests/fixtures/music_metainfo_samples.json new file mode 100644 index 000000000..60bdee97e --- /dev/null +++ b/tests/fixtures/music_metainfo_samples.json @@ -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 [24bit/96kHz] (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} + } + ] +} diff --git a/tests/test_metamusic.py b/tests/test_metamusic.py index 7dde481ea..ac7c87a1f 100644 --- a/tests/test_metamusic.py +++ b/tests/test_metamusic.py @@ -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", ), ( diff --git a/tests/test_metamusic_samples.py b/tests/test_metamusic_samples.py new file mode 100644 index 000000000..0c8731e44 --- /dev/null +++ b/tests/test_metamusic_samples.py @@ -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