feat: real mutagen tagger and MusicBrainz tracklist
This commit is contained in:
@@ -47,6 +47,7 @@ class MusicBrainzResolver:
|
|||||||
|
|
||||||
track_count = None
|
track_count = None
|
||||||
total_duration_s = None
|
total_duration_s = None
|
||||||
|
titles: tuple[str, ...] = ()
|
||||||
rgid = rg.get("id")
|
rgid = rg.get("id")
|
||||||
if rgid:
|
if rgid:
|
||||||
rgfull = musicbrainzngs.get_release_group_by_id(rgid, includes=["releases"])
|
rgfull = musicbrainzngs.get_release_group_by_id(rgid, includes=["releases"])
|
||||||
@@ -62,6 +63,7 @@ class MusicBrainzResolver:
|
|||||||
track_count = len(tracks)
|
track_count = len(tracks)
|
||||||
total_ms = sum(int((t.get("recording") or {}).get("length") or 0) for t in 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
|
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(
|
return MBTarget(
|
||||||
artist=canonical_artist,
|
artist=canonical_artist,
|
||||||
@@ -69,4 +71,5 @@ class MusicBrainzResolver:
|
|||||||
track_count=track_count,
|
track_count=track_count,
|
||||||
total_duration_s=total_duration_s,
|
total_duration_s=total_duration_s,
|
||||||
year=year,
|
year=year,
|
||||||
|
tracklist=titles,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -5,3 +5,4 @@ yt-dlp>=2024.1
|
|||||||
streamrip>=2.0
|
streamrip>=2.0
|
||||||
requests>=2.31
|
requests>=2.31
|
||||||
musicbrainzngs>=0.7.1
|
musicbrainzngs>=0.7.1
|
||||||
|
mutagen>=1.47
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user