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.
This commit is contained in:
2026-07-03 18:25:56 -04:00
parent b7089f865b
commit 8e0ba85086
7 changed files with 325 additions and 100 deletions
+54 -42
View File
@@ -10,6 +10,7 @@ log = logging.getLogger(__name__)
from lintunes import smart, tagging
from lintunes.importers.file_importer import organized_destination, unique_path
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.perf import timed
from lintunes.storage import json_storage
from lintunes.undo import Command, UndoStack
@@ -371,16 +372,18 @@ class LibraryManager(QObject):
if hasattr(track, k) and getattr(track, k) != v}
if not changed:
return
old = {k: getattr(track, k) for k in changed}
if not self._write_track_tags(track, changed):
# File write failed: leave the library untouched so memory and file
# don't diverge, and don't record an un-undoable phantom edit.
return
new_loc = self._maybe_move_file(track, changed)
if new_loc is not None:
old = {**old, "location": track.location}
changed = {**changed, "location": new_loc}
self._apply_track_fields(track_id, changed)
with timed("edit_track_fields %r", track.name):
old = {k: getattr(track, k) for k in changed}
if not self._write_track_tags(track, changed):
# File write failed: leave the library untouched so memory and
# file don't diverge, and don't record an un-undoable phantom
# edit.
return
new_loc = self._maybe_move_file(track, changed)
if new_loc is not None:
old = {**old, "location": track.location}
changed = {**changed, "location": new_loc}
self._apply_track_fields(track_id, changed)
self.undo_stack.push(Command(
"Edit Info",
undo=lambda: self._revert_track_fields(track_id, old),
@@ -395,21 +398,22 @@ class LibraryManager(QObject):
skipped entirely (no file write, no undo entry).
"""
changes = [] # (track_id, new_fields, old_fields)
for track_id in track_ids:
track = self.library.tracks.get(track_id)
if not track:
continue
changed = {k: v for k, v in fields.items()
if hasattr(track, k) and getattr(track, k) != v}
if not changed or not self._write_track_tags(track, changed):
continue
old = {k: getattr(track, k) for k in changed}
new_loc = self._maybe_move_file(track, changed)
if new_loc is not None:
old = {**old, "location": track.location}
changed = {**changed, "location": new_loc}
self._apply_track_fields(track_id, changed)
changes.append((track_id, changed, old))
with timed("edit_tracks_fields (%d tracks)", len(track_ids)):
for track_id in track_ids:
track = self.library.tracks.get(track_id)
if not track:
continue
changed = {k: v for k, v in fields.items()
if hasattr(track, k) and getattr(track, k) != v}
if not changed or not self._write_track_tags(track, changed):
continue
old = {k: getattr(track, k) for k in changed}
new_loc = self._maybe_move_file(track, changed)
if new_loc is not None:
old = {**old, "location": track.location}
changed = {**changed, "location": new_loc}
self._apply_track_fields(track_id, changed)
changes.append((track_id, changed, old))
if not changes:
return
self.undo_stack.push(Command(
@@ -518,12 +522,13 @@ class LibraryManager(QObject):
empty. Same-filesystem rename, so this is safe even while the player
has the file open (the fd stays valid; the path is re-read at next
play). Returns the actual destination (collision-suffixed if taken)."""
dest = unique_path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
src.rename(dest)
root = self.organize_root()
if root is not None:
self._prune_empty_dirs(src.parent, root)
with timed("move file %s", src.name):
dest = unique_path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
src.rename(dest)
root = self.organize_root()
if root is not None:
self._prune_empty_dirs(src.parent, root)
return dest
@staticmethod
@@ -616,18 +621,22 @@ class LibraryManager(QObject):
self._pending_recompute_fields = set()
self._recomputing = True
try:
for pid, playlist in list(self.library.playlists.items()):
if not playlist.is_smart:
continue
criteria = playlist.smart_criteria
if criteria is None or criteria.unsupported or not criteria.live_update:
continue
if fields is not None and not self._criteria_touched(criteria, fields):
continue
self.recompute_smart_playlist(pid)
with timed("smart playlist recompute"):
self._recompute_touched(fields)
finally:
self._recomputing = False
def _recompute_touched(self, fields: set[str] | None):
for pid, playlist in list(self.library.playlists.items()):
if not playlist.is_smart:
continue
criteria = playlist.smart_criteria
if criteria is None or criteria.unsupported or not criteria.live_update:
continue
if fields is not None and not self._criteria_touched(criteria, fields):
continue
self.recompute_smart_playlist(pid)
@staticmethod
def _criteria_touched(criteria, fields: set[str]) -> bool:
if criteria.referenced_attrs() & fields:
@@ -660,13 +669,16 @@ class LibraryManager(QObject):
def flush(self):
self._save_timer.stop()
if self._dirty_tracks:
json_storage.save_tracks(self.library, self.data_dir)
with timed("flush library.json (%d tracks)",
len(self.library.tracks)):
json_storage.save_tracks(self.library, self.data_dir)
self._dirty_tracks = False
self._record_sig(self.data_dir / "library.json")
for pid in list(self._dirty_playlists):
playlist = self.library.playlists.get(pid)
if playlist:
json_storage.save_playlist(playlist, self.data_dir)
with timed("flush playlist %s", pid):
json_storage.save_playlist(playlist, self.data_dir)
self._record_sig(self.data_dir / "playlists" / f"{pid}.json")
self._dirty_playlists.clear()
for pid in list(self._deleted_playlists):