diff --git a/app/chain/music.py b/app/chain/music.py index 51468ece3..feb994f7e 100644 --- a/app/chain/music.py +++ b/app/chain/music.py @@ -17,15 +17,12 @@ from app.core.context import ( ) from app.core.meta import MetaMusic from app.helper.audio import AudioMetadataHelper -from app.helper.music import MusicNameParser from app.log import logger class MusicChain(ChainBase): """音乐元数据搜索、识别与站点搜索参数编排链。""" - _artist_title_pattern = re.compile(r"^\s*(?P.+?)\s+[-–—]\s+(?P.+?)\s*$") - _spaces_pattern = re.compile(r"\s+") # 专辑目录匹配结果缓存:{目录路径: (音频文件数, 匹配结果)},避免逐文件整理时重复请求远端 _album_dir_cache: dict[str, tuple[int, dict[str, MusicInfo]]] = {} _album_dir_cache_max = 128 @@ -34,15 +31,8 @@ class MusicChain(ChainBase): @classmethod def parse_query(cls, query: str) -> MetaMusic: - """将用户输入的搜索关键词解析为音乐元数据。""" - normalized = cls._normalize_text(query) - meta = MetaMusic(org_string=query, title=normalized) - meta.apply_audio_quality(normalized) - match = cls._artist_title_pattern.match(normalized) - if match: - meta.artists = [match.group("artist").strip()] - meta.title = match.group("title").strip() - return meta + """将用户输入的搜索关键词解析为音乐元数据,解析核心在 MetaMusic.apply_title。""" + return MetaMusic(org_string=query, title=query, parse_title=True) @classmethod def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]: @@ -314,7 +304,7 @@ class MusicChain(ChainBase): @staticmethod def _normalize_match_text(value: Optional[str]) -> str: """移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。""" - return re.sub(r"[\W_]+", "", str(value or "").casefold(), flags=re.UNICODE) + return MetaMusic._compact_text(value) @classmethod def is_audio_path(cls, path: str | Path) -> bool: @@ -330,7 +320,7 @@ class MusicChain(ChainBase): else: meta = cls.parse_query(file_path.stem) # WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索 - return MusicNameParser.apply_path_context(meta, file_path) + return meta.apply_path_context(file_path) async def async_recognize_by_path( self, @@ -460,7 +450,7 @@ class MusicChain(ChainBase): @classmethod def _album_meta_from_context(cls, dir_path: Path, metas: list[MetaMusic]) -> MetaMusic: """汇总目录名和文件标签中的专辑线索,作为专辑搜索条件。""" - dir_info = MusicNameParser.parse_album_dir(dir_path.name) + dir_info = MetaMusic.parse_album_dir(dir_path.name) # 文件标签中的专辑信息比目录名更可靠,多数文件一致时优先采用 album_votes: dict[str, int] = {} artist_votes: dict[str, int] = {} @@ -611,4 +601,4 @@ class MusicChain(ChainBase): @classmethod def _normalize_text(cls, value: Optional[str]) -> str: """清理音乐检索文本中的多余空白。""" - return cls._spaces_pattern.sub(" ", str(value or "")).strip() + return re.sub(r"\s+", " ", str(value or "")).strip() diff --git a/app/core/meta/metamusic.py b/app/core/meta/metamusic.py index d58fcd37c..967b1b67d 100644 --- a/app/core/meta/metamusic.py +++ b/app/core/meta/metamusic.py @@ -1,4 +1,5 @@ import re +from pathlib import Path from typing import Any, Optional from app.core.meta.metabase import MetaBase @@ -20,7 +21,7 @@ _BITRATE_PATTERN = re.compile( r"(?<!\d)(?P<value>\d{2,4})\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])", re.IGNORECASE, ) -_LOSSLESS_PATTERN = re.compile(r"(?<!\w)(?:lossless|无损|无损音质)(?!\w)", re.IGNORECASE) +_LOSSLESS_PATTERN = re.compile(r"(?<![A-Za-z0-9])(?:lossless|无损音质|无损)(?![A-Za-z0-9])", re.IGNORECASE) _HIRES_PATTERN = re.compile(r"(?<!\w)(?:hi[ ._-]?res(?:olution)?|高解析|高分辨率音频)(?!\w)", re.IGNORECASE) _AUDIO_FORMAT_ALIASES = { @@ -180,6 +181,112 @@ def _optional_int(value: Any) -> Optional[int]: return None +# 资源标题中的音质规格与发行标记(格式、位深采样、年份括号、发行实体标记), +# 拆分艺术家/曲名前需先剔除,否则「曲名 - FLAC [16B-44.1kHz]」会被误拆成艺术家与曲名 +_MUSIC_QUALITY_TOKEN_RE = re.compile( + r"\[[^\]]*\]|\((?:19|20)\d{2}\)|" + r"\b(?:DSD(?:64|128|256|512)?|DSF|DFF|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|" + r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA|WEB-?DL|WEBRip|WEB)\b|" + r"\b\d{1,3}\s*-?\s*bits?\b|\b\d{2,4}(?:\.\d)?\s*k(?:hz|bps?)\b|" + # 无损声明词与流媒体发行实体标记(- Single / - EP),不是曲名的一部分 + r"\b(?:single|ep|album)\b|(?<![A-Za-z0-9])(?:lossless|无损音质|无损)(?![A-Za-z0-9])", + re.IGNORECASE, +) +# 演唱会/音乐视频种子的视频编码标记:分辨率、编码、容器与声道描述, +# 不是音乐文本信息,不剔除会污染曲名并阻断艺术家/曲名拆分 +_MUSIC_VIDEO_TOKEN_RE = re.compile( + r"\b(?:1080[pi]|720p|2160[pi]|480[pi]|4k|8k|uhd)\b|" + r"\b(?:bluray|blu-ray|bdrip|uhd\s*bd|hddvd|hdvd|hdtv|webrip|remux|" + r"avc|hevc|x26[45]|h\.?26[45]|mpeg-?2|vc-?1|prores|av1)\b|" + r"\b(?:dts(?:-hd\s*(?:ma|hra)?)?(?:\s*[257]\.1)?|" + r"truehd|atmos|ddp?(?:\+[\w.]*)?|eac3|ac3|lpcm|flac\s*[257]\.1|" + r"[257]\.1(?:\s*ch(?:annels?)?)?|stereo|mono)\b|" + r"\b(?:hdr10\+?|dovi|dolby\s*vision|sub(?:title)?s?|chs&cht)\b", + re.IGNORECASE, +) +# 年份括号:(2000)(2000)【2000】形式的发行年份,作为候选消歧线索 +_MUSIC_YEAR_RE = re.compile(r"[\((【]((?:19|20)\d{2})[\))】]") +# 标题尾部独立年份:「xxx音乐会 2018」「Funky Jazz Saxophone 2024」, +# 提取为发行年份线索并从曲名剥离,避免年份文本进入检索式造成零命中 +_MUSIC_TRAILING_YEAR_RE = re.compile(r"(?<!\d)\s+((?:19|20)\d{2})\s*$") +# 无括号年份区间:全集/精选标题尾部的 1967-1995、2015-16,取结束年作为发行年份线索; +# CJK 字符属于 \w,不能用 \b 定界,改用数字负向断言; +# 短年右侧禁止再跟数字,避免把 2024-01-27 这类日期的 2024-01 误当区间; +# 区间位于标题末尾(后随空白/括号/行尾)才整段剔除(全集1967-1995), +# 后随「年」等内容文字时区间是标题的一部分(1995-2000年光华真纪录),仅提取年份保留原文 +_MUSIC_YEAR_RANGE_STRIP_RE = re.compile( + r"(?<!\d)(?:19|20)\d{2}\s*[-–—~~]\s*(?:(?:19|20)(\d{2})|(\d{2}))(?=\s|[\((]|$)" +) +_MUSIC_YEAR_RANGE_DETECT_RE = re.compile( + r"(?<!\d)(?:19|20)\d{2}\s*[-–—~~]\s*(?:(?:19|20)(\d{2})|(\d{2})(?!\d))" +) +# 标题开头的广播/发行日期前缀:2018.01.10、2024-01-27_20-00 等电视录制命名, +# 非音乐文本信息,需剔除后才能正确拆分艺术家与曲名 +_MUSIC_DATE_PREFIX_RE = re.compile( + r"^\s*(?:19|20)\d{2}\s*[.\-/年]\s*\d{1,2}\s*[.\-/月]\s*\d{1,2}\s*日?" + r"(?:\s*[_\-–—\s]\s*\d{1,2}\s*[-:.]\s*\d{2})?" +) +# 多艺术家分隔符:& , , / 、 +_MUSIC_ARTIST_SEPARATOR_RE = re.compile(r"\s*(?:&|,|,|、|/)\s*") +# 合辑资源的 Various Artists 署名别名:VA 是场景命名常用缩写, +# 归一为 MusicBrainz 规范署名 Various Artists 才能命中合辑条目 +_MUSIC_ARTIST_ALIASES = {"va": "Various Artists"} +# VA-Title 无空格连字符前缀写法(场景命名),主拆分不适用需单独处理 +_MUSIC_ALIAS_PREFIX_RE = re.compile( + r"^\s*(?P<alias>VA)\s*[-–—−-]\s*(?P<title>.+\S)\s*$", + re.IGNORECASE, +) +# 艺术家与曲名的主分隔:半角/全角空格包裹的连字符(- – — − -) +_MUSIC_ARTIST_TITLE_RE = re.compile( + r"^\s*(?P<artist>.+?)\s+[\-–—−-]+\s+(?P<title>.+?)\s*$" +) +# 曲名尾部重复的艺术家署名(如「名人名曲-毛阿敏」「xxx - 许茹芸」) +_MUSIC_ARTIST_SUFFIX_RE = re.compile(r"[\-–—−-]\s*(?P<suffix>[^\-–—−-]+?)\s*$") +# 艺术家段尾部的合集修饰词:「邓丽君作品全集」需剥离为「邓丽君」才能命中条目署名 +_MUSIC_COLLECTION_SUFFIX_RE = re.compile(r"(?:的)?(?:作品)?(?:全集|精选集?|合集|精选辑)$") +# 曲序/碟号前缀:碟号-曲序(1-02、CD1.03、Disc2-05)与单曲序(01.、01 -、01 晴天、Track 01) +_MUSIC_DISC_TRACK_PREFIX_RE = re.compile( + r"^\s*(?:(?:cd|disc|disk)\s*)?(?P<disc>\d{1,2})\s*[-._]\s*(?P<num>\d{1,3})" + r"\s*[-–—.。、) ]*\s*(?P<rest>.*\S)?\s*$", + re.IGNORECASE, +) +_MUSIC_TRACK_PREFIX_RE = re.compile( + r"^\s*(?:track\s*)?(?P<num>\d{1,3})\s*[-–—.。、) ]+\s*(?P<rest>.*\S)\s*$", + re.IGNORECASE, +) +# 纯数字文件名:01.wav、Track 12.flac,只能得到曲序没有曲名 +_MUSIC_NUMBER_ONLY_RE = re.compile( + r"^\s*(?:(?:track|cd|disc|disk)\s*)?(?P<num>\d{1,3})\s*$", + re.IGNORECASE, +) +# 碟片目录名:CD1、Disc 2、Disk01 +_MUSIC_DISC_DIR_RE = re.compile( + r"^\s*(?:cd|disc|disk)\s*(?P<num>\d{1,2})\s*$", + re.IGNORECASE, +) +# 目录名中的年份:(2004)、[2004] +_MUSIC_DIR_YEAR_RE = re.compile(r"[(\[]\s*(?P<year>(?:19|20)\d{2})\s*[)\]]") +# 目录名中的括号补充说明(格式、音质、厂牌等),如 [FLAC 24bit-96kHz] +_MUSIC_BRACKET_RE = re.compile(r"\[[^\]]*\]|【[^】]*】|\([^)]*\)") +_MUSIC_SPACES_RE = re.compile(r"\s+") +_MUSIC_COMPACT_RE = re.compile(r"[\W_]+", re.UNICODE) +# 日文资源标题大量使用全角字符(WOWOW、50th、全角空格与括号), +# 归一为半角后才能与 MusicBrainz 条目及内置模式匹配 +_FULLWIDTH_EXCLAMATION = 0xFF01 +_FULLWIDTH_TILDE = 0xFF5E +_HALFWIDTH_OFFSET = 0xFEE0 +_FULLWIDTH_MAP = { + 0x3000: " ", # 全角空格 + 0x300C: "[", 0x300D: "]", # 「」 + 0x300E: "[", 0x300F: "]", # 『』 + 0x3010: "[", 0x3011: "]", # 【】 + 0x300A: "[", 0x300B: "]", # 《》 + 0xFF08: "(", 0xFF09: ")", # () + 0xFF3B: "[", 0xFF3D: "]", # [] + 0xFF5B: "{", 0xFF5D: "}", # {} +} + + def _string_list(value: Any) -> list[str]: """将标签原始值归一为非空字符串列表,兼容单值、列表与逗号分隔。""" if value is None: @@ -191,6 +298,42 @@ def _string_list(value: Any) -> list[str]: return [str(value)] +def _to_halfwidth(value: str) -> str: + """全角字符归一为半角:FF01-FF5E 按偏移换算,全角空格与括号类字符查表替换。""" + + def _translate(char: str) -> str: + code = ord(char) + if _FULLWIDTH_EXCLAMATION <= code <= _FULLWIDTH_TILDE: + return chr(code - _HALFWIDTH_OFFSET) + return _FULLWIDTH_MAP.get(code, char) + + return "".join(_translate(char) for char in str(value or "")) + + +# 场景点分命名的可替换点号:字母/数字两侧的点分单词(Shan.Ge.Liao.Zai.2023)、 +# 四位年份间的点分(1999.2022)与环绕符号的点(Purple.&.Orchestra、-.Live); +# 不含单位数字间的小数点,保护 5.1 声道与 44.1kHz 这类规格写法 +_SCENE_DOT_RE = re.compile( + r"(?<=[A-Za-z])\.(?=[A-Za-z])" + r"|(?<=[A-Za-z])\.(?=\d)" + r"|(?<=\d)\.(?=[A-Za-z])" + r"|(?<=\d{4})\.(?=\d)" + r"|(?<=[A-Za-z0-9])\.(?=[\-–—&+])" + r"|(?<=[\-–—&+])\.(?=[A-Za-z0-9])" +) + + +def _normalize_scene_dots(value: str) -> str: + """场景命名的点分单词(Shan.Ge.Liao.Zai.2023)归一为空格。 + + 点分隔少于 3 处时视为普通缩写(如 E.S.Posthumus)不处理, + 避免破坏艺术家名中的合法点号。 + """ + if len(_SCENE_DOT_RE.findall(value or "")) < 3: + return value + return _SCENE_DOT_RE.sub(" ", value) + + class MetaMusic(MetaBase): """音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。""" @@ -216,6 +359,7 @@ class MetaMusic(MetaBase): isrc: Optional[str] = None, media_source: Optional[str] = None, media_id: Optional[str] = None, + parse_title: bool = False, ): # 音乐无季集概念,仅复用 MetaBase 的基础字段初始化,不触发副标题季集识别 super().__init__(title or org_string or "") @@ -240,6 +384,9 @@ class MetaMusic(MetaBase): self.isrc = isrc self.media_source = media_source self.media_id = media_id + if parse_title: + # 种子/文件名字符串场景:解析艺术家、曲名、年份并补充音质参数 + self.apply_title(self.title or org_string or "") @property def name(self) -> str: @@ -291,6 +438,355 @@ class MetaMusic(MetaBase): self.audio_format = normalize_audio_format(self.audio_format) self.audio_lossless = infer_audio_lossless(self.audio_format, self.audio_lossless) + def apply_title(self, value: Any) -> None: + """解析种子/文件名标题字符串,提取艺术家、曲名、年份并补充音质参数。 + + 与影视 MetaVideo 在构造时解析标题一致,这里是音乐分支的识别核心: + 先剔除音质规格与发行标记,再拆分艺术家与曲名,最后提取曲序前缀。 + """ + raw = str(value or "") + self.apply_audio_quality(raw) + normalized = self._normalize_text(raw) + if not normalized: + return + # 年份括号在规格剔除前提取,作为发行年份线索参与候选消歧 + years = _MUSIC_YEAR_RE.findall(normalized) + if years and not self.year: + # 多个年份括号时取最后一个,资源标题中年份通常位于末位 + self.year = int(years[-1]) + # 音质标记先剔除再拆分,避免规格文本独占曲名位置 + cleaned = self._strip_quality_tokens(normalized) + # 广播/发行日期前缀与年份区间在规格剔除后处理,避免误伤曲名中的短数字序列 + cleaned, range_year = self._strip_date_prefix(cleaned) + if range_year and not self.year: + self.year = range_year + match = _MUSIC_ARTIST_TITLE_RE.match(cleaned) + if match and not self.artists: + self.artists = self._split_artists(match.group("artist")) + self._finalize_title( + self._strip_artist_suffix( + self._clean_tail(match.group("title")), self.artists)) + elif cleaned: + # 场景命名的 VA-Title 无空格连字符写法,主拆分不适用,按别名前缀单独拆分 + alias_match = None if self.artists else _MUSIC_ALIAS_PREFIX_RE.match(cleaned) + if alias_match: + self.artists = [_MUSIC_ARTIST_ALIASES[alias_match.group("alias").casefold()]] + self._finalize_title(self._clean_tail(alias_match.group("title"))) + else: + # CJK 标题常见「专辑名-歌手」无空格连字符写法,主拆分未命中时兑底反向拆分 + artists, title = self._split_cjk_hyphen(cleaned) + if artists: + self.artists = artists + self._finalize_title(self._clean_tail(title)) + else: + title = self._clean_tail(cleaned) + # 无艺术家线索时剥离 CJK 标题尾部的「曲名-歌手」署名,候选比对阶段负责验证身份 + self._finalize_title(self._strip_cjk_artist_suffix(title)) + else: + self.title = None + self._apply_track_prefix() + + @classmethod + def _pop_trailing_year(cls, value: str) -> tuple[str, Optional[int]]: + """剥离曲名尾部独立年份并返回 (曲名, 年份)。""" + match = _MUSIC_TRAILING_YEAR_RE.search(str(value or "")) + if not match: + return value, None + head = value[:match.start()] + # 「Live At Montreux 1999 2022」连续双年份属于标题内容,不剥离 + if re.search(r"(?:19|20)\d{2}\s*$", head): + return value, None + return head.strip(), int(match.group(1)) + + def _finalize_title(self, value: str) -> None: + """设置曲名并提取尾部独立年份作为发行年份线索。""" + title, year = self._pop_trailing_year(value) + if year and not self.year: + self.year = year + self.title = title + + @staticmethod + def _normalize_text(value: Any) -> str: + """全角归一为半角后清理多余空白,文件名消毒下划线统一转空格。""" + text = _to_halfwidth(str(value or "")).replace("_", " ") + # 场景命名用点号分隔单词(Shan.Ge.Liao.Zai.2023),归一为空格便于拆分检索 + text = _normalize_scene_dots(text) + return _MUSIC_SPACES_RE.sub(" ", text).strip() + + @classmethod + def _split_artists(cls, value: str) -> list[str]: + """拆分多艺术家字段(如 章子怡 & 周深),保留顺序供检索与候选比对使用。""" + return [ + cls._canonical_artist(artist.strip()) + for artist in _MUSIC_ARTIST_SEPARATOR_RE.split(value) + if artist.strip() + ] + + @staticmethod + def _canonical_artist(value: str) -> str: + """归一 VA 等合辑艺术家别名为 MusicBrainz 规范署名。""" + return _MUSIC_ARTIST_ALIASES.get(value.casefold(), value) + + @staticmethod + def _clean_tail(value: str) -> str: + """修剪曲名尾部残留:流媒体文件名消毒产生的下划线与悬空分隔符。""" + return re.sub(r"[\s_\-–—−-/]+$", "", str(value or "")).strip() + + @classmethod + def _strip_quality_tokens(cls, value: str) -> str: + """剥离音频格式、视频编码、规格参数与年份括号,保留有效的艺术家与曲名文本。""" + raw = str(value or "") + has_quality_token = bool(_MUSIC_QUALITY_TOKEN_RE.search(raw) or _MUSIC_VIDEO_TOKEN_RE.search(raw)) + text = _MUSIC_QUALITY_TOKEN_RE.sub(" ", raw) + text = _MUSIC_VIDEO_TOKEN_RE.sub(" ", text) + # 规格剥离后可能残留悬空分隔符,统一修剪 + text = cls._normalize_text(re.sub(r"^[\s\-–—−-/]+|[\s\-–—−-/]+$", "", text)) + if not has_quality_token: + return text + # 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB、AAC-FHDMv),整体剔除避免污染曲名; + # 仅在存在音质标记时剔除,否则「艺术家 - 单词曲名」会被误当标签吞掉; + # 要求标签至少含一个字母,避免误吞年份区间尾巴(-1995) + return re.sub( + r"[-–—−-]\s*(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{3,}\s*$", "", text, flags=re.IGNORECASE + ).strip() + + @classmethod + def _strip_artist_suffix(cls, value: str, artists: list[str]) -> str: + """剥离曲名尾部重复的艺术家署名,资源标题常见「曲名 - 艺术家」写法。""" + if not artists or not value: + return value + match = _MUSIC_ARTIST_SUFFIX_RE.search(value) + if not match: + return value + suffix = cls._compact_text(match.group("suffix")) + if not suffix: + return value + if any(suffix == cls._compact_text(artist) for artist in artists): + return value[:match.start()].strip() + return value + + @classmethod + def _strip_cjk_artist_suffix(cls, value: str) -> str: + """无艺术家线索时剥离 CJK 曲名尾部的歌手署名(「因为有你-毛阿敏」)。 + + 仅限曲名与署名都含 CJK 才剥离,避免误伤英文曲名的连字符组成部分 + (如 Live-in-XXX);剥离后的署名身份由候选比对阶段验证。 + """ + if not value: + return value + match = _MUSIC_ARTIST_SUFFIX_RE.search(value) + if not match: + return value + head = value[:match.start()].strip() + suffix = match.group("suffix").strip() + if head and suffix and cls._contains_cjk(head) and cls._contains_cjk(suffix): + return head + return value + + @classmethod + def _strip_date_prefix(cls, value: str) -> tuple[str, Optional[int]]: + """剔除标题开头的广播/发行日期前缀,并提取尾部年份区间结束年。 + + :return: (处理后的文本, 年份区间结束年),无区间时为 None + """ + text = _MUSIC_DATE_PREFIX_RE.sub("", str(value or "")).strip(" -–—_\t") + year: Optional[int] = None + # 年份区间在日期前缀剔除后提取;位于标题末尾的整段剔除,其余仅提取年份线索 + range_match = _MUSIC_YEAR_RANGE_STRIP_RE.search(text) + strip_span = True + if not range_match: + range_match = _MUSIC_YEAR_RANGE_DETECT_RE.search(text) + strip_span = False + if range_match: + end_year = int(range_match.group(1) or range_match.group(2)) + # 两位结束年补世纪:50 以上视为 19xx,否则 20xx + year = end_year + (1900 if end_year >= 50 else 2000) + if strip_span: + text = text[:range_match.start()] + text[range_match.end():] + return cls._normalize_text(text), year + + @staticmethod + def _contains_cjk(value: str) -> bool: + """判断文本是否包含中日韩字符,用于限定裸连字符拆分适用范围。""" + return any( + 0x3040 <= code <= 0x30FF or 0x3400 <= code <= 0x9FFF or 0xAC00 <= code <= 0xD7AF + for code in map(ord, value or "") + ) + + @classmethod + def _split_cjk_hyphen(cls, value: str) -> tuple[Optional[list[str]], str]: + """CJK 文本按无空格连字符拆分「专辑/曲名-艺术家」,两侧均需含 CJK 才采信。 + + 英文标题连字符多为曲名组成部分(如 Alchemy-Live),不适用此拆分; + 多个连字符时取最后一段为艺术家,兼容「作品全集-系列名-艺术家」。 + """ + text = str(value or "").strip() + if not cls._contains_cjk(text): + return None, text + # 依次尝试双破折号(为你盛开——许巍)、半角连字符、全角连字符(已归一)与单破折号 + head, sep, tail = "", "", "" + for separator in ("——", "-", "-", "—"): + head, sep, tail = text.rpartition(separator) + if sep: + break + if not sep: + return None, text + head = head.strip(" \t-–—−-") + tail = tail.strip(" \t-–—−-") + if head and tail and cls._contains_cjk(head) and cls._contains_cjk(tail): + # 分隔符后跟随多个词时只取首词为艺术家(「为你盛开——许巍 巡回演唱会」) + artist_text = tail.split(" ", 1)[0] + # 艺术家段常见「xx作品全集」合集修饰,剥离后才能与条目署名比对 + artist = _MUSIC_COLLECTION_SUFFIX_RE.sub("", artist_text).strip() or artist_text + return [artist], head + return None, text + + @staticmethod + def _compact_text(value: Any) -> str: + """移除大小写、空白与标点,生成比对使用的紧凑文本。""" + return _MUSIC_COMPACT_RE.sub("", str(value or "").casefold()) + + @staticmethod + def _clean_text(value: Any) -> str: + """压缩多余空白,返回可用于匹配和展示的文本。""" + return _MUSIC_SPACES_RE.sub(" ", str(value or "")).strip() + + def _apply_track_prefix(self) -> None: + """提取曲名开头的曲序/碟号前缀(01. 曲名、1-02 曲名、CD1.03 曲名)。""" + if not self.title: + return + track_number, disc_number, remainder = self.split_track_prefix(self.title) + if track_number is None and disc_number is None: + return + if self.track_number is None: + self.track_number = track_number + if self.disc_number is None: + self.disc_number = disc_number + if remainder: + self.title = remainder + + @classmethod + def split_track_prefix(cls, stem: str) -> tuple[Optional[int], Optional[int], Optional[str]]: + """剥离文件名/曲名中的曲序和碟号前缀。 + + :param stem: 不含扩展名的文件名或曲名文本 + :return: (曲序, 碟号, 剥离前缀后的曲名),无法剥离的字段返回 None; + 曲名为 None 表示文本没有携带曲名信息 + """ + text = str(stem or "").strip() + if not text: + return None, None, None + match = _MUSIC_DISC_TRACK_PREFIX_RE.match(text) + if match: + return ( + int(match.group("num")), + int(match.group("disc")), + cls._clean_text(match.group("rest")), + ) + match = _MUSIC_TRACK_PREFIX_RE.match(text) + if match: + return int(match.group("num")), None, cls._clean_text(match.group("rest")) + match = _MUSIC_NUMBER_ONLY_RE.match(text) + if match: + # 纯数字文件名保留原始文本作为兜底标题,只提取曲序 + return int(match.group("num")), None, None + return None, None, None + + @classmethod + def split_artist_title(cls, text: str) -> tuple[Optional[str], str]: + """拆分 `歌手 - 标题` 结构,未命中时原文作为标题返回。""" + cleaned = cls._clean_text(text) + if not cleaned: + return None, "" + match = _MUSIC_ARTIST_TITLE_RE.match(cleaned) + if match: + return cls._clean_text(match.group("artist")), cls._clean_text(match.group("title")) + return None, cleaned + + @classmethod + def parse_disc_dir(cls, name: str) -> Optional[int]: + """识别 CD1、Disc 2 这类碟片子目录并返回碟号。""" + match = _MUSIC_DISC_DIR_RE.match(str(name or "").strip()) + return int(match.group("num")) if match else None + + @classmethod + def parse_album_dir(cls, name: str) -> dict[str, Any]: + """解析专辑目录名,提取歌手、专辑名、年份和音质描述。 + + 支持 `歌手 - 专辑 (2004) [FLAC 24bit-96kHz]` 等常见命名。 + """ + text = cls._clean_text(name) + if not text: + return {} + year = None + year_match = _MUSIC_DIR_YEAR_RE.search(text) + if year_match: + year = int(year_match.group("year")) + text = _MUSIC_DIR_YEAR_RE.sub(" ", text) + # 括号内的格式/音质描述先剥离出专辑名,但仍可用于音质解析 + brackets = " ".join(fragment for fragment in _MUSIC_BRACKET_RE.findall(text)) + album_text = cls._clean_text(_MUSIC_BRACKET_RE.sub(" ", text)) + if not album_text: + return {} + artist, album = cls.split_artist_title(album_text) + return { + "artist": artist, + "album": album, + "year": year, + "quality_text": cls._clean_text(f"{album_text} {brackets}"), + } + + def apply_path_context(self, path: "str | Path") -> "MetaMusic": + """用文件名和目录线索回填音乐元数据中缺失的字段。 + + 仅补充空字段,音频标签中已读取到的内容不会被目录猜测覆盖; + 标题来自文件名兜底(等于文件主干名)时视为缺失,允许用解析结果替换。 + """ + file_path = Path(path) + stem = file_path.stem + title_from_name = not self.title or self.title == stem + + # 文件名前缀:曲序、碟号、曲名 + track_number, disc_number, parsed_title = self.split_track_prefix(stem) + if self.track_number is None and track_number is not None: + self.track_number = track_number + if self.disc_number is None and disc_number is not None: + self.disc_number = disc_number + if title_from_name: + base_title = parsed_title or stem + if not self.artists: + # `歌手 - 曲名` 文件名在无艺术家标签时继续拆分 + artist, title = self.split_artist_title(base_title) + if artist: + self.artists = [artist] + base_title = title + self.title = base_title + + # 目录结构:父目录可能是碟片目录,专辑目录再往上一级 + parent = file_path.parent + album_dir = parent + parent_disc = self.parse_disc_dir(parent.name) + if parent_disc is not None: + if self.disc_number is None: + self.disc_number = parent_disc + album_dir = parent.parent + dir_info = self.parse_album_dir(album_dir.name) + if dir_info: + # 目录名同时带歌手或年份才视为有意的专辑命名,避免把监控根目录误当专辑 + if dir_info.get("artist") or dir_info.get("year"): + if not self.album and dir_info.get("album"): + self.album = dir_info["album"] + if not self.artists and dir_info.get("artist"): + self.artists = [dir_info["artist"]] + if not self.album_artist and dir_info.get("artist"): + self.album_artist = dir_info["artist"] + if self.year is None and dir_info.get("year"): + self.year = dir_info["year"] + # 目录名里的格式、位深、采样率可补齐本地标签未声明的音质参数 + if dir_info.get("quality_text"): + self.apply_audio_quality(dir_info["quality_text"]) + return self + @property def season(self) -> None: """音乐没有季信息,兼容下载与事件链的通用访问。""" diff --git a/app/core/metainfo.py b/app/core/metainfo.py index ac21fc422..62f0393c0 100644 --- a/app/core/metainfo.py +++ b/app/core/metainfo.py @@ -12,7 +12,6 @@ from app.core.meta.infopath import ( should_use_parent_title_for_file_stem, ) from app.core.meta.words import WordsMatcher -from app.helper.music import MusicNameParser from app.log import logger from app.schemas.types import MediaType from app.utils import rust_accel @@ -437,6 +436,7 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] org_string=title, title=Path(title).stem, audio_format=audio_suffix.lstrip(".").upper() or None, + parse_title=True, ) rust_meta = None if not _requires_python_metainfo(title, custom_words): @@ -468,9 +468,10 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None, force_video: bool = org_string=path.name, title=path.stem, audio_format=audio_suffix.lstrip(".").upper() or None, + parse_title=True, ) # 无标签音频只能依靠文件名和目录结构,补充曲序、碟号、歌手和专辑线索 - return MusicNameParser.apply_path_context(music_meta, path) + return music_meta.apply_path_context(path) path_context = " ".join( [path.name, path.parent.name, path.parent.parent.name] ) diff --git a/app/helper/music.py b/app/helper/music.py deleted file mode 100644 index 15685f395..000000000 --- a/app/helper/music.py +++ /dev/null @@ -1,173 +0,0 @@ -import re -from pathlib import Path -from typing import Any, Optional - -from app.core.meta import MetaMusic - - -class MusicNameParser: - """解析音频文件名和目录名,在音频标签缺失时补充音乐识别线索。 - - WAV 等容器不带标签、FLAC/MP3 标签不全时,唯一线索来自文件名和目录结构。 - 这里只负责从文本中提取结构化信息(曲序、碟号、歌手、专辑、年份), - 不访问文件系统以外的任何资源,解析结果按"标签 > 文件名 > 目录名"优先级回填。 - """ - - # 碟号-曲序前缀:1-02、CD1.03、Disc2-05 等,后面可跟分隔符和曲名 - _disc_track_prefix_pattern = re.compile( - r"^\s*(?:(?:cd|disc|disk)\s*)?(?P<disc>\d{1,2})\s*[-._]\s*(?P<num>\d{1,3})" - r"\s*[-–—.。、) ]*\s*(?P<rest>.*\S)?\s*$", - re.IGNORECASE, - ) - # 曲序前缀:01.、01 -、01)、01 晴天、Track 01 - 等 - _track_prefix_pattern = re.compile( - r"^\s*(?:track\s*)?(?P<num>\d{1,3})\s*[-–—.。、) ]+\s*(?P<rest>.*\S)\s*$", - re.IGNORECASE, - ) - # 纯数字文件名:01.wav、Track 12.flac,只能得到曲序没有曲名 - _number_only_pattern = re.compile( - r"^\s*(?:(?:track|cd|disc|disk)\s*)?(?P<num>\d{1,3})\s*$", - re.IGNORECASE, - ) - # 碟片目录名:CD1、Disc 2、Disk01 - _disc_dir_pattern = re.compile( - r"^\s*(?:cd|disc|disk)\s*(?P<num>\d{1,2})\s*$", - re.IGNORECASE, - ) - # 目录名中的年份:(2004)、[2004] - _year_pattern = re.compile(r"[(\[]\s*(?P<year>(?:19|20)\d{2})\s*[)\]]") - # 目录名中的括号补充说明(格式、音质、厂牌等),如 [FLAC 24bit-96kHz] - _bracket_pattern = re.compile(r"\[[^\]]*\]|【[^】]*】|\([^)]*\)") - # 歌手与标题/专辑的分隔符 - _artist_title_pattern = re.compile( - r"^\s*(?P<artist>.+?)\s+[-–—]\s+(?P<title>.+?)\s*$" - ) - _spaces_pattern = re.compile(r"\s+") - - @classmethod - def strip_track_prefix(cls, stem: str) -> tuple[Optional[int], Optional[int], Optional[str]]: - """剥离文件名中的曲序和碟号前缀。 - - :param stem: 不含扩展名的文件名 - :return: (曲序, 碟号, 剥离前缀后的曲名),无法剥离的字段返回 None; - 曲名为 None 表示文件名没有携带曲名信息 - """ - text = str(stem or "").strip() - if not text: - return None, None, None - match = cls._disc_track_prefix_pattern.match(text) - if match: - return ( - int(match.group("num")), - int(match.group("disc")), - cls._clean(match.group("rest")), - ) - match = cls._track_prefix_pattern.match(text) - if match: - return int(match.group("num")), None, cls._clean(match.group("rest")) - match = cls._number_only_pattern.match(text) - if match: - # 纯数字文件名保留原始文本作为兜底标题,只提取曲序 - return int(match.group("num")), None, None - return None, None, None - - @classmethod - def split_artist_title(cls, text: str) -> tuple[Optional[str], str]: - """拆分 `歌手 - 标题` 结构,未命中时原文作为标题返回。""" - match = cls._artist_title_pattern.match(str(text or "").strip()) - if match: - return cls._clean(match.group("artist")), cls._clean(match.group("title")) - return None, cls._clean(text) - - @classmethod - def parse_disc_dir(cls, name: str) -> Optional[int]: - """识别 CD1、Disc 2 这类碟片子目录并返回碟号。""" - match = cls._disc_dir_pattern.match(str(name or "").strip()) - return int(match.group("num")) if match else None - - @classmethod - def parse_album_dir(cls, name: str) -> dict[str, Any]: - """解析专辑目录名,提取歌手、专辑名、年份和音质描述。 - - 支持 `歌手 - 专辑 (2004) [FLAC 24bit-96kHz]` 等常见命名。 - """ - text = cls._clean(name) - if not text: - return {} - year = None - year_match = cls._year_pattern.search(text) - if year_match: - year = int(year_match.group("year")) - text = cls._year_pattern.sub(" ", text) - # 括号内的格式/音质描述先剥离出专辑名,但仍可用于音质解析 - brackets = " ".join( - fragment - for fragment in cls._bracket_pattern.findall(text) - ) - album_text = cls._clean(cls._bracket_pattern.sub(" ", text)) - if not album_text: - return {} - artist, album = cls.split_artist_title(album_text) - return { - "artist": artist, - "album": album, - "year": year, - "quality_text": cls._clean(f"{album_text} {brackets}"), - } - - @classmethod - def apply_path_context(cls, meta: MetaMusic, path: Path) -> MetaMusic: - """用文件名和目录线索回填音乐元数据中缺失的字段。 - - 仅补充空字段,音频标签中已读取到的内容不会被目录猜测覆盖; - 标题来自文件名兜底(等于文件主干名)时视为缺失,允许用解析结果替换。 - """ - file_path = Path(path) - stem = file_path.stem - title_from_name = not meta.title or meta.title == stem - - # 文件名前缀:曲序、碟号、曲名 - track_number, disc_number, parsed_title = cls.strip_track_prefix(stem) - if meta.track_number is None and track_number is not None: - meta.track_number = track_number - if meta.disc_number is None and disc_number is not None: - meta.disc_number = disc_number - if title_from_name: - base_title = parsed_title or stem - if not meta.artists: - # `歌手 - 曲名` 文件名在无艺术家标签时继续拆分 - artist, title = cls.split_artist_title(base_title) - if artist: - meta.artists = [artist] - base_title = title - meta.title = base_title - - # 目录结构:父目录可能是碟片目录,专辑目录再往上一级 - parent = file_path.parent - album_dir = parent - parent_disc = cls.parse_disc_dir(parent.name) - if parent_disc is not None: - if meta.disc_number is None: - meta.disc_number = parent_disc - album_dir = parent.parent - dir_info = cls.parse_album_dir(album_dir.name) - if dir_info: - # 目录名同时带歌手或年份才视为有意的专辑命名,避免把监控根目录误当专辑 - if dir_info.get("artist") or dir_info.get("year"): - if not meta.album and dir_info.get("album"): - meta.album = dir_info["album"] - if not meta.artists and dir_info.get("artist"): - meta.artists = [dir_info["artist"]] - if not meta.album_artist and dir_info.get("artist"): - meta.album_artist = dir_info["artist"] - if meta.year is None and dir_info.get("year"): - meta.year = dir_info["year"] - # 目录名里的格式、位深、采样率可补齐本地标签未声明的音质参数 - if dir_info.get("quality_text"): - meta.apply_audio_quality(dir_info["quality_text"]) - return meta - - @classmethod - def _clean(cls, value: Optional[str]) -> str: - """压缩多余空白,返回可用于匹配和展示的文本。""" - return cls._spaces_pattern.sub(" ", str(value or "")).strip() diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index 9a41427d7..89b842d79 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -5,6 +5,7 @@ from difflib import SequenceMatcher from typing import Any, Iterable, Optional, Tuple, Union from fastapi.concurrency import run_in_threadpool +from requests import Session from app.core.cache import cached from app.core.config import settings @@ -20,6 +21,7 @@ from app.log import logger from app.modules import _ModuleBase from app.schemas.types import MediaRecognizeType, MediaType, ModuleType from app.utils.http import RequestUtils +from app.utils.zhconv import convert as zhconv_convert class MusicBrainzModule(_ModuleBase): @@ -34,6 +36,12 @@ class MusicBrainzModule(_ModuleBase): _request_interval = 1.0 _request_lock = threading.Lock() _last_request_at = 0.0 + # 全局复用 HTTP 会话:keep-alive 省去每次请求的 DNS+TLS 握手(约 6s → 0.4s) + _session: Optional[Session] = None + _session_lock = threading.Lock() + # 服务端繁忙(429/5xx)时的重试次数与退避基数,重试间隔随次数翻倍递增 + _busy_retries = 2 + _busy_backoff = 5.0 # 关联艺术家按关系可读性排序,纪念性质的致敬关系数量庞大且价值低,放到最后 _artist_relation_priority = ( "member of band", @@ -116,40 +124,103 @@ class MusicBrainzModule(_ModuleBase): def _search_recordings(self, meta: MetaMusic, limit: int) -> list[MusicInfo]: """按音频标签条件搜索 Recording,供全局搜索和文件识别复用。""" - query = self._build_query(meta) - if not query: - return [] - payload = self._request_json( - "/recording", - params={"query": query, "limit": max(1, min(limit, 100)), "fmt": "json"}, - ) - return [ - info - for item in (payload or {}).get("recordings") or [] - if (info := self._recording_to_info(item)) - ] + for query in self._recording_queries(meta): + payload = self._request_json( + "/recording", + params={"query": query, "limit": max(1, min(limit, 100)), "fmt": "json"}, + ) + results = [ + info + for item in (payload or {}).get("recordings") or [] + if (info := self._recording_to_info(item)) + ] + if results: + return results + return [] - def _search_albums(self, meta: MetaMusic, limit: int) -> list[MusicInfo]: - """按标题和可选艺术家搜索 Release Group 专辑候选。""" - title = meta.album or meta.title + @classmethod + def _recording_queries(cls, meta: MetaMusic) -> list[str]: + """构造 Recording 检索式阶梯,由严到宽逐级放宽避免零命中。 + + 条目括号多为全角、艺术家署名存在变体(如外文艺名),精确 AND 条件容易零命中; + 放宽后由候选评分负责收紧(已知艺术家时必须艺术家命中),不会产生错误身份。 + """ + title = cls._search_title(meta.title) if not title: return [] - clauses = [f'releasegroup:"{self._escape_query(title)}"'] - if meta.artists: - clauses.append(f'artist:"{self._escape_query(meta.artists[0])}"') - payload = self._request_json( - "/release-group", - params={ - "query": " AND ".join(clauses), - "limit": max(1, min(limit, 100)), - "fmt": "json", - }, - ) - return [ - album.to_music_info() - for item in (payload or {}).get("release-groups") or [] - if (album := self._release_group_to_album(item)) - ] + artist = meta.artists[0] if meta.artists else None + # 括号内的影视 tie-in、版本说明多为半角,与条目全角写法不一致,准备去注释曲名兜底 + bare_title = cls._strip_parenthetical(title) + 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)}"' + if artist and bare_title and bare_title != title else None, + # 艺术家署名变体(外文艺名等)导致 AND 条件零命中时,仅按主体曲名检索, + # 候选挑选阶段要求艺术家命中兜住同名异曲 + f'recording:"{cls._escape_query(bare_title)}"' if bare_title else None, + ]: + if query and query not in queries: + queries.append(query) + return queries + + @classmethod + def _strip_parenthetical(cls, value: str) -> str: + """去除标题中的括号注释,保留主体曲名。""" + text = re.sub(r"[\((][^\))]*[\))]", " ", str(value or "")) + return cls._normalize_text(text) + + @classmethod + def _main_title(cls, value: Optional[str]) -> str: + """提取冒号副标题结构的主标题,兼容全角/半角冒号。""" + text = re.split(r"\s*[::]\s*", str(value or ""), maxsplit=1)[0] + return cls._normalize_text(text) + + def _search_albums(self, meta: MetaMusic, limit: int) -> list[MusicInfo]: + """按标题和可选艺术家搜索 Release Group 专辑候选,检索式同样逐级放宽。""" + for query in self._album_queries(meta): + payload = self._request_json( + "/release-group", + params={ + "query": query, + "limit": max(1, min(limit, 100)), + "fmt": "json", + }, + ) + results = [ + album.to_music_info() + for item in (payload or {}).get("release-groups") or [] + if (album := self._release_group_to_album(item)) + ] + if results: + return results + return [] + + @classmethod + def _album_queries(cls, meta: MetaMusic) -> list[str]: + """构造专辑检索式阶梯:专辑名+艺术家 → 仅专辑名 → 去括号/卷号变体。""" + title = cls._search_title(meta.album or meta.title) + if not title: + return [] + artist = meta.artists[0] if meta.artists else None + # 括号注释与 Vol. 卷号在条目中常以 disambiguation 形式存在,变体名兜底检索 + bare_title = cls._strip_parenthetical(title) + bare_title = re.sub(r",?\s*vol\.?\s*\d+$", "", bare_title, flags=re.IGNORECASE) + 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)}"' + if artist else None, + f'releasegroup:"{cls._escape_query(title)}"', + f'releasegroup:"{cls._escape_query(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, + ]: + if query and query not in queries: + queries.append(query) + return queries def _search_artists(self, meta: MetaMusic, limit: int) -> list[MusicInfo]: """按用户输入中的艺术家部分搜索 Artist 浏览候选。""" @@ -382,8 +453,14 @@ class MusicBrainzModule(_ModuleBase): @staticmethod def _match_text(value: Optional[str]) -> str: - """移除大小写、空白和标点差异,生成相似度比较使用的紧凑文本。""" - return re.sub(r"[\W_]+", "", str(value or "").casefold(), flags=re.UNICODE) + """移除大小写、空白、标点和繁简差异,生成相似度比较使用的紧凑文本。""" + text = str(value or "").casefold() + try: + # 候选比对统一简体,避免条目繁体写法造成失配 + text = zhconv_convert(text, "zh-hans") + except Exception: # pylint: disable=broad-except + pass + return re.sub(r"[\W_]+", "", text, flags=re.UNICODE) @classmethod def _unique_texts(cls, values: Iterable[Optional[str]]) -> list[str]: @@ -455,10 +532,16 @@ class MusicBrainzModule(_ModuleBase): info = self.recognize_music(resolved_source, str(mediaid or meta.media_id)) if info: return info - # 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兜底 + # 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兑底 # 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。 candidates = self._search_recordings(meta, limit=10) matched = self._select_candidate(meta, candidates, source=resolved_source or self._source) + # 整专/单曲发行类资源在 Recording 检索无果时,回退按专辑实体识别; + # 专辑挑选要求标题与艺术家同时命中,无艺术家线索时回退检索必然无果, + # 直接跳过避免浪费限流配额(批量识别场景可减少约半数请求) + if not matched and meta.artists: + albums = self._search_albums(meta, limit=10) + matched = self._select_album_candidate(meta, albums) return matched or self._info_from_meta(meta) async def async_recognize_media( @@ -483,28 +566,101 @@ class MusicBrainzModule(_ModuleBase): def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], source: str) -> Optional[MusicInfo]: """按标题、艺术家和专辑匹配度选择最可信的搜索候选。""" normalized_source = cls._normalize_text(source).casefold() + # 资源标题携带的音质标记先剥离,再与候选曲名比对 + clean_title = cls._search_title(meta.title) + # 条目的影视 tie-in 注释多为全角括号,与资源半角注释无法精确相等, + # 去括号后的主体曲名一致视为弱匹配,且需艺术家同时命中才采信 + bare_title = cls._strip_parenthetical(clean_title) ranked: list[tuple[int, MusicInfo]] = [] for candidate in candidates: if normalized_source and (candidate.source or "").casefold() != normalized_source: continue score = 0 - if meta.title and cls._same_text(meta.title, candidate.title): + # 多艺术家资源任一命中即可,联名候选不会因主艺术家顺序失配 + artist_match = bool(meta.artists) and any( + cls._same_text(artist_name, candidate_artist) + for artist_name in meta.artists + for candidate_artist in candidate.artists + ) + if clean_title and cls._same_text(clean_title, candidate.title): score += 4 - if meta.artists and any( - cls._same_text(meta.artists[0], artist) - for artist in candidate.artists + elif ( + bare_title + and artist_match + and ( + cls._same_text(bare_title, cls._strip_parenthetical(candidate.title)) + # 条目「天國的情人:鄧麗君逝世十周年…」这类冒号副标题,主标题一致视为弱匹配 + or cls._same_text(bare_title, cls._main_title(candidate.title)) + ) ): + score += 2 + if artist_match: score += 3 if meta.album and cls._same_text(meta.album, candidate.album): score += 2 - if meta.isrc and cls._same_text(meta.isrc, candidate.isrc): + isrc_match = bool(meta.isrc) and cls._same_text(meta.isrc, candidate.isrc) + if isrc_match: score += 5 + # 同名多版本(如不同年份的重发单曲)靠发行年份消歧 + 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: + score = 0 ranked.append((score, candidate)) if not ranked: return None ranked.sort(key=lambda item: item[0], reverse=True) return ranked[0][1] if ranked[0][0] > 0 else None + @classmethod + def _select_album_candidate(cls, meta: MetaMusic, albums: Iterable[MusicInfo]) -> Optional[MusicInfo]: + """单曲检索未命中时,从专辑候选中挑选高置信目标。 + + 专辑重名多,要求标题(含去括号弱匹配)与艺术家同时命中才返回, + 避免把音轨身份安到错误专辑上。 + """ + clean_title = cls._search_title(meta.album or meta.title) + if not clean_title: + return None + bare_title = cls._strip_parenthetical(clean_title) + ranked: list[tuple[int, MusicInfo]] = [] + for album in albums: + score = 0 + album_title = album.title or album.album + artist_match = bool(meta.artists) and any( + cls._same_text(artist_name, candidate_artist) + for artist_name in meta.artists + for candidate_artist in album.artists + ) + title_match = False + if cls._same_text(clean_title, album_title): + score += 4 + title_match = True + elif ( + artist_match + and bare_title + and ( + cls._same_text(bare_title, cls._strip_parenthetical(album_title)) + # 条目「天國的情人:鄧麗君逝世十周年…」这类冒号副标题,主标题一致视为弱匹配 + or cls._same_text(bare_title, cls._main_title(album_title)) + ) + ): + # 「我爱夜 (新歌+精选)」对位条目「我爱夜」这类注释差异 + score += 2 + title_match = True + if artist_match: + score += 3 + if meta.year and album.year and int(meta.year) == int(album.year): + score += 1 + # 标题与艺术家缺一不可,仅有标题相似不能采信 + ranked.append((score if title_match and artist_match else 0, album)) + if not ranked: + return None + ranked.sort(key=lambda item: item[0], reverse=True) + return ranked[0][1] if ranked[0][0] > 0 else None + @classmethod def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo: """音乐识别无候选时,把元数据转换为可展示的最小信息。""" @@ -527,13 +683,49 @@ class MusicBrainzModule(_ModuleBase): @classmethod def _same_text(cls, left: Optional[str], right: Optional[str]) -> bool: - """忽略大小写比较两个音乐文本字段。""" - return cls._normalize_text(left).casefold() == cls._normalize_text(right).casefold() + """忽略大小写、空白与标点差异比较两个音乐文本字段。""" + compact_left = cls._match_text(left) + compact_right = cls._match_text(right) + # 空文本不参与比对,避免缺失字段被误判为相等 + return bool(compact_left) and compact_left == compact_right @classmethod def _normalize_text(cls, value: Optional[str]) -> str: - """清理音乐检索文本中的多余空白。""" - return re.sub(r"\s+", " ", str(value or "")).strip() + """清理音乐检索文本中的多余空白,并统一繁简写法避免候选比对失误。""" + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return text + # MusicBrainz 中文条目以简体为主,资源标题可能是繁体,比对前统一转简体 + try: + return zhconv_convert(text, "zh-hans") + except Exception: # pylint: disable=broad-except + return text + + # 资源标题中的音质规格(格式、位深采样参数、年份后缀、发行实体标记)会污染检索式,检索前统一剥离 + _quality_token_pattern = re.compile( + r"\[[^\]]*\]|\((?:19|20)\d{2}\)|" + r"\b(?:DSD(?:64|128|256|512)?|DSF|DFF|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|" + r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA|WEB-?DL|WEBRip|WEB)\b|" + r"\b\d{1,3}\s*-?\s*bits?\b|\b\d{2,4}(?:\.\d)?\s*k(?:hz|bps?)\b|" + # 流媒体发行实体标记(- Single / - EP),不是曲名的一部分 + r"\b(?:single|ep|album)\b", + re.IGNORECASE, + ) + + @classmethod + def _search_title(cls, value: Optional[str]) -> str: + """剥离资源标题中的音频格式、规格参数与年份后缀,只保留曲名用于检索比对。""" + text = cls._quality_token_pattern.sub(" ", str(value or "")) + # 流媒体文件名消毒产生的下划线转空格,避免破坏检索短语 + text = text.replace("_", " ") + # 规格剥离后可能残留悬空分隔符,统一修剪 + text = re.sub(r"^[\s\-–—/]+|[\s\-–—/]+$", "", cls._normalize_text(text)) + # 格式标记后紧跟的场景发布组标签(如 ALAC-HHWEB),整体剔除 + text = re.sub(r"[-–—]\s*[A-Z0-9]{3,}\s*$", "", text) + # 曲名尾部独立年份是发行线索不是曲名一部分(解析阶段通常已提取), + # 检索时剥离避免年份文本造成精确短语零命中 + text = re.sub(r"(?<!\d)\s+(?:19|20)\d{2}$", "", cls._normalize_text(text)) + return cls._normalize_text(text) def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]: """按 MusicBrainz 标准 ID 获取音乐详情,单曲不存在时回退到专辑。""" @@ -634,8 +826,10 @@ class MusicBrainzModule(_ModuleBase): def _build_query(cls, meta: MetaMusic) -> str: """构造 MusicBrainz Recording 搜索表达式。""" clauses = [] - if meta.title: - clauses.append(f'recording:"{cls._escape_query(meta.title)}"') + # 资源标题先剥离音质标记,避免规格文本污染检索式导致零命中 + title = cls._search_title(meta.title) + if title: + clauses.append(f'recording:"{cls._escape_query(title)}"') if meta.artists: clauses.append(f'artist:"{cls._escape_query(meta.artists[0])}"') if meta.album: @@ -997,6 +1191,15 @@ class MusicBrainzModule(_ModuleBase): base = (settings.MUSIC_COVER_PROXY or "https://coverartarchive.org").rstrip("/") return f"{base}/release-group/{release_group_id}/front-500" + @classmethod + def _get_session(cls) -> Session: + """懒创建并复用全局 HTTP 会话,批量识别时避免重复建连。""" + if cls._session is None: + with cls._session_lock: + if cls._session is None: + cls._session = Session() + return cls._session + @classmethod def _wait_for_rate_limit(cls) -> None: """串行控制 MusicBrainz 公共接口的最小请求间隔。""" @@ -1014,32 +1217,49 @@ class MusicBrainzModule(_ModuleBase): path: str, params: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: - """请求 MusicBrainz JSON 接口并统一处理网络和响应错误。""" - cls._wait_for_rate_limit() - response = RequestUtils( - headers={ - "User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)", - "Accept": "application/json", - }, - proxies=settings.PROXY, - timeout=20, - ).get_res(f"{cls._base_url}{path}", params=params) - if response is None: - return None - try: - if response.status_code == 404: - # 单曲与专辑共用同一套 ID 入口,404 属于正常的探测结果 - logger.debug(f"MusicBrainz 资源不存在:{path}") - # 使用空对象区分稳定的不存在与瞬时请求失败,使有界缓存能够复用探测结果。 - return {} - if response.status_code != 200: - logger.warning( - f"MusicBrainz 请求失败:{response.status_code} {response.text[:200]}" - ) + """请求 MusicBrainz JSON 接口并统一处理网络和响应错误。 + + 服务端繁忙(429/5xx)属于瞬时错误,退避重试后再失败才放弃, + 避免批量识别场景下把限流误判为检索零命中。 + """ + attempts = cls._busy_retries + 1 + for attempt in range(attempts): + cls._wait_for_rate_limit() + response = RequestUtils( + headers={ + "User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)", + "Accept": "application/json", + }, + proxies=settings.PROXY, + session=cls._get_session(), + timeout=20, + ).get_res(f"{cls._base_url}{path}", params=params) + if response is None: return None - return response.json() - except (TypeError, ValueError) as err: - logger.warning(f"MusicBrainz 响应解析失败:{err}") - return None - finally: - response.close() + status_code = response.status_code + try: + if status_code == 404: + # 单曲与专辑共用同一套 ID 入口,404 属于正常的探测结果 + logger.debug(f"MusicBrainz 资源不存在:{path}") + # 使用空对象区分稳定的不存在与瞬时请求失败,使有界缓存能够复用探测结果。 + return {} + if status_code == 429 or status_code >= 500: + logger.warning( + f"MusicBrainz 服务繁忙:{status_code} {response.text[:200]}" + ) + if attempt < attempts - 1: + time.sleep(cls._busy_backoff * (2 ** attempt)) + continue + return None + if status_code != 200: + logger.warning( + f"MusicBrainz 请求失败:{status_code} {response.text[:200]}" + ) + return None + return response.json() + except (TypeError, ValueError) as err: + logger.warning(f"MusicBrainz 响应解析失败:{err}") + return None + finally: + response.close() + return None diff --git a/scripts/music_recognize_batch_test.py b/scripts/music_recognize_batch_test.py new file mode 100644 index 000000000..2acb8fb22 --- /dev/null +++ b/scripts/music_recognize_batch_test.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""多站点音乐种子批量识别测试工具。 + +用途: +1. fetch - 用生产数据库中的站点 Cookie 抓取多个站点音乐分区种子标题 +2. test - 对标题批量执行音乐识别链路(与识别测试页相同),输出 CSV 报告与命中率汇总 +3. 抓取结果落盘后可离线重跑 test,用于优化识别程序前后对比 + +约束: +- 生产库仅以只读 URI 模式打开,不做任何写入 +- 识别仅调用 MusicBrainz 公共 API,遵守模块内置限流与退避重试 +- 抓取仅请求各站点音乐分区浏览页若干页,对站点无压力 + +用法: + .venv/bin/python scripts/music_recognize_batch_test.py --fetch # 抓取并测试 + .venv/bin/python scripts/music_recognize_batch_test.py --fetch --sites ptsbao,springsunday + .venv/bin/python scripts/music_recognize_batch_test.py # 用已保存标题离线重测 +""" + +import argparse +import csv +import sqlite3 +import sys +from pathlib import Path + +import yaml + +# 保证从仓库根目录外执行时也能导入 app 包 +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +# 生产数据库(site 表中的站点 Cookie)与站点索引配置源文件目录 +DB_PATH = Path.home() / "Documents" / "MoviePilot" / "user.db" +INDEXER_DIR = Path.home() / "MPProjects" / "MoviePilot-Build" / "sites" / "private" + +TITLES_FILE = ROOT / "config" / "temp" / "music_batch_titles.txt" +REPORT_FILE = ROOT / "config" / "temp" / "music_batch_report.csv" + +# 各站点音乐分区浏览配置:yml 配置、浏览路径({page} 由 SiteSpider 渲染)、每站抓取上限 +# NexusPHP 站点统一用 torrents.php?cat=<音乐分类>,憨憨音乐专区走 special.php +SITES = [ + {"key": "hhanclub", "name": "憨憨", "yml": "hhanclub.yml", + "browse": "special.php?page={page}", "limit": 30}, + {"key": "ptsbao", "name": "烧包乐园", "yml": "ptsbao.yml", + "browse": "torrents.php?cat=414&page={page}", "limit": 30}, + {"key": "springsunday", "name": "春天", "yml": "springsunday.yml", + "browse": "torrents.php?cat=508&page={page}", "limit": 30}, + {"key": "hdhome", "name": "家园", "yml": "hdhome.yml", + "browse": "torrents.php?cat[]=439&cat[]=440&page={page}", "limit": 30}, + {"key": "btschool", "name": "学校", "yml": "btschool.yml", + "browse": "torrents.php?cat=409&page={page}", "limit": 30}, + {"key": "0ff", "name": "自由农场", "yml": "0ff.yml", + "browse": "torrents.php?cat=407&page={page}", "limit": 30}, + {"key": "hdfans", "name": "红豆饭", "yml": "hdfans.yml", + "browse": "torrents.php?cat=406&page={page}", "limit": 30}, + {"key": "wintersakura", "name": "冬樱", "yml": "wintersakura.yml", + "browse": "torrents.php?cat=408&page={page}", "limit": 30}, + {"key": "audiences", "name": "观众", "yml": "audiences.yml", + "browse": "torrents.php?cat=408&page={page}", "limit": 30}, +] + + +def load_site_credentials(domains: list[str]) -> dict[str, dict]: + """只读打开生产库,按域名批量取出站点 Cookie 与 UA。""" + if not DB_PATH.exists(): + sys.exit(f"生产数据库不存在:{DB_PATH}") + credentials: dict[str, dict] = {} + con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) + try: + for domain in domains: + row = con.execute( + "SELECT cookie, ua FROM site WHERE domain = ?", (domain,) + ).fetchone() + if row and row[0]: + credentials[domain] = {"cookie": row[0], "ua": row[1] or None} + finally: + con.close() + return credentials + + +def build_indexer(site: dict, credential: dict) -> dict: + """加载站点索引配置并注入凭据,补充无关键词浏览所需的 browse 配置。 + + 索引 yml 没有 browse 节,这里按站点音乐分区构造浏览路径, + 列表与字段解析复用 yml 中 torrents 节的现有选择器,不改动配置文件。 + """ + with open(INDEXER_DIR / site["yml"], "r", encoding="utf-8") as f: + indexer = yaml.safe_load(f) + indexer["cookie"] = credential["cookie"] + if credential["ua"]: + indexer["ua"] = credential["ua"] + indexer["browse"] = {"path": site["browse"]} + return indexer + + +def fetch_site_titles(site: dict, indexer: dict, max_pages: int) -> list[str]: + """翻页抓取单个站点音乐分区种子标题,按标题去重并保留出现顺序。""" + from app.modules.indexer.spider import SiteSpider + from app.schemas.types import MediaType + + titles: list[str] = [] + seen: set[str] = set() + for page in range(max_pages): + spider = SiteSpider(indexer=indexer, mtype=MediaType.MUSIC, page=page) + torrents = spider.get_torrents() + if spider.is_error: + print(f" [{site['name']}] 第 {page} 页请求失败(Cookie 可能失效或站点不可达),跳过该站点") + return [] + if not torrents: + break + for torrent in torrents: + title = (torrent.get("title") or "").strip() + if not title or title in seen: + continue + seen.add(title) + titles.append(title) + if len(titles) >= site["limit"]: + break + print(f" [{site['name']}] 获取 {len(titles)} 条") + return titles[:site["limit"]] + + +def fetch_titles(site_keys: list[str], max_pages: int) -> list[tuple[str, str]]: + """按配置抓取多个站点音乐分区标题,返回 (站点名, 标题) 列表。""" + sites = [site for site in SITES if not site_keys or site["key"] in site_keys] + unknown = set(site_keys) - {site["key"] for site in sites} + if unknown: + sys.exit(f"未知站点:{', '.join(sorted(unknown))};可选:{', '.join(site['key'] for site in SITES)}") + domains = [] + for site in sites: + with open(INDEXER_DIR / site["yml"], "r", encoding="utf-8") as f: + domain = (yaml.safe_load(f).get("domain") or "").replace("https://", "").replace("http://", "").rstrip("/") + site["domain"] = domain + domains.append(domain) + credentials = load_site_credentials(domains) + + results: list[tuple[str, str]] = [] + for site in sites: + credential = credentials.get(site["domain"]) + if not credential: + print(f" [{site['name']}] 未配置 Cookie,跳过") + continue + indexer = build_indexer(site, credential) + for title in fetch_site_titles(site, indexer, max_pages): + results.append((site["name"], title)) + return results + + +def recognize_one(module, title: str) -> dict: + """对单条标题执行与识别测试页相同的解析+识别链路,并给出失败归因。""" + from app.chain.music import MusicChain + + row = {"title": title} + try: + meta = MusicChain.parse_query(title) + row.update({ + "parsed_title": meta.title, + "parsed_artists": " / ".join(meta.artists or []), + "parsed_format": meta.audio_format or "", + }) + # 拆开检索与候选挑选两步,便于区分零命中与比对失配 + candidates = module._search_recordings(meta, limit=10) + matched = module._select_candidate(meta, candidates, source="musicbrainz") + hit_label = "命中" + albums: list = [] + # 与正式链路一致:专辑挑选要求艺术家命中,无艺术家线索时跳过专辑回退检索 + if not matched and meta.artists: + albums = module._search_albums(meta, limit=10) + matched = module._select_album_candidate(meta, albums) + hit_label = "命中(专辑)" + if matched: + row.update({ + "status": hit_label, + "matched_title": matched.title, + "matched_artists": " / ".join(matched.artists or []), + "matched_album": matched.album or "", + "matched_year": matched.year or "", + "media_id": matched.media_id, + }) + elif candidates: + # 有候选但全部比对失配,列出最接近的候选方便归因 + top = candidates[0] + row.update({ + "status": "候选比对失配", + "matched_title": f"{top.title} | {' / '.join(top.artists or [])}", + }) + elif albums: + row.update({ + "status": "专辑候选失配", + "matched_title": f"{albums[0].title} | {' / '.join(albums[0].artists or [])}", + }) + elif not meta.title: + row.update({"status": "解析失败"}) + else: + row.update({"status": "检索零命中"}) + except Exception as err: # pylint: disable=broad-except + row.update({"status": "异常", "matched_title": str(err)[:200]}) + return row + + +def run_batch(entries: list[tuple[str, str]]) -> list[dict]: + """批量执行识别并写出 CSV 报告,打印命中率汇总。""" + from app.modules.musicbrainz import MusicBrainzModule + + module = MusicBrainzModule() + module.init_module() + rows = [] + for index, (site_name, title) in enumerate(entries, 1): + row = recognize_one(module, title) + row["site"] = site_name + rows.append(row) + if index % 20 == 0 or index == len(entries): + print(f"识别进度 {index}/{len(entries)}") + + REPORT_FILE.parent.mkdir(parents=True, exist_ok=True) + fieldnames = [ + "site", "title", "status", "parsed_title", "parsed_artists", "parsed_format", + "matched_title", "matched_artists", "matched_album", "matched_year", "media_id", + ] + with open(REPORT_FILE, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + # 按状态汇总,输出命中率与失败分布 + summary: dict[str, int] = {} + for row in rows: + summary[row["status"]] = summary.get(row["status"], 0) + 1 + total = len(rows) or 1 + print(f"\n报告已写入:{REPORT_FILE}") + for status, count in sorted(summary.items(), key=lambda kv: -kv[1]): + print(f" {status}: {count} ({count / total:.0%})") + # 分站点命中率,便于定位特定站点标题格式问题 + print("分站点命中率:") + for site_name in sorted({row["site"] for row in rows}): + site_rows = [row for row in rows if row["site"] == site_name] + hits = sum(1 for row in site_rows if row["status"].startswith("命中")) + print(f" {site_name}: {hits}/{len(site_rows)} ({hits / max(len(site_rows), 1):.0%})") + return rows + + +def read_titles_file() -> list[tuple[str, str]]: + """读取落盘标题,兼容旧版纯标题格式(无站点前缀记为「憨憨」)。""" + entries: list[tuple[str, str]] = [] + for line in TITLES_FILE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + if "\t" in line: + site_name, title = line.split("\t", 1) + else: + site_name, title = "憨憨", line + entries.append((site_name, title)) + return entries + + +def main() -> None: + parser = argparse.ArgumentParser(description="多站点音乐种子批量识别测试") + parser.add_argument("--fetch", action="store_true", help="重新抓取种子标题(默认复用已保存列表)") + parser.add_argument("--sites", default="", help="指定站点 key 逗号分隔,缺省抓取全部配置站点") + parser.add_argument("--pages", type=int, default=3, help="每站最大翻页数") + args = parser.parse_args() + + if args.fetch or not TITLES_FILE.exists(): + site_keys = [key.strip() for key in args.sites.split(",") if key.strip()] + entries = fetch_titles(site_keys, max_pages=args.pages) + if not entries: + sys.exit("未抓取到任何种子标题") + TITLES_FILE.parent.mkdir(parents=True, exist_ok=True) + TITLES_FILE.write_text( + "\n".join(f"{site_name}\t{title}" for site_name, title in entries), + encoding="utf-8", + ) + print(f"已保存 {len(entries)} 条标题到 {TITLES_FILE}") + else: + entries = read_titles_file() + print(f"复用已保存的 {len(entries)} 条标题") + + run_batch(entries) + + +if __name__ == "__main__": + main() diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index 8b885e922..d3de4948c 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -218,14 +218,15 @@ def test_python_metainfo_preserves_all_resource_types(title, expected): def test_metainfo_routes_audio_filename_to_music(): - """音频文件名应直接走音乐分支,不再进入影视季集解析。""" + """音频文件名应直接走音乐分支并完成艺术家/曲名拆分,不再进入影视季集解析。""" meta = MetaInfo("周杰伦 - 晴天.flac") assert isinstance(meta, MetaMusic) assert isinstance(meta, MetaBase) assert meta.type == MediaType.MUSIC assert meta.org_string == "周杰伦 - 晴天.flac" - assert meta.title == "周杰伦 - 晴天" + assert meta.title == "晴天" + assert meta.artists == ["周杰伦"] assert meta.audio_format == "FLAC" # 音乐没有季集信息,兼容通用访问 assert meta.season is None diff --git a/tests/test_metamusic.py b/tests/test_metamusic.py new file mode 100644 index 000000000..d5469453e --- /dev/null +++ b/tests/test_metamusic.py @@ -0,0 +1,323 @@ +from app.core.meta import MetaMusic + + +def parse_title(title: str) -> MetaMusic: + """构造种子/文件名标题解析结果,供识别断言复用。""" + return MetaMusic(org_string=title, title=title, parse_title=True) + + +def test_strip_track_prefix_handles_dot_separator(): + """曲序前缀 01. 应剥离并返回曲名。""" + track, disc, title = MetaMusic.split_track_prefix("01.晴天") + + assert (track, disc, title) == (1, None, "晴天") + + +def test_strip_track_prefix_handles_dash_and_space(): + """常见 rip 命名 01 - 曲名 和 01 曲名 都应识别曲序。""" + assert MetaMusic.split_track_prefix("03 - 七里香") == (3, None, "七里香") + assert MetaMusic.split_track_prefix("05 借口") == (5, None, "借口") + + +def test_strip_track_prefix_handles_disc_track_number(): + """碟号-曲序前缀 1-02 应同时提取碟号和曲序。""" + track, disc, title = MetaMusic.split_track_prefix("1-02 半岛铁盒") + + assert (track, disc, title) == (2, 1, "半岛铁盒") + + +def test_strip_track_prefix_handles_number_only_name(): + """纯数字文件名只能得到曲序,曲名返回 None 由调用方兜底。""" + track, disc, title = MetaMusic.split_track_prefix("07") + + assert (track, disc, title) == (7, None, None) + + +def test_strip_track_prefix_keeps_normal_title(): + """普通曲名不应被误判为曲序前缀。""" + assert MetaMusic.split_track_prefix("晴天") == (None, None, None) + assert MetaMusic.split_track_prefix("2002") == (None, None, None) + + +def test_split_artist_title(): + """歌手 - 曲名结构应拆分,无分隔符时原文作为标题。""" + artist, title = MetaMusic.split_artist_title("周杰伦 - 晴天") + + assert artist == "周杰伦" + assert title == "晴天" + assert MetaMusic.split_artist_title("晴天") == (None, "晴天") + + +def test_parse_disc_dir(): + """CD1、Disc 2 等碟片目录应识别碟号。""" + assert MetaMusic.parse_disc_dir("CD1") == 1 + assert MetaMusic.parse_disc_dir("Disc 2") == 2 + assert MetaMusic.parse_disc_dir("disk03") == 3 + assert MetaMusic.parse_disc_dir("无损音乐") is None + + +def test_parse_album_dir_extracts_artist_album_year(): + """专辑目录名应提取歌手、专辑、年份和音质描述。""" + info = MetaMusic.parse_album_dir("周杰伦 - 七里香 (2004) [FLAC 24bit-96kHz]") + + assert info["artist"] == "周杰伦" + assert info["album"] == "七里香" + assert info["year"] == 2004 + assert "FLAC" in info["quality_text"] + + +def test_parse_album_dir_without_artist(): + """没有歌手分隔的目录名整体作为专辑名。""" + info = MetaMusic.parse_album_dir("Random Access Memories (2013)") + + assert info["artist"] is None + assert info["album"] == "Random Access Memories" + assert info["year"] == 2013 + + +def test_apply_path_context_fills_wav_meta(tmp_path): + """无标签 WAV 应从文件名和目录结构补齐曲序、专辑和歌手。""" + album_dir = tmp_path / "周杰伦 - 七里香 (2004) [FLAC]" + album_dir.mkdir() + wav_file = album_dir / "01.我的地盘.wav" + wav_file.write_bytes(b"RIFF") + + meta = MetaMusic(org_string=wav_file.name, title=wav_file.stem, audio_format="WAV") + meta.apply_path_context(wav_file) + + assert meta.track_number == 1 + assert meta.title == "我的地盘" + assert meta.album == "七里香" + assert meta.artists == ["周杰伦"] + assert meta.album_artist == "周杰伦" + assert meta.year == 2004 + + +def test_apply_path_context_uses_disc_subdir(tmp_path): + """CD1 子目录内的文件应继承碟号并向上找到专辑目录。""" + album_dir = tmp_path / "Daft Punk - Discovery (2001)" + disc_dir = album_dir / "CD1" + disc_dir.mkdir(parents=True) + wav_file = disc_dir / "01 - One More Time.flac" + wav_file.write_bytes(b"fLaC") + + meta = MetaMusic(org_string=wav_file.name, title=wav_file.stem, audio_format="FLAC") + meta.apply_path_context(wav_file) + + assert meta.disc_number == 1 + assert meta.track_number == 1 + assert meta.title == "One More Time" + assert meta.album == "Discovery" + assert meta.artists == ["Daft Punk"] + + +def test_apply_path_context_keeps_existing_tags(tmp_path): + """已有标签字段不应被目录猜测覆盖。""" + album_dir = tmp_path / "周杰伦 - 七里香 (2004)" + album_dir.mkdir() + audio_file = album_dir / "01.我的地盘.mp3" + audio_file.write_bytes(b"") + + meta = MetaMusic( + org_string=audio_file.name, + title="我的地盘", + artists=["周杰伦"], + album="七里香", + year=2004, + track_number=1, + audio_format="MP3", + ) + meta.apply_path_context(audio_file) + + assert meta.title == "我的地盘" + assert meta.artists == ["周杰伦"] + assert meta.album == "七里香" + assert meta.year == 2004 + + +def test_apply_title_splits_artist_and_track(): + """标准「歌手 - 曲名」种子标题应拆分艺术家与曲名。""" + meta = parse_title("周杰伦 - 晴天") + + assert meta.artists == ["周杰伦"] + assert meta.title == "晴天" + + +def test_apply_title_splits_multi_artists(): + """多艺术家 & 联名写法应拆分为列表保留顺序。""" + meta = parse_title("章子怡 & 周深 - 灯火里的中国") + + assert meta.artists == ["章子怡", "周深"] + assert meta.title == "灯火里的中国" + + +def test_apply_title_strips_quality_tokens(): + """格式、位深采样与发行标记不应进入曲名。""" + meta = parse_title("[250917] SARD UNDERGROUND - 故障した車 FLAC") + + assert meta.artists == ["SARD UNDERGROUND"] + assert meta.title == "故障した車" + assert meta.audio_format == "FLAC" + + +def test_apply_title_strips_video_tokens(): + """演唱会视频种子的分辨率与编码标记不应进入曲名。""" + meta = parse_title("S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv") + + assert meta.artists == ["S H E"] + assert meta.title == "S H E十七音乐会" + # 尾部年份提取为发行年份线索 + assert meta.year == 2018 + + +def test_apply_title_strips_release_group_tag(): + """格式标记后的发布组标签(大小写混合)应整体剔除。""" + meta = parse_title("某某乐队 - 星空 FLAC-FHDMv") + + assert meta.title == "星空" + + +def test_apply_title_strips_date_prefix(): + """电视录制标题开头的日期前缀应剔除。""" + meta = parse_title("2018.01.10 藤田麻衣子 思い続ければ FLAC") + + assert meta.title == "藤田麻衣子 思い続ければ" + assert meta.audio_format == "FLAC" + + +def test_apply_title_date_prefix_with_time(): + """带时分秒的录制前缀(下划线分隔)同样应剔除。""" + meta = parse_title("2024-01-27_20-00_WOWOWプライム_松任谷由実 50th Anniversary") + + assert "2024" not in (meta.title or "") + assert "松任谷由実" in (meta.title or "") + assert "50th" in (meta.title or "") + + +def test_apply_title_keeps_song_named_with_year(): + """曲名自带的年份数字(非日期结构)不应被误剔除。""" + meta = parse_title("2002年的第一场雪") + + assert meta.title == "2002年的第一场雪" + + +def test_apply_title_year_range_as_year(): + """全集标题尾部的年份区间应提取结束年并剥离出标题。""" + meta = parse_title("天国的情人-邓丽君作品全集1967-1995") + + assert meta.title == "天国的情人" + assert meta.artists == ["邓丽君"] + assert meta.year == 1995 + + +def test_apply_title_year_range_in_title_kept(): + """年份区间后随内容文字时属于标题本身,只提取年份不剥离。""" + meta = parse_title("许茹芸 - 许茹芸1995-2000年光华真纪录 (2001)") + + assert meta.artists == ["许茹芸"] + assert meta.title == "许茹芸1995-2000年光华真纪录" + # 括号年份优先于区间提取的结束年 + assert meta.year == 2001 + + +def test_apply_title_scene_dot_naming(): + """场景点分命名的点号应归一为空格,发布组标签剔除。""" + meta = parse_title("Shan.Ge.Liao.Zai.2023.WEB-DL.FLAC-CMCTA") + + assert meta.title == "Shan Ge Liao Zai" + assert meta.year == 2023 + assert meta.audio_format == "FLAC" + + +def test_apply_title_scene_dot_with_symbols(): + """点分命名中环绕符号的点(.&.、.-.)也应归一并支持艺术家拆分。""" + meta = parse_title( + "Deep.Purple.&.Orchestra.-.Live.At.Montreux.1999.2022.1080p.BluRay.AVC.DTS-HD.MA5.1" + ) + + assert meta.artists == ["Deep Purple", "Orchestra"] + assert meta.title == "Live At Montreux 1999 2022" + + +def test_apply_title_keeps_artist_abbreviation_dots(): + """点分隔少于 3 处的艺术家缩写点号不应被归一。""" + meta = parse_title("E.S.Posthumus - Maraboot") + + assert meta.artists == ["E.S.Posthumus"] + assert meta.title == "Maraboot" + + +def test_apply_title_va_alias_artist(): + """合辑署名 VA 应归一为 MusicBrainz 规范署名 Various Artists。""" + meta = parse_title("VA - Funky Jazz Saxophone 2024 FLAC") + + assert meta.artists == ["Various Artists"] + assert meta.title == "Funky Jazz Saxophone" + assert meta.year == 2024 + + +def test_apply_title_va_scene_prefix(): + """场景命名 VA-Title 无空格连字符写法应按别名前缀拆分。""" + meta = parse_title( + "VA-Once.Upon.a.Time.in.Hollywood.Original.Motion.Picture.Soundtrack.2019.FLAC.24bit.96kHz" + ) + + assert meta.artists == ["Various Artists"] + assert meta.title == "Once Upon a Time in Hollywood Original Motion Picture Soundtrack" + assert meta.year == 2019 + + +def test_apply_title_keeps_single_word_title(): + """无音质标记时「艺术家 - 单词曲名」的曲名不应被当发布组标签剔除。""" + meta = parse_title("E.S.Posthumus - Maraboot") + + assert meta.title == "Maraboot" + + +def test_apply_title_cjk_hyphen_artist_suffix(): + """CJK「曲名-歌手」无空格连字符写法应反向拆分艺术家。""" + meta = parse_title("因为有你-毛阿敏") + + assert meta.artists == ["毛阿敏"] + assert meta.title == "因为有你" + + +def test_apply_title_double_em_dash_split(): + """双破折号分隔的「主题——歌手」写法应拆分艺术家。""" + meta = parse_title("为你盛开——许巍 无尽光芒巡回演唱会 2025") + + assert meta.artists == ["许巍"] + assert meta.title.startswith("为你盛开") + + +def test_apply_title_keeps_english_hyphen_title(): + """英文曲名中的连字符是标题组成部分,不做艺术家拆分。""" + meta = parse_title("Dire Straits - Alchemy-Live 1983") + + assert meta.artists == ["Dire Straits"] + assert meta.title == "Alchemy-Live" + assert meta.year == 1983 + + +def test_apply_title_lossless_declaration(): + """「无损」声明词应剥离出标题并推断无损音质。""" + meta = parse_title("某某 - 晴天 FLAC 无损") + + assert meta.title == "晴天" + assert meta.audio_lossless is True + + +def test_apply_title_track_prefix_in_title(): + """标题中的曲序前缀应提取为曲序并还原曲名。""" + meta = parse_title("01.晴天") + + assert meta.track_number == 1 + assert meta.title == "晴天" + + +def test_apply_title_bracket_date_prefix(): + """发行日期方括号前缀应整体剔除。""" + meta = parse_title("[250917] 某歌手 - 某曲名") + + assert meta.artists == ["某歌手"] + assert meta.title == "某曲名" diff --git a/tests/test_music_chain.py b/tests/test_music_chain.py index ba7f73464..4fc739eca 100644 --- a/tests/test_music_chain.py +++ b/tests/test_music_chain.py @@ -24,6 +24,45 @@ def test_parse_query_keeps_plain_title(): assert meta.title == "Random Access Memories" +def test_parse_query_strips_quality_tokens_before_artist_split(): + """音质规格不应参与艺术家/曲名拆分,年份括号与格式后缀需剔除。""" + meta = MusicChain.parse_query("毛阿敏 - 永遠是朋友(2000) - ALAC [16B-44.1kHz]") + + assert meta.artists == ["毛阿敏"] + assert meta.title == "永遠是朋友" + assert meta.audio_format == "ALAC" + + +def test_parse_query_does_not_misplit_quality_only_tail(): + """无艺术家时格式规格段不能被误拆成曲名,曲名不能丢字。""" + meta = MusicChain.parse_query("永遠是朋友(2000) - ALAC [16B-44.1kHz]") + + assert meta.artists == [] + assert meta.title == "永遠是朋友" + + +def test_parse_query_strips_trailing_artist_suffix(): + """曲名尾部重复的艺术家署名应被剥离,不作为曲名参与检索。""" + meta = MusicChain.parse_query("毛阿敏 - 名人名曲-毛阿敏(2000)") + + assert meta.artists == ["毛阿敏"] + assert meta.title == "名人名曲" + assert meta.year == 2000 + + meta = MusicChain.parse_query("许茹芸 - 争奇斗艳演唱会实况 2 - 许茹芸 (1996)") + + assert meta.artists == ["许茹芸"] + assert meta.title == "争奇斗艳演唱会实况 2" + + +def test_parse_query_keeps_non_artist_suffix(): + """曲名尾段与艺术家不一致时不应被误剥离。""" + meta = MusicChain.parse_query("毛阿敏 - 思念 - 现场版") + + assert meta.artists == ["毛阿敏"] + assert meta.title == "思念 - 现场版" + + def test_build_site_keywords_prefers_artist_album(): """专辑订阅只按艺术家与专辑名搜索,不混入其中某首单曲。""" info = MusicInfo( diff --git a/tests/test_music_name_parser.py b/tests/test_music_name_parser.py deleted file mode 100644 index 077d068c1..000000000 --- a/tests/test_music_name_parser.py +++ /dev/null @@ -1,131 +0,0 @@ -from app.core.meta import MetaMusic -from app.helper.music import MusicNameParser - - -def test_strip_track_prefix_handles_dot_separator(): - """曲序前缀 01. 应剥离并返回曲名。""" - track, disc, title = MusicNameParser.strip_track_prefix("01.晴天") - - assert (track, disc, title) == (1, None, "晴天") - - -def test_strip_track_prefix_handles_dash_and_space(): - """常见 rip 命名 01 - 曲名 和 01 曲名 都应识别曲序。""" - assert MusicNameParser.strip_track_prefix("03 - 七里香") == (3, None, "七里香") - assert MusicNameParser.strip_track_prefix("05 借口") == (5, None, "借口") - - -def test_strip_track_prefix_handles_disc_track_number(): - """碟号-曲序前缀 1-02 应同时提取碟号和曲序。""" - track, disc, title = MusicNameParser.strip_track_prefix("1-02 半岛铁盒") - - assert (track, disc, title) == (2, 1, "半岛铁盒") - - -def test_strip_track_prefix_handles_number_only_name(): - """纯数字文件名只能得到曲序,曲名返回 None 由调用方兜底。""" - track, disc, title = MusicNameParser.strip_track_prefix("07") - - assert (track, disc, title) == (7, None, None) - - -def test_strip_track_prefix_keeps_normal_title(): - """普通曲名不应被误判为曲序前缀。""" - assert MusicNameParser.strip_track_prefix("晴天") == (None, None, None) - assert MusicNameParser.strip_track_prefix("2002") == (None, None, None) - - -def test_split_artist_title(): - """歌手 - 曲名结构应拆分,无分隔符时原文作为标题。""" - artist, title = MusicNameParser.split_artist_title("周杰伦 - 晴天") - - assert artist == "周杰伦" - assert title == "晴天" - assert MusicNameParser.split_artist_title("晴天") == (None, "晴天") - - -def test_parse_disc_dir(): - """CD1、Disc 2 等碟片目录应识别碟号。""" - assert MusicNameParser.parse_disc_dir("CD1") == 1 - assert MusicNameParser.parse_disc_dir("Disc 2") == 2 - assert MusicNameParser.parse_disc_dir("disk03") == 3 - assert MusicNameParser.parse_disc_dir("无损音乐") is None - - -def test_parse_album_dir_extracts_artist_album_year(): - """专辑目录名应提取歌手、专辑、年份和音质描述。""" - info = MusicNameParser.parse_album_dir("周杰伦 - 七里香 (2004) [FLAC 24bit-96kHz]") - - assert info["artist"] == "周杰伦" - assert info["album"] == "七里香" - assert info["year"] == 2004 - assert "FLAC" in info["quality_text"] - - -def test_parse_album_dir_without_artist(): - """没有歌手分隔的目录名整体作为专辑名。""" - info = MusicNameParser.parse_album_dir("Random Access Memories (2013)") - - assert info["artist"] is None - assert info["album"] == "Random Access Memories" - assert info["year"] == 2013 - - -def test_apply_path_context_fills_wav_meta(tmp_path): - """无标签 WAV 应从文件名和目录结构补齐曲序、专辑和歌手。""" - album_dir = tmp_path / "周杰伦 - 七里香 (2004) [FLAC]" - album_dir.mkdir() - wav_file = album_dir / "01.我的地盘.wav" - wav_file.write_bytes(b"RIFF") - - meta = MetaMusic(org_string=wav_file.name, title=wav_file.stem, audio_format="WAV") - MusicNameParser.apply_path_context(meta, wav_file) - - assert meta.track_number == 1 - assert meta.title == "我的地盘" - assert meta.album == "七里香" - assert meta.artists == ["周杰伦"] - assert meta.album_artist == "周杰伦" - assert meta.year == 2004 - - -def test_apply_path_context_uses_disc_subdir(tmp_path): - """CD1 子目录内的文件应继承碟号并向上找到专辑目录。""" - album_dir = tmp_path / "Daft Punk - Discovery (2001)" - disc_dir = album_dir / "CD1" - disc_dir.mkdir(parents=True) - wav_file = disc_dir / "01 - One More Time.flac" - wav_file.write_bytes(b"fLaC") - - meta = MetaMusic(org_string=wav_file.name, title=wav_file.stem, audio_format="FLAC") - MusicNameParser.apply_path_context(meta, wav_file) - - assert meta.disc_number == 1 - assert meta.track_number == 1 - assert meta.title == "One More Time" - assert meta.album == "Discovery" - assert meta.artists == ["Daft Punk"] - - -def test_apply_path_context_keeps_existing_tags(tmp_path): - """已有标签字段不应被目录猜测覆盖。""" - album_dir = tmp_path / "周杰伦 - 七里香 (2004)" - album_dir.mkdir() - audio_file = album_dir / "01.我的地盘.mp3" - audio_file.write_bytes(b"") - - meta = MetaMusic( - org_string=audio_file.name, - title="我的地盘", - artists=["周杰伦"], - album="七里香", - year=2004, - track_number=1, - audio_format="MP3", - ) - MusicNameParser.apply_path_context(meta, audio_file) - - assert meta.title == "我的地盘" - assert meta.artists == ["周杰伦"] - assert meta.album == "七里香" - assert meta.year == 2004 diff --git a/tests/test_musicbrainz_module.py b/tests/test_musicbrainz_module.py index 065092b2a..805341a97 100644 --- a/tests/test_musicbrainz_module.py +++ b/tests/test_musicbrainz_module.py @@ -1,6 +1,7 @@ +from app.core.config import settings +from app.core.context import MusicInfo from app.core.meta import MetaMusic from app.modules.musicbrainz import MusicBrainzModule -from app.core.config import settings def test_musicbrainz_cover_domains_are_allowed_by_image_proxy(): @@ -25,6 +26,35 @@ def test_build_query_uses_structured_music_fields(): ) +def test_build_query_strips_audio_quality_tokens(): + """资源标题中的格式规格与年份后缀不应污染检索式,繁体统一转简体检索。""" + query = MusicBrainzModule._build_query( + MetaMusic( + title="永遠是朋友(2000) - ALAC [16B-44.1kHz]", + artists=["毛阿敏"], + ) + ) + + assert query == 'recording:"永远是朋友" AND artist:"毛阿敏"' + + +def test_select_candidate_matches_traditional_chinese_title(): + """繁体资源标题应能命中 MusicBrainz 简体条目,不因写法差异失配。""" + meta = MetaMusic(title="永遠是朋友", artists=["毛阿敏"]) + candidates = [ + MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="永远是朋友", + artists=["毛阿敏"], + ), + ] + + selected = MusicBrainzModule._select_candidate(meta, candidates, source="musicbrainz") + + assert selected is candidates[0] + + def test_recording_to_info_maps_musicbrainz_payload(): """MusicBrainz Recording 应映射为统一 MusicInfo。""" info = MusicBrainzModule._recording_to_info( @@ -513,3 +543,139 @@ def test_request_json_caches_not_found(monkeypatch): assert first == second == {} assert network_calls["count"] == 1 + + +def test_request_json_retries_on_server_busy(monkeypatch): + """服务端繁忙(429/5xx)属于瞬时错误,应退避重试而不是直接放弃。""" + import time + + import app.modules.musicbrainz as musicbrainz_module + + monkeypatch.setattr( + MusicBrainzModule, "_wait_for_rate_limit", classmethod(lambda cls: None) + ) + monkeypatch.setattr(time, "sleep", lambda _seconds: None) + responses = [ + _FakeMusicBrainzResponse(None, status_code=503), + _FakeMusicBrainzResponse({"id": "recording-busy", "title": "晴天"}), + ] + + def fake_get_res(_self, url, params=None): + """依次返回繁忙与成功响应,验证重试后拿到结果。""" + return responses.pop(0) + + monkeypatch.setattr(musicbrainz_module.RequestUtils, "get_res", fake_get_res) + MusicBrainzModule._request_json.cache_clear() + + result = MusicBrainzModule._request_json("/recording/search-busy", params={"fmt": "json"}) + + assert result == {"id": "recording-busy", "title": "晴天"} + assert not responses + + +def test_same_text_normalizes_traditional_chinese(): + """候选比对应归一繁简差异,条目繁体写法不与简体资源标题失配。""" + assert MusicBrainzModule._same_text("神的遊戲", "神的游戏") + assert MusicBrainzModule._same_text("張懸", "张悬") + assert not MusicBrainzModule._same_text("神的遊戲", "神的冒险") + + +def test_search_title_replaces_sanitized_underscores(): + """流媒体文件名消毒产生的下划线应转空格,不破坏检索短语。""" + assert MusicBrainzModule._search_title("百年經典7_愛的奉獻") == "百年经典7 爱的奉献" + + +def test_select_candidate_matches_traditional_chinese_recording(): + """条目为繁体写法时,简体资源标题仍应命中候选。""" + meta = MetaMusic(title="芸开了", artists=["许茹芸"]) + candidate = MusicInfo( + source="musicbrainz", + music_type="recording", + media_id="recording-1", + title="芸開了", + artists=["許茹芸"], + ) + + matched = MusicBrainzModule._select_candidate(meta, [candidate], source="musicbrainz") + + assert matched is not None + assert matched.media_id == "recording-1" + + +def test_recording_queries_ladder_relaxes_to_bare_title_last(): + """检索阶梯由严到宽放宽,裸标题兜底只能出现在最后一级。""" + queries = MusicBrainzModule._recording_queries( + MetaMusic(title="晴天 (电影版)", artists=["周杰伦"]) + ) + + assert queries[0] == 'recording:"晴天 (电影版)" AND artist:"周杰伦"' + assert queries[1] == 'recording:"晴天 (电影版)"' + assert queries[2] == 'recording:"晴天" AND artist:"周杰伦"' + # 署名变体兜底放在最后,由候选挑选的艺术家要求收紧 + assert queries[-1] == 'recording:"晴天"' + + +def test_select_candidate_rejects_wrong_artist_same_title(): + """已知艺术家时,同名异曲的候选不能因标题相等被采信。""" + meta = MetaMusic(title="因为有你", artists=["毛阿敏"]) + wrong_artist = MusicInfo( + source="musicbrainz", + music_type="recording", + media_id="recording-wrong", + title="因为有你", + artists=["张蔷"], + ) + + assert MusicBrainzModule._select_candidate(meta, [wrong_artist], source="musicbrainz") is None + + +def test_select_album_candidate_requires_title_and_artist(): + """专辑候选需标题(含去括号弱匹配)与艺术家同时命中才采信。""" + meta = MetaMusic(title="我爱夜 (新歌+精选)", artists=["许茹芸"], year=2003) + album_hit = MusicInfo( + source="musicbrainz", + music_type="album", + media_id="album-1", + title="我爱夜", + artists=["许茹芸"], + year=2003, + ) + + matched = MusicBrainzModule._select_album_candidate(meta, [album_hit]) + + assert matched is not None + assert matched.media_id == "album-1" + + album_wrong_artist = MusicInfo( + source="musicbrainz", + music_type="album", + media_id="album-2", + title="我爱夜", + artists=["其他歌手"], + ) + + assert MusicBrainzModule._select_album_candidate(meta, [album_wrong_artist]) is None + + +def test_select_album_candidate_matches_colon_subtitle(): + """条目「主标题:副标题」结构应与资源主标题弱匹配命中。""" + meta = MetaMusic(title="天国的情人", artists=["邓丽君"]) + album = MusicInfo( + source="musicbrainz", + music_type="album", + media_id="album-colon", + title="天國的情人:鄧麗君逝世十周年紀念聲影存集", + artists=["鄧麗君"], + ) + + matched = MusicBrainzModule._select_album_candidate(meta, [album]) + + assert matched is not None + assert matched.media_id == "album-colon" + + +def test_search_title_strips_trailing_year(): + """检索式应剥离曲名尾部独立年份,避免年份文本造成精确短语零命中。""" + assert MusicBrainzModule._search_title("Funky Jazz Saxophone 2024") == "Funky Jazz Saxophone" + # 非尾部年份属于曲名内容,不应剥离 + assert MusicBrainzModule._search_title("2002年的第一场雪") == "2002年的第一场雪"