mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
1914 lines
81 KiB
Python
1914 lines
81 KiB
Python
import re
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from threading import RLock
|
||
from typing import Any, Callable, Optional
|
||
|
||
from app.domain.meta.metabase import MetaBase
|
||
from app.schemas.types import MediaSource, MediaType
|
||
from app.schemas.media import resolve_media_identity
|
||
from app.domain.meta.runtime import get_metainfo_accelerator
|
||
|
||
|
||
_AUDIO_FORMAT_PATTERN = re.compile(
|
||
r"(?<![A-Z])(?P<format>DSD(?:64|128|256|512)?|DSF|DFF|SACD|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|"
|
||
r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA)(?![A-Z])",
|
||
re.IGNORECASE,
|
||
)
|
||
_BIT_DEPTH_PATTERN = re.compile(r"(?<!\d)(?P<value>16|20|24|32)\s*(?:-?bit|bits?|位)(?!\w)", re.IGNORECASE)
|
||
_SAMPLE_RATE_PATTERN = re.compile(
|
||
r"(?<!\d)(?P<value>44(?:\.1)?|48|88(?:\.2)?|96|176(?:\.4)?|192|352(?:\.8)?|384|705(?:\.6)?|768)"
|
||
r"\s*k(?:hz)?(?!\w)",
|
||
re.IGNORECASE,
|
||
)
|
||
_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"(?<![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 = {
|
||
"WAVE": "WAV",
|
||
"AIF": "AIFF",
|
||
"VORBIS": "OGG",
|
||
"M4A": "AAC",
|
||
"DSF": "DSD",
|
||
"DFF": "DSD",
|
||
"SACD": "DSD",
|
||
}
|
||
_LOSSLESS_AUDIO_FORMATS = frozenset({"DSD", "FLAC", "ALAC", "APE", "WAV", "AIFF", "PCM"})
|
||
_LOSSY_AUDIO_FORMATS = frozenset({"MP3", "AAC", "OGG", "OPUS", "WMA"})
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def normalize_audio_format(value: Any) -> Optional[str]:
|
||
"""将音频格式名称归一为订阅筛选和展示使用的规范值。"""
|
||
text = str(value or "").strip().upper()
|
||
if not text:
|
||
return None
|
||
match = _AUDIO_FORMAT_PATTERN.search(text)
|
||
if not match:
|
||
return text
|
||
normalized = match.group("format").upper()
|
||
if normalized.startswith("DSD"):
|
||
return "DSD"
|
||
return _AUDIO_FORMAT_ALIASES.get(normalized, normalized)
|
||
|
||
|
||
def infer_audio_lossless(audio_format: Any, explicit: Optional[bool] = None) -> Optional[bool]:
|
||
"""根据格式推断是否无损;显式识别结果优先于格式推断。"""
|
||
if explicit is not None:
|
||
return bool(explicit)
|
||
normalized = normalize_audio_format(audio_format)
|
||
if normalized in _LOSSLESS_AUDIO_FORMATS:
|
||
return True
|
||
if normalized in _LOSSY_AUDIO_FORMATS:
|
||
return False
|
||
return None
|
||
|
||
|
||
def parse_audio_quality(value: Any) -> dict[str, Any]:
|
||
"""从资源标题或描述中提取声明的格式、位深、采样率和码率。"""
|
||
text = str(value or "")
|
||
format_match = _AUDIO_FORMAT_PATTERN.search(text)
|
||
bit_depth_match = _BIT_DEPTH_PATTERN.search(text)
|
||
sample_rate_match = _SAMPLE_RATE_PATTERN.search(text)
|
||
bitrate_match = _BITRATE_PATTERN.search(text)
|
||
audio_format = normalize_audio_format(format_match.group("format")) if format_match else None
|
||
bit_depth = int(bit_depth_match.group("value")) if bit_depth_match else None
|
||
sample_rate = (
|
||
int(float(sample_rate_match.group("value")) * 1000)
|
||
if sample_rate_match
|
||
else None
|
||
)
|
||
bitrate = int(bitrate_match.group("value")) * 1000 if bitrate_match else None
|
||
explicit_lossless = True if (_LOSSLESS_PATTERN.search(text) or _HIRES_PATTERN.search(text)) else None
|
||
return {
|
||
"audio_format": audio_format,
|
||
"audio_lossless": infer_audio_lossless(audio_format, explicit_lossless),
|
||
"bit_depth": bit_depth,
|
||
"sample_rate": sample_rate,
|
||
"bitrate": bitrate,
|
||
}
|
||
|
||
|
||
def audio_quality_tier(
|
||
audio_format: Any,
|
||
audio_lossless: Optional[bool] = None,
|
||
bit_depth: Optional[int] = None,
|
||
sample_rate: Optional[int] = None,
|
||
bitrate: Optional[int] = None,
|
||
) -> Optional[str]:
|
||
"""返回 hires、lossless 或 lossy 音质等级,未知参数返回 None。"""
|
||
normalized = normalize_audio_format(audio_format)
|
||
lossless = infer_audio_lossless(normalized, audio_lossless)
|
||
if normalized == "DSD" or (lossless and ((bit_depth or 0) >= 24 or (sample_rate or 0) >= 88200)):
|
||
return "hires"
|
||
if lossless:
|
||
return "lossless"
|
||
if lossless is False or normalized or bitrate:
|
||
return "lossy"
|
||
return None
|
||
|
||
|
||
def audio_quality_score(
|
||
audio_format: Any,
|
||
audio_lossless: Optional[bool] = None,
|
||
bit_depth: Optional[int] = None,
|
||
sample_rate: Optional[int] = None,
|
||
bitrate: Optional[int] = None,
|
||
) -> int:
|
||
"""将音乐音质换算为 0 至 100 的稳定洗版优先级。"""
|
||
normalized = normalize_audio_format(audio_format)
|
||
lossless = infer_audio_lossless(normalized, audio_lossless)
|
||
if normalized == "DSD" or (lossless and (bit_depth or 0) >= 24 and (sample_rate or 0) >= 192000):
|
||
return 100
|
||
if lossless:
|
||
score = 86
|
||
if (bit_depth or 0) >= 24:
|
||
score += 5
|
||
elif (bit_depth or 0) >= 16:
|
||
score += 2
|
||
if (sample_rate or 0) >= 176400:
|
||
score += 7
|
||
elif (sample_rate or 0) >= 88200:
|
||
score += 5
|
||
elif sample_rate:
|
||
score += 2
|
||
return min(score, 99)
|
||
if bitrate:
|
||
kbps = bitrate // 1000
|
||
if kbps >= 320:
|
||
return 80
|
||
if kbps >= 256:
|
||
return 70
|
||
if kbps >= 192:
|
||
return 60
|
||
if kbps >= 128:
|
||
return 50
|
||
return 40
|
||
return 35 if normalized in _LOSSY_AUDIO_FORMATS else 0
|
||
|
||
|
||
def format_audio_quality(
|
||
audio_format: Any,
|
||
audio_lossless: Optional[bool] = None,
|
||
bit_depth: Optional[int] = None,
|
||
sample_rate: Optional[int] = None,
|
||
bitrate: Optional[int] = None,
|
||
) -> Optional[str]:
|
||
"""将音频技术参数格式化为适合识别结果和通知展示的紧凑文本。"""
|
||
parts: list[str] = []
|
||
normalized = normalize_audio_format(audio_format)
|
||
if normalized:
|
||
parts.append(normalized)
|
||
if bit_depth:
|
||
parts.append(f"{bit_depth}-bit")
|
||
if sample_rate:
|
||
rate = sample_rate / 1000
|
||
parts.append(f"{rate:g} kHz")
|
||
if bitrate:
|
||
parts.append(f"{round(bitrate / 1000):,} kbps")
|
||
if not parts:
|
||
tier = audio_quality_tier(audio_format, audio_lossless, bit_depth, sample_rate, bitrate)
|
||
if tier:
|
||
parts.append({"hires": "Hi-Res", "lossless": "Lossless", "lossy": "Lossy"}[tier])
|
||
return " · ".join(parts) or None
|
||
|
||
|
||
def _optional_int(value: Any) -> Optional[int]:
|
||
"""将音频技术参数安全转换为整数,空值与非数字返回 None。"""
|
||
if value in (None, ""):
|
||
return None
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
# 资源标题中的音质规格与发行标记(格式、位深采样、年份括号、发行实体标记),
|
||
# 拆分艺术家/曲名前需先剔除,否则「曲名 - FLAC [16B-44.1kHz]」会被误拆成艺术家与曲名
|
||
_MUSIC_FORMAT_TOKEN_ALT = (
|
||
r"DSD(?:64|128|256|512)?|DSF|DFF|SACD|FLAC|ALAC|APE|CUE|WAV|WAVE|AIFF?|PCM|"
|
||
r"MP3|AAC|M4A|OGG|VORBIS|OPUS|WMA|WEB-?DL|WEBRip|WEB"
|
||
)
|
||
_MUSIC_VIDEO_TOKEN_ALT = (
|
||
r"1080[pi]|720p|2160[pi]|480[pi]|4k|8k|uhd|"
|
||
r"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|"
|
||
r"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|"
|
||
r"hdr10\+?|dovi|dolby\s*vision|sub(?:title)?s?|chs&cht"
|
||
)
|
||
_MUSIC_QUALITY_TOKEN_RE = re.compile(
|
||
r"\[[^\]]*\]|\((?:19|20)\d{2}\)|"
|
||
rf"(?<![A-Za-z0-9])(?:{_MUSIC_FORMAT_TOKEN_ALT})(?![A-Za-z0-9])|"
|
||
r"\b\d{1,3}\s*-?\s*bits?\b|\b\d{2,4}(?:(?:[..]|\s)\d)?\s*k(?:hz|bps?)\b|"
|
||
# Single / EP / Album 仅在独立尾段或括号标签中才是发行标记,
|
||
# 不能在这里全局删除,否则 Best Album、Single Ladies 等自然标题会受损。
|
||
r"(?<![A-Za-z0-9])(?:lossless|无损音质|无损|分[轨軌]|整[轨軌]|"
|
||
r"原抓|自抓|自扫|自掃)(?![A-Za-z0-9])|"
|
||
# 合集/精选只有作为独立标签时才删除,不能损伤「楠得精选」「音乐合集」等作品名。
|
||
r"(?<![A-Za-z0-9\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af])"
|
||
r"(?:合集|精选)(?![A-Za-z0-9\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af])",
|
||
re.IGNORECASE,
|
||
)
|
||
# 演唱会/音乐视频种子的视频编码标记:分辨率、编码、容器与声道描述,
|
||
# 不是音乐文本信息,不剔除会污染曲名并阻断艺术家/曲名拆分
|
||
_MUSIC_VIDEO_TOKEN_RE = re.compile(
|
||
rf"(?<![A-Za-z0-9])(?:{_MUSIC_VIDEO_TOKEN_ALT})(?![A-Za-z0-9])",
|
||
re.IGNORECASE,
|
||
)
|
||
# 尾部规格段判定:整段仅由格式词、视频标记、位深采样与无损声明词组成,
|
||
# 曲名含任何自然语言文本时判定失败,保证「曲名 (注释) - WEB-DL」不被误剥;
|
||
# 发布组标签(HHWEB/FHDMv)不是固定词表,由 _strip_spec_segments 的短词规则另行放行
|
||
_MUSIC_SPEC_SEGMENT_RE = re.compile(
|
||
rf"^(?:(?<![A-Za-z0-9])(?:{_MUSIC_FORMAT_TOKEN_ALT}|{_MUSIC_VIDEO_TOKEN_ALT}|single|ep|album)"
|
||
r"(?![A-Za-z0-9])"
|
||
r"|\d{1,3}\s*-?\s*bits?|\d{2,4}(?:(?:[..]|\s)\d)?\s*k(?:hz|bps?)"
|
||
r"|lossless|无损音质|无损|分[轨軌]|整[轨軌]|原抓|自抓|自扫|自掃|合集|精选"
|
||
r"|[\s\-–—−-/+]"
|
||
r")+$",
|
||
re.IGNORECASE,
|
||
)
|
||
# 尾部规格段定位:最后一个「空白+连字符」或「格式词连字符」分隔的片段;
|
||
# 片段内不允许连字符,保证匹配落在最尾部片段而不是从左吞掉整个尾巴
|
||
_MUSIC_TRAILING_SEGMENT_RE = re.compile(
|
||
r"(?:\s+[\-–—−-]+\s*|(?P<prefix>[A-Za-z0-9]+)[\-–—−-]+)"
|
||
r"(?P<segment>[^\s\-–—−-]+(?:\s+[^\s\-–—−-]+)*)\s*$"
|
||
)
|
||
# 规格词后的发布组尾巴:FLAC 2.0-ADE、FLAC 分轨-nbarock、FLAC 2.0-LIVE@ADE。
|
||
# 必须存在已知规格词才剥离,避免把普通的「标题-艺术家」误当发布组。
|
||
_MUSIC_RELEASE_GROUP_RE = re.compile(
|
||
rf"(?<![A-Za-z0-9])(?:{_MUSIC_FORMAT_TOKEN_ALT}|{_MUSIC_VIDEO_TOKEN_ALT})(?![A-Za-z0-9])"
|
||
r"(?:\s*(?:[+/]|\d{1,3}(?:\.\d)?|bits?|k(?:hz|bps?)|lossless|无损|"
|
||
r"分[轨軌]|整[轨軌]|原抓|自抓|自扫|自掃))*"
|
||
r"\s*[-–—−-]+\s*[A-Za-z0-9][A-Za-z0-9@._-]{1,20}\s*$",
|
||
re.IGNORECASE,
|
||
)
|
||
# 括号内的抓轨说明整体删除,避免逐词清理后留下「(+CUE原抓)」等残片。
|
||
_MUSIC_RIP_NOTE_RE = re.compile(
|
||
rf"[\((][^()()]{{0,80}}(?:{_MUSIC_FORMAT_TOKEN_ALT})[^()()]{{0,80}}"
|
||
r"(?:原抓|自抓|自扫|自掃)[^()()]{0,40}[\))]",
|
||
re.IGNORECASE,
|
||
)
|
||
_MUSIC_RIP_SIGNATURE_RE = re.compile(
|
||
rf"(?:{_MUSIC_FORMAT_TOKEN_ALT})[^\r\n]{{0,30}}(?:原抓|自抓|自扫|自掃)",
|
||
re.IGNORECASE,
|
||
)
|
||
# 中文抓轨方式常与格式词粘连(WAV分轨原抓),不能复用要求 ASCII 边界的
|
||
# 格式 token 规则;这些词本身语义明确,单独清理可保留格式词的严格边界。
|
||
_MUSIC_RIP_METHOD_RE = re.compile(r"(?:分轨|分軌|整轨|整軌|原抓|自抓|自扫|自掃)", re.IGNORECASE)
|
||
# 场景音频尾链:作品名 - 2006-FLAC分轨-OpenCD-发布者。
|
||
# 年份属于发行线索,其后的格式、抓轨方式和发布组都不进入音乐名称。
|
||
_MUSIC_AUDIO_RELEASE_TAIL_RE = re.compile(
|
||
rf"(?<!\d)(?P<year>(?:19|20)\d{{2}})\s*[-–—−-]\s*"
|
||
rf"(?:{_MUSIC_FORMAT_TOKEN_ALT})(?:\s*(?:分[轨軌]|整[轨軌]|原抓|自抓|自扫|自掃))*"
|
||
r"(?:\s*[-–—−-]\s*[^\s\-–—−-]+){0,4}\s*$",
|
||
re.IGNORECASE,
|
||
)
|
||
# 圆括号中的联合位深声明(24/48bit)及规格剔除后留下的空括号。
|
||
_MUSIC_PAREN_SPEC_RE = re.compile(
|
||
r"[\((]\s*\d{1,3}\s*/\s*\d{1,3}\s*-?\s*bits?\s*[\))]",
|
||
re.IGNORECASE,
|
||
)
|
||
_MUSIC_EMPTY_BRACKET_RE = re.compile(r"[\((\[]\s*(?:[/+,\-]\s*)*[\))\]]")
|
||
# 尾部花括号通常是唱片目录号或发布标记,仅在末尾剔除,保护正文中的花括号文本。
|
||
_MUSIC_TRAILING_CATALOG_RE = re.compile(r"\s*\{[A-Za-z0-9][^{}]{0,40}\}\s*$")
|
||
# 年份括号:(2000)(2000)【2000】形式的发行年份,作为候选消歧线索
|
||
_MUSIC_YEAR_RE = re.compile(r"[\(\[(【]((?:19|20)\d{2})[\)\])】]")
|
||
# 标题尾部独立年份:「xxx音乐会 2018」「Funky Jazz Saxophone 2024」「系列-2007」,
|
||
# 提取为发行年份线索并从曲名剥离,避免年份文本进入检索式造成零命中
|
||
_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", "various artists": "Various Artists"}
|
||
# VA-Title / Various Artists-Title 无空格连字符前缀写法(场景命名),主拆分不适用需单独处理
|
||
_MUSIC_ALIAS_PREFIX_RE = re.compile(
|
||
r"^\s*(?P<alias>VA|Various\.?\s*Artists)\s*[-–—−-]\s*(?P<title>.+\S)\s*$",
|
||
re.IGNORECASE,
|
||
)
|
||
# 艺术家与曲名的主分隔:半角/全角空格包裹的连字符(- – — − -)
|
||
_MUSIC_ARTIST_TITLE_RE = re.compile(
|
||
r"^\s*(?P<artist>.+?)\s+[\-–—−-]+\s+(?P<title>.+?)\s*$"
|
||
)
|
||
# 曲名后含书名号的括号注释(影视原声说明等):「等得到 (电影《如影随心》主题曲 独唱版)」,
|
||
# 注释内的《》会抢先触发专辑书名号判定,需在结构解析前提取;
|
||
# 单层括号注释(电影版/Live)是 MusicBrainz 条目的消歧后缀,不提取
|
||
_MUSIC_TITLE_COMMENT_RE = re.compile(r"\s*[\((](?P<comment>[^))]*[《》][^))]*)[\))]\s*$")
|
||
# CJK「歌手《专辑名》」书名号命名:提取艺术家与专辑,专辑内尾部 -CD2 为碟号
|
||
_MUSIC_ALBUM_MARKER_RE = re.compile(
|
||
r"^\s*(?P<artist>[^《》]+?)\s*《(?P<album>[^《》]+)》\s*(?P<rest>.*)$"
|
||
)
|
||
_MUSIC_TRAILING_CJK_ALIAS_RE = re.compile(
|
||
r"\s+[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]"
|
||
r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af·・.'’\s]{0,60}$"
|
||
)
|
||
_MUSIC_ALBUM_DISC_RE = re.compile(r"[\s\-–—−-]*(?:cd|disc)\s*(\d{1,2})$", re.IGNORECASE)
|
||
# 曲名尾部重复的艺术家署名(如「名人名曲-毛阿敏」「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)
|
||
# 音乐视频/演唱会资源使用影视场景式命名,但标题语义仍属于音乐。
|
||
# 这里按 token 分类后再清理,避免用一个跨越整行的大正则吞掉年份后的演出名称。
|
||
_MUSIC_SCENE_RESOLUTION_RE = re.compile(
|
||
r"^(?:(?:480|576|720|1080|2160)[pi]|[248]k)$",
|
||
re.IGNORECASE,
|
||
)
|
||
_MUSIC_SCENE_SOURCE_RE = re.compile(
|
||
r"^(?:uhd|blu[-.]?ray|bdrip|remux|web[-.]?dl|webrip|hdtv|uhdtv|"
|
||
r"hd[-.]?dvd|dvd|dvdrip|2cd\+blu[-.]?ray)$",
|
||
re.IGNORECASE,
|
||
)
|
||
_MUSIC_SCENE_VIDEO_RE = re.compile(
|
||
r"^(?:x26[45](?:[._-]?(?:8|10|12)bits?)?|h[.]?26[45]|avc|hevc|"
|
||
r"mpeg[-.]?2|vc[-.]?1|prores|av1)$",
|
||
re.IGNORECASE,
|
||
)
|
||
_MUSIC_SCENE_EFFECT_RE = re.compile(
|
||
r"^(?:sdr|hdr(?:10[+]?)?|hdrvivid|dovi|dv|dolbyvision|3d|repack|hlg|hq)$",
|
||
re.IGNORECASE,
|
||
)
|
||
_MUSIC_SCENE_AUDIO_RE = re.compile(
|
||
r"^(?:dts(?:-hd)?(?:ma|hra)?|truehd|atmos|ddp|dd[+]?|eac3|ac3|"
|
||
r"lpcm|aac|flac|pcm|opus|vorbis)(?:[257][.]1|2[.]0)?$",
|
||
re.IGNORECASE,
|
||
)
|
||
_MUSIC_SCENE_AUDIO_AUX_RE = re.compile(r"^(?:ma|hra)(?:[257][.]1|2[.]0)?$", re.IGNORECASE)
|
||
_MUSIC_SCENE_CHANNEL_RE = re.compile(r"^(?:1[.]0|2[.]0|[257][.]1)(?:ch(?:annels?)?)?$", re.IGNORECASE)
|
||
_MUSIC_SCENE_BIT_RE = re.compile(r"^(?:8|10|12|16|20|24|32)[-_.]?bits?$", re.IGNORECASE)
|
||
_MUSIC_SCENE_FPS_RE = re.compile(r"^[0-9]{2,3}fps$", re.IGNORECASE)
|
||
_MUSIC_SCENE_AUDIO_COUNT_RE = re.compile(r"^[0-9]{1,2}audios?$", re.IGNORECASE)
|
||
_MUSIC_SCENE_YEAR_TOKEN_RE = re.compile(r"^(?:19|20)[0-9]{2}$")
|
||
_MUSIC_SCENE_DATE_TOKEN_RE = re.compile(r"^(?P<year>[0-9]{2})(?:0[1-9]|1[0-2])(?:0[1-9]|[12][0-9]|3[01])$")
|
||
_MUSIC_SCENE_YEAR_RANGE_TOKEN_RE = re.compile(
|
||
r"^(?P<begin>(?:19|20)[0-9]{2})[-–—~~](?P<end>(?:(?:19|20)[0-9]{2}|[0-9]{2}))$"
|
||
)
|
||
_MUSIC_SCENE_RELEASE_GROUP_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9@._-]{1,20}$")
|
||
_MUSIC_SCENE_PUNCTUATED_TECH_RE = re.compile(
|
||
r"([,;])(?=(?:blu[-.]?ray|web[-.]?dl|hdtv|remux|avc|hevc|x26[45]|h[.]?26[45]))",
|
||
re.IGNORECASE,
|
||
)
|
||
_MUSIC_SCENE_PLATFORM_TOKENS = frozenset({
|
||
"AMZN", "BAHA", "CR", "FRIDAY", "HMAX", "IQ", "IT", "LINETV",
|
||
"MYTVSUPER", "NF", "OTOTOY",
|
||
})
|
||
_MUSIC_SCENE_LOCALE_TOKENS = frozenset({"GERMAN", "ITA", "JPN"})
|
||
_MUSIC_LATIN_HYPHEN_NON_ARTIST_SUFFIXES = frozenset({"cd", "disc", "part", "type", "vol", "volume"})
|
||
# 日文资源标题大量使用全角字符(WOWOW、50th、全角空格与括号),
|
||
# 归一为半角后才能与 MusicBrainz 条目及内置模式匹配
|
||
_FULLWIDTH_EXCLAMATION = 0xFF01
|
||
_FULLWIDTH_TILDE = 0xFF5E
|
||
_HALFWIDTH_OFFSET = 0xFEE0
|
||
_FULLWIDTH_MAP = {
|
||
0x3000: " ", # 全角空格
|
||
0x3010: "[", 0x3011: "]", # 【】
|
||
# 日文引号「」/『』与《》承载作品名,不属于格式注释,保留原样交给命名模式。
|
||
0xFF08: "(", 0xFF09: ")", # ()
|
||
0xFF3B: "[", 0xFF3D: "]", # []
|
||
0xFF5B: "{", 0xFF5D: "}", # {}
|
||
}
|
||
|
||
|
||
def _string_list(value: Any) -> list[str]:
|
||
"""将标签原始值归一为非空字符串列表,兼容单值、列表与逗号分隔。"""
|
||
if value is None:
|
||
return []
|
||
if isinstance(value, str):
|
||
return [value] if value else []
|
||
if isinstance(value, (list, tuple, set)):
|
||
return [str(item) for item in value if str(item)]
|
||
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)
|
||
|
||
|
||
# 连续单字母空格序列是缩写点号被全角归一/场景点分压平的结果(S.H.E -> S H E),
|
||
# 还原为点号缩写(S.H.E)才能与 MusicBrainz 条目署名比对
|
||
_LETTER_RUN_RE = re.compile(r"(?<![A-Za-z])((?:[A-Za-z] ){2,}[A-Za-z])(?![A-Za-z])")
|
||
|
||
|
||
def _restore_letter_abbrev(value: str) -> str:
|
||
"""把连续单字母空格序列还原为点号缩写(S H E -> S.H.E)。"""
|
||
return _LETTER_RUN_RE.sub(
|
||
lambda m: ".".join(m.group(1).split(" ")), str(value or "")
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MusicNameContext:
|
||
"""音乐命名公共清理后的解析上下文,供命名模式和解析器共享。"""
|
||
|
||
raw: str
|
||
normalized: str
|
||
text: str
|
||
artists: tuple[str, ...]
|
||
year: Optional[int] = None
|
||
comment: Optional[str] = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _MusicSceneTokenResult:
|
||
"""音乐视频场景 token 清理结果,仅供 metamusic 内置模式使用。"""
|
||
|
||
text: str
|
||
year: Optional[int]
|
||
categories: frozenset[str]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MusicNamePattern:
|
||
"""可动态注册的音乐命名模式。"""
|
||
|
||
name: str
|
||
matcher: Callable[[MusicNameContext], Optional[Any]]
|
||
priority: int = 0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MusicNamePatternMatch:
|
||
"""第一层命名模式匹配结果。"""
|
||
|
||
pattern_name: str
|
||
payload: Any
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MusicNameParseResult:
|
||
"""第二层解析器提取出的音乐命名字段。"""
|
||
|
||
title: Optional[str]
|
||
artists: Optional[list[str]] = None
|
||
album: Optional[str] = None
|
||
year: Optional[int] = None
|
||
disc_number: Optional[int] = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MusicNameParser:
|
||
"""可动态注册、按命名模式选择的音乐解析器。"""
|
||
|
||
name: str
|
||
patterns: tuple[str, ...]
|
||
handler: Callable[
|
||
[MusicNameContext, MusicNamePatternMatch], Optional[MusicNameParseResult]
|
||
]
|
||
priority: int = 0
|
||
|
||
|
||
class MusicNameRegistry:
|
||
"""音乐命名模式与解析器的两层动态注册中心。"""
|
||
|
||
_patterns: dict[str, MusicNamePattern] = {}
|
||
_parsers: dict[str, MusicNameParser] = {}
|
||
_default_patterns: dict[str, MusicNamePattern] = {}
|
||
_default_parsers: dict[str, MusicNameParser] = {}
|
||
_lock = RLock()
|
||
|
||
@classmethod
|
||
def register_pattern(cls, pattern: MusicNamePattern, replace: bool = False) -> None:
|
||
"""注册命名模式,同名模式仅在 ``replace=True`` 时替换。"""
|
||
with cls._lock:
|
||
if pattern.name in cls._patterns and not replace:
|
||
raise ValueError(f"音乐命名模式已存在:{pattern.name}")
|
||
cls._patterns[pattern.name] = pattern
|
||
|
||
@classmethod
|
||
def unregister_pattern(cls, name: str) -> bool:
|
||
"""按名称注销命名模式,返回是否实际移除。"""
|
||
with cls._lock:
|
||
return cls._patterns.pop(name, None) is not None
|
||
|
||
@classmethod
|
||
def register_parser(cls, parser: MusicNameParser, replace: bool = False) -> None:
|
||
"""注册解析器,同名解析器仅在 ``replace=True`` 时替换。"""
|
||
with cls._lock:
|
||
if parser.name in cls._parsers and not replace:
|
||
raise ValueError(f"音乐命名解析器已存在:{parser.name}")
|
||
cls._parsers[parser.name] = parser
|
||
|
||
@classmethod
|
||
def unregister_parser(cls, name: str) -> bool:
|
||
"""按名称注销解析器,返回是否实际移除。"""
|
||
with cls._lock:
|
||
return cls._parsers.pop(name, None) is not None
|
||
|
||
@classmethod
|
||
def get_patterns(cls) -> tuple[MusicNamePattern, ...]:
|
||
"""按优先级返回当前已注册的命名模式快照。"""
|
||
with cls._lock:
|
||
return tuple(sorted(cls._patterns.values(), key=lambda item: item.priority, reverse=True))
|
||
|
||
@classmethod
|
||
def get_parsers(cls) -> tuple[MusicNameParser, ...]:
|
||
"""按优先级返回当前已注册的解析器快照。"""
|
||
with cls._lock:
|
||
return tuple(sorted(cls._parsers.values(), key=lambda item: item.priority, reverse=True))
|
||
|
||
@classmethod
|
||
def match_pattern(cls, context: MusicNameContext) -> Optional[MusicNamePatternMatch]:
|
||
"""执行第一层命名模式匹配,返回首个命中的模式及载荷。"""
|
||
for pattern in cls.get_patterns():
|
||
payload = pattern.matcher(context)
|
||
if payload is not None:
|
||
return MusicNamePatternMatch(pattern_name=pattern.name, payload=payload)
|
||
return None
|
||
|
||
@classmethod
|
||
def match_parser(cls, matched: MusicNamePatternMatch) -> Optional[MusicNameParser]:
|
||
"""执行第二层解析器匹配,选择支持该模式且优先级最高的解析器。"""
|
||
return next(
|
||
(
|
||
parser
|
||
for parser in cls.get_parsers()
|
||
if matched.pattern_name in parser.patterns or "*" in parser.patterns
|
||
),
|
||
None,
|
||
)
|
||
|
||
@classmethod
|
||
def parse(cls, context: MusicNameContext) -> Optional[MusicNameParseResult]:
|
||
"""依次匹配命名模式和解析器,并返回结构化音乐字段。"""
|
||
matched = cls.match_pattern(context)
|
||
if not matched:
|
||
return None
|
||
parser = cls.match_parser(matched)
|
||
if not parser:
|
||
return None
|
||
return parser.handler(context, matched)
|
||
|
||
@classmethod
|
||
def _capture_default_components(cls) -> None:
|
||
"""保存内置命名组件的对象快照,供 Rust 快路判断兼容性。"""
|
||
with cls._lock:
|
||
cls._default_patterns = dict(cls._patterns)
|
||
cls._default_parsers = dict(cls._parsers)
|
||
|
||
@classmethod
|
||
def _uses_default_components(cls) -> bool:
|
||
"""判断当前注册表是否仍为未替换的内置命名组件。"""
|
||
with cls._lock:
|
||
if not cls._default_patterns or not cls._default_parsers:
|
||
return False
|
||
if (
|
||
cls._patterns.keys() != cls._default_patterns.keys()
|
||
or cls._parsers.keys() != cls._default_parsers.keys()
|
||
):
|
||
return False
|
||
return all(
|
||
component is cls._default_patterns[name]
|
||
for name, component in cls._patterns.items()
|
||
) and all(
|
||
component is cls._default_parsers[name]
|
||
for name, component in cls._parsers.items()
|
||
)
|
||
|
||
|
||
class MetaMusic(MetaBase):
|
||
"""音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。"""
|
||
|
||
def __init__(
|
||
self,
|
||
org_string: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
artists: Optional[list[str]] = None,
|
||
album: Optional[str] = None,
|
||
album_artist: Optional[str] = None,
|
||
year: Optional[int] = None,
|
||
disc_number: Optional[int] = None,
|
||
track_number: Optional[int] = None,
|
||
total_discs: Optional[int] = None,
|
||
total_tracks: Optional[int] = None,
|
||
version: Optional[str] = None,
|
||
audio_format: Optional[str] = None,
|
||
audio_lossless: Optional[bool] = None,
|
||
bit_depth: Optional[int] = None,
|
||
sample_rate: Optional[int] = None,
|
||
bitrate: Optional[int] = None,
|
||
duration: Optional[int] = None,
|
||
isrc: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
parse_title: bool = False,
|
||
):
|
||
"""初始化音乐标题、标签、音频规格和统一媒体身份。"""
|
||
# 音乐无季集概念,仅复用 MetaBase 的基础字段初始化,不触发副标题季集识别
|
||
super().__init__(title or org_string or "")
|
||
self.type = MediaType.MUSIC
|
||
self.org_string = org_string
|
||
self.title = title
|
||
self.artists = list(artists) if artists else []
|
||
self.album = album
|
||
self.album_artist = album_artist
|
||
self.year = year
|
||
self.disc_number = disc_number
|
||
self.track_number = track_number
|
||
self.total_discs = total_discs
|
||
self.total_tracks = total_tracks
|
||
self.version = version
|
||
self.audio_format = normalize_audio_format(audio_format)
|
||
self.audio_lossless = infer_audio_lossless(self.audio_format, audio_lossless)
|
||
self.bit_depth = bit_depth
|
||
self.sample_rate = sample_rate
|
||
self.bitrate = bitrate
|
||
self.duration = duration
|
||
self.isrc = isrc
|
||
self.media_source, self.media_id = resolve_media_identity(
|
||
media_source=media_source,
|
||
media_id=media_id,
|
||
)
|
||
if parse_title:
|
||
# 种子/文件名字符串场景:解析艺术家、曲名、年份并补充音质参数
|
||
self.apply_title(self.title or org_string or "")
|
||
|
||
@classmethod
|
||
def parse_query(cls, query: str) -> "MetaMusic":
|
||
"""把用户输入或资源标题解析为音乐元数据。"""
|
||
return cls(org_string=query, title=query, parse_title=True)
|
||
|
||
@classmethod
|
||
def from_music_info(cls, info: Any) -> "MetaMusic":
|
||
"""把标准音乐信息转换为下载、整理和站点搜索使用的元数据。"""
|
||
return cls(
|
||
title=info.title,
|
||
artists=list(info.artists),
|
||
album=info.album,
|
||
album_artist=info.album_artist,
|
||
year=info.year,
|
||
disc_number=info.disc_number,
|
||
track_number=info.track_number,
|
||
total_discs=getattr(info, "total_discs", None),
|
||
total_tracks=info.total_tracks,
|
||
version=info.version,
|
||
audio_format=info.audio_format,
|
||
audio_lossless=info.audio_lossless,
|
||
bit_depth=info.bit_depth,
|
||
sample_rate=info.sample_rate,
|
||
bitrate=info.bitrate,
|
||
duration=info.duration,
|
||
isrc=info.isrc,
|
||
media_source=info.media_source,
|
||
media_id=info.media_id,
|
||
)
|
||
|
||
@classmethod
|
||
def from_album_context(
|
||
cls,
|
||
directory_name: str,
|
||
tracks: list["MetaMusic"],
|
||
) -> "MetaMusic":
|
||
"""按目录名和多数音轨标签汇总专辑识别条件。"""
|
||
directory = cls.parse_album_dir(directory_name)
|
||
album_votes: dict[str, int] = {}
|
||
artist_votes: dict[str, int] = {}
|
||
for track in tracks:
|
||
if track.album:
|
||
album_votes[track.album] = album_votes.get(track.album, 0) + 1
|
||
artist = track.album_artist or (track.artists[0] if track.artists else None)
|
||
if artist:
|
||
artist_votes[artist] = artist_votes.get(artist, 0) + 1
|
||
majority_album = max(album_votes, key=album_votes.get) if album_votes else None
|
||
majority_artist = max(artist_votes, key=artist_votes.get) if artist_votes else None
|
||
threshold = max(2, len(tracks) // 2)
|
||
album = majority_album if majority_album and album_votes[majority_album] >= threshold else None
|
||
artist = majority_artist if majority_artist and artist_votes[majority_artist] >= threshold else None
|
||
return cls(
|
||
org_string=directory_name,
|
||
title=album or directory.get("album") or directory_name,
|
||
album=album or directory.get("album"),
|
||
artists=[artist or directory.get("artist")]
|
||
if artist or directory.get("artist") else [],
|
||
album_artist=artist or directory.get("artist"),
|
||
year=directory.get("year"),
|
||
)
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
"""返回搜索和展示使用的音乐名称,优先专辑名其次标题。"""
|
||
return self.album or self.title or ""
|
||
|
||
@name.setter
|
||
def name(self, value: Optional[str]) -> None:
|
||
"""辅助识别链回写标题时落到 title 字段,保持音乐名称可写。"""
|
||
self.title = value or None
|
||
|
||
@property
|
||
def original_name(self) -> str:
|
||
"""返回未经过通用识别词处理的原始名称,兼容影视识别链的公共访问。"""
|
||
return self.org_string or self.title or self.album or ""
|
||
|
||
@property
|
||
def artist(self) -> str:
|
||
"""返回兼容现有展示组件的艺术家文本。"""
|
||
return " / ".join(self.artists)
|
||
|
||
@property
|
||
def audio_quality(self) -> Optional[str]:
|
||
"""返回 hires、lossless 或 lossy 音质等级。"""
|
||
return audio_quality_tier(
|
||
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
|
||
)
|
||
|
||
@property
|
||
def audio_quality_score(self) -> int:
|
||
"""返回订阅洗版使用的音质优先级。"""
|
||
return audio_quality_score(
|
||
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
|
||
)
|
||
|
||
@property
|
||
def audio_specs(self) -> Optional[str]:
|
||
"""返回识别结果和通知使用的格式化音频参数。"""
|
||
return format_audio_quality(
|
||
self.audio_format, self.audio_lossless, self.bit_depth, self.sample_rate, self.bitrate
|
||
)
|
||
|
||
def apply_audio_quality(self, value: Any, overwrite: bool = False) -> None:
|
||
"""从资源文本补充音质参数,默认保留文件标签读取到的实际值。"""
|
||
parsed = parse_audio_quality(value)
|
||
for key, parsed_value in parsed.items():
|
||
if parsed_value is not None and (overwrite or getattr(self, key, None) is None):
|
||
setattr(self, key, parsed_value)
|
||
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:
|
||
"""解析种子/文件名标题字符串,提取艺术家、曲名、年份并补充音质参数。
|
||
|
||
公共层先完成字符归一、音质与干扰信息剔除;随后由注册中心依次匹配
|
||
命名模式和对应解析器,最后统一回填结构化字段并提取曲序前缀。
|
||
"""
|
||
raw = str(value or "")
|
||
accelerator = get_metainfo_accelerator()
|
||
if accelerator and MusicNameRegistry._uses_default_components():
|
||
rust_result = accelerator.parse_metamusic(
|
||
raw,
|
||
artists=list(self.artists) or None,
|
||
year=self.year,
|
||
)
|
||
if rust_result and self._apply_rust_title_result(rust_result):
|
||
return
|
||
self.apply_audio_quality(raw)
|
||
context = self._prepare_name_context(
|
||
raw=raw,
|
||
artists=self.artists,
|
||
year=self.year,
|
||
)
|
||
if not context.normalized:
|
||
return
|
||
parsed = MusicNameRegistry.parse(context)
|
||
if not parsed:
|
||
if not context.text:
|
||
self.title = None
|
||
if self.year is None:
|
||
self.year = context.year
|
||
return
|
||
self._apply_name_result(context, parsed)
|
||
self._apply_track_prefix()
|
||
|
||
def _apply_rust_title_result(self, parsed: dict[str, Any]) -> bool:
|
||
"""回填 Rust 音乐解析结果,并保留调用方已有的高可信字段。"""
|
||
if "title" not in parsed:
|
||
return False
|
||
parsed_meta = type(self).from_dict(parsed)
|
||
self.title = parsed_meta.title
|
||
for field_name in (
|
||
"artists",
|
||
"album",
|
||
"year",
|
||
"disc_number",
|
||
"track_number",
|
||
"audio_format",
|
||
"audio_lossless",
|
||
"bit_depth",
|
||
"sample_rate",
|
||
"bitrate",
|
||
):
|
||
current_value = getattr(self, field_name, None)
|
||
parsed_value = getattr(parsed_meta, field_name, None)
|
||
if current_value in (None, "", []) and parsed_value not in (None, "", []):
|
||
setattr(self, field_name, parsed_value)
|
||
return True
|
||
|
||
@classmethod
|
||
def _prepare_name_context(
|
||
cls,
|
||
raw: str,
|
||
artists: list[str],
|
||
year: Optional[int],
|
||
) -> MusicNameContext:
|
||
"""统一归一命名文本并剔除音质、视频、日期等干扰信息。"""
|
||
normalized = cls._normalize_text(raw)
|
||
parsed_year = year
|
||
years = _MUSIC_YEAR_RE.findall(normalized)
|
||
if years and parsed_year is None:
|
||
# 多个年份括号时末位通常才是资源的发行年份。
|
||
parsed_year = int(years[-1])
|
||
clean_source, release_year = cls._strip_audio_release_tail(normalized)
|
||
if release_year and parsed_year is None:
|
||
parsed_year = release_year
|
||
cleaned = cls._strip_quality_tokens(cls._strip_spec_segments(clean_source))
|
||
cleaned, range_year = cls._strip_date_prefix(cleaned)
|
||
if range_year and parsed_year is None:
|
||
parsed_year = range_year
|
||
comment = None
|
||
comment_match = _MUSIC_TITLE_COMMENT_RE.search(cleaned)
|
||
if comment_match:
|
||
# 含书名号的版本注释会干扰专辑模式,先移出并在解析后统一拼回。
|
||
comment = comment_match.group("comment").strip()
|
||
cleaned = cleaned[: comment_match.start()].strip()
|
||
return MusicNameContext(
|
||
raw=raw,
|
||
normalized=normalized,
|
||
text=cleaned,
|
||
artists=tuple(artists),
|
||
year=parsed_year,
|
||
comment=comment,
|
||
)
|
||
|
||
def _apply_name_result(
|
||
self,
|
||
context: MusicNameContext,
|
||
parsed: MusicNameParseResult,
|
||
) -> None:
|
||
"""把解析器结果回填到当前对象,并保留调用方已有的高可信字段。"""
|
||
self.title = parsed.title
|
||
if context.comment and self.title:
|
||
self.title = f"{self.title} ({context.comment})"
|
||
if parsed.artists is not None:
|
||
self.artists = list(parsed.artists)
|
||
if parsed.album is not None:
|
||
self.album = parsed.album
|
||
if self.year is None:
|
||
self.year = parsed.year or context.year
|
||
if self.disc_number is None:
|
||
self.disc_number = parsed.disc_number
|
||
|
||
@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))
|
||
|
||
@classmethod
|
||
def _parse_title_year(cls, value: str) -> tuple[str, Optional[int]]:
|
||
"""返回剥离尾部独立年份后的曲名及发行年份线索。"""
|
||
title, year = cls._pop_trailing_year(value)
|
||
return title, year
|
||
|
||
@staticmethod
|
||
def _normalize_text(value: Any) -> str:
|
||
"""全角归一为半角后清理多余空白,文件名消毒下划线统一转空格。"""
|
||
text = _to_halfwidth(str(value or "")).replace("_", " ")
|
||
# 场景命名用点号分隔单词(Shan.Ge.Liao.Zai.2023),归一为空格便于拆分检索
|
||
text = _normalize_scene_dots(text)
|
||
# 缩写点号被全角归一/场景点分压平成单字母空格序列,在点分之后还原(S H E -> S.H.E)
|
||
text = _restore_letter_abbrev(text)
|
||
return _MUSIC_SPACES_RE.sub(" ", text).strip()
|
||
|
||
@classmethod
|
||
def _scene_token_category(cls, token: str, allow_release_suffix: bool = True) -> Optional[str]:
|
||
"""识别音乐视频场景 token 类别,不在这里决定是否删除。"""
|
||
value = str(token or "").strip(" \t[](){};,\"")
|
||
if not value:
|
||
return None
|
||
if _MUSIC_SCENE_YEAR_TOKEN_RE.fullmatch(value):
|
||
return "year"
|
||
if _MUSIC_SCENE_DATE_TOKEN_RE.fullmatch(value):
|
||
return "date"
|
||
if _MUSIC_SCENE_YEAR_RANGE_TOKEN_RE.fullmatch(value):
|
||
return "year_range"
|
||
patterns = (
|
||
("resolution", _MUSIC_SCENE_RESOLUTION_RE),
|
||
("source", _MUSIC_SCENE_SOURCE_RE),
|
||
("video", _MUSIC_SCENE_VIDEO_RE),
|
||
("effect", _MUSIC_SCENE_EFFECT_RE),
|
||
("audio", _MUSIC_SCENE_AUDIO_RE),
|
||
("audio_aux", _MUSIC_SCENE_AUDIO_AUX_RE),
|
||
("channel", _MUSIC_SCENE_CHANNEL_RE),
|
||
("bit", _MUSIC_SCENE_BIT_RE),
|
||
("fps", _MUSIC_SCENE_FPS_RE),
|
||
("audio_count", _MUSIC_SCENE_AUDIO_COUNT_RE),
|
||
)
|
||
for category, pattern in patterns:
|
||
if pattern.fullmatch(value):
|
||
return category
|
||
upper_value = value.upper()
|
||
if upper_value in _MUSIC_SCENE_PLATFORM_TOKENS or value == "iT":
|
||
return "platform"
|
||
if upper_value in _MUSIC_SCENE_LOCALE_TOKENS:
|
||
return "locale"
|
||
if allow_release_suffix:
|
||
# 技术 token 与发布组常粘连为 H.265-CHORTLE、x264@JJL;
|
||
# 完整 token 未命中时只剥离最后一段,再验证左侧确为技术字段。
|
||
for separator_char in ("-", "@"):
|
||
head, separator, tail = value.rpartition(separator_char)
|
||
if not separator or not _MUSIC_SCENE_RELEASE_GROUP_RE.fullmatch(tail):
|
||
continue
|
||
head_category = cls._scene_token_category(head, allow_release_suffix=False)
|
||
if head_category in {
|
||
"resolution", "source", "video", "effect", "audio",
|
||
"audio_aux", "channel", "bit", "fps", "audio_count",
|
||
}:
|
||
return head_category
|
||
return None
|
||
|
||
@classmethod
|
||
def _parse_music_scene_tokens(cls, value: str) -> Optional[_MusicSceneTokenResult]:
|
||
"""按音乐语义清理影视场景 token,强特征不足时不接管标题。"""
|
||
normalized = cls._normalize_text(value)
|
||
# 分类标签有时紧贴规格(Type-A,D,Blu-ray),只在已知技术词前补空格。
|
||
normalized = _MUSIC_SCENE_PUNCTUATED_TECH_RE.sub(r"\1 ", normalized)
|
||
tokens = normalized.split()
|
||
if not tokens:
|
||
return None
|
||
categories = [cls._scene_token_category(token) for token in tokens]
|
||
|
||
# 场景点分归一可能把 H.265 拆成 H 265,组合识别后同时标记两个 token。
|
||
for index in range(len(tokens) - 1):
|
||
if tokens[index].upper() == "H" and tokens[index + 1] in {"264", "265"}:
|
||
categories[index] = categories[index + 1] = "video"
|
||
|
||
# MA/HRA 与声道数字本身可能是作品名称,只在紧邻音频编码时作为规格清理。
|
||
for index, category in enumerate(categories):
|
||
if category != "audio_aux":
|
||
continue
|
||
neighbors = categories[max(0, index - 1): index] + categories[index + 1: index + 2]
|
||
categories[index] = "audio" if "audio" in neighbors else None
|
||
for index, category in enumerate(categories):
|
||
if category != "channel":
|
||
continue
|
||
nearby = categories[max(0, index - 2): index] + categories[index + 1: index + 3]
|
||
if "audio" not in nearby:
|
||
categories[index] = None
|
||
|
||
category_set = {category for category in categories if category}
|
||
primary_count = len(category_set.intersection({"resolution", "source", "video"}))
|
||
strong_signature = primary_count >= 2 or (
|
||
"audio" in category_set
|
||
and bool(category_set.intersection({"resolution", "source"}))
|
||
)
|
||
if not strong_signature:
|
||
return None
|
||
|
||
parsed_year = None
|
||
kept_tokens: list[str] = []
|
||
standalone_year_count = categories.count("year")
|
||
removable = {
|
||
"resolution", "source", "video", "effect", "audio", "channel",
|
||
"bit", "fps", "audio_count", "platform", "locale",
|
||
}
|
||
for token, category in zip(tokens, categories):
|
||
if category == "year":
|
||
# 连续双年份常是演出名称的一部分(Live At Montreux 1999 2022),
|
||
# 只有唯一的独立年份才作为发行线索提取。
|
||
if standalone_year_count > 1:
|
||
kept_tokens.append(token)
|
||
continue
|
||
parsed_year = int(token.strip("[](){};,\""))
|
||
continue
|
||
if category == "date":
|
||
date_match = _MUSIC_SCENE_DATE_TOKEN_RE.fullmatch(token.strip("[](){};,\""))
|
||
if date_match:
|
||
short_year = int(date_match.group("year"))
|
||
parsed_year = 2000 + short_year if short_year < 70 else 1900 + short_year
|
||
continue
|
||
if category == "year_range":
|
||
match = _MUSIC_SCENE_YEAR_RANGE_TOKEN_RE.fullmatch(
|
||
token.strip("[](){};,\"")
|
||
)
|
||
if match:
|
||
end_year = match.group("end")
|
||
parsed_year = int(
|
||
end_year if len(end_year) == 4 else f"{match.group('begin')[:2]}{end_year}"
|
||
)
|
||
continue
|
||
if category in removable:
|
||
continue
|
||
kept_tokens.append(token)
|
||
|
||
cleaned = cls._clean_tail(" ".join(kept_tokens)).rstrip(" ,;")
|
||
cleaned = re.sub(r"\s+([,;:!?])", r"\1", cleaned)
|
||
cleaned = cls._normalize_text(cleaned)
|
||
if not cleaned:
|
||
return None
|
||
return _MusicSceneTokenResult(
|
||
text=cleaned,
|
||
year=parsed_year,
|
||
categories=frozenset(category_set),
|
||
)
|
||
|
||
@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_spec_segments(cls, value: str) -> str:
|
||
"""从尾部反复剥离纯规格段(- WEB-DL - 16bit ALAC-HHWEB)。
|
||
|
||
规格段在艺术家/曲名拆分前整段剔除,避免规格后缀把主拆分位置推到
|
||
「艺术家 - 曲名」的连字符上;曲名含自然语言文本的段永远不会被误剥。
|
||
"""
|
||
text = str(value or "").strip()
|
||
while True:
|
||
segment_match = _MUSIC_TRAILING_SEGMENT_RE.search(text)
|
||
if not segment_match:
|
||
return text
|
||
prefix = segment_match.group("prefix")
|
||
segment = segment_match.group("segment")
|
||
# 无空格连字符段(FLAC-HHWEB)只有紧跟格式词时才是发布组标签,
|
||
# 否则是「曲名-歌手」类连字符命名,交由后续拆分规则处理
|
||
if prefix and not re.fullmatch(rf"{_MUSIC_FORMAT_TOKEN_ALT}", prefix, re.IGNORECASE):
|
||
return text
|
||
# 年份括号是发行线索不是规格词,占位后再判定,避免「曲名 (2000)」被误当规格段
|
||
probe = re.sub(r"\((?:19|20)\d{2}\)", " ", segment)
|
||
has_spec_token = bool(
|
||
_MUSIC_QUALITY_TOKEN_RE.search(probe) or _MUSIC_VIDEO_TOKEN_RE.search(probe)
|
||
)
|
||
# 加号是 APE+CUE 类格式联合写法的分隔符,占位为空格后再判定
|
||
probe = _MUSIC_VIDEO_TOKEN_RE.sub(" ", _MUSIC_QUALITY_TOKEN_RE.sub(" ", probe))
|
||
residue = probe.replace("+", " ").strip()
|
||
if residue and not cls._is_spec_residue(residue, bool(prefix)):
|
||
return text
|
||
# 不含任何规格词的纯标签段,仅在紧跟格式词的无空格形态下才是发布组标签;
|
||
# 「艺术家 - 单词曲名」的曲名段不含规格词,必须保留
|
||
if not has_spec_token and not prefix:
|
||
return text
|
||
text = text[: segment_match.start()].rstrip()
|
||
|
||
@staticmethod
|
||
def _strip_audio_release_tail(value: str) -> tuple[str, Optional[int]]:
|
||
"""剥离年份开头的音频格式发布尾链,并返回发行年份。"""
|
||
text = str(value or "").strip()
|
||
match = _MUSIC_AUDIO_RELEASE_TAIL_RE.search(text)
|
||
if not match:
|
||
return text, None
|
||
return text[:match.start()].rstrip(" \t-–—−-"), int(match.group("year"))
|
||
|
||
@classmethod
|
||
def _is_spec_residue(cls, residue: str, has_prefix: bool) -> bool:
|
||
"""判定规格词替换后的残留是否为发布组标签而非自然语言曲名。
|
||
|
||
发布组标签只出现在格式词无空格连字符形态(FLAC-HHWEB、AAC-FHDMv),
|
||
此时短字母数字组合可放行;空白连字符分隔的段(艺术家 - 曲名 FLAC)
|
||
残留可能是自然语言曲名,仅严格规格词表可过。
|
||
"""
|
||
if _MUSIC_SPEC_SEGMENT_RE.fullmatch(residue):
|
||
return True
|
||
tokens = residue.split()
|
||
return has_prefix and bool(tokens) and all(
|
||
len(token) <= 8 and re.fullmatch(r"[A-Za-z0-9]+", token) for token in tokens
|
||
)
|
||
|
||
@classmethod
|
||
def _strip_quality_tokens(cls, value: str) -> str:
|
||
"""剥离音频格式、视频编码、规格参数与年份括号,保留有效的艺术家与曲名文本。"""
|
||
raw = _MUSIC_RIP_NOTE_RE.sub(" ", str(value or ""))
|
||
raw = _MUSIC_PAREN_SPEC_RE.sub(" ", raw)
|
||
raw = _MUSIC_RELEASE_GROUP_RE.sub(" ", raw)
|
||
text = _MUSIC_QUALITY_TOKEN_RE.sub(" ", raw)
|
||
text = _MUSIC_RIP_METHOD_RE.sub(" ", text)
|
||
text = _MUSIC_VIDEO_TOKEN_RE.sub(" ", text)
|
||
text = _MUSIC_TRAILING_CATALOG_RE.sub(" ", text)
|
||
text = _MUSIC_EMPTY_BRACKET_RE.sub(" ", text)
|
||
# 规格剥离后可能残留悬空分隔符(含 APE+CUE 类格式联合写法残留的加号),统一修剪
|
||
return cls._normalize_text(re.sub(r"^[\s\-–—−-/+]+|[\s\-–—−-/+]+$", "", text))
|
||
|
||
@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()
|
||
artist_token = suffix.split(" ", 1)[0].strip(" ,,、;;")
|
||
if (
|
||
head
|
||
and artist_token
|
||
and cls._contains_cjk(head)
|
||
and cls._contains_cjk(artist_token)
|
||
):
|
||
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]
|
||
if not cls._contains_cjk(artist_text):
|
||
return None, text
|
||
# 艺术家段常见「xx作品全集」合集修饰,剥离后才能与条目署名比对
|
||
artist_text = artist_text.strip(" ,,、;;")
|
||
artist = _MUSIC_COLLECTION_SUFFIX_RE.sub("", artist_text).strip() or artist_text
|
||
return [artist], head
|
||
return None, text
|
||
|
||
@classmethod
|
||
def _split_latin_hyphen(cls, value: str) -> tuple[Optional[list[str]], str]:
|
||
"""拉丁「艺术家-专辑」无空格连字符命名拆分(Gene Clark-White Light)。
|
||
|
||
首个连字符左侧为艺术家;两侧均需含空格(至少两个词)才采信:
|
||
左侧保护 Jay-Z 类连字符艺术家名,右侧排除 -ProfessorP 类发布组标签;
|
||
艺术家身份由候选比对验证。
|
||
"""
|
||
text = str(value or "").strip()
|
||
if cls._contains_cjk(text):
|
||
return None, text
|
||
head, sep, tail = text.partition("-")
|
||
if not sep:
|
||
return None, text
|
||
head = head.strip(" \t-–—−-")
|
||
tail = tail.strip(" \t-–—−-")
|
||
head_suffix_raw = head.rsplit(" ", 1)[-1]
|
||
head_suffix = head_suffix_raw.casefold()
|
||
tail_prefix = tail.split(" ", 1)[0]
|
||
all_caps_compound = (
|
||
1 < len(head_suffix_raw) <= 5
|
||
and 1 < len(tail_prefix) <= 5
|
||
and head_suffix_raw.isalpha()
|
||
and tail_prefix.isalpha()
|
||
and head_suffix_raw.isupper()
|
||
and tail_prefix.isupper()
|
||
)
|
||
if (
|
||
head
|
||
and tail
|
||
and " " in head
|
||
and " " in tail
|
||
and head_suffix not in _MUSIC_LATIN_HYPHEN_NON_ARTIST_SUFFIXES
|
||
and not all_caps_compound
|
||
):
|
||
return cls._split_artists(head), tail
|
||
return None, text
|
||
|
||
# 「艺术家 年份 专辑」三明治结构:艺术家段为不超过 4 个词的拉丁词组;
|
||
# 贪婪匹配艺术家段,避免懒惰量词把首个词当艺术家、剩余词混入专辑名
|
||
_YEAR_SANDWICH_RE = re.compile(
|
||
r"^(?P<artist>[A-Za-z][A-Za-z0-9&+.'’\- ]*)\s+(?P<year>(?:19|20)\d{2})\s+(?P<rest>\S.*)$"
|
||
)
|
||
|
||
@classmethod
|
||
def _split_year_sandwich(cls, value: str) -> tuple[Optional[list[str]], str, Optional[int]]:
|
||
"""按中部独立年份拆分「艺术家 年份 专辑」(Jacky Cheung 1987 Jacky)。
|
||
|
||
:return: (艺术家列表, 专辑名, 年份),不适用时 (None, 原文, None)
|
||
"""
|
||
match = cls._YEAR_SANDWICH_RE.match(str(value or "").strip())
|
||
if not match:
|
||
return None, str(value or ""), None
|
||
artist_text = match.group("artist").strip()
|
||
raw_rest = match.group("rest").strip()
|
||
rest = raw_rest.strip(" \t-–—−-")
|
||
# 艺术家段限 4 个词以内;年份后紧跟另一个年份时是年份区间不是三明治结构;
|
||
# 剩余段原文需以字母开头:「… 2014 2.0 -MINIBEL」数字开头、
|
||
# 「… 2013 -PTer」发布组标签连字符开头都不是专辑名,拒绝拆分
|
||
if (
|
||
len(artist_text.split()) <= 4
|
||
and rest
|
||
and not re.match(r"(?:19|20)\d{2}\b", raw_rest)
|
||
and raw_rest[0].isalpha()
|
||
and any(char.isalpha() for char in rest)
|
||
):
|
||
return cls._split_artists(artist_text), rest, int(match.group("year"))
|
||
return None, str(value or ""), None
|
||
|
||
@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
|
||
|
||
# 曲序/碟号前缀是文件路径的强结构,先于通用艺术家-标题模式剥离,
|
||
# 避免「01 - One More Time」把 01 误判为艺术家。
|
||
track_number, disc_number, filename_title = self.split_track_prefix(stem)
|
||
# 文件名解析使用独立对象,防止 apply_title 覆盖真实音频标签;这里只合并空字段。
|
||
filename_meta = MetaMusic(
|
||
org_string=file_path.name,
|
||
title=filename_title or stem,
|
||
audio_format=file_path.suffix.lstrip(".").upper() or None,
|
||
parse_title=True,
|
||
)
|
||
if filename_meta.track_number is None:
|
||
filename_meta.track_number = track_number
|
||
if filename_meta.disc_number is None:
|
||
filename_meta.disc_number = disc_number
|
||
if title_from_name and filename_meta.title:
|
||
self.title = filename_meta.title
|
||
for field_name in (
|
||
"artists",
|
||
"album",
|
||
"year",
|
||
"disc_number",
|
||
"track_number",
|
||
"total_discs",
|
||
"total_tracks",
|
||
"version",
|
||
"isrc",
|
||
"audio_format",
|
||
"audio_lossless",
|
||
"bit_depth",
|
||
"sample_rate",
|
||
"bitrate",
|
||
):
|
||
current_value = getattr(self, field_name, None)
|
||
parsed_value = getattr(filename_meta, field_name, None)
|
||
if current_value in (None, "", []) and parsed_value not in (None, "", []):
|
||
setattr(self, field_name, parsed_value)
|
||
|
||
# 目录结构:父目录可能是碟片目录,专辑目录再往上一级
|
||
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:
|
||
"""音乐没有季信息,兼容下载与事件链的通用访问。"""
|
||
return None
|
||
|
||
@property
|
||
def episode(self) -> None:
|
||
"""音乐没有集信息,兼容下载与历史记录的通用访问。"""
|
||
return None
|
||
|
||
@property
|
||
def apply_words(self) -> list[str]:
|
||
"""音乐当前不应用影视自定义识别词。"""
|
||
return []
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""转换为可持久化和传输的字典,字段集与 schemas.MusicMeta 对齐。"""
|
||
return {
|
||
"type": self.type.value,
|
||
"org_string": self.org_string,
|
||
"title": self.title,
|
||
"artists": list(self.artists),
|
||
"artist": self.artist,
|
||
"album": self.album,
|
||
"album_artist": self.album_artist,
|
||
"year": self.year,
|
||
"disc_number": self.disc_number,
|
||
"track_number": self.track_number,
|
||
"total_discs": self.total_discs,
|
||
"total_tracks": self.total_tracks,
|
||
"version": self.version,
|
||
"audio_format": self.audio_format,
|
||
"audio_lossless": self.audio_lossless,
|
||
"audio_quality": self.audio_quality,
|
||
"audio_quality_score": self.audio_quality_score,
|
||
"audio_specs": self.audio_specs,
|
||
"bit_depth": self.bit_depth,
|
||
"sample_rate": self.sample_rate,
|
||
"bitrate": self.bitrate,
|
||
"duration": self.duration,
|
||
"isrc": self.isrc,
|
||
"media_source": self.media_source,
|
||
"media_id": self.media_id,
|
||
}
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: dict[str, Any]) -> "MetaMusic":
|
||
"""从字典恢复音乐解析结果,兼容 artists/artist 两种键。"""
|
||
raw_type = data.get("type")
|
||
if raw_type not in (None, MediaType.MUSIC, MediaType.MUSIC.value, "music"):
|
||
raise ValueError(f"不支持的音乐媒体类型:{raw_type}")
|
||
return cls(
|
||
org_string=data.get("org_string"),
|
||
title=data.get("title"),
|
||
artists=_string_list(data.get("artists") or data.get("artist")),
|
||
album=data.get("album"),
|
||
album_artist=data.get("album_artist"),
|
||
year=_optional_int(data.get("year")),
|
||
disc_number=_optional_int(data.get("disc_number")),
|
||
track_number=_optional_int(data.get("track_number")),
|
||
total_discs=_optional_int(data.get("total_discs")),
|
||
total_tracks=_optional_int(data.get("total_tracks")),
|
||
version=data.get("version"),
|
||
audio_format=data.get("audio_format"),
|
||
audio_lossless=data.get("audio_lossless"),
|
||
bit_depth=_optional_int(data.get("bit_depth")),
|
||
sample_rate=_optional_int(data.get("sample_rate")),
|
||
bitrate=_optional_int(data.get("bitrate")),
|
||
duration=_optional_int(data.get("duration")),
|
||
isrc=data.get("isrc"),
|
||
media_source=data.get("media_source"),
|
||
media_id=data.get("media_id"),
|
||
)
|
||
|
||
|
||
def _build_name_result(
|
||
context: MusicNameContext,
|
||
value: str,
|
||
artists: Optional[list[str]] = None,
|
||
album: Optional[str] = None,
|
||
year: Optional[int] = None,
|
||
disc_number: Optional[int] = None,
|
||
) -> MusicNameParseResult:
|
||
"""统一剥离曲名尾部年份并构造解析结果。"""
|
||
title, title_year = MetaMusic._parse_title_year(value)
|
||
return MusicNameParseResult(
|
||
title=title,
|
||
artists=artists,
|
||
album=album,
|
||
year=context.year or year or title_year,
|
||
disc_number=disc_number,
|
||
)
|
||
|
||
|
||
def _match_dangling_artist(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配规格剥离后仅剩艺术家和悬空分隔符的命名。"""
|
||
if context.artists or not context.text:
|
||
return None
|
||
return re.fullmatch(r"(?P<artist>.+?)\s+[\-–—−-]+", context.text)
|
||
|
||
|
||
def _parse_dangling_artist(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析仅包含艺术家的悬空分隔符命名。"""
|
||
return MusicNameParseResult(
|
||
title=None,
|
||
artists=MetaMusic._split_artists(matched.payload.group("artist")),
|
||
year=context.year,
|
||
)
|
||
|
||
|
||
def _match_music_video_scene(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配具有强影视规格组合的音乐视频或演唱会场景命名。"""
|
||
return MetaMusic._parse_music_scene_tokens(context.normalized)
|
||
|
||
|
||
def _parse_music_video_scene(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""清理场景 token 后重新进入音乐模式,避免采用影视媒体类型和标题截断规则。"""
|
||
scene: _MusicSceneTokenResult = matched.payload
|
||
scene_context = MusicNameContext(
|
||
raw=scene.text,
|
||
normalized=scene.text,
|
||
text=scene.text,
|
||
artists=context.artists,
|
||
year=context.year or scene.year,
|
||
)
|
||
parsed = MusicNameRegistry.parse(scene_context)
|
||
if parsed:
|
||
return parsed
|
||
return MusicNameParseResult(
|
||
title=scene.text,
|
||
year=scene_context.year,
|
||
)
|
||
|
||
|
||
def _match_album_marker(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配 CJK 书名号专辑命名。"""
|
||
if context.artists:
|
||
return None
|
||
return _MUSIC_ALBUM_MARKER_RE.match(context.text)
|
||
|
||
|
||
def _parse_album_marker(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析 CJK 书名号命名中的艺术家、专辑、碟号和标题。"""
|
||
marker = matched.payload
|
||
song_hint: Optional[str] = None
|
||
bilingual_prefix = False
|
||
artist_prefix = marker.group("artist")
|
||
artists, head_title = MetaMusic._split_cjk_hyphen(artist_prefix)
|
||
if artists:
|
||
song_hint = MetaMusic._clean_tail(head_title)
|
||
else:
|
||
# 中英双语原声常写成「English Artist - English Album 中文艺人 - 《中文片名》」。
|
||
# 优先保留首个标准 artist-title 结构,并只在英文标题后确有 CJK 别名时剥离别名。
|
||
standard_prefix = _MUSIC_ARTIST_TITLE_RE.match(artist_prefix)
|
||
if standard_prefix:
|
||
candidate_artist = standard_prefix.group("artist")
|
||
candidate_title = MetaMusic._clean_tail(standard_prefix.group("title"))
|
||
alias_match = _MUSIC_TRAILING_CJK_ALIAS_RE.search(candidate_title)
|
||
if (
|
||
alias_match
|
||
and not MetaMusic._contains_cjk(candidate_artist)
|
||
and re.search(r"[A-Za-z]", candidate_title[:alias_match.start()])
|
||
):
|
||
artists = MetaMusic._split_artists(candidate_artist)
|
||
song_hint = candidate_title[:alias_match.start()].strip()
|
||
bilingual_prefix = True
|
||
else:
|
||
artists = MetaMusic._split_artists(artist_prefix)
|
||
else:
|
||
artists = MetaMusic._split_artists(artist_prefix)
|
||
album = MetaMusic._normalize_text(marker.group("album"))
|
||
disc_number = None
|
||
disc_match = _MUSIC_ALBUM_DISC_RE.search(album)
|
||
if disc_match:
|
||
disc_number = int(disc_match.group(1))
|
||
album = album[:disc_match.start()].strip()
|
||
rest = marker.group("rest").strip(" \t-–—−-_《》.")
|
||
rest, rest_year = MetaMusic._parse_title_year(rest)
|
||
parsed_year = context.year or rest_year
|
||
if bilingual_prefix:
|
||
# 书名号后的「电影原声带」是中文发行类型说明,不应覆盖英文专辑标题。
|
||
rest = ""
|
||
if rest and re.fullmatch(r"(?:19|20)\d{2}", rest):
|
||
parsed_year = parsed_year or int(rest)
|
||
rest = ""
|
||
rest_disc = (
|
||
re.fullmatch(r"(?:cd|disc|disk)\s*(\d{1,2})", rest, re.IGNORECASE)
|
||
if rest
|
||
else None
|
||
)
|
||
if rest_disc:
|
||
disc_number = disc_number or int(rest_disc.group(1))
|
||
rest = ""
|
||
return _build_name_result(
|
||
context=context,
|
||
value=rest or song_hint or album,
|
||
artists=artists,
|
||
album=album,
|
||
year=parsed_year,
|
||
disc_number=disc_number,
|
||
)
|
||
|
||
|
||
def _match_artist_title(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配带空格分隔符的标准艺术家和标题命名。"""
|
||
if context.artists:
|
||
return None
|
||
return _MUSIC_ARTIST_TITLE_RE.match(context.text)
|
||
|
||
|
||
def _parse_artist_title(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析标准艺术家和标题命名。"""
|
||
artists = MetaMusic._split_artists(matched.payload.group("artist"))
|
||
title = MetaMusic._strip_artist_suffix(
|
||
MetaMusic._clean_tail(matched.payload.group("title")),
|
||
artists,
|
||
)
|
||
return _build_name_result(context=context, value=title, artists=artists)
|
||
|
||
|
||
def _match_alias_prefix(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配 VA 等合辑别名的无空格前缀命名。"""
|
||
if context.artists:
|
||
return None
|
||
return _MUSIC_ALIAS_PREFIX_RE.match(context.text)
|
||
|
||
|
||
def _parse_alias_prefix(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析 VA 等合辑别名前缀并归一艺术家名称。"""
|
||
alias = matched.payload.group("alias").casefold()
|
||
artists = [_MUSIC_ARTIST_ALIASES.get(alias, "Various Artists")]
|
||
return _build_name_result(
|
||
context=context,
|
||
value=MetaMusic._clean_tail(matched.payload.group("title")),
|
||
artists=artists,
|
||
)
|
||
|
||
|
||
def _match_cjk_artist_title_rip(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配带抓轨或 SACD 尾标的 CJK「艺术家-标题」命名,避免反拆。"""
|
||
release_signature = _MUSIC_RIP_SIGNATURE_RE.search(context.normalized) or re.search(
|
||
r"SACD\s*$", context.normalized, re.IGNORECASE
|
||
)
|
||
if context.artists or not release_signature:
|
||
return None
|
||
artist, separator, title = context.text.partition("-")
|
||
artist = artist.strip(" \t-–—−-")
|
||
title = title.strip(" \t-–—−-")
|
||
if (
|
||
separator
|
||
and artist
|
||
and title
|
||
and MetaMusic._contains_cjk(artist)
|
||
and MetaMusic._contains_cjk(title)
|
||
):
|
||
return artist, title
|
||
return None
|
||
|
||
|
||
def _parse_cjk_artist_title_rip(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析带抓轨或 SACD 尾标的 CJK 艺术家和标题。"""
|
||
artist, title = matched.payload
|
||
return _build_name_result(
|
||
context=context,
|
||
value=MetaMusic._clean_tail(title),
|
||
artists=MetaMusic._split_artists(artist),
|
||
)
|
||
|
||
|
||
def _match_cjk_hyphen(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配 CJK 无空格连字符命名。"""
|
||
artists, title = MetaMusic._split_cjk_hyphen(context.text)
|
||
return (artists, title) if artists else None
|
||
|
||
|
||
def _parse_cjk_hyphen(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析 CJK 无空格连字符命名。"""
|
||
artists, title = matched.payload
|
||
return _build_name_result(
|
||
context=context,
|
||
value=MetaMusic._clean_tail(title),
|
||
artists=artists,
|
||
)
|
||
|
||
|
||
def _match_latin_hyphen(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配拉丁多词艺术家和专辑的无空格连字符命名。"""
|
||
artists, title = MetaMusic._split_latin_hyphen(context.text)
|
||
return (artists, title) if artists else None
|
||
|
||
|
||
def _parse_latin_hyphen(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析拉丁无空格连字符命名。"""
|
||
artists, title = matched.payload
|
||
return _build_name_result(
|
||
context=context,
|
||
value=MetaMusic._clean_tail(title),
|
||
artists=artists,
|
||
)
|
||
|
||
|
||
def _match_year_sandwich(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配艺术家、年份、标题三段式命名。"""
|
||
artists, title, year = MetaMusic._split_year_sandwich(context.text)
|
||
return (artists, title, year) if artists else None
|
||
|
||
|
||
def _parse_year_sandwich(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析艺术家、年份、标题三段式命名。"""
|
||
artists, title, year = matched.payload
|
||
return _build_name_result(
|
||
context=context,
|
||
value=MetaMusic._clean_tail(title),
|
||
artists=artists,
|
||
year=year,
|
||
)
|
||
|
||
|
||
def _match_fallback(context: MusicNameContext) -> Optional[Any]:
|
||
"""匹配未命中结构化模式的非空音乐标题。"""
|
||
return context.text or None
|
||
|
||
|
||
def _parse_fallback(
|
||
context: MusicNameContext,
|
||
matched: MusicNamePatternMatch,
|
||
) -> MusicNameParseResult:
|
||
"""解析无结构标题并保留调用方已有艺术家字段。"""
|
||
title = MetaMusic._strip_cjk_artist_suffix(MetaMusic._clean_tail(matched.payload))
|
||
return _build_name_result(context=context, value=title)
|
||
|
||
|
||
def _register_default_name_components() -> None:
|
||
"""注册内置命名模式及其解析器,扩展可用更高优先级覆盖选择。"""
|
||
patterns = (
|
||
MusicNamePattern("dangling_artist", _match_dangling_artist, 800),
|
||
MusicNamePattern("music_video_scene", _match_music_video_scene, 750),
|
||
MusicNamePattern("album_marker", _match_album_marker, 700),
|
||
MusicNamePattern("artist_title", _match_artist_title, 600),
|
||
MusicNamePattern("alias_prefix", _match_alias_prefix, 500),
|
||
MusicNamePattern("cjk_artist_title_rip", _match_cjk_artist_title_rip, 450),
|
||
MusicNamePattern("cjk_hyphen", _match_cjk_hyphen, 400),
|
||
MusicNamePattern("latin_hyphen", _match_latin_hyphen, 300),
|
||
MusicNamePattern("year_sandwich", _match_year_sandwich, 200),
|
||
MusicNamePattern("fallback", _match_fallback, -100),
|
||
)
|
||
parsers = (
|
||
MusicNameParser("dangling_artist", ("dangling_artist",), _parse_dangling_artist),
|
||
MusicNameParser("music_video_scene", ("music_video_scene",), _parse_music_video_scene),
|
||
MusicNameParser("album_marker", ("album_marker",), _parse_album_marker),
|
||
MusicNameParser("artist_title", ("artist_title",), _parse_artist_title),
|
||
MusicNameParser("alias_prefix", ("alias_prefix",), _parse_alias_prefix),
|
||
MusicNameParser(
|
||
"cjk_artist_title_rip",
|
||
("cjk_artist_title_rip",),
|
||
_parse_cjk_artist_title_rip,
|
||
),
|
||
MusicNameParser("cjk_hyphen", ("cjk_hyphen",), _parse_cjk_hyphen),
|
||
MusicNameParser("latin_hyphen", ("latin_hyphen",), _parse_latin_hyphen),
|
||
MusicNameParser("year_sandwich", ("year_sandwich",), _parse_year_sandwich),
|
||
MusicNameParser("fallback", ("fallback",), _parse_fallback),
|
||
)
|
||
for pattern in patterns:
|
||
MusicNameRegistry.register_pattern(pattern)
|
||
for parser in parsers:
|
||
MusicNameRegistry.register_parser(parser)
|
||
MusicNameRegistry._capture_default_components()
|
||
|
||
|
||
_register_default_name_components()
|