Files
lintunes/lintunes/updater.py
T
travandClaude Opus 5 211cb50a9e v0.7.0: startup speed — the 21k-track library stops freezing
Three reported symptoms, one shape: every operation was whole-library,
whole-file, on the Qt main thread. Measured on the real library (21,482
tracks, 506 playlists, 15 MB library.json).

The track table sorted through a QSortFilterProxyModel, which asks data()
for a value on every comparison — 580k Python round trips, 8.5 s per table
load, paid again on every reload. TrackTableModel now keeps _tracks in
canonical (playlist) order plus an _order index list and sorts a key list
computed once per track. Nothing used the proxy's filtering. Sorting by #
is the identity order, so "source row" still means "playlist position" for
drag-reorder. 8.5 s -> 0.13 s; MainWindow() 18.1 s -> 0.79 s.

Smart rules compile to closures once per evaluate() instead of being
re-dispatched per track — the operator lookup, casefolding the query and
parsing the rule's own date constants all leave the 21k-iteration loop
(_match_date was re-parsing its own constant 21,482 times per rule).
Verified identical membership against the old evaluator on all 20 real
smart playlists. 2.54 s -> 0.72 s, and it now runs after the first paint.

Also: _reconcile_track skips two to_dict() round trips per unchanged track
(MERGEABLE_FIELDS in the resolver is the single source of truth for what a
merge touches); no git subprocess on the startup path (Updater.enabled is
probed lazily on the background thread, version-button tooltip deferred);
and .resolved/ is pruned to the newest 10 snapshots — it had reached 1.7 GB
across 73 snapshots inside the Syncthing share.

Time to interactive window ~15 s -> 3.2 s. The mid-session freeze when
Syncthing delivers a change — a full reload + recompute + table rebuild
with the window already on screen, which is what GNOME was offering to
force-quit — ~16 s -> 3.8 s.

The merge rework itself (playlist ordering, per-machine play journal,
quieting the dialog) is queued in TASKS.md as Round 36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:09:07 -04:00

123 lines
4.7 KiB
Python

"""Self-update via git.
LinTunes runs straight from its git checkout (editable install), so an update
is just a fast-forward `git pull` followed by an app relaunch. The Updater
periodically fetches the checkout's upstream and reports how many commits
behind we are; `update_async()` applies them with `--ff-only` (never a merge,
never a conflicted tree). Network git calls run on daemon threads — same
pattern as lastfm.py — and report back through Qt signals.
When the package isn't running from a git checkout (or has no upstream), the
updater disables itself silently and the version display is just a label.
"""
import subprocess
import threading
from pathlib import Path
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
FIRST_CHECK_MS = 10 * 1000 # shortly after launch, off the startup path
RECHECK_INTERVAL_MS = 4 * 3600 * 1000 # then every 4 hours
class Updater(QObject):
update_available = pyqtSignal(int) # commits behind upstream
update_applied = pyqtSignal() # pull succeeded; caller restarts the app
update_failed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._root = str(Path(__file__).resolve().parents[1])
self._busy = False
self._timer = None
self._enabled = None # probed lazily — see enabled
@property
def enabled(self) -> bool:
"""Whether this checkout is a git repo with an upstream.
Probed on first use rather than in __init__: the two `git rev-parse`
calls have a 15 s timeout each, and running them during startup put
that stall on the main thread before the window was up. First use is
now `_check`, on a background thread.
"""
if self._enabled is None:
self._enabled = (
self._git("rev-parse", "--show-toplevel") is not None
and self._git("rev-parse", "--abbrev-ref", "@{upstream}")
is not None)
return self._enabled
def short_hash(self) -> str | None:
"""The checkout's current commit, for telling builds apart."""
return self._git("rev-parse", "--short", "HEAD")
def start_checking(self):
"""First check shortly after launch, then every few hours."""
QTimer.singleShot(FIRST_CHECK_MS, self.check_async)
self._timer = QTimer(self)
self._timer.setInterval(RECHECK_INTERVAL_MS)
self._timer.timeout.connect(self.check_async)
self._timer.start()
def check_async(self):
if self._busy:
return
self._busy = True
threading.Thread(target=self._check, daemon=True).start()
def update_async(self):
if not self.enabled or self._busy:
return
self._busy = True
threading.Thread(target=self._update, daemon=True).start()
# ---- internals (run on daemon threads) ----
def _check(self):
try:
if not self.enabled:
return
if self._git("fetch", "--quiet", timeout=60) is None:
return # offline / host unreachable — perfectly normal, stay quiet
behind = self._git("rev-list", "--count", "HEAD..@{upstream}")
if behind and behind.isdigit() and int(behind) > 0:
self.update_available.emit(int(behind))
finally:
self._busy = False
def _update(self):
try:
proc = subprocess.run(
["git", "-C", self._root, "pull", "--ff-only", "--quiet"],
capture_output=True, text=True, timeout=120)
if proc.returncode == 0:
self.update_applied.emit()
else:
lines = [l.strip() for l in
(proc.stderr or proc.stdout).strip().splitlines()
if l.strip()]
# git's actual reason is the error:/fatal: line, not the
# trailing "Aborting"
message = next(
(l for l in lines if l.startswith(("error:", "fatal:"))),
lines[-1] if lines else "git pull failed")
self.update_failed.emit(message)
except Exception as e:
self.update_failed.emit(str(e))
finally:
self._busy = False
def _git(self, *args, timeout=15) -> str | None:
"""Run git in the repo root; stripped stdout, or None on any failure."""
try:
proc = subprocess.run(["git", "-C", self._root, *args],
capture_output=True, text=True,
timeout=timeout)
except Exception:
return None
if proc.returncode != 0:
return None
return proc.stdout.strip()