v0.3.0: cancelable device sync

A "✕" button sits left of the sync progress group and expands to
"cancel transfer" on hover; clicking asks Cancel Transfer / Keep
Copying (the copy keeps running under the dialog). Cancel stops the
worker at the next chunk boundary, removes the in-flight partial file,
and rewrites the m3u to list only tracks actually on the device — so a
cancelled sync always leaves a coherent partial playlist. That last bit
also closes a pre-existing gap: stale files are deleted before copying,
so an interruption could previously leave the old m3u pointing at
deleted files.

Recoverability proven both ways: a test asserts cancel-then-resync ends
byte-identical to an uninterrupted sync, and a live run against the
real Rabbit (cancel mid-file-2 of 3) recovered with kept=1/copies=2.
This commit is contained in:
2026-08-07 18:08:41 -04:00
parent a0a1aea57e
commit 4a3c66e7ff
5 changed files with 285 additions and 10 deletions
+66 -5
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from PyQt6.QtWidgets import (
QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit,
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
QLabel, QMessageBox, QFileDialog, QProgressBar,
QLabel, QMessageBox, QFileDialog, QProgressBar, QPushButton,
)
from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence
@@ -37,6 +37,32 @@ def album_tracks(library, artist: str, album: str) -> list:
and (t.album_artist or t.artist).casefold() == artist_cf]
class _CancelSyncButton(QPushButton):
"""Compact "✕" beside the sync progress bar that expands to say what it
does ("cancel transfer") while hovered, then shrinks back."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFlat(True)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setToolTip("Cancel the transfer")
self.setStyleSheet("QPushButton { border: none; padding: 0 4px; }")
self._apply("✕")
def _apply(self, text: str):
self.setText(text)
self.setFixedWidth(self.fontMetrics().horizontalAdvance(text) + 14)
def enterEvent(self, event):
self._apply("cancel transfer")
super().enterEvent(event)
def leaveEvent(self, event):
self._apply("✕")
super().leaveEvent(event)
class MainWindow(QMainWindow):
def __init__(self, manager, prefs, lastfm=None, parent=None):
super().__init__(parent)
@@ -106,6 +132,10 @@ class MainWindow(QMainWindow):
# status bar's right and survive showMessage. No stretch (see above)
# and hidden while idle so it costs no space. Text lives in its own
# label — QProgressBar's overlay can't fit a sentence at this width.
self._sync_cancel = _CancelSyncButton()
self._sync_cancel.hide()
self._sync_cancel.clicked.connect(self._confirm_cancel_sync)
self.statusBar().addPermanentWidget(self._sync_cancel)
self._sync_label = QLabel()
self._sync_label.hide()
self.statusBar().addPermanentWidget(self._sync_label)
@@ -325,16 +355,49 @@ class MainWindow(QMainWindow):
self._sync_worker = device_sync.DeviceSyncWorker(plan, self)
self._sync_worker.progress.connect(self._on_sync_progress)
self._sync_worker.finished.connect(self._on_sync_finished)
self._sync_worker.cancelled.connect(self._on_sync_cancelled)
self._sync_worker.failed.connect(self._on_sync_failed)
self._sync_device_name = device.name
self._sync_label.setText(f"Copying to {device.name}")
self._sync_progress.setRange(0, max(plan.bytes_to_copy // 1024, 1))
self._sync_progress.setValue(0)
self._sync_cancel.show()
self._sync_label.show()
self._sync_progress.show()
self._sync_inhibitor.inhibit()
self._sync_worker.start()
def _hide_sync_widgets(self):
self._sync_cancel.hide()
self._sync_label.hide()
self._sync_progress.hide()
def _confirm_cancel_sync(self):
if self._sync_worker is None or not self._sync_worker.busy():
return
box = QMessageBox(self)
box.setWindowTitle("Cancel transfer")
box.setText(f"Cancel the transfer to the {self._sync_device_name}?\n"
"Tracks already copied stay on the device; syncing again "
"later picks up where this left off.")
cancel_button = box.addButton(
"Cancel Transfer", QMessageBox.ButtonRole.DestructiveRole)
keep = box.addButton("Keep Copying", QMessageBox.ButtonRole.RejectRole)
box.setDefaultButton(keep)
box.exec()
# The transfer keeps running while the dialog is up — it may well
# have finished by now, in which case there is nothing to cancel.
if (box.clickedButton() is cancel_button
and self._sync_worker is not None):
self._sync_worker.cancel()
def _on_sync_cancelled(self, summary):
self._sync_inhibitor.release()
self._hide_sync_widgets()
self.statusBar().showMessage(
f"Sync cancelled — {summary['copied']} of {summary['total']} "
"tracks copied; sync again to finish.", 8000)
def _on_sync_progress(self, done_kib, total_kib, label):
self._sync_progress.setRange(0, total_kib)
self._sync_progress.setValue(done_kib)
@@ -345,8 +408,7 @@ class MainWindow(QMainWindow):
def _on_sync_finished(self, summary):
self._sync_inhibitor.release()
self._sync_label.hide()
self._sync_progress.hide()
self._hide_sync_widgets()
msg = (f"Synced “{summary['playlist']}” to the {summary['device']}: "
f"{summary['copied']} copied, {summary['kept']} up to date, "
f"{summary['removed']} removed")
@@ -359,8 +421,7 @@ class MainWindow(QMainWindow):
def _on_sync_failed(self, message):
self._sync_inhibitor.release()
self._sync_label.hide()
self._sync_progress.hide()
self._hide_sync_widgets()
QMessageBox.warning(self, "Sync failed", message)
# ---- view switching ----