v0.2.0: Device menu — sync a playlist to the Rabbit R1

New Device menu (left of Track) with "Sync Playlist to Rabbit", enabled
only when a playlist is showing and the Rabbit is plugged in. The Rabbit
mounts over MTP/gvfs (not mass storage), so device_sync.py drives it
with plain file I/O honoring the MTP caveats: no copystat, diff by
name+size. One-way mirror into Music/<Playlist>/ on the device — stale
files deleted (that folder only), Auxio-importable .m3u carries the
order, free space checked up front with a needed-vs-available alert, and
a right-justified status-bar progress bar tracks the chunked copies.
Verified live against the real Rabbit end to end.
This commit is contained in:
2026-08-07 17:23:24 -04:00
parent 2a3ca6c672
commit 9accc93820
6 changed files with 626 additions and 3 deletions
+87 -2
View File
@@ -1,14 +1,15 @@
import shutil
from pathlib import Path
from PyQt6.QtWidgets import (
QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit,
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
QLabel, QMessageBox, QFileDialog,
QLabel, QMessageBox, QFileDialog, QProgressBar,
)
from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence
from lintunes import mpris, tagging, theme
from lintunes import device_sync, mpris, tagging, theme
from lintunes.art_search import AlbumArtFetcher
from lintunes.eventlog import log_control
from lintunes.inhibit import SleepInhibitor
@@ -101,6 +102,15 @@ class MainWindow(QMainWindow):
lambda msg: self.statusBar().showMessage(msg, 8000))
self._updater.update_applied.connect(self._restart_for_update)
self._updater.start_checking()
# Device-sync progress, right-justified: permanent widgets sit on the
# status bar's right and survive showMessage. No stretch (see above)
# and hidden while idle so it costs no space.
self._sync_progress = QProgressBar()
self._sync_progress.setFixedWidth(220)
self._sync_progress.setTextVisible(True)
self._sync_progress.hide()
self.statusBar().addPermanentWidget(self._sync_progress)
self._sync_worker = None
# Wiring
self._transport.play_clicked.connect(self.play_pause)
@@ -226,6 +236,15 @@ class MainWindow(QMainWindow):
self._add_action(view_menu, "Toggle Column Browser", "Ctrl+B",
self._library_view.toggle_browser)
device_menu = bar.addMenu("&Device")
self._sync_action = self._add_action(
device_menu, "Sync Playlist to Rabbit", "",
self._sync_playlist_to_device)
self._sync_action.setEnabled(False)
# Re-checked every time the menu opens: cheap (one gvfs listdir), and
# always reflects plug/unplug and the current view.
device_menu.aboutToShow.connect(self._refresh_device_actions)
track_menu = bar.addMenu("&Track")
self._add_action(track_menu, "Get Info", "Ctrl+I", self._info_for_current_view)
self._add_action(track_menu, "Go to Current Song", "Ctrl+L",
@@ -250,6 +269,72 @@ class MainWindow(QMainWindow):
self._redo_action.setText(
f"Redo {stack.redo_label()}" if can_redo else "Redo")
# ---- device sync ----
def _refresh_device_actions(self):
syncing = self._sync_worker is not None and self._sync_worker.busy()
on_playlist = (self._content.currentWidget() is self._playlist_view
and bool(self._playlist_view.playlist_id))
self._sync_action.setEnabled(
not syncing and on_playlist
and device_sync.find_device() is not None)
def _sync_playlist_to_device(self):
# Everything may have changed since the menu opened — re-verify.
if self._sync_worker is not None and self._sync_worker.busy():
return
device = device_sync.find_device()
playlist = self._manager.library.playlists.get(
self._playlist_view.playlist_id)
if device is None or playlist is None:
return
tracks = [self._manager.library.tracks[tid]
for tid in playlist.track_ids
if tid in self._manager.library.tracks]
try:
plan = device_sync.plan_sync(
playlist.name, tracks, device.music_dir, device)
free = shutil.disk_usage(device.root).free
except OSError as e:
QMessageBox.warning(self, "Sync failed",
f"Couldn't read the {device.name}: {e}")
return
needed = plan.bytes_to_copy + device_sync.SPACE_MARGIN
if free + plan.bytes_freed < needed:
QMessageBox.warning(
self, "Not enough space",
f"Syncing “{playlist.name}” needs "
f"{device_sync.format_bytes(needed - plan.bytes_freed)}, but the "
f"{device.name} only has {device_sync.format_bytes(free)} free.")
return
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.failed.connect(self._on_sync_failed)
self._sync_progress.setRange(0, max(plan.bytes_to_copy // 1024, 1))
self._sync_progress.setValue(0)
self._sync_progress.setFormat(f"Syncing “{playlist.name}”…")
self._sync_progress.show()
self._sync_worker.start()
def _on_sync_progress(self, done_kib, total_kib, label):
self._sync_progress.setRange(0, total_kib)
self._sync_progress.setValue(done_kib)
self._sync_progress.setFormat(f"Syncing {label} · %p%")
def _on_sync_finished(self, summary):
self._sync_progress.hide()
msg = (f"Synced “{summary['playlist']}” to the {summary['device']}: "
f"{summary['copied']} copied, {summary['kept']} up to date, "
f"{summary['removed']} removed")
if summary["skipped"]:
msg += f", {summary['skipped']} skipped (no local file)"
self.statusBar().showMessage(msg, 8000)
def _on_sync_failed(self, message):
self._sync_progress.hide()
QMessageBox.warning(self, "Sync failed", message)
# ---- view switching ----
def _show_library(self):