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>
275 lines
8.8 KiB
Python
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
|