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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
337 lines
13 KiB
Python
337 lines
13 KiB
Python
"""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 _Cancelled(Exception):
|
|
"""Internal: unwinds _run when cancel() was requested."""
|
|
|
|
|
|
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.
|
|
|
|
`cancel()` (thread-safe, no-op once the run ended) stops the transfer at
|
|
the next chunk boundary: the in-flight partial file is removed and the
|
|
m3u is rewritten to list only tracks actually on the device, so a
|
|
cancelled sync always leaves a coherent (if partial) playlist that the
|
|
next sync completes.
|
|
"""
|
|
|
|
progress = pyqtSignal(int, int, str) # done_kib, total_kib, "12/240 Artist - Title"
|
|
finished = pyqtSignal(dict)
|
|
cancelled = pyqtSignal(dict)
|
|
failed = pyqtSignal(str)
|
|
|
|
def __init__(self, plan: SyncPlan, parent=None):
|
|
super().__init__(parent)
|
|
self._plan = plan
|
|
self._busy = False
|
|
self._cancel = threading.Event()
|
|
|
|
def busy(self) -> bool:
|
|
return self._busy
|
|
|
|
def cancel(self):
|
|
self._cancel.set()
|
|
|
|
def _check_cancel(self):
|
|
if self._cancel.is_set():
|
|
raise _Cancelled
|
|
|
|
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
|
|
device_name = plan.device.name if plan.device else "device"
|
|
dest = None
|
|
removed = 0
|
|
completed: set[str] = set()
|
|
vanished: set[str] = set()
|
|
try:
|
|
self._check_cancel()
|
|
plan.dest_dir.mkdir(parents=True, exist_ok=True)
|
|
for name in plan.stale:
|
|
self._check_cancel()
|
|
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):
|
|
self._check_cancel()
|
|
label = f"{i}/{len(plan.copies)} {Path(item.dest_name).stem}"
|
|
# A source gone since planning means Syncthing moved/deleted
|
|
# it under us (e.g. a metadata edit on the other machine
|
|
# relocated the file) — skip it, don't abort the transfer.
|
|
# Once open, the fd pins the inode, so the read stays
|
|
# consistent even if the file is moved mid-copy.
|
|
try:
|
|
fsrc = open(item.src, "rb")
|
|
except FileNotFoundError:
|
|
vanished.add(item.dest_name)
|
|
done += item.size
|
|
self.progress.emit(done // 1024, total_kib, label)
|
|
continue
|
|
dest = plan.dest_dir / item.dest_name
|
|
# Manual chunked copy: byte-accurate progress, and no
|
|
# copystat (gvfs-MTP rejects it).
|
|
with 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)
|
|
self._check_cancel()
|
|
dest = None
|
|
completed.add(item.dest_name)
|
|
# Written last so an interrupted sync leaves the old m3u intact.
|
|
# Vanished tracks are left out so the m3u only lists files that
|
|
# are really there; the next sync picks up their new locations.
|
|
entries = [e for e in plan.entries if e[0] not in vanished]
|
|
(plan.dest_dir / plan.m3u_name).write_text(
|
|
build_m3u(entries), encoding="utf-8")
|
|
self.finished.emit({
|
|
"playlist": plan.playlist_name, "device": device_name,
|
|
"copied": len(plan.copies) - len(vanished), "kept": plan.kept,
|
|
"removed": removed, "skipped": plan.skipped,
|
|
"vanished": len(vanished),
|
|
})
|
|
except _Cancelled:
|
|
if dest is not None:
|
|
try:
|
|
dest.unlink() # the mid-flight partial file
|
|
except OSError:
|
|
pass
|
|
# Leave a coherent playlist: an m3u of only what is actually on
|
|
# the device now (kept files + copies that fully landed). The
|
|
# next sync completes the rest.
|
|
not_copied = ({item.dest_name for item in plan.copies}
|
|
- completed)
|
|
entries = [e for e in plan.entries
|
|
if e[0] not in vanished and e[0] not in not_copied]
|
|
try:
|
|
(plan.dest_dir / plan.m3u_name).write_text(
|
|
build_m3u(entries), encoding="utf-8")
|
|
except OSError:
|
|
pass # device going away; the size diff self-heals anyway
|
|
self.cancelled.emit({
|
|
"playlist": plan.playlist_name, "device": device_name,
|
|
"copied": len(completed), "total": len(plan.copies),
|
|
"removed": removed,
|
|
})
|
|
except OSError as e:
|
|
if dest is not None:
|
|
try:
|
|
dest.unlink() # drop the half-copied file if the mount survives
|
|
except OSError:
|
|
pass
|
|
self.failed.emit(
|
|
f"Sync failed: {e}. Is the {device_name} still connected?")
|