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.
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)
|