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

View File

@@ -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<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_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()

View File

@@ -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:
"""音乐没有季信息,兼容下载与事件链的通用访问。"""

View File

@@ -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]
)

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()

View File

@@ -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

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()

View File

@@ -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

323
tests/test_metamusic.py Normal file
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_プライム_松任谷由実  ")
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 == "某曲名"

View File

@@ -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(

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

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.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年的第一场雪"