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 re
|
||||
|
||||
from lyra_worker.library import safe_name
|
||||
|
||||
_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).
|
||||
|
||||
Files are sorted; titles come from `tracklist` by position when available,
|
||||
else the filename stem. Pure — no filesystem or tag I/O.
|
||||
Files are sorted; titles come from `tracklist` by position when available. A file BEYOND
|
||||
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]] = []
|
||||
audio = sorted(n for n in names if os.path.splitext(n)[1].lower() in _AUDIO_EXT)
|
||||
for i, name in enumerate(audio):
|
||||
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}"
|
||||
plan.append((name, new_name, title, i + 1))
|
||||
return plan
|
||||
@@ -29,7 +56,7 @@ class MutagenTagger:
|
||||
import mutagen
|
||||
|
||||
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:
|
||||
path = os.path.join(album_dir, old_name)
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user