Files
lintunes/lintunes/tagging.py
T
travandClaude Fable 5 332b47a018 v0.1.1: perf timings + single-save tag writes + coalesced browser rebuild
Diagnosing the Ctrl+I save hang on the Debian machine (GUI thread blocks
past mutter's 5s check-alive):

- lintunes/perf.py: timed() context manager logging wall-clock ms at INFO
  on lintunes.perf — instruments tag/artwork saves, file moves,
  edit_track(s)_fields, the browser rebuild, smart recompute, and the
  debounced JSON flush, so the slow machine can tell us which stage
  eats the time (journalctl --user or a terminal run).
- tagging.write_tags: ONE parse + ONE save per edit. grouping/
  compilation/bpm ride the main save via registered Easy keys
  (GRP1/TCMP/TBPM on EasyID3, cpil on EasyMP4) instead of
  _write_extra_tags re-parsing and re-saving the audio file.
- LibraryView: browser rebuilds coalesce through a 0ms single-shot
  timer — an N-track Get Info edit rebuilds the genre/artist/album
  cascade once instead of N times over the whole library.

Moving the writes off the GUI thread is deliberately deferred until the
Debian timings say which stage dominates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:25:56 -04:00

275 lines
8.8 KiB
Python

"""Read and write audio file tags (mp3/m4a/flac) via mutagen.
A small uniform field dict is used throughout:
name, artist, album_artist, album, genre, composer, grouping, comments,
year (int), track_number, track_count, disc_number, disc_count,
compilation (bool), total_time (ms, read-only), bit_rate, sample_rate, size.
"""
from pathlib import Path
import mutagen
from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, COMM, GRP1, TCMP, TBPM
from mutagen.mp3 import MP3
from mutagen.mp4 import MP4
from mutagen.flac import FLAC
from lintunes.perf import timed
# EasyID3 has no 'comment' key by default; COMM frames need explicit handling.
def _comm_get(id3, _key):
frame = id3.get("COMM::eng") or id3.get("COMM::XXX")
return list(frame.text) if frame else []
def _comm_set(id3, _key, value):
id3.delall("COMM")
id3.add(COMM(encoding=3, lang="eng", desc="", text=value))
def _comm_delete(id3, _key):
id3.delall("COMM")
EasyID3.RegisterKey("comment", _comm_get, _comm_set, _comm_delete)
# Register grouping/compilation/bpm as Easy keys (iTunes-style frames) so
# write_tags can set every field on one object and save the file ONCE —
# a second parse+save per edit is real I/O on big files / slow disks.
def _register_id3_frame_key(key: str, frame_cls):
frame_id = frame_cls.__name__
def getter(id3, _key):
frame = id3.get(frame_id)
return list(frame.text) if frame else []
def setter(id3, _key, value):
id3.delall(frame_id)
id3.add(frame_cls(encoding=3, text=value))
def deleter(id3, _key):
id3.delall(frame_id)
EasyID3.RegisterKey(key, getter, setter, deleter)
_register_id3_frame_key("grouping", GRP1) # iTunes GRP1, not TIT1
_register_id3_frame_key("compilation", TCMP)
_register_id3_frame_key("bpm", TBPM)
# EasyMP4 is missing 'composer' (©wrt) out of the box
from mutagen.easymp4 import EasyMP4Tags # noqa: E402
if "composer" not in EasyMP4Tags.Set:
EasyMP4Tags.RegisterTextKey("composer", "\xa9wrt")
if "grouping" not in EasyMP4Tags.Set:
EasyMP4Tags.RegisterTextKey("grouping", "\xa9grp")
if "bpm" not in EasyMP4Tags.Set:
EasyMP4Tags.RegisterIntKey("bpm", "tmpo")
def _cpil_get(tags, _key):
return ["1"] if tags.get("cpil") else []
def _cpil_set(tags, _key, value):
tags["cpil"] = bool(value and str(value[0]) not in ("", "0"))
def _cpil_delete(tags, _key):
tags.pop("cpil", None)
if "compilation" not in EasyMP4Tags.Set:
EasyMP4Tags.RegisterKey("compilation", _cpil_get, _cpil_set, _cpil_delete)
# Easy-interface tag names shared by EasyID3 / EasyMP4 / FLAC (Vorbis)
_TEXT_FIELDS = {
"name": "title",
"artist": "artist",
"album_artist": "albumartist",
"album": "album",
"genre": "genre",
"composer": "composer",
"comments": "comment",
}
EDITABLE_FIELDS = list(_TEXT_FIELDS) + [
"grouping", "year", "track_number", "track_count",
"disc_number", "disc_count", "compilation", "bpm",
]
def read_tags(path: str | Path) -> dict:
"""Read tags + audio properties into Track-style fields."""
audio = mutagen.File(str(path), easy=True)
if audio is None:
return {}
fields = {}
for our_key, tag_key in _TEXT_FIELDS.items():
values = audio.tags.get(tag_key) if audio.tags else None
if values:
fields[our_key] = str(values[0])
if audio.tags:
for src, num_key, count_key in (("tracknumber", "track_number", "track_count"),
("discnumber", "disc_number", "disc_count")):
values = audio.tags.get(src)
if values:
num, _, count = str(values[0]).partition("/")
if num.strip().isdigit():
fields[num_key] = int(num)
if count.strip().isdigit():
fields[count_key] = int(count)
date_values = audio.tags.get("date") or audio.tags.get("originaldate")
if date_values:
year_part = str(date_values[0])[:4]
if year_part.isdigit():
fields["year"] = int(year_part)
info = audio.info
if info is not None:
if getattr(info, "length", 0):
fields["total_time"] = int(info.length * 1000)
if getattr(info, "bitrate", 0):
fields["bit_rate"] = info.bitrate // 1000
if getattr(info, "sample_rate", 0):
fields["sample_rate"] = info.sample_rate
fields["size"] = Path(path).stat().st_size
fields["kind"] = _kind_for(audio)
return fields
def write_tags(path: str | Path, fields: dict):
"""Write the given Track-style fields to the file's tags.
One parse and ONE save per call: grouping/compilation/bpm are covered by
the Easy keys registered above, so no second raw-tag pass is needed."""
path = str(path)
audio = mutagen.File(path, easy=True)
if audio is None:
raise ValueError(f"Unsupported audio file: {path}")
if audio.tags is None:
audio.add_tags()
for our_key, tag_key in _TEXT_FIELDS.items():
if our_key in fields:
value = fields[our_key]
if value:
audio.tags[tag_key] = [str(value)]
else:
audio.tags.pop(tag_key, None)
if "year" in fields:
if fields["year"]:
audio.tags["date"] = [str(fields["year"])]
else:
audio.tags.pop("date", None)
for num_key, count_key, tag_key in (("track_number", "track_count", "tracknumber"),
("disc_number", "disc_count", "discnumber")):
if num_key in fields or count_key in fields:
num = fields.get(num_key, 0)
count = fields.get(count_key, 0)
if num and count:
audio.tags[tag_key] = [f"{num}/{count}"]
elif num:
audio.tags[tag_key] = [str(num)]
else:
audio.tags.pop(tag_key, None)
if "grouping" in fields:
_set_or_pop(audio.tags, "grouping",
str(fields["grouping"]) if fields["grouping"] else None)
if "bpm" in fields:
_set_or_pop(audio.tags, "bpm",
str(fields["bpm"]) if fields["bpm"] else None)
if "compilation" in fields:
_set_or_pop(audio.tags, "compilation",
"1" if fields["compilation"] else None)
with timed("tag save %s", path):
audio.save()
def _set_or_pop(tags, key, value):
if value:
tags[key] = [value]
return
try:
del tags[key] # FLAC's VCommentDict has no pop-with-default
except KeyError:
pass
def _kind_for(audio) -> str:
name = type(audio).__name__
return {
"EasyMP3": "MPEG audio file",
"MP3": "MPEG audio file",
"EasyMP4": "AAC audio file",
"MP4": "AAC audio file",
"FLAC": "FLAC audio file",
}.get(name, name)
def write_artwork(path: str | Path, image_bytes: bytes, mime: str = "image/jpeg"):
"""Replace the file's embedded cover art (front cover) with image_bytes."""
from mutagen.id3 import APIC
from mutagen.mp4 import MP4Cover
from mutagen.flac import Picture
audio = mutagen.File(str(path))
if audio is None:
raise ValueError(f"Unsupported audio file: {path}")
if isinstance(audio, MP4):
fmt = (MP4Cover.FORMAT_PNG if mime == "image/png"
else MP4Cover.FORMAT_JPEG)
if audio.tags is None:
audio.add_tags()
audio.tags["covr"] = [MP4Cover(image_bytes, imageformat=fmt)]
elif isinstance(audio, FLAC):
picture = Picture()
picture.type = 3 # front cover
picture.mime = mime
picture.desc = "Cover"
picture.data = image_bytes
audio.clear_pictures()
audio.add_picture(picture)
elif hasattr(audio, "tags") and (isinstance(audio, MP3) or
isinstance(audio.tags, ID3)):
if audio.tags is None:
audio.add_tags()
audio.tags.delall("APIC")
audio.tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover",
data=image_bytes))
else:
raise ValueError(f"Don't know how to embed artwork in {path}")
with timed("artwork save %s", path):
audio.save()
def read_embedded_artwork(path: str | Path) -> bytes | None:
"""Return the first embedded cover image's bytes, or None."""
try:
audio = mutagen.File(str(path))
except Exception:
return None
if audio is None:
return None
if isinstance(audio, MP4):
covers = audio.tags.get("covr") if audio.tags else None
if covers:
return bytes(covers[0])
elif isinstance(audio, FLAC):
if audio.pictures:
return audio.pictures[0].data
elif audio.tags is not None:
# ID3 (mp3, aiff): APIC frames
for key in audio.tags.keys():
if key.startswith("APIC"):
return audio.tags[key].data
return None