refactor(music): 音乐识别核心下沉 MetaMusic 并优化 MusicBrainz 检索

- 标题解析核心从 app/helper/music.py 迁移到 MetaMusic(apply_title),
  删除 music helper,metainfo/chain 调用点切换
- 解析增强:全角归一、场景点分命名、年份区间双模式、日期前缀、
  视频标记剔除、VA 合辑署名归一、尾部年份提取、CJK 连字符拆分
- MusicBrainz 性能:全局 Session keep-alive 复用(6.3s→0.35s/请求),
  无艺术家线索时跳过专辑回退检索
- 候选挑选支持冒号副标题弱匹配,检索式剥离尾部年份减少无效检索
- 测试:test_metamusic 32 案例、musicbrainz 弱匹配/检索式新用例
This commit is contained in:
jxxghp
2026-08-11 08:18:27 +08:00
parent a15b8542ea
commit f09f81df72
11 changed files with 1614 additions and 399 deletions
+6 -16
View File
@@ -17,15 +17,12 @@ from app.core.context import (
) )
from app.core.meta import MetaMusic from app.core.meta import MetaMusic
from app.helper.audio import AudioMetadataHelper from app.helper.audio import AudioMetadataHelper
from app.helper.music import MusicNameParser
from app.log import logger from app.log import logger
class MusicChain(ChainBase): class MusicChain(ChainBase):
"""音乐元数据搜索、识别与站点搜索参数编排链。""" """音乐元数据搜索、识别与站点搜索参数编排链。"""
_artist_title_pattern = re.compile(r"^\s*(?P<artist>.+?)\s+[-–—]\s+(?P<title>.+?)\s*$")
_spaces_pattern = re.compile(r"\s+")
# 专辑目录匹配结果缓存:{目录路径: (音频文件数, 匹配结果)},避免逐文件整理时重复请求远端 # 专辑目录匹配结果缓存:{目录路径: (音频文件数, 匹配结果)},避免逐文件整理时重复请求远端
_album_dir_cache: dict[str, tuple[int, dict[str, MusicInfo]]] = {} _album_dir_cache: dict[str, tuple[int, dict[str, MusicInfo]]] = {}
_album_dir_cache_max = 128 _album_dir_cache_max = 128
@@ -34,15 +31,8 @@ class MusicChain(ChainBase):
@classmethod @classmethod
def parse_query(cls, query: str) -> MetaMusic: def parse_query(cls, query: str) -> MetaMusic:
"""将用户输入的搜索关键词解析为音乐元数据。""" """将用户输入的搜索关键词解析为音乐元数据,解析核心在 MetaMusic.apply_title"""
normalized = cls._normalize_text(query) return MetaMusic(org_string=query, title=query, parse_title=True)
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
@classmethod @classmethod
def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]: def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]:
@@ -314,7 +304,7 @@ class MusicChain(ChainBase):
@staticmethod @staticmethod
def _normalize_match_text(value: Optional[str]) -> str: 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 @classmethod
def is_audio_path(cls, path: str | Path) -> bool: def is_audio_path(cls, path: str | Path) -> bool:
@@ -330,7 +320,7 @@ class MusicChain(ChainBase):
else: else:
meta = cls.parse_query(file_path.stem) meta = cls.parse_query(file_path.stem)
# WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索 # WAV 无标签、FLAC/MP3 标签不全时,依靠文件名和目录结构补充识别线索
return MusicNameParser.apply_path_context(meta, file_path) return meta.apply_path_context(file_path)
async def async_recognize_by_path( async def async_recognize_by_path(
self, self,
@@ -460,7 +450,7 @@ class MusicChain(ChainBase):
@classmethod @classmethod
def _album_meta_from_context(cls, dir_path: Path, metas: list[MetaMusic]) -> MetaMusic: 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] = {} album_votes: dict[str, int] = {}
artist_votes: dict[str, int] = {} artist_votes: dict[str, int] = {}
@@ -611,4 +601,4 @@ class MusicChain(ChainBase):
@classmethod @classmethod
def _normalize_text(cls, value: Optional[str]) -> str: 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()
+497 -1
View File
@@ -1,4 +1,5 @@
import re import re
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from app.core.meta.metabase import MetaBase 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])", r"(?<!\d)(?P<value>\d{2,4})\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])",
re.IGNORECASE, 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) _HIRES_PATTERN = re.compile(r"(?<!\w)(?:hi[ ._-]?res(?:olution)?|高解析|高分辨率音频)(?!\w)", re.IGNORECASE)
_AUDIO_FORMAT_ALIASES = { _AUDIO_FORMAT_ALIASES = {
@@ -180,6 +181,112 @@ def _optional_int(value: Any) -> Optional[int]:
return None 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]: def _string_list(value: Any) -> list[str]:
"""将标签原始值归一为非空字符串列表,兼容单值、列表与逗号分隔。""" """将标签原始值归一为非空字符串列表,兼容单值、列表与逗号分隔。"""
if value is None: if value is None:
@@ -191,6 +298,42 @@ def _string_list(value: Any) -> list[str]:
return [str(value)] 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): class MetaMusic(MetaBase):
"""音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。""" """音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。"""
@@ -216,6 +359,7 @@ class MetaMusic(MetaBase):
isrc: Optional[str] = None, isrc: Optional[str] = None,
media_source: Optional[str] = None, media_source: Optional[str] = None,
media_id: Optional[str] = None, media_id: Optional[str] = None,
parse_title: bool = False,
): ):
# 音乐无季集概念,仅复用 MetaBase 的基础字段初始化,不触发副标题季集识别 # 音乐无季集概念,仅复用 MetaBase 的基础字段初始化,不触发副标题季集识别
super().__init__(title or org_string or "") super().__init__(title or org_string or "")
@@ -240,6 +384,9 @@ class MetaMusic(MetaBase):
self.isrc = isrc self.isrc = isrc
self.media_source = media_source self.media_source = media_source
self.media_id = media_id self.media_id = media_id
if parse_title:
# 种子/文件名字符串场景:解析艺术家、曲名、年份并补充音质参数
self.apply_title(self.title or org_string or "")
@property @property
def name(self) -> str: def name(self) -> str:
@@ -291,6 +438,355 @@ class MetaMusic(MetaBase):
self.audio_format = normalize_audio_format(self.audio_format) self.audio_format = normalize_audio_format(self.audio_format)
self.audio_lossless = infer_audio_lossless(self.audio_format, self.audio_lossless) 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 @property
def season(self) -> None: def season(self) -> None:
"""音乐没有季信息,兼容下载与事件链的通用访问。""" """音乐没有季信息,兼容下载与事件链的通用访问。"""
+3 -2
View File
@@ -12,7 +12,6 @@ from app.core.meta.infopath import (
should_use_parent_title_for_file_stem, should_use_parent_title_for_file_stem,
) )
from app.core.meta.words import WordsMatcher from app.core.meta.words import WordsMatcher
from app.helper.music import MusicNameParser
from app.log import logger from app.log import logger
from app.schemas.types import MediaType from app.schemas.types import MediaType
from app.utils import rust_accel 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, org_string=title,
title=Path(title).stem, title=Path(title).stem,
audio_format=audio_suffix.lstrip(".").upper() or None, audio_format=audio_suffix.lstrip(".").upper() or None,
parse_title=True,
) )
rust_meta = None rust_meta = None
if not _requires_python_metainfo(title, custom_words): 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, org_string=path.name,
title=path.stem, title=path.stem,
audio_format=audio_suffix.lstrip(".").upper() or None, 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_context = " ".join(
[path.name, path.parent.name, path.parent.parent.name] [path.name, path.parent.name, path.parent.parent.name]
) )
-173
View File
@@ -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()
+250 -30
View File
@@ -5,6 +5,7 @@ from difflib import SequenceMatcher
from typing import Any, Iterable, Optional, Tuple, Union from typing import Any, Iterable, Optional, Tuple, Union
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from requests import Session
from app.core.cache import cached from app.core.cache import cached
from app.core.config import settings from app.core.config import settings
@@ -20,6 +21,7 @@ from app.log import logger
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.zhconv import convert as zhconv_convert
class MusicBrainzModule(_ModuleBase): class MusicBrainzModule(_ModuleBase):
@@ -34,6 +36,12 @@ class MusicBrainzModule(_ModuleBase):
_request_interval = 1.0 _request_interval = 1.0
_request_lock = threading.Lock() _request_lock = threading.Lock()
_last_request_at = 0.0 _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 = ( _artist_relation_priority = (
"member of band", "member of band",
@@ -116,40 +124,103 @@ class MusicBrainzModule(_ModuleBase):
def _search_recordings(self, meta: MetaMusic, limit: int) -> list[MusicInfo]: def _search_recordings(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按音频标签条件搜索 Recording,供全局搜索和文件识别复用。""" """按音频标签条件搜索 Recording,供全局搜索和文件识别复用。"""
query = self._build_query(meta) for query in self._recording_queries(meta):
if not query:
return []
payload = self._request_json( payload = self._request_json(
"/recording", "/recording",
params={"query": query, "limit": max(1, min(limit, 100)), "fmt": "json"}, params={"query": query, "limit": max(1, min(limit, 100)), "fmt": "json"},
) )
return [ results = [
info info
for item in (payload or {}).get("recordings") or [] for item in (payload or {}).get("recordings") or []
if (info := self._recording_to_info(item)) if (info := self._recording_to_info(item))
] ]
if results:
return results
return []
def _search_albums(self, meta: MetaMusic, limit: int) -> list[MusicInfo]: @classmethod
"""按标题和可选艺术家搜索 Release Group 专辑候选。""" def _recording_queries(cls, meta: MetaMusic) -> list[str]:
title = meta.album or meta.title """构造 Recording 检索式阶梯,由严到宽逐级放宽避免零命中。
条目括号多为全角、艺术家署名存在变体(如外文艺名),精确 AND 条件容易零命中;
放宽后由候选评分负责收紧(已知艺术家时必须艺术家命中),不会产生错误身份。
"""
title = cls._search_title(meta.title)
if not title: if not title:
return [] return []
clauses = [f'releasegroup:"{self._escape_query(title)}"'] artist = meta.artists[0] if meta.artists else None
if meta.artists: # 括号内的影视 tie-in、版本说明多为半角,与条目全角写法不一致,准备去注释曲名兜底
clauses.append(f'artist:"{self._escape_query(meta.artists[0])}"') 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( payload = self._request_json(
"/release-group", "/release-group",
params={ params={
"query": " AND ".join(clauses), "query": query,
"limit": max(1, min(limit, 100)), "limit": max(1, min(limit, 100)),
"fmt": "json", "fmt": "json",
}, },
) )
return [ results = [
album.to_music_info() album.to_music_info()
for item in (payload or {}).get("release-groups") or [] for item in (payload or {}).get("release-groups") or []
if (album := self._release_group_to_album(item)) 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]: def _search_artists(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按用户输入中的艺术家部分搜索 Artist 浏览候选。""" """按用户输入中的艺术家部分搜索 Artist 浏览候选。"""
@@ -382,8 +453,14 @@ class MusicBrainzModule(_ModuleBase):
@staticmethod @staticmethod
def _match_text(value: Optional[str]) -> str: 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 @classmethod
def _unique_texts(cls, values: Iterable[Optional[str]]) -> list[str]: 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)) info = self.recognize_music(resolved_source, str(mediaid or meta.media_id))
if info: if info:
return info return info
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据 # 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据
# 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。 # 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。
candidates = self._search_recordings(meta, limit=10) candidates = self._search_recordings(meta, limit=10)
matched = self._select_candidate(meta, candidates, source=resolved_source or self._source) 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) return matched or self._info_from_meta(meta)
async def async_recognize_media( 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]: def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], source: str) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的搜索候选。""" """按标题、艺术家和专辑匹配度选择最可信的搜索候选。"""
normalized_source = cls._normalize_text(source).casefold() 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]] = [] ranked: list[tuple[int, MusicInfo]] = []
for candidate in candidates: for candidate in candidates:
if normalized_source and (candidate.source or "").casefold() != normalized_source: if normalized_source and (candidate.source or "").casefold() != normalized_source:
continue continue
score = 0 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 score += 4
if meta.artists and any( elif (
cls._same_text(meta.artists[0], artist) bare_title
for artist in candidate.artists 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 score += 3
if meta.album and cls._same_text(meta.album, candidate.album): if meta.album and cls._same_text(meta.album, candidate.album):
score += 2 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 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)) ranked.append((score, candidate))
if not ranked: if not ranked:
return None return None
ranked.sort(key=lambda item: item[0], reverse=True) ranked.sort(key=lambda item: item[0], reverse=True)
return ranked[0][1] if ranked[0][0] > 0 else None 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 @classmethod
def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo: def _info_from_meta(cls, meta: MetaMusic) -> MusicInfo:
"""音乐识别无候选时,把元数据转换为可展示的最小信息。""" """音乐识别无候选时,把元数据转换为可展示的最小信息。"""
@@ -527,13 +683,49 @@ class MusicBrainzModule(_ModuleBase):
@classmethod @classmethod
def _same_text(cls, left: Optional[str], right: Optional[str]) -> bool: 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 @classmethod
def _normalize_text(cls, value: Optional[str]) -> str: 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]: def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]:
"""按 MusicBrainz 标准 ID 获取音乐详情,单曲不存在时回退到专辑。""" """按 MusicBrainz 标准 ID 获取音乐详情,单曲不存在时回退到专辑。"""
@@ -634,8 +826,10 @@ class MusicBrainzModule(_ModuleBase):
def _build_query(cls, meta: MetaMusic) -> str: def _build_query(cls, meta: MetaMusic) -> str:
"""构造 MusicBrainz Recording 搜索表达式。""" """构造 MusicBrainz Recording 搜索表达式。"""
clauses = [] 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: if meta.artists:
clauses.append(f'artist:"{cls._escape_query(meta.artists[0])}"') clauses.append(f'artist:"{cls._escape_query(meta.artists[0])}"')
if meta.album: if meta.album:
@@ -997,6 +1191,15 @@ class MusicBrainzModule(_ModuleBase):
base = (settings.MUSIC_COVER_PROXY or "https://coverartarchive.org").rstrip("/") base = (settings.MUSIC_COVER_PROXY or "https://coverartarchive.org").rstrip("/")
return f"{base}/release-group/{release_group_id}/front-500" 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 @classmethod
def _wait_for_rate_limit(cls) -> None: def _wait_for_rate_limit(cls) -> None:
"""串行控制 MusicBrainz 公共接口的最小请求间隔。""" """串行控制 MusicBrainz 公共接口的最小请求间隔。"""
@@ -1014,7 +1217,13 @@ class MusicBrainzModule(_ModuleBase):
path: str, path: str,
params: Optional[dict[str, Any]] = None, params: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""请求 MusicBrainz JSON 接口并统一处理网络和响应错误。""" """请求 MusicBrainz JSON 接口并统一处理网络和响应错误。
服务端繁忙(429/5xx)属于瞬时错误,退避重试后再失败才放弃,
避免批量识别场景下把限流误判为检索零命中。
"""
attempts = cls._busy_retries + 1
for attempt in range(attempts):
cls._wait_for_rate_limit() cls._wait_for_rate_limit()
response = RequestUtils( response = RequestUtils(
headers={ headers={
@@ -1022,19 +1231,29 @@ class MusicBrainzModule(_ModuleBase):
"Accept": "application/json", "Accept": "application/json",
}, },
proxies=settings.PROXY, proxies=settings.PROXY,
session=cls._get_session(),
timeout=20, timeout=20,
).get_res(f"{cls._base_url}{path}", params=params) ).get_res(f"{cls._base_url}{path}", params=params)
if response is None: if response is None:
return None return None
status_code = response.status_code
try: try:
if response.status_code == 404: if status_code == 404:
# 单曲与专辑共用同一套 ID 入口,404 属于正常的探测结果 # 单曲与专辑共用同一套 ID 入口,404 属于正常的探测结果
logger.debug(f"MusicBrainz 资源不存在:{path}") logger.debug(f"MusicBrainz 资源不存在:{path}")
# 使用空对象区分稳定的不存在与瞬时请求失败,使有界缓存能够复用探测结果。 # 使用空对象区分稳定的不存在与瞬时请求失败,使有界缓存能够复用探测结果。
return {} return {}
if response.status_code != 200: if status_code == 429 or status_code >= 500:
logger.warning( logger.warning(
f"MusicBrainz 请求失败{response.status_code} {response.text[:200]}" 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 None
return response.json() return response.json()
@@ -1043,3 +1262,4 @@ class MusicBrainzModule(_ModuleBase):
return None return None
finally: finally:
response.close() response.close()
return None
+283
View File
@@ -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()
+3 -2
View File
@@ -218,14 +218,15 @@ def test_python_metainfo_preserves_all_resource_types(title, expected):
def test_metainfo_routes_audio_filename_to_music(): def test_metainfo_routes_audio_filename_to_music():
"""音频文件名应直接走音乐分支,不再进入影视季集解析。""" """音频文件名应直接走音乐分支并完成艺术家/曲名拆分,不再进入影视季集解析。"""
meta = MetaInfo("周杰伦 - 晴天.flac") meta = MetaInfo("周杰伦 - 晴天.flac")
assert isinstance(meta, MetaMusic) assert isinstance(meta, MetaMusic)
assert isinstance(meta, MetaBase) assert isinstance(meta, MetaBase)
assert meta.type == MediaType.MUSIC assert meta.type == MediaType.MUSIC
assert meta.org_string == "周杰伦 - 晴天.flac" assert meta.org_string == "周杰伦 - 晴天.flac"
assert meta.title == "周杰伦 - 晴天" assert meta.title == "晴天"
assert meta.artists == ["周杰伦"]
assert meta.audio_format == "FLAC" assert meta.audio_format == "FLAC"
# 音乐没有季集信息,兼容通用访问 # 音乐没有季集信息,兼容通用访问
assert meta.season is None assert meta.season is None
+323
View File
@@ -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 == "某曲名"
+39
View File
@@ -24,6 +24,45 @@ def test_parse_query_keeps_plain_title():
assert meta.title == "Random Access Memories" 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(): def test_build_site_keywords_prefers_artist_album():
"""专辑订阅只按艺术家与专辑名搜索,不混入其中某首单曲。""" """专辑订阅只按艺术家与专辑名搜索,不混入其中某首单曲。"""
info = MusicInfo( info = MusicInfo(
-131
View File
@@ -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
+167 -1
View File
@@ -1,6 +1,7 @@
from app.core.config import settings
from app.core.context import MusicInfo
from app.core.meta import MetaMusic from app.core.meta import MetaMusic
from app.modules.musicbrainz import MusicBrainzModule from app.modules.musicbrainz import MusicBrainzModule
from app.core.config import settings
def test_musicbrainz_cover_domains_are_allowed_by_image_proxy(): 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(): def test_recording_to_info_maps_musicbrainz_payload():
"""MusicBrainz Recording 应映射为统一 MusicInfo。""" """MusicBrainz Recording 应映射为统一 MusicInfo。"""
info = MusicBrainzModule._recording_to_info( info = MusicBrainzModule._recording_to_info(
@@ -513,3 +543,139 @@ def test_request_json_caches_not_found(monkeypatch):
assert first == second == {} assert first == second == {}
assert network_calls["count"] == 1 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年的第一场雪"