From 55cf88f66e59c18ceeaf7abfeb2958679e815a2f Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 15 Jul 2026 02:37:08 +0200 Subject: [PATCH] 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 " - ", 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) --- worker/lyra_worker/_mutagen.py | 37 +++++++++++++++++++++++++++++----- worker/tests/test_mutagen.py | 32 ++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/worker/lyra_worker/_mutagen.py b/worker/lyra_worker/_mutagen.py index 96aaffc..a73e16f 100644 --- a/worker/lyra_worker/_mutagen.py +++ b/worker/lyra_worker/_mutagen.py @@ -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 ` - ` 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) diff --git a/worker/tests/test_mutagen.py b/worker/tests/test_mutagen.py index 852ef1d..5fcdf78 100644 --- a/worker/tests/test_mutagen.py +++ b/worker/tests/test_mutagen.py @@ -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():