diff --git a/worker/lyra_worker/_musicbrainz.py b/worker/lyra_worker/_musicbrainz.py index 293032e..8cd8852 100644 --- a/worker/lyra_worker/_musicbrainz.py +++ b/worker/lyra_worker/_musicbrainz.py @@ -47,6 +47,7 @@ class MusicBrainzResolver: track_count = None total_duration_s = None + titles: tuple[str, ...] = () rgid = rg.get("id") if rgid: rgfull = musicbrainzngs.get_release_group_by_id(rgid, includes=["releases"]) @@ -62,6 +63,7 @@ class MusicBrainzResolver: track_count = len(tracks) total_ms = sum(int((t.get("recording") or {}).get("length") or 0) for t in tracks) total_duration_s = total_ms // 1000 if total_ms else None + titles = tuple((t.get("recording") or {}).get("title", "") for t in tracks) return MBTarget( artist=canonical_artist, @@ -69,4 +71,5 @@ class MusicBrainzResolver: track_count=track_count, total_duration_s=total_duration_s, year=year, + tracklist=titles, ) diff --git a/worker/lyra_worker/_mutagen.py b/worker/lyra_worker/_mutagen.py new file mode 100644 index 0000000..6390063 --- /dev/null +++ b/worker/lyra_worker/_mutagen.py @@ -0,0 +1,48 @@ +import os + +from lyra_worker.library import safe_name + +_AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"} + + +def plan_track_names(names: list[str], tracklist: tuple[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. + """ + 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] + new_name = f"{i + 1:02d} {safe_name(title)}{ext}" + plan.append((name, new_name, title, i + 1)) + return plan + + +class MutagenTagger: + """Real Tagger using mutagen (lazily imported). The tag-write is not unit-tested + offline (needs real audio); the naming logic (`plan_track_names`) is.""" + + def tag_album(self, album_dir: str, target) -> None: + import mutagen + + names = os.listdir(album_dir) + for old_name, new_name, title, number in plan_track_names(names, target.tracklist): + path = os.path.join(album_dir, old_name) + audio = mutagen.File(path, easy=True) + if audio is None: # unreadable/unsupported file — leave it untouched + continue + audio["artist"] = target.artist + audio["albumartist"] = target.artist + audio["album"] = target.album + audio["title"] = title + audio["tracknumber"] = str(number) + if target.year: + audio["date"] = str(target.year) + audio.save() + + new_path = os.path.join(album_dir, new_name) + if os.path.abspath(new_path) != os.path.abspath(path) and not os.path.exists(new_path): + os.rename(path, new_path) diff --git a/worker/requirements.txt b/worker/requirements.txt index 906dc41..6565a3e 100644 --- a/worker/requirements.txt +++ b/worker/requirements.txt @@ -5,3 +5,4 @@ yt-dlp>=2024.1 streamrip>=2.0 requests>=2.31 musicbrainzngs>=0.7.1 +mutagen>=1.47 diff --git a/worker/tests/test_mutagen.py b/worker/tests/test_mutagen.py new file mode 100644 index 0000000..852ef1d --- /dev/null +++ b/worker/tests/test_mutagen.py @@ -0,0 +1,20 @@ +from lyra_worker._mutagen import plan_track_names + + +def test_plan_uses_tracklist_titles_and_numbers(): + names = ["02-b.flac", "01-a.flac"] # unsorted on purpose + plan = plan_track_names(names, ("Alpha", "Beta")) + assert plan == [ + ("01-a.flac", "01 Alpha.flac", "Alpha", 1), + ("02-b.flac", "02 Beta.flac", "Beta", 2), + ] + + +def test_plan_falls_back_to_filename_stem_and_sanitizes(): + plan = plan_track_names(["10 - AC_DC.mp3"], ()) # no tracklist + assert plan[0][1] == "01 10 - AC_DC.mp3" # stem reused, renumbered, safe + + +def test_plan_ignores_non_audio_files(): + plan = plan_track_names(["cover.jpg", "song.opus", "notes.txt"], ()) + assert [p[0] for p in plan] == ["song.opus"] diff --git a/worker/tests/test_mutagen_live.py b/worker/tests/test_mutagen_live.py new file mode 100644 index 0000000..6a802c3 --- /dev/null +++ b/worker/tests/test_mutagen_live.py @@ -0,0 +1,31 @@ +import os +import shutil + +import pytest + +pytestmark = pytest.mark.skipif( + not (os.environ.get("LYRA_LIVE_TESTS") and os.environ.get("LYRA_SAMPLE_AUDIO")), + reason="tags a real audio file; set LYRA_LIVE_TESTS=1 + LYRA_SAMPLE_AUDIO=/path/to/a.flac to run", +) + + +def test_tags_and_renames_a_real_file(tmp_path): + import mutagen + + from lyra_worker._mutagen import MutagenTagger + from lyra_worker.types import MBTarget + + ext = os.path.splitext(os.environ["LYRA_SAMPLE_AUDIO"])[1] + src = tmp_path / f"track1{ext}" + shutil.copy(os.environ["LYRA_SAMPLE_AUDIO"], src) + + target = MBTarget(artist="John Mayer", album="Continuum", year=2006, tracklist=("Stop This Train",)) + MutagenTagger().tag_album(str(tmp_path), target) + + out = tmp_path / f"01 Stop This Train{ext}" + assert out.exists() + tagged = mutagen.File(str(out), easy=True) + assert tagged["artist"] == ["John Mayer"] + assert tagged["album"] == ["Continuum"] + assert tagged["title"] == ["Stop This Train"] + assert tagged["tracknumber"] == ["1"]