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:
+54
-42
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user