fix(worker): canonicalize bonus-track filenames past the MB tracklist
A track beyond the MusicBrainz tracklist (a Qobuz bonus track) kept streamrip's raw name (e.g. "13. John Mayer - St. Patrick's Day (Album Version).flac") while the main tracklist got clean "## Title" names. plan_track_names now canonicalizes such extras: strip the leading track-number prefix and a leading "<artist> - ", yielding "13 St. Patrick's Day (Album Version).flac". Requires an explicit delimiter after the number so titles like "99 Luftballons" are left alone; with no tracklist resolved at all the on-disk stem is still kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,48 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from lyra_worker.library import safe_name
|
from lyra_worker.library import safe_name
|
||||||
|
|
||||||
_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
|
_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"}
|
||||||
|
|
||||||
|
# Leading "13.", "13 -", "13)" style track-number prefix streamrip bakes into a bonus-track
|
||||||
|
# filename. Requires an explicit delimiter so a real title that merely starts with a number
|
||||||
|
# (e.g. "99 Luftballons") is left alone.
|
||||||
|
_NUM_PREFIX = re.compile(r"^\s*\d+\s*[.):\-]\s*")
|
||||||
|
|
||||||
def plan_track_names(names: list[str], tracklist: tuple[str, ...]) -> list[tuple[str, str, str, int]]:
|
|
||||||
|
def _clean_extra_title(stem: str, artist: str = "") -> str:
|
||||||
|
"""Derive a display title for a track BEYOND the MusicBrainz tracklist (a bonus track that
|
||||||
|
streamrip named itself, e.g. `13. John Mayer - St. Patrick's Day (Album Version)`). Strip a
|
||||||
|
leading track-number prefix, then a leading `<artist> - ` if present, so the example yields
|
||||||
|
`St. Patrick's Day (Album Version)`. Falls back to the stem if nothing strips."""
|
||||||
|
s = _NUM_PREFIX.sub("", stem).strip()
|
||||||
|
if artist:
|
||||||
|
s = re.sub(r"^" + re.escape(artist) + r"\s*-\s*", "", s, flags=re.IGNORECASE).strip()
|
||||||
|
return s or stem
|
||||||
|
|
||||||
|
|
||||||
|
def plan_track_names(
|
||||||
|
names: list[str], tracklist: tuple[str, ...], artist: str = ""
|
||||||
|
) -> list[tuple[str, str, str, int]]:
|
||||||
"""Map audio filenames -> (old_name, new_name, title, track_number).
|
"""Map audio filenames -> (old_name, new_name, title, track_number).
|
||||||
|
|
||||||
Files are sorted; titles come from `tracklist` by position when available,
|
Files are sorted; titles come from `tracklist` by position when available. A file BEYOND
|
||||||
else the filename stem. Pure — no filesystem or tag I/O.
|
the tracklist (a bonus track streamrip named itself) is canonicalized via `_clean_extra_title`
|
||||||
|
so it gets a clean `## Title.ext` name too; with no tracklist at all the on-disk stem is kept.
|
||||||
|
Pure — no filesystem or tag I/O.
|
||||||
"""
|
"""
|
||||||
plan: list[tuple[str, str, str, int]] = []
|
plan: list[tuple[str, str, str, int]] = []
|
||||||
audio = sorted(n for n in names if os.path.splitext(n)[1].lower() in _AUDIO_EXT)
|
audio = sorted(n for n in names if os.path.splitext(n)[1].lower() in _AUDIO_EXT)
|
||||||
for i, name in enumerate(audio):
|
for i, name in enumerate(audio):
|
||||||
ext = os.path.splitext(name)[1].lower()
|
ext = os.path.splitext(name)[1].lower()
|
||||||
title = tracklist[i] if i < len(tracklist) else os.path.splitext(name)[0]
|
stem = os.path.splitext(name)[0]
|
||||||
|
if i < len(tracklist):
|
||||||
|
title = tracklist[i]
|
||||||
|
elif tracklist: # a genuine bonus track past a known tracklist -> canonicalize its name
|
||||||
|
title = _clean_extra_title(stem, artist)
|
||||||
|
else: # no tracklist resolved at all -> keep the on-disk stem (renumbered)
|
||||||
|
title = stem
|
||||||
new_name = f"{i + 1:02d} {safe_name(title)}{ext}"
|
new_name = f"{i + 1:02d} {safe_name(title)}{ext}"
|
||||||
plan.append((name, new_name, title, i + 1))
|
plan.append((name, new_name, title, i + 1))
|
||||||
return plan
|
return plan
|
||||||
@@ -29,7 +56,7 @@ class MutagenTagger:
|
|||||||
import mutagen
|
import mutagen
|
||||||
|
|
||||||
names = os.listdir(album_dir)
|
names = os.listdir(album_dir)
|
||||||
for old_name, new_name, title, number in plan_track_names(names, target.tracklist):
|
for old_name, new_name, title, number in plan_track_names(names, target.tracklist, target.artist):
|
||||||
try:
|
try:
|
||||||
path = os.path.join(album_dir, old_name)
|
path = os.path.join(album_dir, old_name)
|
||||||
audio = mutagen.File(path, easy=True)
|
audio = mutagen.File(path, easy=True)
|
||||||
|
|||||||
@@ -1,4 +1,34 @@
|
|||||||
from lyra_worker._mutagen import plan_track_names
|
from lyra_worker._mutagen import _clean_extra_title, plan_track_names
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_extra_title_strips_number_and_artist_prefix():
|
||||||
|
assert _clean_extra_title(
|
||||||
|
"13. John Mayer - St. Patrick's Day (Album Version)", "John Mayer"
|
||||||
|
) == "St. Patrick's Day (Album Version)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_extra_title_strips_number_without_artist_prefix():
|
||||||
|
assert _clean_extra_title("13. St. Patrick's Day", "John Mayer") == "St. Patrick's Day"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_extra_title_leaves_plain_title_and_numeric_titles():
|
||||||
|
assert _clean_extra_title("Hidden Track", "John Mayer") == "Hidden Track"
|
||||||
|
# "99 Luftballons" has no delimiter after the number -> not a track-number prefix
|
||||||
|
assert _clean_extra_title("99 Luftballons", "Nena") == "99 Luftballons"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_canonicalizes_bonus_track_beyond_tracklist():
|
||||||
|
names = [f"{i:02d} Track{i}.flac" for i in range(1, 13)] + [
|
||||||
|
"13. John Mayer - St. Patrick's Day (Album Version).flac"
|
||||||
|
]
|
||||||
|
tracklist = tuple(f"Track{i}" for i in range(1, 13))
|
||||||
|
plan = plan_track_names(names, tracklist, artist="John Mayer")
|
||||||
|
assert plan[12] == (
|
||||||
|
"13. John Mayer - St. Patrick's Day (Album Version).flac",
|
||||||
|
"13 St. Patrick's Day (Album Version).flac",
|
||||||
|
"St. Patrick's Day (Album Version)",
|
||||||
|
13,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_plan_uses_tracklist_titles_and_numbers():
|
def test_plan_uses_tracklist_titles_and_numbers():
|
||||||
|
|||||||
Reference in New Issue
Block a user