Files
lintunes/lintunes/gui/device_sync_dialog.py
T
trav c59aa26263 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
2026-09-06 03:25:40 -04:00

108 lines
4.3 KiB
Python

"""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()