feat: real mutagen tagger and MusicBrainz tracklist

This commit is contained in:
Jonathan
2026-07-10 23:52:19 +02:00
parent 1c02a870f3
commit 32deac65a6
5 changed files with 103 additions and 0 deletions
+3
View File
@@ -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,
)
+48
View File
@@ -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)
+1
View File
@@ -5,3 +5,4 @@ yt-dlp>=2024.1
streamrip>=2.0
requests>=2.31
musicbrainzngs>=0.7.1
mutagen>=1.47
+20
View File
@@ -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"]
+31
View File
@@ -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"]