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>
131 lines
5.0 KiB
Python
131 lines
5.0 KiB
Python
"""Round 20: Ctrl+I save-path performance.
|
|
|
|
- ``tagging.write_tags`` now does ONE mutagen parse + ONE save per call:
|
|
grouping/compilation/bpm ride the same save (via registered Easy keys)
|
|
instead of a second full parse+save of the audio file.
|
|
- ``LibraryView`` coalesces browser rebuilds through a 0 ms single-shot
|
|
timer, so an N-track edit rebuilds the genre/artist/album cascade once.
|
|
- ``perf.timed`` logs wall-clock durations on the ``lintunes.perf`` logger.
|
|
"""
|
|
import logging
|
|
|
|
import mutagen
|
|
import pytest
|
|
|
|
from lintunes import tagging
|
|
from lintunes.library_manager import LibraryManager
|
|
from lintunes.models import Track
|
|
from lintunes.models.library import Library
|
|
from lintunes.perf import timed
|
|
|
|
|
|
def _manager(tmp_path, tracks=()):
|
|
library = Library()
|
|
for track in tracks:
|
|
library.tracks[track.track_id] = track
|
|
return LibraryManager(library, tmp_path)
|
|
|
|
|
|
EXTRAS = {"grouping": "Test Group", "compilation": True, "bpm": 128}
|
|
|
|
|
|
# ---- single parse + save in write_tags ----
|
|
|
|
class TestSingleSave:
|
|
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
|
|
def test_one_parse_per_write(self, fixture, request, monkeypatch):
|
|
path = request.getfixturevalue(fixture)
|
|
opens = []
|
|
real_file = mutagen.File
|
|
|
|
def counting_file(*args, **kwargs):
|
|
opens.append(args)
|
|
return real_file(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(tagging.mutagen, "File", counting_file)
|
|
tagging.write_tags(path, {"name": "Title", **EXTRAS})
|
|
assert len(opens) == 1
|
|
|
|
def test_mp3_extras_use_itunes_frames(self, mp3_file):
|
|
tagging.write_tags(mp3_file, EXTRAS)
|
|
raw = mutagen.File(mp3_file)
|
|
assert [str(t) for t in raw.tags["GRP1"].text] == ["Test Group"]
|
|
assert str(raw.tags["TCMP"].text[0]) == "1"
|
|
assert str(raw.tags["TBPM"].text[0]) == "128"
|
|
|
|
def test_m4a_extras_use_itunes_atoms(self, m4a_file):
|
|
tagging.write_tags(m4a_file, EXTRAS)
|
|
raw = mutagen.File(m4a_file)
|
|
assert raw.tags["\xa9grp"] == ["Test Group"]
|
|
assert bool(raw.tags["cpil"])
|
|
assert list(raw.tags["tmpo"]) == [128]
|
|
|
|
def test_flac_extras_use_vorbis_comments(self, flac_file):
|
|
tagging.write_tags(flac_file, EXTRAS)
|
|
raw = mutagen.File(flac_file)
|
|
assert raw["grouping"] == ["Test Group"]
|
|
assert raw["compilation"] == ["1"]
|
|
assert raw["bpm"] == ["128"]
|
|
|
|
@pytest.mark.parametrize("fixture", ["mp3_file", "m4a_file", "flac_file"])
|
|
def test_clearing_extras_removes_them(self, fixture, request):
|
|
path = request.getfixturevalue(fixture)
|
|
tagging.write_tags(path, EXTRAS)
|
|
tagging.write_tags(path, {"grouping": "", "compilation": False,
|
|
"bpm": 0})
|
|
easy = mutagen.File(path, easy=True)
|
|
for key in ("grouping", "compilation", "bpm"):
|
|
assert not easy.tags.get(key), key
|
|
|
|
|
|
# ---- coalesced browser rebuild ----
|
|
|
|
class TestCoalescedBrowserRebuild:
|
|
def _view_with_counter(self, qapp, tmp_path, monkeypatch, tracks):
|
|
from lintunes.gui.library_view import LibraryView
|
|
manager = _manager(tmp_path, tracks)
|
|
view = LibraryView(manager)
|
|
rebuilds = []
|
|
# _rebuild_browser looks methods up on self, so patching one of them
|
|
# counts rebuilds without disturbing the timer wiring.
|
|
monkeypatch.setattr(view, "_populate_genres",
|
|
lambda: rebuilds.append(1))
|
|
return manager, view, rebuilds
|
|
|
|
def test_multi_edit_rebuilds_once(self, qapp, tmp_path, monkeypatch):
|
|
tracks = [Track(track_id=i, name=f"T{i}", artist="Old")
|
|
for i in (1, 2, 3)]
|
|
manager, view, rebuilds = self._view_with_counter(
|
|
qapp, tmp_path, monkeypatch, tracks)
|
|
manager.edit_tracks_fields([1, 2, 3], {"artist": "New"})
|
|
assert rebuilds == [] # deferred to the event loop
|
|
qapp.processEvents()
|
|
assert len(rebuilds) == 1
|
|
assert all(t.artist == "New" for t in tracks)
|
|
|
|
def test_single_edit_still_rebuilds(self, qapp, tmp_path, monkeypatch):
|
|
track = Track(track_id=1, name="T", genre="Pop")
|
|
manager, view, rebuilds = self._view_with_counter(
|
|
qapp, tmp_path, monkeypatch, [track])
|
|
manager.edit_track_fields(1, {"genre": "Rock"})
|
|
qapp.processEvents()
|
|
assert len(rebuilds) == 1
|
|
|
|
def test_non_browser_edit_skips_rebuild(self, qapp, tmp_path, monkeypatch):
|
|
track = Track(track_id=1, name="T")
|
|
manager, view, rebuilds = self._view_with_counter(
|
|
qapp, tmp_path, monkeypatch, [track])
|
|
manager.edit_track_fields(1, {"name": "Renamed"})
|
|
qapp.processEvents()
|
|
assert rebuilds == []
|
|
|
|
|
|
# ---- perf logging ----
|
|
|
|
def test_timed_logs_duration(caplog):
|
|
with caplog.at_level(logging.INFO, logger="lintunes.perf"):
|
|
with timed("unit test %s", "label"):
|
|
pass
|
|
messages = [r.getMessage() for r in caplog.records]
|
|
assert any("unit test label" in m and "ms" in m for m in messages)
|