From 9053db926d20ab48271ff9f89ddb9152cb5a3d9a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 26 Aug 2026 23:11:38 +0800 Subject: [PATCH] fix(music): align album tracks by exact title --- app/chain/media.py | 78 ++++++++++++++++++++++++++------- tests/test_music_album_match.py | 52 ++++++++++++++++++++++ 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/app/chain/media.py b/app/chain/media.py index 2d010b810..4842e0c97 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -1209,33 +1209,81 @@ class MediaChain(ChainBase, metaclass=Singleton): return tuple(str(path.relative_to(directory)).casefold() for path in files) @staticmethod + def _music_track_title_key(value: Optional[str]) -> str: + """统一繁简、大小写和标点,生成专辑曲目精确对位键。""" + text = str(value or "") + try: + text = zhconv_convert(text, "zh-hans") + except Exception: # pylint: disable=broad-except + pass + return MetaMusic.compact_text(text) + + @classmethod def _align_music_album_tracks( + cls, files: list[Path], metas: list[MetaMusic], tracks: list[MusicInfo], ) -> dict[Path, MusicInfo]: - """优先按碟号和曲序把专辑曲目对位到本地文件。""" + """优先按精确曲名、再按碟号和曲序把专辑曲目对位到本地文件。""" matched: dict[Path, MusicInfo] = {} - used: set[tuple[int, int]] = set() - by_position = { - (track.disc_number or 1, track.track_number): track - for track in tracks if track.track_number - } + used: set[int] = set() + indexed_tracks = list(enumerate(tracks)) + by_title: dict[str, list[int]] = {} + for index, track in indexed_tracks: + key = cls._music_track_title_key(track.title) + if key: + by_title.setdefault(key, []).append(index) + pending: list[tuple[Path, MetaMusic]] = [] for file, meta in zip(files, metas): - key = (meta.disc_number or 1, meta.track_number or 0) - track = by_position.get(key) if meta.track_number else None - if track and key not in used: - matched[file] = track - used.add(key) + candidates = [ + index for index in by_title.get(cls._music_track_title_key(meta.title), []) + if index not in used + ] + if candidates: + local_position = (meta.disc_number or 1, meta.track_number or 0) + index = next( + ( + candidate for candidate in candidates + if ( + tracks[candidate].disc_number or 1, + tracks[candidate].track_number or 0, + ) == local_position + ), + candidates[0], + ) + matched[file] = tracks[index] + used.add(index) else: pending.append((file, meta)) + + by_position: dict[tuple[int, int], list[int]] = {} + for index, track in indexed_tracks: + if track.track_number: + by_position.setdefault( + (track.disc_number or 1, track.track_number), [] + ).append(index) + unresolved: list[tuple[Path, MetaMusic]] = [] + for file, meta in pending: + key = (meta.disc_number or 1, meta.track_number or 0) + candidates = [ + index for index in by_position.get(key, []) + if index not in used + ] if meta.track_number else [] + if candidates: + index = candidates[0] + matched[file] = tracks[index] + used.add(index) + else: + unresolved.append((file, meta)) remaining = [ - track for track in tracks - if (track.disc_number or 1, track.track_number or 0) not in used + track for index, track in indexed_tracks if index not in used ] - pending.sort(key=lambda item: (item[1].disc_number or 1, item[0].name.casefold())) - for (file, _), track in zip(pending, remaining): + unresolved.sort( + key=lambda item: (item[1].disc_number or 1, item[0].name.casefold()) + ) + for (file, _), track in zip(unresolved, remaining): matched[file] = track return matched diff --git a/tests/test_music_album_match.py b/tests/test_music_album_match.py index 76f0d617a..32b596a6a 100644 --- a/tests/test_music_album_match.py +++ b/tests/test_music_album_match.py @@ -1,4 +1,5 @@ import asyncio +from pathlib import Path from unittest.mock import AsyncMock, Mock import pytest @@ -165,6 +166,57 @@ def test_recognize_album_directory_maps_files(tmp_path, media_chain, monkeypatch source_chain.match_music_album.assert_called_once() +def test_align_album_tracks_prefers_exact_titles_over_conflicting_positions(): + """本地曲序与发行版本冲突时,精确曲名必须优先,避免整张专辑错位。""" + files = [ + Path("费玉清-真的好想你.flac"), + Path("费玉清-冬之夜.flac"), + Path("费玉清-愛是一個圓.flac"), + Path("04.flac"), + ] + metas = [ + MetaMusic(title="真的好想你", track_number=1), + MetaMusic(title="冬之夜", track_number=2), + MetaMusic(title="愛是一個圓", track_number=3), + MetaMusic(title="04", track_number=4), + ] + tracks = [ + MusicInfo( + media_source="musicbrainz", + media_id="winter", + title="冬之夜", + track_number=1, + ), + MusicInfo( + media_source="musicbrainz", + media_id="circle", + title="爱是一个圆", + track_number=2, + ), + MusicInfo( + media_source="musicbrainz", + media_id="miss", + title="真的好想你", + track_number=3, + ), + MusicInfo( + media_source="musicbrainz", + media_id="fallback", + title="一生的朋友", + track_number=4, + ), + ] + + matched = MediaChain._align_music_album_tracks(files, metas, tracks) + + assert [matched[file].media_id for file in files] == [ + "miss", + "winter", + "circle", + "fallback", + ] + + def test_async_recognize_album_directory_calls_async_module( tmp_path, media_chain,