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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:23:24 -04:00
co-authored by Claude Fable 5
parent d6639710fd
commit 62fadd0a2d
6 changed files with 626 additions and 3 deletions
+8
View File
@@ -97,6 +97,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
so the desktop's media keys / now-playing popup control playback. Spacebar and so the desktop's media keys / now-playing popup control playback. Spacebar and
arrow keys are handled locally via `MainWindow.eventFilter`. arrow keys are handled locally via `MainWindow.eventFilter`.
- **`lintunes/device_sync.py`** — one-way playlist sync to the Rabbit R1 (Device
menu). The Rabbit mounts via **MTP/gvfs** (a FUSE path under
`/run/user/<uid>/gvfs`), not mass storage — so plain file I/O, but never
copystat and never trust mtimes (diff by name+size). Sync owns exactly
`Music/<Playlist Name>/` on the device (creates/overwrites/deletes there,
plus an Auxio-importable `.m3u`); it never deletes outside that folder and
only ever *reads* local library files.
## Conventions & gotchas ## Conventions & gotchas
- **Tests are organized as `tests/test_roundN.py`** — each development round adds - **Tests are organized as `tests/test_roundN.py`** — each development round adds
+34
View File
@@ -3,6 +3,40 @@
Legend: `[ ]` todo · `[~]` in progress · `[x]` done. Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
When a round closes, move its finished items to `tasks-done.md`. When a round closes, move its finished items to `tasks-done.md`.
# tasks
- [ ] ability to delete songs from library
- [ ] archive the done tasks in here to another file, this is crufty....
# old
## Round 26 — Device menu: sync playlist to Rabbit R1 (v0.2.0)
- [x] Device menu (left of Track) with "Sync Playlist to Rabbit"; grayed out on
the Library view, while syncing, or when no Rabbit is plugged in.
- [x] `device_sync.py`: MTP/gvfs detection (the Rabbit mounts via MTP, not mass
storage), name+size incremental diff, stale-file deletion scoped to the
playlist's own folder, Auxio-importable `.m3u`, chunked-copy worker thread.
- [x] Up-front free-space check with a needed-vs-available alert.
- [x] Right-justified sync progress bar in the status bar (version left,
totals middle, sync right).
Tests in `tests/test_round26.py`. Feature round → minor bump **0.2.0**.
## Round 25 — folder highlight while dragging a playlist (v0.1.6) ## Round 25 — folder highlight while dragging a playlist (v0.1.6)
Tests in `tests/test_round25.py`. Fix round → patch bump **0.1.6**. Tests in `tests/test_round25.py`. Fix round → patch bump **0.1.6**.
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.1.6" __version__ = "0.2.0"
+272
View File
@@ -0,0 +1,272 @@
"""One-way playlist sync to a USB-connected media player (Rabbit R1).
The Rabbit mounts over MTP, which gvfs exposes as a FUSE directory under
/run/user/<uid>/gvfs — so plain file I/O works, with two MTP caveats honored
throughout: never copy metadata (copystat raises EPERM on gvfs-MTP) and never
trust mtimes (compare files by name + size instead).
Contract: sync is strictly lintunes → device. LinTunes owns exactly
``Music/<Playlist Name>/`` on the device — files there are created, replaced,
and deleted freely to mirror the playlist, but nothing outside that folder is
ever touched (sibling playlist folders, Android's ``.thumbnails``), and local
library files are only ever read. A playlist renamed in lintunes syncs to a
fresh folder; the old one is left for manual cleanup. Alongside the audio
files an ``.m3u`` (UTF-8, filenames relative to its own folder) carries the
playlist order — the format Auxio on the Rabbit imports.
"""
import os
import re
import threading
from dataclasses import dataclass, field
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
CHUNK = 1024 * 1024 # copy unit; also the progress granularity
SPACE_MARGIN = 16 * 1024 * 1024 # headroom the free-space check insists on
# Characters Android/FAT-style storage can't take in a filename.
_FORBIDDEN = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
@dataclass
class Device:
name: str # human name for UI strings, e.g. "Rabbit R1"
root: Path # storage root ("Internal shared storage") — disk_usage target
music_dir: Path # root / "Music"
def find_rabbit(gvfs_root: Path | None = None) -> Device | None:
"""The connected Rabbit R1's storage, or None when it isn't plugged in."""
if gvfs_root is None:
gvfs_root = Path(f"/run/user/{os.getuid()}/gvfs")
try:
mounts = list(gvfs_root.iterdir())
except OSError:
return None
for mount in mounts:
if not mount.name.startswith("mtp:host=") or "rabbit" not in mount.name.lower():
continue
try:
# The single MTP storage volume ("Internal shared storage").
storage = next((c for c in sorted(mount.iterdir()) if c.is_dir()), None)
except OSError:
continue
if storage is not None:
return Device("Rabbit R1", storage, storage / "Music")
return None
def find_device() -> Device | None:
"""The first supported connected device (only the Rabbit for now)."""
return find_rabbit()
def sanitize_name(name: str) -> str:
"""A playlist/track name reduced to a safe cross-filesystem filename."""
cleaned = " ".join(_FORBIDDEN.sub(" ", name).split())
cleaned = cleaned.strip(". ")[:150].strip(". ")
return cleaned or "Untitled"
def track_display(track) -> str:
"""The 'Artist - Title' line shown in the m3u and progress text."""
name = track.name or Path(track.location).stem or f"Track {track.track_id}"
return f"{track.artist} - {name}" if track.artist else name
def track_filename(track) -> str:
ext = Path(track.location).suffix
return sanitize_name(track_display(track)) + ext
def format_bytes(n: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
n /= 1024
return f"{n:.1f} GB"
@dataclass
class SyncItem:
track_id: int
src: Path
dest_name: str
size: int
@dataclass
class SyncPlan:
device: Device
playlist_name: str
dest_dir: Path
m3u_name: str
copies: list = field(default_factory=list) # SyncItems to (re)copy
kept: int = 0 # already on device, same size
stale: list = field(default_factory=list) # filenames in dest_dir to delete
skipped: int = 0 # tracks with no usable local file
entries: list = field(default_factory=list) # (dest_name, secs, display) m3u order
bytes_to_copy: int = 0
bytes_freed: int = 0
def plan_sync(playlist_name: str, tracks: list, music_dir: Path,
device: Device | None = None) -> SyncPlan:
"""Diff the playlist against what's already in its device folder.
Pure planning — nothing is created, copied, or deleted here. Existing
device files matching by name + size are kept; everything else in the
folder (old m3us included) is marked stale. Subdirectories are never
touched.
"""
folder = sanitize_name(playlist_name)
plan = SyncPlan(device=device, playlist_name=playlist_name,
dest_dir=music_dir / folder, m3u_name=folder + ".m3u")
# Resolve each unique track to a real local file.
items: dict[int, SyncItem] = {}
usable: list = []
seen_skipped: set[int] = set()
for track in tracks:
if track.track_id in items:
usable.append(track)
continue
if track.track_id in seen_skipped:
continue
src = Path(track.location) if track.location else None
if src is None or not src.is_file():
seen_skipped.add(track.track_id)
continue
items[track.track_id] = SyncItem(
track.track_id, src, track_filename(track), src.stat().st_size)
usable.append(track)
plan.skipped = len(seen_skipped)
# Disambiguate name collisions with a stable [track_id] suffix. Every
# member of a colliding group gets the suffix (not just the "second" one)
# so names never depend on playlist order or membership history.
by_name: dict[str, list[SyncItem]] = {}
for item in items.values():
by_name.setdefault(item.dest_name, []).append(item)
for group in by_name.values():
if len(group) > 1:
for item in group:
stem, ext = os.path.splitext(item.dest_name)
item.dest_name = f"{stem} [{item.track_id}]{ext}"
for track in usable:
item = items[track.track_id]
plan.entries.append(
(item.dest_name, round(track.total_time / 1000), track_display(track)))
# Diff against the device folder by name + size (mtimes lie over MTP).
on_device: dict[str, int] = {}
try:
for entry in os.scandir(plan.dest_dir):
if entry.is_file(follow_symlinks=False):
on_device[entry.name] = entry.stat().st_size
except FileNotFoundError:
pass
expected = {item.dest_name for item in items.values()} | {plan.m3u_name}
for item in items.values():
if on_device.get(item.dest_name) == item.size:
plan.kept += 1
else:
plan.copies.append(item)
plan.bytes_to_copy += item.size
for name, size in on_device.items():
if name not in expected:
plan.stale.append(name)
plan.bytes_freed += size
return plan
def build_m3u(entries: list) -> str:
lines = ["#EXTM3U"]
for dest_name, secs, display in entries:
lines.append(f"#EXTINF:{secs},{display}")
lines.append(dest_name)
return "\n".join(lines) + "\n"
class DeviceSyncWorker(QObject):
"""Runs a SyncPlan on a daemon thread, reporting through signals.
Progress is emitted in KiB (a pyqtSignal(int) is a C int — byte counts
overflow past 2 GiB). An unplug mid-copy surfaces as `failed`; the next
sync self-heals because the partial file loses the name+size diff.
"""
progress = pyqtSignal(int, int, str) # done_kib, total_kib, "12/240 Artist - Title"
finished = pyqtSignal(dict)
failed = pyqtSignal(str)
def __init__(self, plan: SyncPlan, parent=None):
super().__init__(parent)
self._plan = plan
self._busy = False
def busy(self) -> bool:
return self._busy
def start(self):
if self._busy:
return
self._busy = True
threading.Thread(target=self._run_guarded, daemon=True).start()
def _run_guarded(self):
try:
self._run()
finally:
self._busy = False
def _run(self):
plan = self._plan
dest = None
try:
plan.dest_dir.mkdir(parents=True, exist_ok=True)
removed = 0
for name in plan.stale:
try:
(plan.dest_dir / name).unlink()
removed += 1
except OSError:
pass # one stubborn file shouldn't kill the sync
total_kib = max(plan.bytes_to_copy // 1024, 1)
done = 0
for i, item in enumerate(plan.copies, start=1):
label = f"{i}/{len(plan.copies)} {Path(item.dest_name).stem}"
dest = plan.dest_dir / item.dest_name
# Manual chunked copy: byte-accurate progress, and no
# copystat (gvfs-MTP rejects it).
with open(item.src, "rb") as fsrc, open(dest, "wb") as fdst:
while True:
chunk = fsrc.read(CHUNK)
if not chunk:
break
fdst.write(chunk)
done += len(chunk)
self.progress.emit(done // 1024, total_kib, label)
dest = None
# Written last so an interrupted sync leaves the old m3u intact.
(plan.dest_dir / plan.m3u_name).write_text(
build_m3u(plan.entries), encoding="utf-8")
device_name = plan.device.name if plan.device else "device"
self.finished.emit({
"playlist": plan.playlist_name, "device": device_name,
"copied": len(plan.copies), "kept": plan.kept,
"removed": removed, "skipped": plan.skipped,
})
except OSError as e:
if dest is not None:
try:
dest.unlink() # drop the half-copied file if the mount survives
except OSError:
pass
device_name = plan.device.name if plan.device else "device"
self.failed.emit(
f"Sync failed: {e}. Is the {device_name} still connected?")
+87 -2
View File
@@ -1,14 +1,15 @@
import shutil
from pathlib import Path from pathlib import Path
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit, QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit,
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication, QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
QLabel, QMessageBox, QFileDialog, QLabel, QMessageBox, QFileDialog, QProgressBar,
) )
from PyQt6.QtCore import Qt, QEvent, QTimer from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence 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.art_search import AlbumArtFetcher
from lintunes.eventlog import log_control from lintunes.eventlog import log_control
from lintunes.inhibit import SleepInhibitor from lintunes.inhibit import SleepInhibitor
@@ -101,6 +102,15 @@ class MainWindow(QMainWindow):
lambda msg: self.statusBar().showMessage(msg, 8000)) lambda msg: self.statusBar().showMessage(msg, 8000))
self._updater.update_applied.connect(self._restart_for_update) self._updater.update_applied.connect(self._restart_for_update)
self._updater.start_checking() 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 # Wiring
self._transport.play_clicked.connect(self.play_pause) 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._add_action(view_menu, "Toggle Column Browser", "Ctrl+B",
self._library_view.toggle_browser) 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") track_menu = bar.addMenu("&Track")
self._add_action(track_menu, "Get Info", "Ctrl+I", self._info_for_current_view) 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", self._add_action(track_menu, "Go to Current Song", "Ctrl+L",
@@ -250,6 +269,72 @@ class MainWindow(QMainWindow):
self._redo_action.setText( self._redo_action.setText(
f"Redo {stack.redo_label()}" if can_redo else "Redo") 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 ---- # ---- view switching ----
def _show_library(self): def _show_library(self):
+224
View File
@@ -0,0 +1,224 @@
"""Round 26: Device menu — sync a playlist to the Rabbit R1.
The Rabbit connects over MTP/gvfs (a FUSE path), so all sync logic lives in
device_sync.py as plain file I/O testable against tmp_path stand-ins: a fake
gvfs tree for detection, a fake device Music dir for planning and copying.
The diff is name+size (MTP mtimes are unreliable), collision suffixes are
order-independent, and only files inside the playlist's own folder are ever
deleted.
"""
import os
import pytest
from lintunes import device_sync
from lintunes.device_sync import (
DeviceSyncWorker, build_m3u, find_rabbit, plan_sync, sanitize_name,
track_filename,
)
from lintunes.models import Track
def _track(tid, name, artist, path, total_time=180_000):
return Track(track_id=tid, name=name, artist=artist,
location=str(path), total_time=total_time)
def _audio(tmp_path, filename, data=b"x" * 100):
path = tmp_path / "local" / filename
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
class TestNames:
def test_sanitize_strips_forbidden_chars(self):
assert sanitize_name('Road / Trip: "mix"?') == "Road Trip mix"
def test_sanitize_trims_dots_and_spaces(self):
assert sanitize_name(" ..hits.. ") == "hits"
def test_sanitize_empty_falls_back(self):
assert sanitize_name("???") == "Untitled"
def test_track_filename_artist_and_title(self, tmp_path):
src = _audio(tmp_path, "whatever.mp3")
track = _track(1, "Song", "Band", src)
assert track_filename(track) == "Band - Song.mp3"
def test_track_filename_no_artist(self, tmp_path):
src = _audio(tmp_path, "whatever.m4a")
assert track_filename(_track(1, "Song", "", src)) == "Song.m4a"
def test_track_filename_falls_back_to_stem(self, tmp_path):
src = _audio(tmp_path, "raw_rip.mp3")
assert track_filename(_track(1, "", "", src)) == "raw_rip.mp3"
class TestPlan:
def test_fresh_sync_copies_everything(self, tmp_path):
a = _audio(tmp_path, "a.mp3", b"a" * 50)
b = _audio(tmp_path, "b.mp3", b"b" * 70)
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
plan = plan_sync("Mix", tracks, tmp_path / "Music")
assert plan.dest_dir == tmp_path / "Music" / "Mix"
assert plan.m3u_name == "Mix.m3u"
assert [i.dest_name for i in plan.copies] == ["X - A.mp3", "Y - B.mp3"]
assert plan.bytes_to_copy == 120
assert plan.kept == 0 and not plan.stale and plan.skipped == 0
def test_incremental_keeps_matching_and_deletes_stale(self, tmp_path):
a = _audio(tmp_path, "a.mp3", b"a" * 50)
b = _audio(tmp_path, "b.mp3", b"b" * 70)
dest = tmp_path / "Music" / "Mix"
dest.mkdir(parents=True)
(dest / "X - A.mp3").write_bytes(b"a" * 50) # same size -> kept
(dest / "Y - B.mp3").write_bytes(b"old") # size mismatch -> recopy
(dest / "Gone - Track.mp3").write_bytes(b"z" * 30) # stale
(dest / "Old Name.m3u").write_bytes(b"#EXTM3U\n") # stale old m3u
(dest / "subdir").mkdir() # never touched
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
plan = plan_sync("Mix", tracks, tmp_path / "Music")
assert plan.kept == 1
assert [i.dest_name for i in plan.copies] == ["Y - B.mp3"]
assert sorted(plan.stale) == ["Gone - Track.mp3", "Old Name.m3u"]
assert plan.bytes_freed == 30 + len(b"#EXTM3U\n")
def test_collision_suffixes_are_order_independent(self, tmp_path):
a = _audio(tmp_path, "a.mp3")
b = _audio(tmp_path, "b.mp3")
t1, t2 = _track(1, "Same", "Band", a), _track(2, "Same", "Band", b)
names = {i.track_id: i.dest_name
for i in plan_sync("Mix", [t1, t2], tmp_path / "Music").copies}
reversed_names = {i.track_id: i.dest_name
for i in plan_sync("Mix", [t2, t1],
tmp_path / "Music").copies}
assert names == reversed_names
assert names[1] == "Band - Same [1].mp3"
assert names[2] == "Band - Same [2].mp3"
def test_missing_locations_are_skipped(self, tmp_path):
real = _audio(tmp_path, "real.mp3")
tracks = [
_track(1, "Real", "X", real),
_track(2, "No file", "X", tmp_path / "nope.mp3"),
Track(track_id=3, name="No location", location=""),
]
plan = plan_sync("Mix", tracks, tmp_path / "Music")
assert plan.skipped == 2
assert len(plan.copies) == 1 and len(plan.entries) == 1
def test_duplicate_track_ids_copy_once_listed_twice(self, tmp_path):
a = _audio(tmp_path, "a.mp3")
track = _track(1, "A", "X", a)
plan = plan_sync("Mix", [track, track], tmp_path / "Music")
assert len(plan.copies) == 1
assert [e[0] for e in plan.entries] == ["X - A.mp3", "X - A.mp3"]
def test_folder_and_m3u_use_sanitized_name(self, tmp_path):
plan = plan_sync("Road / Trip?", [], tmp_path / "Music")
assert plan.dest_dir.name == "Road Trip"
assert plan.m3u_name == "Road Trip.m3u"
class TestM3u:
def test_build_m3u(self):
text = build_m3u([("X - A.mp3", 181, "X - A"), ("Y - B.mp3", 45, "Y - B")])
assert text == ("#EXTM3U\n"
"#EXTINF:181,X - A\nX - A.mp3\n"
"#EXTINF:45,Y - B\nY - B.mp3\n")
def test_entry_seconds_round_from_ms(self, tmp_path):
a = _audio(tmp_path, "a.mp3")
plan = plan_sync("Mix", [_track(1, "A", "X", a, total_time=181_499)],
tmp_path / "Music")
assert plan.entries[0][1] == 181
class TestWorker:
def _synced(self, qapp, plan):
worker = DeviceSyncWorker(plan)
results = {}
worker.finished.connect(lambda s: results.update(s))
worker.failed.connect(lambda m: results.update(error=m))
worker._run() # synchronously on the test thread; signals fire directly
return results
def test_end_to_end(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3", os.urandom(3 * 1024) )
b = _audio(tmp_path, "b.mp3", b"b" * 70)
dest = tmp_path / "Music" / "Mix"
dest.mkdir(parents=True)
(dest / "Stale.mp3").write_bytes(b"old")
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
plan = plan_sync("Mix", tracks, tmp_path / "Music")
summary = self._synced(qapp, plan)
assert summary["copied"] == 2 and summary["removed"] == 1
assert (dest / "X - A.mp3").read_bytes() == a.read_bytes()
assert (dest / "Y - B.mp3").read_bytes() == b.read_bytes()
assert not (dest / "Stale.mp3").exists()
assert (dest / "Mix.m3u").read_text(encoding="utf-8") == build_m3u(
plan.entries)
def test_progress_reports_kib(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3", b"a" * (2 * device_sync.CHUNK))
plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music")
worker = DeviceSyncWorker(plan)
seen = []
worker.progress.connect(lambda d, t, l: seen.append((d, t, l)))
worker._run()
assert seen[-1][0] == seen[-1][1] == 2 * device_sync.CHUNK // 1024
assert "1/1" in seen[-1][2]
def test_source_vanishing_reports_failure(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3")
plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music")
a.unlink() # unplugged/deleted between plan and run
result = self._synced(qapp, plan)
assert "Sync failed" in result["error"]
# No half-copied file left behind at full-looking size.
leftover = plan.dest_dir / "X - A.mp3"
assert not leftover.exists() or leftover.stat().st_size != plan.copies[0].size
def test_empty_playlist_clears_folder(self, qapp, tmp_path):
dest = tmp_path / "Music" / "Mix"
dest.mkdir(parents=True)
(dest / "Old - Song.mp3").write_bytes(b"x")
plan = plan_sync("Mix", [], tmp_path / "Music")
summary = self._synced(qapp, plan)
assert summary["removed"] == 1
assert sorted(p.name for p in dest.iterdir()) == ["Mix.m3u"]
assert (dest / "Mix.m3u").read_text() == "#EXTM3U\n"
class TestFindRabbit:
def _gvfs(self, tmp_path, host):
storage = tmp_path / "gvfs" / host / "Internal shared storage"
(storage / "Music").mkdir(parents=True)
return tmp_path / "gvfs"
def test_finds_rabbit_mount(self, tmp_path):
gvfs = self._gvfs(tmp_path, "mtp:host=unknown_Rabbit_R1_919109A4")
device = find_rabbit(gvfs)
assert device is not None
assert device.name == "Rabbit R1"
assert device.music_dir == (gvfs / "mtp:host=unknown_Rabbit_R1_919109A4"
/ "Internal shared storage" / "Music")
def test_match_is_case_insensitive(self, tmp_path):
gvfs = self._gvfs(tmp_path, "mtp:host=RABBIT_r1_abc")
assert find_rabbit(gvfs) is not None
def test_ignores_other_mtp_devices(self, tmp_path):
gvfs = self._gvfs(tmp_path, "mtp:host=Google_Pixel_8_xyz")
assert find_rabbit(gvfs) is None
def test_no_gvfs_dir_is_none(self, tmp_path):
assert find_rabbit(tmp_path / "does-not-exist") is None