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.
This commit is contained in:
2026-08-19 17:09:07 -04:00
parent 72994a6a6a
commit e69917a290
14 changed files with 658 additions and 141 deletions
+105 -29
View File
@@ -7,7 +7,7 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import (
Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex,
QSortFilterProxyModel, pyqtSignal,
pyqtSignal,
QMimeData, QUrl, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer,
)
from PyQt6.QtGui import (
@@ -23,7 +23,6 @@ from lintunes.gui import drag_ghost
TRACKS_MIME = "application/x-lintunes-tracks"
SORT_ROLE = Qt.ItemDataRole.UserRole
# While dragging a track within this many pixels of the list's top/bottom edge,
# the list auto-scrolls so off-screen rows can be reached.
@@ -167,7 +166,7 @@ class RatingDelegate(QStyledItemDelegate):
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt,
painter, opt.widget)
track = model.track_at(self._view.proxy.mapToSource(index).row())
track = model.track_at(model.source_row(index.row()))
stars = max(0, min(5, (track.rating or 0) // 20))
hovered = self._view.is_rating_hovered(index)
if not stars and not hovered:
@@ -290,11 +289,41 @@ def reveal_paths(paths: list[str]) -> None:
QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(paths[0])))
def _sort_key(value):
"""Comparable key for one cell. Everything collapses to a (text, number)
pair so a column holding a mix of strings, numbers and None never asks
Python to compare a str with a float."""
if value is None:
return ("", 0.0)
if isinstance(value, str):
return (value.casefold(), 0.0)
try:
return ("", float(value))
except (TypeError, ValueError):
return (str(value).casefold(), 0.0)
class TrackTableModel(QAbstractTableModel):
"""Track grid model that sorts itself.
``_tracks`` stays in **canonical order** — the order ``set_tracks`` was
handed, i.e. the playlist's manual order — and ``_order`` maps a displayed
row to its index in it. Sorting only reshuffles ``_order``, so "source row"
keeps meaning "position in the playlist" for drag-reorder and selection,
and sorting by the ``#`` column is just the identity order.
This replaces a QSortFilterProxyModel: the proxy called ``data()`` once per
comparison, which cost ~8.5 s per load on a 21k-track library. Sorting a
precomputed key list here is ~200x faster, and nothing needed the proxy's
filtering.
"""
def __init__(self, parent=None, with_index_column=False):
super().__init__(parent)
self._tracks: list[Track] = []
self._rows_by_id: dict[int, list[int]] = {}
self._order: list[int] = [] # display row -> canonical row
self._view_of: list[int] = [] # canonical row -> display row
self._sort: tuple[str, bool] = (INDEX_FIELD, True) # field, ascending
self._fields: list[str] = []
self._with_index = with_index_column
self._now_playing_id: int | None = None
@@ -330,8 +359,62 @@ class TrackTableModel(QAbstractTableModel):
self._rows_by_id = {}
for row, track in enumerate(self._tracks):
self._rows_by_id.setdefault(track.track_id, []).append(row)
self._rebuild_order()
self.endResetModel()
def _rebuild_order(self):
"""Recompute the display order from the current sort. O(n log n) with a
key computed once per track, rather than once per comparison."""
count = len(self._tracks)
field, ascending = self._sort
if field == INDEX_FIELD or field not in COLUMN_MAP:
self._order = list(range(count))
else:
keys = [_sort_key(getattr(track, field, None))
for track in self._tracks]
self._order = sorted(range(count), key=keys.__getitem__,
reverse=not ascending)
self._view_of = [0] * count
for display_row, canonical_row in enumerate(self._order):
self._view_of[canonical_row] = display_row
def sort(self, column: int, order=Qt.SortOrder.AscendingOrder):
"""Called by QTableView when a header section is clicked (or by
sortByColumn). Keeps selection alive by remapping persistent indexes."""
if not self._fields or not 0 <= column < len(self._fields):
return
self._sort = (self._fields[column],
order == Qt.SortOrder.AscendingOrder)
# Order matters: QItemSelectionModel turns the current selection into
# persistent indexes in response to layoutAboutToBeChanged, so snapshot
# the list after emitting it or the selection is dropped.
self.layoutAboutToBeChanged.emit()
old_indexes = self.persistentIndexList()
old_order = self._order
self._rebuild_order()
new_indexes = []
for index in old_indexes:
row = index.row()
if 0 <= row < len(old_order):
new_indexes.append(
self.index(self._view_of[old_order[row]], index.column()))
else:
new_indexes.append(QModelIndex())
self.changePersistentIndexList(old_indexes, new_indexes)
self.layoutChanged.emit()
def source_row(self, display_row: int) -> int:
"""Displayed row -> canonical row (what the proxy's mapToSource did)."""
if 0 <= display_row < len(self._order):
return self._order[display_row]
return -1
def display_row(self, canonical_row: int) -> int:
"""Canonical row -> displayed row (the proxy's mapFromSource)."""
if 0 <= canonical_row < len(self._view_of):
return self._view_of[canonical_row]
return -1
def track_at(self, row: int) -> Track:
return self._tracks[row]
@@ -344,7 +427,8 @@ class TrackTableModel(QAbstractTableModel):
return rows[0] if rows else None
def refresh_track(self, track_id: int):
for row in self._rows_by_id.get(track_id, ()):
for canonical_row in self._rows_by_id.get(track_id, ()):
row = self._view_of[canonical_row]
self.dataChanged.emit(self.index(row, 0),
self.index(row, self.columnCount() - 1))
@@ -371,10 +455,10 @@ class TrackTableModel(QAbstractTableModel):
if not index.isValid():
return None
field = self._fields[index.column()]
track = self._tracks[index.row()]
track = self._tracks[self._order[index.row()]]
if field == INDEX_FIELD:
if role == Qt.ItemDataRole.DisplayRole or role == SORT_ROLE:
if role == Qt.ItemDataRole.DisplayRole:
return index.row() + 1
return None
@@ -385,10 +469,6 @@ class TrackTableModel(QAbstractTableModel):
return speaker_pixmap(self._now_playing_active, color)
value = getattr(track, field, "")
if role == SORT_ROLE:
if isinstance(value, str):
return value.lower()
return value if value is not None else 0
if role == Qt.ItemDataRole.DisplayRole:
if field == "total_time":
return format_time(value)
@@ -437,10 +517,8 @@ class TrackTableView(QTableView):
self._content_editable = True
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
self.proxy = QSortFilterProxyModel(self)
self.proxy.setSourceModel(self.model_)
self.proxy.setSortRole(SORT_ROLE)
self.setModel(self.proxy)
# The model sorts itself (see TrackTableModel) — no proxy in between.
self.setModel(self.model_)
self.setAlternatingRowColors(True)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
@@ -528,10 +606,10 @@ class TrackTableView(QTableView):
src_row = self.model_.row_for_id(track_id)
if src_row is None:
return False
proxy_index = self.proxy.mapFromSource(self.model_.index(src_row, 0))
self.setCurrentIndex(proxy_index)
self.selectRow(proxy_index.row())
self.scrollTo(proxy_index,
index = self.model_.index(self.model_.display_row(src_row), 0)
self.setCurrentIndex(index)
self.selectRow(index.row())
self.scrollTo(index,
QAbstractItemView.ScrollHint.PositionAtCenter)
return True
@@ -572,14 +650,12 @@ class TrackTableView(QTableView):
# ---- selection / ordering helpers ----
def view_order_track_ids(self) -> list[int]:
ids = []
for proxy_row in range(self.proxy.rowCount()):
source_row = self.proxy.mapToSource(self.proxy.index(proxy_row, 0)).row()
ids.append(self.model_.track_at(source_row).track_id)
return ids
model = self.model_
return [model.track_at(model.source_row(row)).track_id
for row in range(model.rowCount())]
def selected_source_rows(self) -> list[int]:
rows = {self.proxy.mapToSource(idx).row()
rows = {self.model_.source_row(idx.row())
for idx in self.selectionModel().selectedRows()}
return sorted(rows)
@@ -759,7 +835,7 @@ class TrackTableView(QTableView):
for persistent in (old, new):
if persistent is not None and persistent.isValid():
self.viewport().update(self.visualRect(
self.proxy.index(persistent.row(), persistent.column())))
self.model_.index(persistent.row(), persistent.column())))
def mouseMoveEvent(self, event):
index = self.indexAt(event.position().toPoint())
@@ -775,7 +851,7 @@ class TrackTableView(QTableView):
pos = event.position().toPoint()
index = self.indexAt(pos)
if self._is_rating_index(index):
track = self.model_.track_at(self.proxy.mapToSource(index).row())
track = self.model_.track_at(self.model_.source_row(index.row()))
slot = rating_slot_at(
pos.x() - self.visualRect(index).left(),
rating_slot_width(QFontMetrics(self.font())))
@@ -952,15 +1028,15 @@ class TrackTableView(QTableView):
def _indicator_y(self, pos) -> int:
"""Viewport y for the insertion line matching where the drop lands."""
count = self.proxy.rowCount()
count = self.model_.rowCount()
if count == 0:
return 0
row = self._drop_row(pos)
if row is None: # drop appends
row = count
if row < count:
return self.visualRect(self.proxy.index(row, 0)).top()
return self.visualRect(self.proxy.index(count - 1, 0)).bottom() + 1
return self.visualRect(self.model_.index(row, 0)).top()
return self.visualRect(self.model_.index(count - 1, 0)).bottom() + 1
def _set_drop_indicator(self, y: int | None):
if y != self._drop_indicator_y:
+13 -4
View File
@@ -6,7 +6,7 @@ finds upstream commits it grows a "*" and becomes a button: click → confirm
git pull → the main window restarts the app on `update_applied`.
"""
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QPalette
from PyQt6.QtWidgets import QMessageBox, QPushButton
@@ -24,15 +24,24 @@ class VersionButton(QPushButton):
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.refresh_theme()
self.setText(f"v{__version__}")
commit = updater.short_hash()
self._base_tooltip = (f"LinTunes v{__version__} ({commit})" if commit
else f"LinTunes v{__version__}")
self._base_tooltip = f"LinTunes v{__version__}"
self.setToolTip(self._base_tooltip)
# short_hash() shells out to git; do it once the window is up rather
# than on the startup path (it only decorates the tooltip).
QTimer.singleShot(0, self._load_commit_tooltip)
updater.update_available.connect(self._on_update_available)
updater.update_failed.connect(self._on_update_failed)
self.clicked.connect(self._on_clicked)
def _load_commit_tooltip(self):
commit = self._updater.short_hash()
if not commit:
return
self._base_tooltip = f"LinTunes v{__version__} ({commit})"
if not self._update_ready:
self.setToolTip(self._base_tooltip)
def refresh_theme(self):
# Subtle but readable: the theme's text color at ~55% opacity
# (palette(mid) is too close to the light backgrounds to read).