v0.17.0: the desktop already knows what the songs are

The first half of andTunes, a music player for the Rabbit R1. Auxio re-reads
Android's MediaStore on every launch, which is the "your songs will show up
here" hang — so the answer isn't a faster scanner, it's not scanning at all.
LinTunes already knows the artist, album and year of every file it just
copied, so it writes them into Music/andTunes/library.json and the app reads
one file instead of indexing a filesystem.

New lintunes/andtunes/: layout.py (a de-duplicated Media/<Artist>/<Album>/
tree, so a song in three playlists is stored once), manifest.py (pure, Qt-free),
art.py (one 480 px cover per album via QImage, cached and invalidated by the
audio file's mtime — which is exactly what embedding new art moves), and
sync.py, a third sibling of plan_export/ExportWorker.

Three rules the code depends on: planning never renders art (a mutagen open
per album, and planning runs on the GUI thread); the index is written last and
after a cancel lists only tracks whose files actually landed; and every delete
goes through layout.assert_inside, which is why the old Music/<Playlist>/
folders are structurally unreachable rather than merely un-referenced.

Connections gains "Sync to Rabbit" and "Rabbit Sync Settings…" — the ticked
set is the device's contents, so unticking is how a playlist comes off, which
nothing could do before. The selection rides preferences.json so both machines
agree. The old per-playlist sync stays, renamed "(Auxio)": removing it now
would leave the device full of files and nothing able to open them until the
app exists.

The app itself is rounds 48-50; its board is andtunes/TASKS.md, including the
measured screen facts (480x640 px at density override 160 — one dp is half its
usual physical size on a 2.88" panel).

Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
This commit is contained in:
2026-09-06 03:25:40 -04:00
parent 33d74ab1f5
commit c59aa26263
15 changed files with 1780 additions and 11 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.16.0"
__version__ = "0.17.0"
+21
View File
@@ -0,0 +1,21 @@
"""The andTunes side of the house: what the Rabbit R1 app expects to find.
`device_sync.py` is the transport — how bytes cross the MTP mount. This
package is the *format*: where files live under ``Music/andTunes/`` on the
device, what the library index looks like, and how album art is rendered for a
2.88" screen. Keeping them apart means the app's on-device contract can change
without touching the copy loop, and vice versa.
"""
from .layout import (
ART_DIR, BUTTONS_DIR, FORMAT_VERSION, MANIFEST_NAME, MEDIA_DIR,
PLAYLISTS_DIR, ROOT_NAME, DeviceLayoutError, album_key, art_relpath,
assert_inside, media_relpath, playlist_relpath, root_for,
)
__all__ = [
"ART_DIR", "BUTTONS_DIR", "FORMAT_VERSION", "MANIFEST_NAME", "MEDIA_DIR",
"PLAYLISTS_DIR", "ROOT_NAME", "DeviceLayoutError", "album_key",
"art_relpath", "assert_inside", "media_relpath", "playlist_relpath",
"root_for",
]
+101
View File
@@ -0,0 +1,101 @@
"""Album covers, rendered once per album for a 2.88" screen.
Art lives in ID3/MP4/FLAC tags, not as files, so somebody has to decode it.
Doing that on the Rabbit means a MediaMetadataRetriever open per album on a
Helio P35; doing it here means the device reads a plain JPEG that is already
the right size. LinTunes has mutagen and Qt's image stack, so it does the work.
Keyed by album rather than track — one cover for a twelve-song release is one
mutagen open instead of twelve, and one file on the device instead of twelve
copies of the same picture.
Rendering uses QImage (not QPixmap), which is safe off the GUI thread — this
runs on the sync worker's daemon thread.
"""
import os
from pathlib import Path
from PyQt6.QtCore import QBuffer, QByteArray, QIODevice, Qt
from PyQt6.QtGui import QImage
from .. import tagging
from .layout import album_key
# The panel is 480 px wide, so a cover never needs to be bigger than that;
# the list thumbnails downsample from the same file on the device.
ART_SIZE = 480
JPEG_QUALITY = 85
def _cache_dir() -> Path:
base = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
return Path(base) / "lintunes" / "andtunes-art"
def render_jpeg(data: bytes) -> bytes | None:
"""Scale raw cover bytes down to ART_SIZE and re-encode as JPEG.
Never upscales: a small embedded cover stays small rather than being
blown up into a bigger file that looks no better.
"""
image = QImage()
if not image.loadFromData(QByteArray(data)):
return None
if image.width() > ART_SIZE or image.height() > ART_SIZE:
image = image.scaled(ART_SIZE, ART_SIZE,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
buffer = QBuffer()
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
if not image.save(buffer, "JPEG", JPEG_QUALITY):
return None
return bytes(buffer.data())
def album_art_jpeg(track) -> bytes | None:
"""The cover for this track's album, ready to write to the device.
Cached under $XDG_CACHE_HOME/lintunes/andtunes-art so a re-sync doesn't
re-decode every album. The cache entry is stale once the audio file is
newer than it — which is exactly right, because embedding new artwork
rewrites the file and moves its mtime. Nothing prunes this cache; it is
one small JPEG per album and `clear_cache()` empties it.
"""
if track is None or not track.location:
return None
src = Path(track.location)
try:
src_mtime = src.stat().st_mtime
except OSError:
return None
cache = _cache_dir() / f"{album_key(track)}.jpg"
try:
if cache.stat().st_mtime >= src_mtime:
return cache.read_bytes()
except OSError:
pass # not cached yet, or unreadable — fall through and re-render
raw = tagging.read_embedded_artwork(src)
if not raw:
return None
jpeg = render_jpeg(raw)
if not jpeg:
return None
try:
cache.parent.mkdir(parents=True, exist_ok=True)
tmp = cache.with_suffix(".tmp")
tmp.write_bytes(jpeg)
tmp.replace(cache)
except OSError:
pass # a cache we can't write is a slow sync, not a failed one
return jpeg
def clear_cache():
for path in _cache_dir().glob("*.jpg"):
try:
path.unlink()
except OSError:
pass
+116
View File
@@ -0,0 +1,116 @@
"""Where everything lives under ``Music/andTunes/`` on the device.
The old per-playlist sync stores a song once *per playlist*, which is fine for
handing Auxio a folder and wrong for an app with Artists/Albums/Songs screens:
"all songs it knows about" would have to de-duplicate at read time, and a song
in three playlists would cost three copies of the file. So andTunes gets an
iTunes-shaped media tree — each track stored exactly once — with playlists
reduced to m3u files pointing into it.
Music/andTunes/
library.json the index the app reads at startup
Media/<Artist>/<Album>/04 Song.mp3
Art/<album key>.jpg one cover per album, not per track
Playlists/<Name>.m3u
Buttons/*.png the user's own menu artwork
plays/ counts the device writes back
Two of those directories are the *device's*, not ours: `Buttons` holds artwork
the user dropped there by hand and `plays` is written by the app. Sync never
deletes inside either — see PRESERVED_DIRS.
"""
import hashlib
import os
from pathlib import Path
from ..device_sync import sanitize_name
# Bumped when the on-device layout or manifest changes shape. The app refuses
# a format it doesn't know rather than half-reading it.
FORMAT_VERSION = 1
ROOT_NAME = "andTunes" # under <storage>/Music/
MEDIA_DIR = "Media"
ART_DIR = "Art"
PLAYLISTS_DIR = "Playlists"
BUTTONS_DIR = "Buttons"
PLAYS_DIR = "plays"
MANIFEST_NAME = "library.json"
# Subtrees of the andTunes root that sync reads but never writes or prunes.
PRESERVED_DIRS = frozenset({BUTTONS_DIR, PLAYS_DIR})
UNKNOWN_ARTIST = "Unknown Artist"
UNKNOWN_ALBUM = "Unknown Album"
class DeviceLayoutError(Exception):
"""A path that would land outside the andTunes root."""
def root_for(device) -> Path:
"""The andTunes root on a connected device."""
return device.music_dir / ROOT_NAME
def assert_inside(root: Path, path: Path) -> Path:
"""Return `path`, or raise if it isn't strictly inside `root`.
Every delete on the device goes through here. andTunes is the first
feature that removes a *directory* from the Rabbit, and the blast radius
of getting that wrong is somebody's music — so the containment check is a
hard gate rather than a careful caller. It also means the old
``Music/<Playlist>/`` folders are structurally untouchable: they aren't
under the root, so no code path in this package can reach them.
"""
root_r = Path(os.path.abspath(root))
path_r = Path(os.path.abspath(path))
if path_r == root_r or not path_r.is_relative_to(root_r):
raise DeviceLayoutError(f"{path} is not inside {root}")
return path
def _artist_folder(track) -> str:
return sanitize_name(track.album_artist or track.artist or UNKNOWN_ARTIST)
def _album_folder(track) -> str:
return sanitize_name(track.album or UNKNOWN_ALBUM)
def media_relpath(track) -> str:
"""``Media/<Artist>/<Album>/04 Song.mp3``, relative to the andTunes root.
Numbering follows the iTunes tree `filename_tags` already knows how to
read back: a bare ``NN`` track number, or ``D-NN`` once a release has more
than one disc. A track with no number keeps just its title.
"""
stem = sanitize_name(track.name or Path(track.location).stem
or f"Track {track.track_id}")
if track.track_number:
if track.disc_number and (track.disc_count > 1 or track.disc_number > 1):
stem = f"{track.disc_number}-{track.track_number:02d} {stem}"
else:
stem = f"{track.track_number:02d} {stem}"
ext = Path(track.location).suffix
return f"{MEDIA_DIR}/{_artist_folder(track)}/{_album_folder(track)}/{stem}{ext}"
def album_key(track) -> str:
"""A stable 8-hex id for the album a track belongs to.
Keyed on album artist + album so every track of a release shares one cover
file — 300 albums instead of 3000 covers, and one mutagen open per album
at sync time instead of one per song.
"""
ident = f"{(track.album_artist or track.artist).lower()}\x00{track.album.lower()}"
return hashlib.sha1(ident.encode("utf-8")).hexdigest()[:8]
def art_relpath(key: str) -> str:
return f"{ART_DIR}/{key}.jpg"
def playlist_relpath(name: str) -> str:
return f"{PLAYLISTS_DIR}/{sanitize_name(name)}.m3u"
+73
View File
@@ -0,0 +1,73 @@
"""Build ``library.json`` — the whole point of andTunes.
Every other Android player rebuilds its library by scanning the device:
Auxio reads Android's MediaStore on each launch, which is what leaves the R1
sitting on "your songs will show up here". But LinTunes *already knows* the
answer — it has the artist, album, year and track number for every file it
just copied. So it writes them down, and the app opens by parsing one file
instead of indexing a filesystem.
Pure functions only: no Qt, no device, no I/O. The shape is deliberately flat
— one track list, playlists as id references. Artists and albums are grouped
on the device in a single pass at load, which is cheaper than shipping three
redundant lists and parsing them.
"""
from datetime import datetime, timezone
from .layout import FORMAT_VERSION
def _utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def track_entry(track, media_rel: str, art_rel: str | None = None) -> dict:
"""One track's row. Empty and zero fields are omitted.
Sparse rows the way `Track.to_dict` is sparse: a library of a few thousand
tracks is the file the app parses before it can draw anything, so every
key that says nothing is latency on the R1's cold start.
"""
entry = {"id": track.track_id, "title": track.name, "path": media_rel}
optional = {
"artist": track.artist,
"album_artist": track.album_artist,
"album": track.album,
"year": track.year,
"track": track.track_number,
"disc": track.disc_number,
"secs": round(track.total_time / 1000) if track.total_time else 0,
}
for key, value in optional.items():
if value:
entry[key] = value
if art_rel:
entry["art"] = art_rel
return entry
def build_manifest(entries, playlists, *, generator: str,
generated: str | None = None) -> dict:
"""The complete index.
`entries` is a list of ``(track, media_rel, art_rel_or_None)`` in no
particular order; `playlists` is ``(name, persistent_id, [track_id, …])``
with the ids in playing order. A playlist's ids are filtered to tracks
that actually made it onto the device, so the app never resolves a
dangling reference — the m3u is filtered the same way by the worker.
"""
rows = [track_entry(track, rel, art) for track, rel, art in entries]
known = {row["id"] for row in rows}
rows.sort(key=lambda r: r["id"])
return {
"format": FORMAT_VERSION,
"generated": generated or _utc_now(),
"generator": generator,
"tracks": rows,
"playlists": [
{"id": pid, "name": name,
"tracks": [tid for tid in track_ids if tid in known]}
for name, pid, track_ids in playlists
],
}
+430
View File
@@ -0,0 +1,430 @@
"""Sync the whole opted-in library into ``Music/andTunes/`` on the device.
A sibling of `device_sync.plan_sync` / `DeviceSyncWorker` and of
`export/exporter.py`: same shape (a pure planning function, then a worker on a
daemon thread reporting through Qt signals), same MTP caveats (never copystat,
never trust mtimes — diff by name + size), same "write the index last" rule
that keeps an interrupted run from leaving a manifest naming files that aren't
there.
What differs, and why it's a separate worker rather than a flag on the old one:
* One tree for the whole library instead of one folder per playlist, so paths
are nested and directories have to be created and pruned.
* An album-art pass, which reads local files the planner never touches.
* Several trailing files (every m3u, then `library.json`) instead of one m3u.
The old per-playlist sync is untouched and still on the menu — it is what
feeds Auxio, and it stays until andTunes can actually play a song.
"""
import json
import os
import threading
from dataclasses import dataclass, field
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
from .. import __version__
from ..device_sync import CHUNK, build_m3u, track_display
from . import art as art_mod
from . import layout
from .layout import (
MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath, assert_inside,
media_relpath, playlist_relpath,
)
from .manifest import build_manifest
@dataclass
class MediaItem:
track_id: int
src: Path
rel: str # "Media/Artist/Album/04 Song.mp3", posix, under the root
size: int
track: object = None
@dataclass
class PlaylistEntry:
name: str
persistent_id: str
rel: str # "Playlists/Name.m3u"
entries: list = field(default_factory=list) # (media_rel, secs, display)
track_ids: list = field(default_factory=list) # playing order, with repeats
@dataclass
class AndTunesPlan:
device: object
root: Path
items: list = field(default_factory=list) # every track that should be there
copies: list = field(default_factory=list) # the subset needing transfer
kept: int = 0
stale: list = field(default_factory=list) # rel paths to delete
skipped: int = 0
playlists: list = field(default_factory=list) # PlaylistEntry
albums: dict = field(default_factory=dict) # album key -> representative MediaItem
copy_art: bool = True
bytes_to_copy: int = 0
bytes_freed: int = 0
def _relative_to_playlists(media_rel: str) -> str:
"""A media path as seen from inside the Playlists/ folder."""
return "../" + media_rel
def plan_andtunes_sync(playlists, tracks_by_id, root: Path, device=None,
copy_art: bool = True) -> AndTunesPlan:
"""Diff the selected playlists against the andTunes tree already there.
Pure planning — nothing is created, copied, or deleted. Album art is
deliberately *not* rendered here: it means a mutagen open per album, and
planning runs on the GUI thread. The worker does it, which costs the
progress bar an accurate byte total for a few hundred KB of JPEG and buys
a window that doesn't freeze.
"""
plan = AndTunesPlan(device=device, root=Path(root), copy_art=copy_art)
# Resolve every unique track across every selected playlist to a real file.
items: dict[int, MediaItem] = {}
skipped: set[int] = set()
for playlist in playlists:
for tid in playlist.track_ids:
if tid in items or tid in skipped:
continue
track = tracks_by_id.get(tid)
src = Path(track.location) if track and track.location else None
if src is None or not src.is_file():
skipped.add(tid)
continue
items[tid] = MediaItem(tid, src, media_relpath(track),
src.stat().st_size, track)
plan.skipped = len(skipped)
# Same collision rule as the per-playlist sync: every member of a
# colliding group gets the [track_id] suffix, so a name never depends on
# which playlist happened to be walked first.
by_rel: dict[str, list[MediaItem]] = {}
for item in items.values():
by_rel.setdefault(item.rel, []).append(item)
for group in by_rel.values():
if len(group) > 1:
for item in group:
stem, ext = os.path.splitext(item.rel)
item.rel = f"{stem} [{item.track_id}]{ext}"
plan.items = sorted(items.values(), key=lambda i: i.track_id)
# One representative track per album for the art pass. Lowest track id
# wins so the choice doesn't depend on playlist order either.
for item in plan.items:
plan.albums.setdefault(album_key(item.track), item)
for playlist in playlists:
entry = PlaylistEntry(name=playlist.name,
persistent_id=playlist.persistent_id,
rel=playlist_relpath(playlist.name))
for tid in playlist.track_ids:
item = items.get(tid)
if item is None:
continue # no local file; left out of both m3u and manifest
entry.entries.append((
_relative_to_playlists(item.rel),
round(item.track.total_time / 1000),
track_display(item.track)))
entry.track_ids.append(tid)
plan.playlists.append(entry)
# Diff against the device by name + size (mtimes lie over MTP).
on_device = _scan_device(plan.root)
expected = {item.rel for item in plan.items}
expected |= {entry.rel for entry in plan.playlists}
if plan.copy_art:
expected |= {art_relpath(key) for key in plan.albums}
expected.add(MANIFEST_NAME)
for item in plan.items:
if on_device.get(item.rel) == item.size:
plan.kept += 1
else:
plan.copies.append(item)
plan.bytes_to_copy += item.size
for rel, size in sorted(on_device.items()):
if rel not in expected:
plan.stale.append(rel)
plan.bytes_freed += size
return plan
def _scan_device(root: Path) -> dict[str, int]:
"""Every file under the andTunes root, by root-relative posix path.
`Buttons/` (the user's own menu artwork) and `plays/` (written by the app)
are skipped entirely — sync reads those, it doesn't own them.
"""
found: dict[str, int] = {}
root = Path(root)
for dirpath, dirnames, filenames in os.walk(root):
rel_dir = Path(dirpath).relative_to(root)
if rel_dir == Path("."):
dirnames[:] = [d for d in dirnames if d not in PRESERVED_DIRS]
for name in filenames:
rel = (rel_dir / name).as_posix()
try:
found[rel] = (Path(dirpath) / name).stat().st_size
except OSError:
continue
return found
class _Cancelled(Exception):
"""Internal: unwinds _run when cancel() was requested."""
class AndTunesSyncWorker(QObject):
"""Runs an AndTunesPlan 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) and the label names the phase, because the art and
index phases move no audio bytes.
Cancelling stops at the next chunk boundary and still writes the index:
the manifest and m3us are rewritten to list only what is actually on the
device, so a cancelled sync leaves andTunes a smaller working library
rather than a broken one. Re-syncing picks up the rest.
"""
progress = pyqtSignal(int, int, str) # done_kib, total_kib, phase label
finished = pyqtSignal(dict)
cancelled = pyqtSignal(dict)
failed = pyqtSignal(str)
def __init__(self, plan: AndTunesPlan, 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
# ---- the run ----
def _run(self):
plan = self._plan
device_name = plan.device.name if plan.device else "device"
self._dest = None # in-flight partial, removed on abort
self._removed = 0
self._present: set[int] = set() # track ids really on the device
self._art: dict[str, str] = {} # album key -> art rel path
self._total_kib = max(plan.bytes_to_copy // 1024, 1)
self._done = 0
try:
self._check_cancel()
plan.root.mkdir(parents=True, exist_ok=True)
to_copy = {item.track_id for item in plan.copies}
self._present = {item.track_id for item in plan.items
if item.track_id not in to_copy}
self._delete_stale()
self._copy_media()
self._sync_art()
self._write_index()
self._prune_empty_dirs()
self.finished.emit({
"device": device_name,
"playlists": len(plan.playlists),
"copied": len(self._present) - plan.kept,
"kept": plan.kept,
"removed": self._removed,
"skipped": plan.skipped,
"albums": len(self._art),
"tracks": len(self._present),
})
except _Cancelled:
self._drop_partial()
# Leave a library that matches the device: the index lists only
# tracks whose files are really there. Art isn't re-rendered here
# — _write_index reads what is already on the device.
try:
self._write_index()
except OSError:
pass # device going away; the size diff self-heals next time
self.cancelled.emit({
"device": device_name,
"copied": len(self._present) - plan.kept,
"total": len(plan.copies),
"removed": self._removed,
})
except OSError as e:
self._drop_partial()
self.failed.emit(
f"Sync failed: {e}. Is the {device_name} still connected?")
def _drop_partial(self):
if self._dest is None:
return
try:
self._dest.unlink()
except OSError:
pass
self._dest = None
def _emit(self, label: str):
self.progress.emit(self._done // 1024, self._total_kib, label)
def _delete_stale(self):
plan = self._plan
for rel in plan.stale:
self._check_cancel()
try:
target = assert_inside(plan.root, plan.root / rel)
target.unlink()
self._removed += 1
except layout.DeviceLayoutError:
continue # never reachable by construction; refuse anyway
except OSError:
pass # one stubborn file shouldn't kill the sync
def _copy_media(self):
plan = self._plan
for i, item in enumerate(plan.copies, start=1):
self._check_cancel()
label = f"{i}/{len(plan.copies)} {Path(item.rel).stem}"
# A source gone since planning means Syncthing moved it under us
# (a metadata edit on the other machine relocates files) — skip
# it, don't abort. Once open, the fd pins the inode.
try:
fsrc = open(item.src, "rb")
except FileNotFoundError:
self._done += item.size
self._emit(label)
continue
dest = plan.root / item.rel
dest.parent.mkdir(parents=True, exist_ok=True)
self._dest = dest
# 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)
self._done += len(chunk)
self._emit(label)
self._check_cancel()
self._dest = None
self._present.add(item.track_id)
def _sync_art(self):
"""One cover per album, written only when it differs by size."""
plan = self._plan
if not plan.copy_art:
return
total = len(plan.albums)
for i, (key, item) in enumerate(sorted(plan.albums.items()), start=1):
self._check_cancel()
if item.track_id not in self._present:
continue
self._emit(f"Album art {i}/{total}")
rel = art_relpath(key)
dest = plan.root / rel
jpeg = art_mod.album_art_jpeg(item.track)
if not jpeg:
# The album lost its art (or never had any): don't leave a
# cover behind that the manifest no longer points at.
try:
assert_inside(plan.root, dest).unlink()
except (layout.DeviceLayoutError, OSError):
pass
continue
try:
if dest.stat().st_size != len(jpeg):
raise FileNotFoundError
except OSError:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(jpeg)
self._art[key] = rel
def _write_index(self):
"""The m3us and then library.json — always last, always consistent.
Everything here describes what is on the device *now*, so an
interrupted sync leaves the app a shorter library rather than one
with holes in it.
"""
plan = self._plan
for entry in plan.playlists:
rows = [row for row, tid in zip(entry.entries, entry.track_ids)
if tid in self._present]
dest = plan.root / entry.rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(build_m3u(rows), encoding="utf-8")
# Which covers are on the device right now, rather than which ones
# this run wrote — so a cancelled sync still points at art an earlier
# sync left behind.
have_art = {key for key in plan.albums
if (plan.root / art_relpath(key)).exists()}
entries = [(item.track, item.rel,
art_relpath(k) if (k := album_key(item.track)) in have_art
else None)
for item in plan.items if item.track_id in self._present]
playlists = [(e.name, e.persistent_id, e.track_ids)
for e in plan.playlists]
payload = build_manifest(entries, playlists,
generator=f"lintunes {__version__}")
(plan.root / MANIFEST_NAME).write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8")
def _prune_empty_dirs(self):
"""Drop folders an artist or album left behind when it went away.
Bottom-up so a whole emptied branch goes in one pass, guarded like
every other delete, and never inside Buttons/ or plays/ — those are
the device's, and an empty one still belongs to it.
"""
plan = self._plan
for dirpath, _dirnames, _filenames in os.walk(plan.root, topdown=False):
current = Path(dirpath)
if current == plan.root:
continue
rel = current.relative_to(plan.root)
if rel.parts[0] in PRESERVED_DIRS:
continue
# Ask the filesystem, not os.walk: walking bottom-up means a
# parent's listing was taken before its children were removed, so
# an emptied branch would only lose its deepest folder per sync.
try:
if any(current.iterdir()):
continue
except OSError:
continue
try:
assert_inside(plan.root, current).rmdir()
except (layout.DeviceLayoutError, OSError):
pass
+107
View File
@@ -0,0 +1,107 @@
"""Pick which playlists live on the Rabbit.
The old menu synced whichever playlist happened to be open, which made
"remove this from the device" an operation with no button: nothing ever
deleted a playlist folder. Here the ticked set *is* the device's contents —
unticking is how a playlist comes off, and the next sync does it.
The selection is stored in preferences.json, so it rides the same Syncthing
share as the library and both machines agree on what belongs on the device.
"""
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QCheckBox, QDialog, QDialogButtonBox, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QVBoxLayout,
)
from ..models.playlist import PlaylistType
def syncable_playlists(library) -> list:
"""Playlists a user can put on a device, in name order.
Folders hold no tracks and system playlists aren't the user's to sync;
a smart playlist is fine — by the time we see it, its membership has
already been computed.
"""
return sorted(
(p for p in library.playlists.values()
if p.playlist_type != PlaylistType.FOLDER and not p.is_system),
key=lambda p: p.name.casefold())
class DeviceSyncSettingsDialog(QDialog):
def __init__(self, prefs, library, parent=None, device_name="Rabbit"):
super().__init__(parent)
self.setWindowTitle(f"{device_name} Sync Settings")
self._prefs = prefs
self._playlists = syncable_playlists(library)
selected = set(prefs.device_sync.get("playlist_ids", []))
layout = QVBoxLayout(self)
layout.addWidget(QLabel(
f"Playlists to keep on the {device_name}. Unticking one removes "
"it from the device on the next sync."))
# 500 playlists is a normal library here, so the list is unusable
# without a filter. Hiding rows doesn't renumber them, so the
# row-to-playlist mapping in selected_ids() stays valid.
self._filter = QLineEdit(self)
self._filter.setPlaceholderText("Filter playlists…")
self._filter.setClearButtonEnabled(True)
self._filter.textChanged.connect(self._apply_filter)
layout.addWidget(self._filter)
self._list = QListWidget(self)
for playlist in self._playlists:
item = QListWidgetItem(
f"{playlist.name} ({len(playlist.track_ids)})", self._list)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked
if playlist.persistent_id in selected
else Qt.CheckState.Unchecked)
layout.addWidget(self._list)
self._count = QLabel(self)
layout.addWidget(self._count)
self._list.itemChanged.connect(lambda _item: self._refresh_count())
self._refresh_count()
self._art = QCheckBox("Copy album art to the device", self)
self._art.setChecked(bool(prefs.device_sync.get("copy_art", True)))
self._art.setToolTip(
"Covers are read from the tags and scaled to 480 px — one file "
"per album, not per song.")
layout.addWidget(self._art)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel, parent=self)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _apply_filter(self, text: str):
needle = text.strip().casefold()
for row, playlist in enumerate(self._playlists):
self._list.item(row).setHidden(
bool(needle) and needle not in playlist.name.casefold())
def _refresh_count(self):
n = len(self.selected_ids())
self._count.setText(
f"{n} playlist{'' if n == 1 else 's'} selected"
if n else "No playlists selected — nothing will be synced.")
def selected_ids(self) -> list:
return [playlist.persistent_id
for row, playlist in enumerate(self._playlists)
if self._list.item(row).checkState() == Qt.CheckState.Checked]
def accept(self):
self._prefs.update_device_sync(playlist_ids=self.selected_ids(),
copy_art=self._art.isChecked())
super().accept()
+119 -9
View File
@@ -10,6 +10,8 @@ from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence
from lintunes import device_sync, mpris, music_folder, tagging, theme
from lintunes.andtunes import layout as andtunes_layout
from lintunes.andtunes import sync as andtunes_sync
from lintunes.art_search import AlbumArtFetcher
from lintunes.cast.controller import CastController
from lintunes.eventlog import log_control
@@ -20,6 +22,7 @@ from lintunes.player import Player
from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog
from lintunes.gui.cast_dialog import ChromecastDialog
from lintunes.gui.device_sync_dialog import DeviceSyncSettingsDialog
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
from lintunes.gui.identify_dialog import IdentifyDialog
from lintunes.gui.sidebar import SidebarPanel
@@ -157,6 +160,7 @@ class MainWindow(QMainWindow):
self._sync_progress.hide()
self.statusBar().addPermanentWidget(self._sync_progress)
self._sync_worker = None
self._andtunes_worker = None
self._sync_device_name = ""
# Export shares the status-bar progress widgets with device sync —
# only one of the two may run at a time (each guards on the other).
@@ -333,8 +337,18 @@ class MainWindow(QMainWindow):
# Mnemonic on the "n": Alt+C already belongs to &Controls.
connections_menu = bar.addMenu("Co&nnections")
self._andtunes_action = self._add_action(
connections_menu, "Sync to Rabbit", "", self._sync_to_andtunes)
self._andtunes_action.setEnabled(False)
self._add_action(connections_menu, "Rabbit Sync Settings…", "",
self._show_device_sync_settings)
connections_menu.addSeparator()
# The per-playlist sync stays until andTunes can actually play a
# song: it is what feeds Auxio, and pulling it in the same round that
# adds the new tree would leave the device full of files and nothing
# able to open them.
self._sync_action = self._add_action(
connections_menu, "Sync Playlist to Rabbit", "",
connections_menu, "Sync Playlist to Rabbit (Auxio)", "",
self._sync_playlist_to_device)
self._sync_action.setEnabled(False)
connections_menu.addSeparator()
@@ -371,9 +385,12 @@ class MainWindow(QMainWindow):
busy = self._busy_worker() is not None
on_playlist = (self._content.currentWidget() is self._playlist_view
and bool(self._playlist_view.playlist_id))
device = device_sync.find_device()
self._sync_action.setEnabled(
not busy and on_playlist
and device_sync.find_device() is not None)
not busy and on_playlist and device is not None)
# Whole-library sync doesn't care which view is open — only that a
# device is plugged in and at least one playlist is ticked.
self._andtunes_action.setEnabled(not busy and device is not None)
if self._cast.is_connected():
self._cast_action.setText(
@@ -470,7 +487,8 @@ class MainWindow(QMainWindow):
def _busy_worker(self):
"""The transfer currently owning the status-bar progress widgets."""
for worker in (self._sync_worker, self._export_worker):
for worker in (self._sync_worker, self._andtunes_worker,
self._export_worker):
if worker is not None and worker.busy():
return worker
return None
@@ -479,7 +497,10 @@ class MainWindow(QMainWindow):
if self._export_worker is not None and self._export_worker.busy():
self._confirm_cancel_export()
return
if self._sync_worker is None or not self._sync_worker.busy():
worker = self._sync_worker
if self._andtunes_worker is not None and self._andtunes_worker.busy():
worker = self._andtunes_worker
if worker is None or not worker.busy():
return
box = QMessageBox(self)
box.setWindowTitle("Cancel transfer")
@@ -493,9 +514,8 @@ class MainWindow(QMainWindow):
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()
if box.clickedButton() is cancel_button and worker is not None:
worker.cancel()
def _on_sync_cancelled(self, summary):
self._sync_inhibitor.release()
@@ -530,6 +550,95 @@ class MainWindow(QMainWindow):
self._hide_sync_widgets()
QMessageBox.warning(self, "Sync failed", message)
# ---- connections: andTunes (whole-library sync) ----
def _show_device_sync_settings(self):
device = device_sync.find_device()
dialog = DeviceSyncSettingsDialog(
self._prefs, self._manager.library, self,
device_name=device.name if device else "Rabbit")
dialog.exec()
def _selected_sync_playlists(self) -> list:
"""The ticked playlists, in the order they were ticked in settings.
Ids that no longer resolve are skipped rather than cleaned up: a
playlist can be missing because the other machine hasn't synced yet,
and silently forgetting the choice would be worse than ignoring it.
"""
library = self._manager.library
return [library.playlists[pid]
for pid in self._prefs.device_sync.get("playlist_ids", [])
if pid in library.playlists]
def _sync_to_andtunes(self):
# Everything may have changed since the menu opened — re-verify.
if self._busy_worker() is not None:
return
device = device_sync.find_device()
if device is None:
return
playlists = self._selected_sync_playlists()
if not playlists:
QMessageBox.information(
self, "Nothing to sync",
"No playlists are set to sync yet.\n\n"
"Pick them in Connections → Rabbit Sync Settings…")
return
root = andtunes_layout.root_for(device)
try:
plan = andtunes_sync.plan_andtunes_sync(
playlists, self._manager.library.tracks, root, device,
copy_art=bool(self._prefs.device_sync.get("copy_art", True)))
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 {len(playlists)} playlist(s) needs "
f"{device_sync.format_bytes(needed - plan.bytes_freed)}, but "
f"the {device.name} only has "
f"{device_sync.format_bytes(free)} free.")
return
self._andtunes_worker = andtunes_sync.AndTunesSyncWorker(plan, self)
self._andtunes_worker.progress.connect(self._on_andtunes_progress)
self._andtunes_worker.finished.connect(self._on_andtunes_finished)
self._andtunes_worker.cancelled.connect(self._on_sync_cancelled)
self._andtunes_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._andtunes_worker.start()
def _on_andtunes_progress(self, done_kib, total_kib, label):
# Unlike the per-playlist sync this run has phases that move no audio
# bytes (album art, then the index), so the label carries the story.
self._sync_progress.setRange(0, total_kib)
self._sync_progress.setValue(done_kib)
self._sync_label.setText(f"{self._sync_device_name} · {label}")
def _on_andtunes_finished(self, summary):
self._sync_inhibitor.release()
self._hide_sync_widgets()
msg = (f"Synced {summary['playlists']} playlist(s) to the "
f"{summary['device']}: {summary['tracks']} tracks "
f"({summary['copied']} copied, {summary['kept']} up to date), "
f"{summary['albums']} covers, {summary['removed']} removed")
if summary["skipped"]:
msg += f", {summary['skipped']} skipped (no local file)"
self.statusBar().showMessage(msg, 10000)
# ---- export ----
def _refresh_export_action(self):
@@ -1309,7 +1418,8 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
# Quitting mid-transfer abandons a partial file on the device (it
# self-heals next sync, but silently) — make it a deliberate choice.
if self._sync_worker is not None and self._sync_worker.busy():
if self._busy_worker() is not None and self._busy_worker() is not (
self._export_worker):
box = QMessageBox(self)
box.setWindowTitle("Sync in progress")
box.setText(
+17
View File
@@ -33,6 +33,14 @@ DEFAULTS = {
"acoustid": {
"api_key": "",
},
# Which playlists belong on the andTunes device, by persistent_id. Kept
# here rather than on the Playlist so it stays out of the conflict-merge
# machinery — and because riding the syncthing share is the point: both
# machines should agree on what the Rabbit is carrying.
"device_sync": {
"playlist_ids": [],
"copy_art": True,
},
}
@@ -89,6 +97,15 @@ class Preferences(QObject):
def acoustid(self) -> dict:
return self._data["acoustid"]
def update_device_sync(self, **fields):
self._data["device_sync"].update(fields)
self.save()
self.changed.emit()
@property
def device_sync(self) -> dict:
return self._data["device_sync"]
def save(self):
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(".json.tmp")