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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.6.1"
__version__ = "0.7.0"
+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).
+8 -1
View File
@@ -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
as the conflict resolver (max play/skip counts, newest date_modified wins),
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()
_merge_track_fields(merged, disk_track.to_dict()) # disk wins where newer
for key, value in merged.items():
+10 -3
View File
@@ -102,11 +102,16 @@ def run_import(xml_path: Path, music_root: str | None, data_dir: Path):
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],
qt_args: list[str] | None = None) -> bool:
import logging
logging.basicConfig(level=logging.INFO)
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QMessageBox, QFontDialog
from lintunes import theme
@@ -135,9 +140,6 @@ def run_gui(data_dir: Path, files: list[Path],
theme.apply_theme(app, prefs)
_ensure_now_playing_font(prefs)
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)
window = MainWindow(manager, prefs, lastfm)
app.installEventFilter(window)
@@ -153,6 +155,11 @@ def run_gui(data_dir: Path, files: list[Path],
watcher.changed.connect(manager.check_for_external_changes)
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:
window.show_conflict_summary(startup_conflicts)
if files:
+154 -91
View File
@@ -284,80 +284,14 @@ def _parse_dt(value) -> Optional[datetime]:
return dt
def _match_string(op, track_val, qval) -> bool:
t = ("" if track_val is None else str(track_val)).casefold()
q = ("" if qval is None else str(qval)).casefold()
if op == "is":
return t == q
if op == "is_not":
return t != q
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
_STRING_OPS = {
"is": lambda value, query: value == query,
"is_not": lambda value, query: value != query,
"contains": lambda value, query: query in value,
"not_contains": lambda value, query: query not in value,
"starts_with": lambda value, query: value.startswith(query),
"ends_with": lambda value, query: value.endswith(query),
}
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)
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)
if meta is None:
return False
track_val = getattr(track, meta.track_attr, None)
return _ALWAYS_FALSE
attr = meta.track_attr
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):
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:
return _match_date(rule.operator, track_val, rule, now)
return _compile_date(attr, rule.operator, rule, now)
if meta.type is FieldType.BOOL:
return bool(track_val) == bool(rule.value)
return False
wanted = bool(rule.value)
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:
return True
results = (
_match_group(c, track, now) if isinstance(c, SmartGroup)
else _match_rule(c, track, now)
for c in group.children
)
return all(results) if group.conjunction == "all" else any(results)
return _ALWAYS_TRUE
children = [
_compile_group(child, now) if isinstance(child, SmartGroup)
else _compile_rule(child, now)
for child in group.children
]
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):
@@ -447,7 +509,8 @@ def evaluate(criteria: SmartCriteria, tracks, now: Optional[datetime] = None,
"""
if now is 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:
matched = _apply_limit(matched, criteria.limit, seed)
return sorted(t.track_id for t in matched)
+32
View File
@@ -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
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
class ConflictSummary:
@@ -30,6 +35,7 @@ def resolve_conflicts(data_dir: Path) -> list[ConflictSummary]:
list when there was nothing to do)."""
if not data_dir.exists():
return []
_prune_backups(data_dir)
conflict_files = _find_conflict_files(data_dir)
if not conflict_files:
@@ -94,6 +100,20 @@ def restore_backup(backup_dir: Path, data_dir: Path) -> list[str]:
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]]:
results = []
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)
# 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]:
"""Merge conflict's track fields into orig in place; return change notes."""
notes = []
+20 -6
View File
@@ -31,9 +31,23 @@ class Updater(QObject):
self._root = str(Path(__file__).resolve().parents[1])
self._busy = False
self._timer = None
self.enabled = (self._git("rev-parse", "--show-toplevel") is not None
and self._git("rev-parse", "--abbrev-ref", "@{upstream}")
is not 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."""
@@ -41,8 +55,6 @@ class Updater(QObject):
def start_checking(self):
"""First check shortly after launch, then every few hours."""
if not self.enabled:
return
QTimer.singleShot(FIRST_CHECK_MS, self.check_async)
self._timer = QTimer(self)
self._timer.setInterval(RECHECK_INTERVAL_MS)
@@ -50,7 +62,7 @@ class Updater(QObject):
self._timer.start()
def check_async(self):
if not self.enabled or self._busy:
if self._busy:
return
self._busy = True
threading.Thread(target=self._check, daemon=True).start()
@@ -65,6 +77,8 @@ class Updater(QObject):
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}")