From 2dd1813015bfe390c56a0509ba976b6cc21113ff Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 29 Sep 2026 20:09:11 +0800 Subject: [PATCH] fix(music): preserve album directory year and version evidence --- app/domain/meta/metamusic.py | 45 +++++++++++-- tests/test_metamusic.py | 2 +- tests/test_music_directory_evidence.py | 91 ++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 tests/test_music_directory_evidence.py diff --git a/app/domain/meta/metamusic.py b/app/domain/meta/metamusic.py index c40a36020..fd35a49b3 100644 --- a/app/domain/meta/metamusic.py +++ b/app/domain/meta/metamusic.py @@ -391,7 +391,12 @@ _MUSIC_DIR_YEAR_RE = re.compile(r"[(\[]\s*(?P(?:19|20)\d{2})\s*[)\]]") _MUSIC_DIR_PREFIX_YEAR_RE = re.compile( r"^(?P(?:19|20)\d{2})\s*[-._\s]+(?!(?:19|20)\d{2}\b)" ) -# 目录名中的括号补充说明(格式、音质、厂牌等),如 [FLAC 24bit-96kHz] +# 独立中间年份不能与数字专辑名混淆,必须同时存在艺人、年份及作品三个部分。 +_MUSIC_ALBUM_YEAR_RE = re.compile( + r"^(?P.+?)\s*[-–—−-]\s*(?P(?:19|20)\d{2})" + r"\s*[-–—−-]\s*(?P\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"]) diff --git a/tests/test_metamusic.py b/tests/test_metamusic.py index 341cc0eb7..9aa2f40b5 100644 --- a/tests/test_metamusic.py +++ b/tests/test_metamusic.py @@ -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 diff --git a/tests/test_music_directory_evidence.py b/tests/test_music_directory_evidence.py new file mode 100644 index 000000000..04e5edc41 --- /dev/null +++ b/tests/test_music_directory_evidence.py @@ -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"