mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
fix(music): align album tracks by exact title
This commit is contained in:
+63
-15
@@ -1209,33 +1209,81 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
|||||||
return tuple(str(path.relative_to(directory)).casefold() for path in files)
|
return tuple(str(path.relative_to(directory)).casefold() for path in files)
|
||||||
|
|
||||||
@staticmethod
|
@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(
|
def _align_music_album_tracks(
|
||||||
|
cls,
|
||||||
files: list[Path],
|
files: list[Path],
|
||||||
metas: list[MetaMusic],
|
metas: list[MetaMusic],
|
||||||
tracks: list[MusicInfo],
|
tracks: list[MusicInfo],
|
||||||
) -> dict[Path, MusicInfo]:
|
) -> dict[Path, MusicInfo]:
|
||||||
"""优先按碟号和曲序把专辑曲目对位到本地文件。"""
|
"""优先按精确曲名、再按碟号和曲序把专辑曲目对位到本地文件。"""
|
||||||
matched: dict[Path, MusicInfo] = {}
|
matched: dict[Path, MusicInfo] = {}
|
||||||
used: set[tuple[int, int]] = set()
|
used: set[int] = set()
|
||||||
by_position = {
|
indexed_tracks = list(enumerate(tracks))
|
||||||
(track.disc_number or 1, track.track_number): track
|
by_title: dict[str, list[int]] = {}
|
||||||
for track in tracks if track.track_number
|
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]] = []
|
pending: list[tuple[Path, MetaMusic]] = []
|
||||||
for file, meta in zip(files, metas):
|
for file, meta in zip(files, metas):
|
||||||
key = (meta.disc_number or 1, meta.track_number or 0)
|
candidates = [
|
||||||
track = by_position.get(key) if meta.track_number else None
|
index for index in by_title.get(cls._music_track_title_key(meta.title), [])
|
||||||
if track and key not in used:
|
if index not in used
|
||||||
matched[file] = track
|
]
|
||||||
used.add(key)
|
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:
|
else:
|
||||||
pending.append((file, meta))
|
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 = [
|
remaining = [
|
||||||
track for track in tracks
|
track for index, track in indexed_tracks if index not in used
|
||||||
if (track.disc_number or 1, track.track_number or 0) not in used
|
|
||||||
]
|
]
|
||||||
pending.sort(key=lambda item: (item[1].disc_number or 1, item[0].name.casefold()))
|
unresolved.sort(
|
||||||
for (file, _), track in zip(pending, remaining):
|
key=lambda item: (item[1].disc_number or 1, item[0].name.casefold())
|
||||||
|
)
|
||||||
|
for (file, _), track in zip(unresolved, remaining):
|
||||||
matched[file] = track
|
matched[file] = track
|
||||||
return matched
|
return matched
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
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()
|
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(
|
def test_async_recognize_album_directory_calls_async_module(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
media_chain,
|
media_chain,
|
||||||
|
|||||||
Reference in New Issue
Block a user