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>
This commit is contained in:
@@ -9,6 +9,36 @@ When a round closes, move its finished items to `tasks-done.md`.
|
|||||||
|
|
||||||
- [ ] archive the done tasks in here to another file, this is crufty....
|
- [ ] archive the done tasks in here to another file, this is crufty....
|
||||||
|
|
||||||
|
## Round 36 — the merge rework (planned, Round 35 covered the speed half)
|
||||||
|
|
||||||
|
The three symptoms in Round 35 shared a root cause; that round fixed the
|
||||||
|
performance half. This is the correctness half.
|
||||||
|
|
||||||
|
- [ ] **Playlist merges lose position.** `_merge_playlist` is a 2-way union with
|
||||||
|
no common ancestor: it takes one side's order wholesale and *appends* the
|
||||||
|
other side's extras, so a track inserted in the middle on one machine
|
||||||
|
arrives at the tail on the other (this is what happened to `nissa one`).
|
||||||
|
Replace with an anchor-based merge — insert each side-only track after its
|
||||||
|
nearest preceding common anchor. Same in `_reconcile_playlist`.
|
||||||
|
- [ ] **mtime is a lie for playlists.** `mark_playlist_settings_dirty` rewrites
|
||||||
|
the whole playlist file for UI-only changes, and because the last column
|
||||||
|
is stretch-sized, *resizing the window* rewrites the open playlist's JSON.
|
||||||
|
So "most recently edited" often means "most recently resized". Give
|
||||||
|
`Playlist` a `date_modified` bumped only in `_set_track_ids`, and merge on
|
||||||
|
that.
|
||||||
|
- [ ] **`max()` play counts discard concurrent plays.** Base 100, one machine
|
||||||
|
plays 5 (105), the other plays 3 (103) → merge keeps 105 and those 3 are
|
||||||
|
gone. Per-machine `plays/<machine-id>.json` journal (machine id kept
|
||||||
|
outside the synced dir): only its owner ever writes it, so play data can
|
||||||
|
never conflict, effective count = base + sum of journals, and `library.json`
|
||||||
|
stops being rewritten every 3 s during playback — which is what generates
|
||||||
|
the conflicts in the first place.
|
||||||
|
- [ ] **Quiet the merge dialog.** Lossless merges (counts and dates only) should
|
||||||
|
be a status-bar line, not a window; keep the dialog for lossy cases, and
|
||||||
|
reuse one instance so six can never stack up again.
|
||||||
|
- [ ] Consider a `.stignore` for `.resolved` so merge backups stop syncing
|
||||||
|
(Round 35 bounded the folder to 10 snapshots, but it still replicates).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.6.1"
|
__version__ = "0.7.0"
|
||||||
|
|||||||
+105
-29
@@ -7,7 +7,7 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from PyQt6.QtCore import (
|
from PyQt6.QtCore import (
|
||||||
Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex,
|
Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex,
|
||||||
QSortFilterProxyModel, pyqtSignal,
|
pyqtSignal,
|
||||||
QMimeData, QUrl, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer,
|
QMimeData, QUrl, QPoint, QPointF, QRect, QRectF, QMetaType, QTimer,
|
||||||
)
|
)
|
||||||
from PyQt6.QtGui import (
|
from PyQt6.QtGui import (
|
||||||
@@ -23,7 +23,6 @@ from lintunes.gui import drag_ghost
|
|||||||
|
|
||||||
|
|
||||||
TRACKS_MIME = "application/x-lintunes-tracks"
|
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,
|
# 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.
|
# 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,
|
style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt,
|
||||||
painter, opt.widget)
|
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))
|
stars = max(0, min(5, (track.rating or 0) // 20))
|
||||||
hovered = self._view.is_rating_hovered(index)
|
hovered = self._view.is_rating_hovered(index)
|
||||||
if not stars and not hovered:
|
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])))
|
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):
|
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):
|
def __init__(self, parent=None, with_index_column=False):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._tracks: list[Track] = []
|
self._tracks: list[Track] = []
|
||||||
self._rows_by_id: dict[int, list[int]] = {}
|
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._fields: list[str] = []
|
||||||
self._with_index = with_index_column
|
self._with_index = with_index_column
|
||||||
self._now_playing_id: int | None = None
|
self._now_playing_id: int | None = None
|
||||||
@@ -330,8 +359,62 @@ class TrackTableModel(QAbstractTableModel):
|
|||||||
self._rows_by_id = {}
|
self._rows_by_id = {}
|
||||||
for row, track in enumerate(self._tracks):
|
for row, track in enumerate(self._tracks):
|
||||||
self._rows_by_id.setdefault(track.track_id, []).append(row)
|
self._rows_by_id.setdefault(track.track_id, []).append(row)
|
||||||
|
self._rebuild_order()
|
||||||
self.endResetModel()
|
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:
|
def track_at(self, row: int) -> Track:
|
||||||
return self._tracks[row]
|
return self._tracks[row]
|
||||||
|
|
||||||
@@ -344,7 +427,8 @@ class TrackTableModel(QAbstractTableModel):
|
|||||||
return rows[0] if rows else None
|
return rows[0] if rows else None
|
||||||
|
|
||||||
def refresh_track(self, track_id: int):
|
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.dataChanged.emit(self.index(row, 0),
|
||||||
self.index(row, self.columnCount() - 1))
|
self.index(row, self.columnCount() - 1))
|
||||||
|
|
||||||
@@ -371,10 +455,10 @@ class TrackTableModel(QAbstractTableModel):
|
|||||||
if not index.isValid():
|
if not index.isValid():
|
||||||
return None
|
return None
|
||||||
field = self._fields[index.column()]
|
field = self._fields[index.column()]
|
||||||
track = self._tracks[index.row()]
|
track = self._tracks[self._order[index.row()]]
|
||||||
|
|
||||||
if field == INDEX_FIELD:
|
if field == INDEX_FIELD:
|
||||||
if role == Qt.ItemDataRole.DisplayRole or role == SORT_ROLE:
|
if role == Qt.ItemDataRole.DisplayRole:
|
||||||
return index.row() + 1
|
return index.row() + 1
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -385,10 +469,6 @@ class TrackTableModel(QAbstractTableModel):
|
|||||||
return speaker_pixmap(self._now_playing_active, color)
|
return speaker_pixmap(self._now_playing_active, color)
|
||||||
|
|
||||||
value = getattr(track, field, "")
|
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 role == Qt.ItemDataRole.DisplayRole:
|
||||||
if field == "total_time":
|
if field == "total_time":
|
||||||
return format_time(value)
|
return format_time(value)
|
||||||
@@ -437,10 +517,8 @@ class TrackTableView(QTableView):
|
|||||||
self._content_editable = True
|
self._content_editable = True
|
||||||
|
|
||||||
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
|
self.model_ = TrackTableModel(self, with_index_column=playlist_mode)
|
||||||
self.proxy = QSortFilterProxyModel(self)
|
# The model sorts itself (see TrackTableModel) — no proxy in between.
|
||||||
self.proxy.setSourceModel(self.model_)
|
self.setModel(self.model_)
|
||||||
self.proxy.setSortRole(SORT_ROLE)
|
|
||||||
self.setModel(self.proxy)
|
|
||||||
|
|
||||||
self.setAlternatingRowColors(True)
|
self.setAlternatingRowColors(True)
|
||||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
@@ -528,10 +606,10 @@ class TrackTableView(QTableView):
|
|||||||
src_row = self.model_.row_for_id(track_id)
|
src_row = self.model_.row_for_id(track_id)
|
||||||
if src_row is None:
|
if src_row is None:
|
||||||
return False
|
return False
|
||||||
proxy_index = self.proxy.mapFromSource(self.model_.index(src_row, 0))
|
index = self.model_.index(self.model_.display_row(src_row), 0)
|
||||||
self.setCurrentIndex(proxy_index)
|
self.setCurrentIndex(index)
|
||||||
self.selectRow(proxy_index.row())
|
self.selectRow(index.row())
|
||||||
self.scrollTo(proxy_index,
|
self.scrollTo(index,
|
||||||
QAbstractItemView.ScrollHint.PositionAtCenter)
|
QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -572,14 +650,12 @@ class TrackTableView(QTableView):
|
|||||||
# ---- selection / ordering helpers ----
|
# ---- selection / ordering helpers ----
|
||||||
|
|
||||||
def view_order_track_ids(self) -> list[int]:
|
def view_order_track_ids(self) -> list[int]:
|
||||||
ids = []
|
model = self.model_
|
||||||
for proxy_row in range(self.proxy.rowCount()):
|
return [model.track_at(model.source_row(row)).track_id
|
||||||
source_row = self.proxy.mapToSource(self.proxy.index(proxy_row, 0)).row()
|
for row in range(model.rowCount())]
|
||||||
ids.append(self.model_.track_at(source_row).track_id)
|
|
||||||
return ids
|
|
||||||
|
|
||||||
def selected_source_rows(self) -> list[int]:
|
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()}
|
for idx in self.selectionModel().selectedRows()}
|
||||||
return sorted(rows)
|
return sorted(rows)
|
||||||
|
|
||||||
@@ -759,7 +835,7 @@ class TrackTableView(QTableView):
|
|||||||
for persistent in (old, new):
|
for persistent in (old, new):
|
||||||
if persistent is not None and persistent.isValid():
|
if persistent is not None and persistent.isValid():
|
||||||
self.viewport().update(self.visualRect(
|
self.viewport().update(self.visualRect(
|
||||||
self.proxy.index(persistent.row(), persistent.column())))
|
self.model_.index(persistent.row(), persistent.column())))
|
||||||
|
|
||||||
def mouseMoveEvent(self, event):
|
def mouseMoveEvent(self, event):
|
||||||
index = self.indexAt(event.position().toPoint())
|
index = self.indexAt(event.position().toPoint())
|
||||||
@@ -775,7 +851,7 @@ class TrackTableView(QTableView):
|
|||||||
pos = event.position().toPoint()
|
pos = event.position().toPoint()
|
||||||
index = self.indexAt(pos)
|
index = self.indexAt(pos)
|
||||||
if self._is_rating_index(index):
|
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(
|
slot = rating_slot_at(
|
||||||
pos.x() - self.visualRect(index).left(),
|
pos.x() - self.visualRect(index).left(),
|
||||||
rating_slot_width(QFontMetrics(self.font())))
|
rating_slot_width(QFontMetrics(self.font())))
|
||||||
@@ -952,15 +1028,15 @@ class TrackTableView(QTableView):
|
|||||||
|
|
||||||
def _indicator_y(self, pos) -> int:
|
def _indicator_y(self, pos) -> int:
|
||||||
"""Viewport y for the insertion line matching where the drop lands."""
|
"""Viewport y for the insertion line matching where the drop lands."""
|
||||||
count = self.proxy.rowCount()
|
count = self.model_.rowCount()
|
||||||
if count == 0:
|
if count == 0:
|
||||||
return 0
|
return 0
|
||||||
row = self._drop_row(pos)
|
row = self._drop_row(pos)
|
||||||
if row is None: # drop appends
|
if row is None: # drop appends
|
||||||
row = count
|
row = count
|
||||||
if row < count:
|
if row < count:
|
||||||
return self.visualRect(self.proxy.index(row, 0)).top()
|
return self.visualRect(self.model_.index(row, 0)).top()
|
||||||
return self.visualRect(self.proxy.index(count - 1, 0)).bottom() + 1
|
return self.visualRect(self.model_.index(count - 1, 0)).bottom() + 1
|
||||||
|
|
||||||
def _set_drop_indicator(self, y: int | None):
|
def _set_drop_indicator(self, y: int | None):
|
||||||
if y != self._drop_indicator_y:
|
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`.
|
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.QtGui import QPalette
|
||||||
from PyQt6.QtWidgets import QMessageBox, QPushButton
|
from PyQt6.QtWidgets import QMessageBox, QPushButton
|
||||||
|
|
||||||
@@ -24,15 +24,24 @@ class VersionButton(QPushButton):
|
|||||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||||
self.refresh_theme()
|
self.refresh_theme()
|
||||||
self.setText(f"v{__version__}")
|
self.setText(f"v{__version__}")
|
||||||
commit = updater.short_hash()
|
self._base_tooltip = f"LinTunes v{__version__}"
|
||||||
self._base_tooltip = (f"LinTunes v{__version__} ({commit})" if commit
|
|
||||||
else f"LinTunes v{__version__}")
|
|
||||||
self.setToolTip(self._base_tooltip)
|
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_available.connect(self._on_update_available)
|
||||||
updater.update_failed.connect(self._on_update_failed)
|
updater.update_failed.connect(self._on_update_failed)
|
||||||
self.clicked.connect(self._on_clicked)
|
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):
|
def refresh_theme(self):
|
||||||
# Subtle but readable: the theme's text color at ~55% opacity
|
# Subtle but readable: the theme's text color at ~55% opacity
|
||||||
# (palette(mid) is too close to the light backgrounds to read).
|
# (palette(mid) is too close to the light backgrounds to read).
|
||||||
|
|||||||
@@ -930,7 +930,14 @@ def _reconcile_track(mem_track, disk_track):
|
|||||||
"""Fold the disk copy of a track into the in-memory one using the same rules
|
"""Fold the disk copy of a track into the in-memory one using the same rules
|
||||||
as the conflict resolver (max play/skip counts, newest date_modified wins),
|
as the conflict resolver (max play/skip counts, newest date_modified wins),
|
||||||
mutating mem_track in place so any view holding it stays valid."""
|
mutating mem_track in place so any view holding it stays valid."""
|
||||||
from lintunes.storage.conflict_resolver import _merge_track_fields
|
from lintunes.storage.conflict_resolver import (
|
||||||
|
MERGEABLE_FIELDS, _merge_track_fields)
|
||||||
|
# A sync typically changes a handful of tracks out of tens of thousands.
|
||||||
|
# Comparing the mergeable fields first skips two to_dict() round trips per
|
||||||
|
# untouched track, which is most of the cost of a reload.
|
||||||
|
if all(getattr(mem_track, f, None) == getattr(disk_track, f, None)
|
||||||
|
for f in MERGEABLE_FIELDS):
|
||||||
|
return
|
||||||
merged = mem_track.to_dict()
|
merged = mem_track.to_dict()
|
||||||
_merge_track_fields(merged, disk_track.to_dict()) # disk wins where newer
|
_merge_track_fields(merged, disk_track.to_dict()) # disk wins where newer
|
||||||
for key, value in merged.items():
|
for key, value in merged.items():
|
||||||
|
|||||||
+10
-3
@@ -102,11 +102,16 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
|
|||||||
print("Import complete!")
|
print("Import complete!")
|
||||||
|
|
||||||
|
|
||||||
|
# Long enough for the first paint to land before the smart recompute runs.
|
||||||
|
SMART_RECOMPUTE_DELAY_MS = 50
|
||||||
|
|
||||||
|
|
||||||
def run_gui(data_dir: Path, files: list[Path],
|
def run_gui(data_dir: Path, files: list[Path],
|
||||||
qt_args: list[str] | None = None) -> bool:
|
qt_args: list[str] | None = None) -> bool:
|
||||||
import logging
|
import logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QTimer
|
||||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
|
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
|
||||||
|
|
||||||
from lintunes import theme
|
from lintunes import theme
|
||||||
@@ -135,9 +140,6 @@ def run_gui(data_dir: Path, files: list[Path],
|
|||||||
theme.apply_theme(app, prefs)
|
theme.apply_theme(app, prefs)
|
||||||
_ensure_now_playing_font(prefs)
|
_ensure_now_playing_font(prefs)
|
||||||
manager = LibraryManager(library, data_dir)
|
manager = LibraryManager(library, data_dir)
|
||||||
# Rebuild smart-playlist membership from criteria now that the full library
|
|
||||||
# (and any merged sync conflicts) is loaded. Cheap when nothing changed.
|
|
||||||
manager.recompute_all_smart()
|
|
||||||
lastfm = LastFm(prefs, data_dir)
|
lastfm = LastFm(prefs, data_dir)
|
||||||
window = MainWindow(manager, prefs, lastfm)
|
window = MainWindow(manager, prefs, lastfm)
|
||||||
app.installEventFilter(window)
|
app.installEventFilter(window)
|
||||||
@@ -153,6 +155,11 @@ def run_gui(data_dir: Path, files: list[Path],
|
|||||||
watcher.changed.connect(manager.check_for_external_changes)
|
watcher.changed.connect(manager.check_for_external_changes)
|
||||||
|
|
||||||
window.show()
|
window.show()
|
||||||
|
# Rebuild smart-playlist membership from criteria now that the full library
|
||||||
|
# (and any merged sync conflicts) is loaded. Deferred until after the first
|
||||||
|
# paint: it's a full pass over every track per smart playlist, and running
|
||||||
|
# it inline left the compositor without a responsive window.
|
||||||
|
QTimer.singleShot(SMART_RECOMPUTE_DELAY_MS, manager.recompute_all_smart)
|
||||||
if startup_conflicts:
|
if startup_conflicts:
|
||||||
window.show_conflict_summary(startup_conflicts)
|
window.show_conflict_summary(startup_conflicts)
|
||||||
if files:
|
if files:
|
||||||
|
|||||||
+154
-91
@@ -284,80 +284,14 @@ def _parse_dt(value) -> Optional[datetime]:
|
|||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
def _match_string(op, track_val, qval) -> bool:
|
_STRING_OPS = {
|
||||||
t = ("" if track_val is None else str(track_val)).casefold()
|
"is": lambda value, query: value == query,
|
||||||
q = ("" if qval is None else str(qval)).casefold()
|
"is_not": lambda value, query: value != query,
|
||||||
if op == "is":
|
"contains": lambda value, query: query in value,
|
||||||
return t == q
|
"not_contains": lambda value, query: query not in value,
|
||||||
if op == "is_not":
|
"starts_with": lambda value, query: value.startswith(query),
|
||||||
return t != q
|
"ends_with": lambda value, query: value.endswith(query),
|
||||||
if op == "contains":
|
}
|
||||||
return q in t
|
|
||||||
if op == "not_contains":
|
|
||||||
return q not in t
|
|
||||||
if op == "starts_with":
|
|
||||||
return t.startswith(q)
|
|
||||||
if op == "ends_with":
|
|
||||||
return t.endswith(q)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _match_numeric(op, track_val, qval, qval2) -> bool:
|
|
||||||
try:
|
|
||||||
t = float(track_val or 0)
|
|
||||||
q = float(qval or 0)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
if op == "is":
|
|
||||||
return t == q
|
|
||||||
if op == "is_not":
|
|
||||||
return t != q
|
|
||||||
if op == "greater":
|
|
||||||
return t > q
|
|
||||||
if op == "less":
|
|
||||||
return t < q
|
|
||||||
if op == "in_range":
|
|
||||||
try:
|
|
||||||
q2 = float(qval2 or 0)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
lo, hi = (q, q2) if q <= q2 else (q2, q)
|
|
||||||
return lo <= t <= hi
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _match_date(op, track_val, rule: "SmartRule", now: datetime) -> bool:
|
|
||||||
dt = _parse_dt(track_val)
|
|
||||||
if op in ("after", "before", "in_range"):
|
|
||||||
# iTunes treats a null date (never played/skipped/etc.) as the distant
|
|
||||||
# past: "before X" matches it; "after"/"in range" don't.
|
|
||||||
d = dt if dt is not None else datetime.min
|
|
||||||
if op == "in_range":
|
|
||||||
lo, hi = _parse_dt(rule.value), _parse_dt(rule.value2)
|
|
||||||
if lo is None or hi is None:
|
|
||||||
return False
|
|
||||||
if lo > hi:
|
|
||||||
lo, hi = hi, lo
|
|
||||||
return lo <= d <= hi
|
|
||||||
q = _parse_dt(rule.value)
|
|
||||||
if q is None:
|
|
||||||
return False
|
|
||||||
return d > q if op == "after" else d < q
|
|
||||||
if op == "not_in_range":
|
|
||||||
if dt is None:
|
|
||||||
return True
|
|
||||||
lo, hi = _parse_dt(rule.value), _parse_dt(rule.value2)
|
|
||||||
if lo is None or hi is None:
|
|
||||||
return True
|
|
||||||
if lo > hi:
|
|
||||||
lo, hi = hi, lo
|
|
||||||
return not (lo <= dt <= hi)
|
|
||||||
if op in ("in_last", "not_in_last"):
|
|
||||||
seconds = _rule_window_seconds(rule)
|
|
||||||
if op == "in_last":
|
|
||||||
return dt is not None and (now - dt) <= timedelta(seconds=seconds)
|
|
||||||
return dt is None or (now - dt) > timedelta(seconds=seconds)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _rule_window_seconds(rule: "SmartRule") -> float:
|
def _rule_window_seconds(rule: "SmartRule") -> float:
|
||||||
@@ -368,31 +302,159 @@ def _rule_window_seconds(rule: "SmartRule") -> float:
|
|||||||
return count * _UNIT_SECONDS.get(rule.unit or "days", 86400)
|
return count * _UNIT_SECONDS.get(rule.unit or "days", 86400)
|
||||||
|
|
||||||
|
|
||||||
def _match_rule(rule: SmartRule, track, now: datetime) -> bool:
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Rule compilation
|
||||||
|
#
|
||||||
|
# Each rule becomes a closure once per evaluate() instead of being re-dispatched
|
||||||
|
# per track. That hoists everything that depends only on the rule — the operator
|
||||||
|
# lookup, casefolding the query, parsing the rule's own date constants — out of
|
||||||
|
# a loop that runs 21k+ times per smart playlist.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_ALWAYS_FALSE = lambda track: False # noqa: E731
|
||||||
|
_ALWAYS_TRUE = lambda track: True # noqa: E731
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_string(attr, op, value):
|
||||||
|
compare = _STRING_OPS.get(op)
|
||||||
|
if compare is None:
|
||||||
|
return _ALWAYS_FALSE
|
||||||
|
query = ("" if value is None else str(value)).casefold()
|
||||||
|
|
||||||
|
def matches(track):
|
||||||
|
track_val = getattr(track, attr, None)
|
||||||
|
text = ("" if track_val is None else str(track_val)).casefold()
|
||||||
|
return compare(text, query)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_numeric(attr, op, value, value2):
|
||||||
|
try:
|
||||||
|
query = float(value or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return _ALWAYS_FALSE
|
||||||
|
if op == "in_range":
|
||||||
|
try:
|
||||||
|
query2 = float(value2 or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return _ALWAYS_FALSE
|
||||||
|
low, high = (query, query2) if query <= query2 else (query2, query)
|
||||||
|
|
||||||
|
def in_range(track):
|
||||||
|
try:
|
||||||
|
return low <= float(getattr(track, attr, None) or 0) <= high
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return in_range
|
||||||
|
|
||||||
|
compare = {
|
||||||
|
"is": lambda t: t == query,
|
||||||
|
"is_not": lambda t: t != query,
|
||||||
|
"greater": lambda t: t > query,
|
||||||
|
"less": lambda t: t < query,
|
||||||
|
}.get(op)
|
||||||
|
if compare is None:
|
||||||
|
return _ALWAYS_FALSE
|
||||||
|
|
||||||
|
def matches(track):
|
||||||
|
try:
|
||||||
|
return compare(float(getattr(track, attr, None) or 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_date(attr, op, rule, now):
|
||||||
|
if op in ("after", "before", "in_range"):
|
||||||
|
# iTunes treats a null date (never played/skipped/etc.) as the distant
|
||||||
|
# past: "before X" matches it; "after"/"in range" don't.
|
||||||
|
if op == "in_range":
|
||||||
|
low, high = _parse_dt(rule.value), _parse_dt(rule.value2)
|
||||||
|
if low is None or high is None:
|
||||||
|
return _ALWAYS_FALSE
|
||||||
|
if low > high:
|
||||||
|
low, high = high, low
|
||||||
|
return lambda track: (
|
||||||
|
low <= (_parse_dt(getattr(track, attr, None)) or datetime.min)
|
||||||
|
<= high)
|
||||||
|
query = _parse_dt(rule.value)
|
||||||
|
if query is None:
|
||||||
|
return _ALWAYS_FALSE
|
||||||
|
if op == "after":
|
||||||
|
return lambda track: (
|
||||||
|
_parse_dt(getattr(track, attr, None)) or datetime.min) > query
|
||||||
|
return lambda track: (
|
||||||
|
_parse_dt(getattr(track, attr, None)) or datetime.min) < query
|
||||||
|
|
||||||
|
if op == "not_in_range":
|
||||||
|
low, high = _parse_dt(rule.value), _parse_dt(rule.value2)
|
||||||
|
if low is None or high is None:
|
||||||
|
return _ALWAYS_TRUE
|
||||||
|
if low > high:
|
||||||
|
low, high = high, low
|
||||||
|
|
||||||
|
def not_in_range(track):
|
||||||
|
parsed = _parse_dt(getattr(track, attr, None))
|
||||||
|
return parsed is None or not low <= parsed <= high
|
||||||
|
return not_in_range
|
||||||
|
|
||||||
|
if op in ("in_last", "not_in_last"):
|
||||||
|
window = timedelta(seconds=_rule_window_seconds(rule))
|
||||||
|
if op == "in_last":
|
||||||
|
def in_last(track):
|
||||||
|
parsed = _parse_dt(getattr(track, attr, None))
|
||||||
|
return parsed is not None and (now - parsed) <= window
|
||||||
|
return in_last
|
||||||
|
|
||||||
|
def not_in_last(track):
|
||||||
|
parsed = _parse_dt(getattr(track, attr, None))
|
||||||
|
return parsed is None or (now - parsed) > window
|
||||||
|
return not_in_last
|
||||||
|
|
||||||
|
return _ALWAYS_FALSE
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_rule(rule: SmartRule, now: datetime):
|
||||||
meta = FIELD_REGISTRY.get(rule.field)
|
meta = FIELD_REGISTRY.get(rule.field)
|
||||||
if meta is None:
|
if meta is None:
|
||||||
return False
|
return _ALWAYS_FALSE
|
||||||
track_val = getattr(track, meta.track_attr, None)
|
attr = meta.track_attr
|
||||||
if meta.type is FieldType.STRING:
|
if meta.type is FieldType.STRING:
|
||||||
return _match_string(rule.operator, track_val, rule.value)
|
return _compile_string(attr, rule.operator, rule.value)
|
||||||
if meta.type in (FieldType.INT, FieldType.DURATION, FieldType.RATING):
|
if meta.type in (FieldType.INT, FieldType.DURATION, FieldType.RATING):
|
||||||
return _match_numeric(rule.operator, track_val, rule.value, rule.value2)
|
return _compile_numeric(attr, rule.operator, rule.value, rule.value2)
|
||||||
if meta.type is FieldType.DATE:
|
if meta.type is FieldType.DATE:
|
||||||
return _match_date(rule.operator, track_val, rule, now)
|
return _compile_date(attr, rule.operator, rule, now)
|
||||||
if meta.type is FieldType.BOOL:
|
if meta.type is FieldType.BOOL:
|
||||||
return bool(track_val) == bool(rule.value)
|
wanted = bool(rule.value)
|
||||||
return False
|
return lambda track: bool(getattr(track, attr, None)) is wanted
|
||||||
|
return _ALWAYS_FALSE
|
||||||
|
|
||||||
|
|
||||||
def _match_group(group: SmartGroup, track, now: datetime) -> bool:
|
def _compile_group(group: SmartGroup, now: datetime):
|
||||||
if not group.children:
|
if not group.children:
|
||||||
return True
|
return _ALWAYS_TRUE
|
||||||
results = (
|
children = [
|
||||||
_match_group(c, track, now) if isinstance(c, SmartGroup)
|
_compile_group(child, now) if isinstance(child, SmartGroup)
|
||||||
else _match_rule(c, track, now)
|
else _compile_rule(child, now)
|
||||||
for c in group.children
|
for child in group.children
|
||||||
)
|
]
|
||||||
return all(results) if group.conjunction == "all" else any(results)
|
if len(children) == 1:
|
||||||
|
return children[0]
|
||||||
|
if group.conjunction == "all":
|
||||||
|
def matches_all(track):
|
||||||
|
for child in children:
|
||||||
|
if not child(track):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
return matches_all
|
||||||
|
|
||||||
|
def matches_any(track):
|
||||||
|
for child in children:
|
||||||
|
if child(track):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
return matches_any
|
||||||
|
|
||||||
|
|
||||||
def _selection_key(selection: str, pid_seed: int):
|
def _selection_key(selection: str, pid_seed: int):
|
||||||
@@ -447,7 +509,8 @@ def evaluate(criteria: SmartCriteria, tracks, now: Optional[datetime] = None,
|
|||||||
"""
|
"""
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
matched = [t for t in tracks if _match_group(criteria.root, t, now)]
|
predicate = _compile_group(criteria.root, now)
|
||||||
|
matched = [t for t in tracks if predicate(t)]
|
||||||
if criteria.limit.enabled and criteria.limit.count > 0:
|
if criteria.limit.enabled and criteria.limit.count > 0:
|
||||||
matched = _apply_limit(matched, criteria.limit, seed)
|
matched = _apply_limit(matched, criteria.limit, seed)
|
||||||
return sorted(t.track_id for t in matched)
|
return sorted(t.track_id for t in matched)
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ from lintunes.storage.json_storage import read_json, write_json
|
|||||||
# Syncthing conflict file pattern: filename.sync-conflict-20240101-123456-ABCDEFG.json
|
# Syncthing conflict file pattern: filename.sync-conflict-20240101-123456-ABCDEFG.json
|
||||||
CONFLICT_PATTERN = re.compile(r"^(.+)\.sync-conflict-(\d{8}-\d{6})-\w+(\.\w+)$")
|
CONFLICT_PATTERN = re.compile(r"^(.+)\.sync-conflict-(\d{8}-\d{6})-\w+(\.\w+)$")
|
||||||
|
|
||||||
|
# How many pre-merge snapshots to keep in <data_dir>/.resolved. Each library.json
|
||||||
|
# merge stores two 15 MB copies, and the folder rides the Syncthing share, so an
|
||||||
|
# unbounded history is replicated to every machine (it had reached 1.7 GB).
|
||||||
|
BACKUPS_TO_KEEP = 10
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConflictSummary:
|
class ConflictSummary:
|
||||||
@@ -30,6 +35,7 @@ def resolve_conflicts(data_dir: Path) -> list[ConflictSummary]:
|
|||||||
list when there was nothing to do)."""
|
list when there was nothing to do)."""
|
||||||
if not data_dir.exists():
|
if not data_dir.exists():
|
||||||
return []
|
return []
|
||||||
|
_prune_backups(data_dir)
|
||||||
|
|
||||||
conflict_files = _find_conflict_files(data_dir)
|
conflict_files = _find_conflict_files(data_dir)
|
||||||
if not conflict_files:
|
if not conflict_files:
|
||||||
@@ -94,6 +100,20 @@ def restore_backup(backup_dir: Path, data_dir: Path) -> list[str]:
|
|||||||
return restored
|
return restored
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_backups(data_dir: Path, keep: int = BACKUPS_TO_KEEP):
|
||||||
|
"""Drop all but the newest `keep` snapshots in <data_dir>/.resolved.
|
||||||
|
|
||||||
|
Snapshot directories are named with a sortable timestamp, so newest-last by
|
||||||
|
name is newest-last by time."""
|
||||||
|
root = data_dir / ".resolved"
|
||||||
|
if not root.is_dir():
|
||||||
|
return
|
||||||
|
snapshots = sorted((d for d in root.iterdir() if d.is_dir()),
|
||||||
|
key=lambda d: d.name)
|
||||||
|
for stale in snapshots[:max(0, len(snapshots) - keep)]:
|
||||||
|
shutil.rmtree(stale, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _find_conflict_files(data_dir: Path) -> list[tuple[Path, Path]]:
|
def _find_conflict_files(data_dir: Path) -> list[tuple[Path, Path]]:
|
||||||
results = []
|
results = []
|
||||||
for path in data_dir.rglob("*.sync-conflict-*"):
|
for path in data_dir.rglob("*.sync-conflict-*"):
|
||||||
@@ -140,6 +160,18 @@ def _merge_library(original_path: Path, conflict_path: Path) -> ConflictSummary:
|
|||||||
return ConflictSummary("library.json", "library", lines)
|
return ConflictSummary("library.json", "library", lines)
|
||||||
|
|
||||||
|
|
||||||
|
# Every field _merge_track_fields reads or writes. If two copies of a track
|
||||||
|
# agree on all of these, merging them is a no-op — which lets callers skip the
|
||||||
|
# work entirely (see library_manager._reconcile_track).
|
||||||
|
MERGEABLE_FIELDS = (
|
||||||
|
"play_count", "skip_count", "play_date_utc", "skip_date", "date_added",
|
||||||
|
"loved", "rating", "name", "artist", "album_artist", "album", "genre",
|
||||||
|
"composer", "grouping", "comments", "sort_name", "sort_artist",
|
||||||
|
"sort_album_artist", "sort_album", "sort_composer", "location",
|
||||||
|
"date_modified",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _merge_track_fields(orig: dict, conflict: dict) -> list[str]:
|
def _merge_track_fields(orig: dict, conflict: dict) -> list[str]:
|
||||||
"""Merge conflict's track fields into orig in place; return change notes."""
|
"""Merge conflict's track fields into orig in place; return change notes."""
|
||||||
notes = []
|
notes = []
|
||||||
|
|||||||
+20
-6
@@ -31,9 +31,23 @@ class Updater(QObject):
|
|||||||
self._root = str(Path(__file__).resolve().parents[1])
|
self._root = str(Path(__file__).resolve().parents[1])
|
||||||
self._busy = False
|
self._busy = False
|
||||||
self._timer = None
|
self._timer = None
|
||||||
self.enabled = (self._git("rev-parse", "--show-toplevel") is not None
|
self._enabled = None # probed lazily — see enabled
|
||||||
and self._git("rev-parse", "--abbrev-ref", "@{upstream}")
|
|
||||||
is not None)
|
@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:
|
def short_hash(self) -> str | None:
|
||||||
"""The checkout's current commit, for telling builds apart."""
|
"""The checkout's current commit, for telling builds apart."""
|
||||||
@@ -41,8 +55,6 @@ class Updater(QObject):
|
|||||||
|
|
||||||
def start_checking(self):
|
def start_checking(self):
|
||||||
"""First check shortly after launch, then every few hours."""
|
"""First check shortly after launch, then every few hours."""
|
||||||
if not self.enabled:
|
|
||||||
return
|
|
||||||
QTimer.singleShot(FIRST_CHECK_MS, self.check_async)
|
QTimer.singleShot(FIRST_CHECK_MS, self.check_async)
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.setInterval(RECHECK_INTERVAL_MS)
|
self._timer.setInterval(RECHECK_INTERVAL_MS)
|
||||||
@@ -50,7 +62,7 @@ class Updater(QObject):
|
|||||||
self._timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
def check_async(self):
|
def check_async(self):
|
||||||
if not self.enabled or self._busy:
|
if self._busy:
|
||||||
return
|
return
|
||||||
self._busy = True
|
self._busy = True
|
||||||
threading.Thread(target=self._check, daemon=True).start()
|
threading.Thread(target=self._check, daemon=True).start()
|
||||||
@@ -65,6 +77,8 @@ class Updater(QObject):
|
|||||||
|
|
||||||
def _check(self):
|
def _check(self):
|
||||||
try:
|
try:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
if self._git("fetch", "--quiet", timeout=60) is None:
|
if self._git("fetch", "--quiet", timeout=60) is None:
|
||||||
return # offline / host unreachable — perfectly normal, stay quiet
|
return # offline / host unreachable — perfectly normal, stay quiet
|
||||||
behind = self._git("rev-list", "--count", "HEAD..@{upstream}")
|
behind = self._git("rev-list", "--count", "HEAD..@{upstream}")
|
||||||
|
|||||||
@@ -1,5 +1,51 @@
|
|||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
### Round 35 (2026-08-19) — Startup speed: the 21k-track library stops freezing (v0.7.0)
|
||||||
|
|
||||||
|
Reported as three separate complaints — "startup takes a minute or two and GNOME
|
||||||
|
offers to force quit", a playlist insert that jumped to the end, and the pile of
|
||||||
|
merge dialogs — which turned out to share one shape: every operation was
|
||||||
|
whole-library, whole-file, on the Qt main thread. Measured against the real
|
||||||
|
library (21,482 tracks, 506 playlists, 15 MB `library.json`).
|
||||||
|
|
||||||
|
- [x] **The track table sorts itself.** `TrackTableView` ran its model 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. The model 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 (the column browser filters by
|
||||||
|
handing `set_tracks` a shorter list). 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.**
|
||||||
|
- [x] **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 against the previous implementation on all 20 real smart
|
||||||
|
playlists: **identical membership, 0 mismatches**. **2.54 s → 0.72 s.**
|
||||||
|
- [x] Smart recompute deferred until after the first paint (`main.py`), so the
|
||||||
|
compositor always has a window that answers.
|
||||||
|
- [x] `_reconcile_track` skips the two `to_dict()` round trips when a track is
|
||||||
|
unchanged (`MERGEABLE_FIELDS` in the resolver is now the single source of
|
||||||
|
truth for what a merge can touch). A sync changes a handful of tracks out
|
||||||
|
of 21k; this was most of the cost of a reload.
|
||||||
|
- [x] No git subprocess on the startup path: `Updater.enabled` is probed lazily
|
||||||
|
on the background check thread (it was two `rev-parse` calls with 15 s
|
||||||
|
timeouts in `__init__`), and the version button loads its commit-hash
|
||||||
|
tooltip after the window is up.
|
||||||
|
- [x] `.resolved/` is pruned to the newest 10 snapshots. It had reached **1.7 GB
|
||||||
|
across 73 snapshots**, never pruned — and it lives inside the Syncthing
|
||||||
|
share, so every 15 MB pre-merge copy was being replicated to the other
|
||||||
|
machine.
|
||||||
|
|
||||||
|
Net: **time-to-interactive-window ~15 s → 3.2 s**, and the mid-session freeze
|
||||||
|
when Syncthing delivers a change (which is what GNOME was actually offering to
|
||||||
|
force-quit — a full reload + recompute + table rebuild with the window already
|
||||||
|
on screen) **~16 s → 3.8 s**.
|
||||||
|
|
||||||
|
Not in this round, deliberately: the merge rework itself (playlist ordering,
|
||||||
|
per-machine play journal, quieting the dialog) — see TASKS.md.
|
||||||
|
|
||||||
### Round 34 (2026-08-15) — The parking brake: stop playing audio at 3am (v0.6.1)
|
### Round 34 (2026-08-15) — The parking brake: stop playing audio at 3am (v0.6.1)
|
||||||
|
|
||||||
The long-running "LinTunes plays by itself" haunting, finally attributed and
|
The long-running "LinTunes plays by itself" haunting, finally attributed and
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ def _manager(tmp_path, n=4):
|
|||||||
|
|
||||||
def _select_source_row(table, source_row):
|
def _select_source_row(table, source_row):
|
||||||
"""Select the visual row backing a given source-model row (sort-proof)."""
|
"""Select the visual row backing a given source-model row (sort-proof)."""
|
||||||
proxy_index = table.proxy.mapFromSource(table.model_.index(source_row, 0))
|
table.selectRow(table.model_.display_row(source_row))
|
||||||
table.selectRow(proxy_index.row())
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class TestRatingClicks:
|
|||||||
emitted = []
|
emitted = []
|
||||||
view.rating_edited.connect(lambda tid, r: emitted.append((tid, r)))
|
view.rating_edited.connect(lambda tid, r: emitted.append((tid, r)))
|
||||||
col = view.model_.fields.index("rating")
|
col = view.model_.fields.index("rating")
|
||||||
rect = view.visualRect(view.proxy.index(0, col))
|
rect = view.visualRect(view.model_.index(0, col))
|
||||||
slot_w = rating_slot_width(QFontMetrics(view.font()))
|
slot_w = rating_slot_width(QFontMetrics(view.font()))
|
||||||
# Click the 4th slot → 80.
|
# Click the 4th slot → 80.
|
||||||
x = rect.left() + RATING_LEFT_PAD + int(slot_w * 3.5)
|
x = rect.left() + RATING_LEFT_PAD + int(slot_w * 3.5)
|
||||||
@@ -356,10 +356,10 @@ class TestRatingClicks:
|
|||||||
track = Track(track_id=1, name="A", rating=40)
|
track = Track(track_id=1, name="A", rating=40)
|
||||||
view = self._view(qapp, [track])
|
view = self._view(qapp, [track])
|
||||||
col = view.model_.fields.index("rating")
|
col = view.model_.fields.index("rating")
|
||||||
index = view.proxy.index(0, col)
|
index = view.model_.index(0, col)
|
||||||
view._set_rating_hover(index)
|
view._set_rating_hover(index)
|
||||||
assert view.is_rating_hovered(index)
|
assert view.is_rating_hovered(index)
|
||||||
assert not view.is_rating_hovered(view.proxy.index(0, 0))
|
assert not view.is_rating_hovered(view.model_.index(0, 0))
|
||||||
view._set_rating_hover(None)
|
view._set_rating_hover(None)
|
||||||
assert not view.is_rating_hovered(index)
|
assert not view.is_rating_hovered(index)
|
||||||
|
|
||||||
|
|||||||
@@ -134,9 +134,12 @@ def button(qapp):
|
|||||||
return VersionButton(fake), fake
|
return VersionButton(fake), fake
|
||||||
|
|
||||||
|
|
||||||
def test_button_shows_version_and_hash(button):
|
def test_button_shows_version_and_hash(qapp, button):
|
||||||
btn, fake = button
|
btn, fake = button
|
||||||
assert btn.text() == f"v{__version__}"
|
assert btn.text() == f"v{__version__}"
|
||||||
|
# The commit hash is filled in just after construction (short_hash() shells
|
||||||
|
# out to git, so it stays off the startup path).
|
||||||
|
qapp.processEvents()
|
||||||
assert "abc1234" in btn.toolTip()
|
assert "abc1234" in btn.toolTip()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Round 35: startup speed.
|
||||||
|
|
||||||
|
The track table used to sort through a QSortFilterProxyModel, which called
|
||||||
|
data() once per comparison — 8.5 s to show a 21k-track library. It now sorts
|
||||||
|
itself over a precomputed key list. Smart-playlist rules are compiled to
|
||||||
|
closures once per evaluate() instead of being re-dispatched per track, and
|
||||||
|
.resolved snapshots are pruned instead of growing without bound.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
from lintunes.models import Track
|
||||||
|
from lintunes.gui.track_table import TrackTableView, INDEX_FIELD
|
||||||
|
|
||||||
|
|
||||||
|
def _tracks(rows):
|
||||||
|
return [Track(track_id=tid, name=name, artist=artist, play_count=plays)
|
||||||
|
for tid, name, artist, plays in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def view(qapp):
|
||||||
|
table = TrackTableView()
|
||||||
|
table.model_.set_visible_columns(["name", "artist", "play_count"])
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
# ---- in-model sorting -------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSorting:
|
||||||
|
ROWS = [(1, "Ship", "Zoe", 3),
|
||||||
|
(2, "Black Blood", "adele", 10),
|
||||||
|
(3, "Thunder Peel", "Bob", 10),
|
||||||
|
(4, "Sev Beni", None, 1)]
|
||||||
|
|
||||||
|
def test_sorts_case_insensitively(self, view):
|
||||||
|
view.set_tracks(_tracks(self.ROWS))
|
||||||
|
view.sortByColumn(view.model_.fields.index("artist"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
# None sorts with the empty string, then adele < Bob < Zoe (casefolded:
|
||||||
|
# a plain str sort would have put "Bob" and "Zoe" before "adele").
|
||||||
|
assert view.view_order_track_ids() == [4, 2, 3, 1]
|
||||||
|
|
||||||
|
def test_descending_reverses(self, view):
|
||||||
|
view.set_tracks(_tracks(self.ROWS))
|
||||||
|
view.sortByColumn(view.model_.fields.index("artist"),
|
||||||
|
Qt.SortOrder.DescendingOrder)
|
||||||
|
assert view.view_order_track_ids() == [1, 3, 2, 4]
|
||||||
|
|
||||||
|
def test_sort_is_stable_within_ties(self, view):
|
||||||
|
view.set_tracks(_tracks(self.ROWS))
|
||||||
|
view.sortByColumn(view.model_.fields.index("play_count"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
# 2 and 3 both have 10 plays and keep their given order.
|
||||||
|
assert view.view_order_track_ids() == [4, 1, 2, 3]
|
||||||
|
|
||||||
|
def test_mixed_none_and_numbers_do_not_raise(self, view):
|
||||||
|
view.set_tracks([Track(track_id=1, name="A", year=None),
|
||||||
|
Track(track_id=2, name="B", year=1999)])
|
||||||
|
view.model_.set_visible_columns(["name", "year"])
|
||||||
|
view.sortByColumn(view.model_.fields.index("year"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
assert view.view_order_track_ids() == [1, 2]
|
||||||
|
|
||||||
|
def test_set_tracks_keeps_the_current_sort(self, view):
|
||||||
|
view.set_tracks(_tracks(self.ROWS))
|
||||||
|
view.sortByColumn(view.model_.fields.index("artist"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
view.set_tracks(_tracks(self.ROWS[:2]))
|
||||||
|
assert view.view_order_track_ids() == [2, 1]
|
||||||
|
|
||||||
|
def test_selection_survives_a_re_sort(self, view):
|
||||||
|
view.set_tracks(_tracks(self.ROWS))
|
||||||
|
view.sortByColumn(view.model_.fields.index("artist"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
view.selectRow(0) # track 4
|
||||||
|
assert view.selected_track_ids() == [4]
|
||||||
|
view.sortByColumn(view.model_.fields.index("artist"),
|
||||||
|
Qt.SortOrder.DescendingOrder)
|
||||||
|
assert view.selected_track_ids() == [4]
|
||||||
|
|
||||||
|
|
||||||
|
class TestManualOrder:
|
||||||
|
"""A playlist's canonical order must survive sorting: '#' restores it, and
|
||||||
|
'source rows' stay playlist positions so drag-reorder targets the right
|
||||||
|
tracks."""
|
||||||
|
|
||||||
|
ROWS = [(1, "C", "c", 0), (2, "A", "a", 0), (3, "B", "b", 0)]
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def playlist_view(self, qapp):
|
||||||
|
table = TrackTableView(playlist_mode=True)
|
||||||
|
table.model_.set_visible_columns(["name"])
|
||||||
|
table.set_tracks(_tracks(self.ROWS))
|
||||||
|
return table
|
||||||
|
|
||||||
|
def test_index_column_restores_manual_order(self, playlist_view):
|
||||||
|
playlist_view.sortByColumn(playlist_view.model_.fields.index("name"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
assert playlist_view.view_order_track_ids() == [2, 3, 1]
|
||||||
|
playlist_view.sortByColumn(
|
||||||
|
playlist_view.model_.fields.index(INDEX_FIELD),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
assert playlist_view.view_order_track_ids() == [1, 2, 3]
|
||||||
|
assert playlist_view.is_manual_sort()
|
||||||
|
|
||||||
|
def test_selection_maps_to_canonical_rows_while_sorted(self, playlist_view):
|
||||||
|
playlist_view.sortByColumn(playlist_view.model_.fields.index("name"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
playlist_view.selectRow(0) # displays "A" = track 2, playlist row 1
|
||||||
|
assert playlist_view.selected_source_rows() == [1]
|
||||||
|
assert playlist_view.selected_track_ids() == [2]
|
||||||
|
|
||||||
|
def test_index_column_numbers_the_displayed_rows(self, playlist_view):
|
||||||
|
playlist_view.sortByColumn(playlist_view.model_.fields.index("name"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
model = playlist_view.model_
|
||||||
|
col = model.fields.index(INDEX_FIELD)
|
||||||
|
assert [model.data(model.index(row, col)) for row in range(3)] == [1, 2, 3]
|
||||||
|
|
||||||
|
def test_reveal_track_finds_a_sorted_row(self, playlist_view):
|
||||||
|
playlist_view.sortByColumn(playlist_view.model_.fields.index("name"),
|
||||||
|
Qt.SortOrder.AscendingOrder)
|
||||||
|
assert playlist_view.reveal_track(1) # "C", last when sorted
|
||||||
|
assert playlist_view.selected_track_ids() == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- compiled smart rules ---------------------------------------------------
|
||||||
|
|
||||||
|
class TestSmartCompilation:
|
||||||
|
"""The rewrite is only safe if it matches the old evaluator exactly."""
|
||||||
|
|
||||||
|
def _criteria(self, rules, conjunction="all"):
|
||||||
|
from lintunes.smart import SmartCriteria, SmartGroup, SmartRule
|
||||||
|
return SmartCriteria(root=SmartGroup(
|
||||||
|
conjunction=conjunction,
|
||||||
|
children=[SmartRule(**r) for r in rules]))
|
||||||
|
|
||||||
|
def test_string_match_is_case_insensitive(self):
|
||||||
|
from lintunes import smart
|
||||||
|
tracks = [Track(track_id=1, name="A", artist="Björk"),
|
||||||
|
Track(track_id=2, name="B", artist="bjork")]
|
||||||
|
criteria = self._criteria(
|
||||||
|
[dict(field="artist", operator="contains", value="BJÖ")])
|
||||||
|
assert smart.evaluate(criteria, tracks) == [1]
|
||||||
|
|
||||||
|
def test_date_rule_constant_is_parsed_once_not_per_track(self):
|
||||||
|
from lintunes import smart
|
||||||
|
tracks = [Track(track_id=1, name="A", play_date_utc="2026-01-01T00:00:00"),
|
||||||
|
Track(track_id=2, name="B", play_date_utc="2020-01-01T00:00:00"),
|
||||||
|
Track(track_id=3, name="C", play_date_utc=None)]
|
||||||
|
criteria = self._criteria([dict(field="last_played", operator="after",
|
||||||
|
value="2025-01-01T00:00:00")])
|
||||||
|
assert smart.evaluate(criteria, tracks) == [1]
|
||||||
|
# A null date is the distant past: "before" matches it, "after" doesn't.
|
||||||
|
criteria = self._criteria([dict(field="last_played", operator="before",
|
||||||
|
value="2025-01-01T00:00:00")])
|
||||||
|
assert smart.evaluate(criteria, tracks) == [2, 3]
|
||||||
|
|
||||||
|
def test_unparseable_date_constant_matches_nothing(self):
|
||||||
|
from lintunes import smart
|
||||||
|
tracks = [Track(track_id=1, name="A", play_date_utc="2026-01-01T00:00:00")]
|
||||||
|
criteria = self._criteria([dict(field="last_played", operator="after",
|
||||||
|
value="not a date")])
|
||||||
|
assert smart.evaluate(criteria, tracks) == []
|
||||||
|
|
||||||
|
def test_in_last_window(self):
|
||||||
|
from lintunes import smart
|
||||||
|
now = datetime(2026, 8, 19, 12, 0, 0)
|
||||||
|
tracks = [Track(track_id=1, name="A", play_date_utc="2026-08-18T12:00:00"),
|
||||||
|
Track(track_id=2, name="B", play_date_utc="2026-01-01T00:00:00"),
|
||||||
|
Track(track_id=3, name="C", play_date_utc=None)]
|
||||||
|
criteria = self._criteria([dict(field="last_played", operator="in_last",
|
||||||
|
value="7", unit="days")])
|
||||||
|
assert smart.evaluate(criteria, tracks, now=now) == [1]
|
||||||
|
criteria = self._criteria([dict(field="last_played",
|
||||||
|
operator="not_in_last",
|
||||||
|
value="7", unit="days")])
|
||||||
|
assert smart.evaluate(criteria, tracks, now=now) == [2, 3]
|
||||||
|
|
||||||
|
def test_any_conjunction(self):
|
||||||
|
from lintunes import smart
|
||||||
|
tracks = [Track(track_id=1, name="A", play_count=50),
|
||||||
|
Track(track_id=2, name="B", artist="Zoe"),
|
||||||
|
Track(track_id=3, name="C")]
|
||||||
|
criteria = self._criteria(
|
||||||
|
[dict(field="play_count", operator="greater", value="10"),
|
||||||
|
dict(field="artist", operator="is", value="Zoe")],
|
||||||
|
conjunction="any")
|
||||||
|
assert smart.evaluate(criteria, tracks) == [1, 2]
|
||||||
|
|
||||||
|
def test_numeric_range_and_bad_values(self):
|
||||||
|
from lintunes import smart
|
||||||
|
tracks = [Track(track_id=1, name="A", play_count=5),
|
||||||
|
Track(track_id=2, name="B", play_count=50)]
|
||||||
|
criteria = self._criteria([dict(field="play_count",
|
||||||
|
operator="in_range",
|
||||||
|
value="40", value2="60")])
|
||||||
|
assert smart.evaluate(criteria, tracks) == [2]
|
||||||
|
criteria = self._criteria([dict(field="play_count", operator="greater",
|
||||||
|
value="not a number")])
|
||||||
|
assert smart.evaluate(criteria, tracks) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- .resolved pruning ------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBackupPruning:
|
||||||
|
def test_keeps_only_the_newest_snapshots(self, tmp_path):
|
||||||
|
from lintunes.storage import conflict_resolver
|
||||||
|
root = tmp_path / ".resolved"
|
||||||
|
for stamp in ("20260101-000000", "20260102-000000", "20260103-000000"):
|
||||||
|
(root / stamp / "original").mkdir(parents=True)
|
||||||
|
(root / stamp / "original" / "library.json").write_text("{}")
|
||||||
|
conflict_resolver._prune_backups(tmp_path, keep=2)
|
||||||
|
assert sorted(d.name for d in root.iterdir()) == ["20260102-000000",
|
||||||
|
"20260103-000000"]
|
||||||
|
|
||||||
|
def test_prune_runs_even_with_no_conflicts(self, tmp_path):
|
||||||
|
from lintunes.storage import conflict_resolver
|
||||||
|
root = tmp_path / ".resolved"
|
||||||
|
for i in range(conflict_resolver.BACKUPS_TO_KEEP + 3):
|
||||||
|
(root / f"2026010{i // 10}-00000{i % 10}").mkdir(parents=True)
|
||||||
|
assert conflict_resolver.resolve_conflicts(tmp_path) == []
|
||||||
|
assert len(list(root.iterdir())) == conflict_resolver.BACKUPS_TO_KEEP
|
||||||
|
|
||||||
|
def test_missing_backup_dir_is_fine(self, tmp_path):
|
||||||
|
from lintunes.storage import conflict_resolver
|
||||||
|
conflict_resolver._prune_backups(tmp_path) # must not raise
|
||||||
Reference in New Issue
Block a user