fix(music): preserve album directory year and version evidence

This commit is contained in:
jxxghp
2026-09-29 20:09:11 +08:00
parent ea048d3c81
commit 2dd1813015
3 changed files with 130 additions and 8 deletions

View File

@@ -391,7 +391,12 @@ _MUSIC_DIR_YEAR_RE = re.compile(r"[(\[]\s*(?P<year>(?:19|20)\d{2})\s*[)\]]")
_MUSIC_DIR_PREFIX_YEAR_RE = re.compile(
r"^(?P<year>(?:19|20)\d{2})\s*[-._\s]+(?!(?:19|20)\d{2}\b)"
)
# 目录名中的括号补充说明(格式、音质、厂牌等),如 [FLAC 24bit-96kHz]
# 独立中间年份不能与数字专辑名混淆,必须同时存在艺人、年份及作品三个部分。
_MUSIC_ALBUM_YEAR_RE = re.compile(
r"^(?P<artist>.+?)\s*[-–—−-]\s*(?P<year>(?:19|20)\d{2})"
r"\s*[-–—−-]\s*(?P<title>\S.*)$"
)
# 目录括号既可能是规格,也可能是作品或发行版本的一部分,需要按内容区分。
_MUSIC_BRACKET_RE = re.compile(r"\[[^\]]*\]|【[^】]*】|\([^)]*\)")
_MUSIC_RECORDING_VERSION_RE = re.compile(
r"[\[((【]([^\]))】]*(?:\blive\b|\bremix\b|\binstrumental\b|\bacoustic\b|"
@@ -1002,6 +1007,7 @@ class MetaMusic(MetaBase):
if artist or directory.get("artist") else [],
album_artist=artist or directory.get("artist"),
year=directory.get("year"),
version=directory.get("version"),
)
@property
@@ -1060,7 +1066,7 @@ class MetaMusic(MetaBase):
公共层先完成字符归一、音质与干扰信息剔除;随后由注册中心依次匹配
命名模式和对应解析器,最后统一回填结构化字段并提取曲序前缀。
"""
raw = str(value or "")
raw = self._normalize_album_year(str(value or ""))
if not self.version:
self.version = self._resource_version(raw, None)
accelerator = get_metainfo_accelerator()
@@ -1665,15 +1671,35 @@ class MetaMusic(MetaBase):
match = _MUSIC_DISC_DIR_RE.match(str(name or "").strip())
return int(match.group("num")) if match else None
@staticmethod
def _normalize_album_year(value: str) -> str:
"""在 Python/Rust 分流前统一艺人-年份-作品,保留数字作品及年份区间。"""
if _MUSIC_YEAR_RANGE_DETECT_RE.search(value) or _MUSIC_AUDIO_RELEASE_TAIL_RE.search(value):
return value
match = _MUSIC_ALBUM_YEAR_RE.fullmatch(value.strip())
if not match or match.group("artist").strip().isdigit():
return value
artist, year, title = match.group("artist", "year", "title")
return f"{artist.strip()} - {title.strip()} ({year})"
@staticmethod
def _clean_directory_bracket(match: re.Match[str]) -> str:
"""只删除已知规格/内容评级,未知括号及版本名称保留作为作品证据。"""
content = match.group()[1:-1].strip()
if _MUSIC_SPEC_SEGMENT_RE.fullmatch(content) or content.casefold() in {"explicit", "clean"}:
return " "
return match.group()
@classmethod
def parse_album_dir(cls, name: str) -> dict[str, Any]:
"""解析专辑目录名,提取歌手、专辑名、年份和音质描述。
支持 `歌手 - 专辑 (2004) [FLAC 24bit-96kHz]` 等常见命名。
"""
text = cls._clean_text(name)
text = cls._normalize_album_year(cls._normalize_text(name))
if not text:
return {}
quality_text = text
year = None
prefixed_year = False
year_match = _MUSIC_DIR_YEAR_RE.search(text)
@@ -1686,9 +1712,10 @@ class MetaMusic(MetaBase):
year = int(prefix_year_match.group("year"))
text = text[prefix_year_match.end():]
prefixed_year = True
# 括号内的格式/音质描述先剥离出专辑名,但仍可用于音质解析
brackets = " ".join(fragment for fragment in _MUSIC_BRACKET_RE.findall(text))
album_text = cls._clean_text(_MUSIC_BRACKET_RE.sub(" ", text))
# Live/Deluxe 以及未知副标题均参与身份确认,不能像音质标签一样整体删除。
album_text = cls._clean_text(cls._strip_spec_segments(
_MUSIC_BRACKET_RE.sub(cls._clean_directory_bracket, text)
))
if not album_text:
return {}
artist, album = (
@@ -1700,7 +1727,8 @@ class MetaMusic(MetaBase):
"artist": artist,
"album": album,
"year": year,
"quality_text": cls._clean_text(f"{album_text} {brackets}"),
"version": cls._resource_version(album, None),
"quality_text": quality_text,
}
def apply_path_context(self, path: "str | Path") -> "MetaMusic":
@@ -1771,6 +1799,9 @@ class MetaMusic(MetaBase):
self.album_artist = dir_info["artist"]
if self.year is None and dir_info.get("year"):
self.year = dir_info["year"]
if title_from_name and not self.version and dir_info.get("version"):
# 目录版本可补无标签曲目,真实歌曲标签不能被整张专辑的版本推测覆盖。
self.version = dir_info["version"]
# 目录名里的格式、位深、采样率可补齐本地标签未声明的音质参数
if dir_info.get("quality_text"):
self.apply_audio_quality(dir_info["quality_text"])

View File

@@ -577,7 +577,7 @@ def test_parse_album_dir_with_prefix_year_does_not_invent_artist():
)
assert info["artist"] is None
assert info["album"] == "All Too Well - Recorded at Long Pond Studios"
assert info["album"] == "All Too Well (Sad Girl Autumn Version) - Recorded at Long Pond Studios"
assert info["year"] == 2021

View File

@@ -0,0 +1,91 @@
"""PT 音乐目录名称在两种解析引擎下保留作品、年份和版本证据。"""
from pathlib import Path
import pytest
from app.adapters.system import rust
from app.domain.meta import runtime
from app.domain.meta.metamusic import MetaMusic
@pytest.fixture(params=["python", "rust"])
def parser_engine(request, monkeypatch):
"""为实际文件名与目录上下文切换解析引擎,Rust 不可用时明确跳过。"""
if request.param == "rust":
if not rust.is_available():
pytest.skip("moviepilot_rust 扩展未安装")
monkeypatch.setattr(rust, "is_enabled", lambda: True)
def reject_python_fallback(*_args, **_kwargs):
"""确保双引擎回归实际进入 Rust,而非隐式回退 Python。"""
raise AssertionError("目录证据样本不应从 Rust 回退 Python")
monkeypatch.setattr(MetaMusic, "_prepare_name_context", reject_python_fallback)
monkeypatch.setattr(runtime, "_metainfo_accelerator", rust if request.param == "rust" else None)
return request.param
@pytest.mark.parametrize("name", [
"周杰伦 - 2001 - 范特西 [FLAC]", "周杰伦-2001-范特西 [FLAC]",
"Daft Punk - 2001 - Discovery [FLAC]",
])
def test_middle_year_is_shared_by_resource_and_directory(name, parser_engine):
"""有三个明确部分时,各入口应使用相同艺人、专辑和年份。"""
resource = MetaMusic.parse_resource(name)
directory = MetaMusic.parse_album_dir(name)
path = MetaMusic().apply_path_context(Path("/music") / name / "01.flac")
assert resource.year == directory["year"] == path.year == 2001
assert resource.title == directory["album"] == path.album
assert resource.artists == path.artists == [directory["artist"]]
@pytest.mark.parametrize("suffix", ["(Live)", "(Deluxe Edition)", "[Live]", "(Unknown Subtitle)", "(现场版)"])
def test_directory_keeps_identity_qualifiers(suffix, parser_engine):
"""括号中的版本及未知作品副标题不能作为规格噪声丢弃。"""
name = f"Artist - Record {suffix} (2001) [FLAC 24bit-96kHz]"
meta = MetaMusic().apply_path_context(Path("/music") / name / "03.flac")
assert suffix.replace("(", "(").replace(")", ")") in meta.album
assert meta.year == 2001
assert meta.artists == ["Artist"]
assert meta.track_number == 3
assert meta.bit_depth == 24
assert meta.sample_rate == 96000
if suffix in {"(Live)", "[Live]"}:
assert meta.version == "Live"
def test_tagged_track_version_is_not_inferred_from_album_directory():
"""现场专辑中的有标签曲目也可能是录音室附赠版,不能强写录音版本。"""
meta = MetaMusic(title="Studio Bonus", artists=["Artist"], album="Record")
meta.apply_path_context("/music/Artist - Record (Live) (2001)/03.flac")
assert meta.title == "Studio Bonus"
assert meta.album == "Record"
assert meta.version is None
@pytest.mark.parametrize("name,album,year", [
("Taylor Swift - 1989 [FLAC]", "1989", None),
("2020 - Record (Live) [FLAC]", "Record (Live)", 2020),
("Artist - Best Album (2001) [FLAC]", "Best Album", 2001),
])
def test_directory_does_not_guess_numeric_names(name, album, year):
"""数字专辑名、年份前缀和自然标题保持原本语义。"""
result = MetaMusic.parse_album_dir(name)
assert result["album"] == album
assert result["year"] == year
if name.startswith("2020"):
assert result["artist"] is None
def test_album_context_retains_directory_version():
"""整专匹配同样接收目录录音版本,不能只在单文件入口保留。"""
meta = MetaMusic.from_album_context("Artist - Record (Live) (2001) [FLAC]", [])
assert meta.title == "Record (Live)"
assert meta.version == "Live"