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:
@@ -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 ----
|
||||
|
||||
Reference in New Issue
Block a user