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