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:
Jonathan
2026-07-15 02:37:08 +02:00
parent 0c5e73eb17
commit 55cf88f66e
2 changed files with 63 additions and 6 deletions
+32 -5
View File
@@ -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)
+31 -1
View File
@@ -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():