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:
+105
-29
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user