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).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
This commit is contained in:
2026-09-06 03:25:40 -04:00
co-authored by Claude Opus 5
parent 4acc57b648
commit cc1ca5ef78
15 changed files with 1780 additions and 11 deletions
+73
View File
@@ -1,5 +1,78 @@
## Done
### Round 47 (2026-09-06) — LinTunes writes the device's library (v0.17.0)
The first half of andTunes, a music player for the Rabbit R1 that never scans
anything. Auxio re-reads Android's MediaStore on every launch, which is what
leaves the Rabbit sitting on "your songs will show up here" — so the answer
isn't a faster scanner, it's not scanning: LinTunes already knows the artist,
album and year of every file it just copied, so it writes them down and the
app reads one JSON file. This round builds the desktop side; the app itself is
rounds 48–50 (`andtunes/TASKS.md`).
- [x] **A de-duplicated tree instead of a folder per playlist.**
`lintunes/andtunes/layout.py` lays out `Music/andTunes/` — `Media/
<Artist>/<Album>/04 Song.mp3` (the iTunes shape `filename_tags` already
reads back, with a `2-04` prefix once a release has more than one disc),
`Art/<key>.jpg`, `Playlists/<Name>.m3u`, `library.json`. A song in three
playlists is stored once, which the Artists/Albums/Songs screens need
anyway and which saves the space three copies cost.
- [x] **`library.json`, the reason the app opens fast.**
`andtunes/manifest.py` is pure — no Qt, no device, no I/O. Rows are
sparse the way `Track.to_dict` is sparse, because that file is what the
R1 parses before it can draw anything. Artists and albums are *not*
shipped: grouping a flat list on device beats parsing three redundant
ones. A playlist's ids are filtered to tracks that actually landed, so
the app never resolves a dangling reference.
- [x] **One cover per album, not per song.** `andtunes/art.py` reads the
embedded artwork with mutagen, scales to 480 px (the panel's width) and
re-encodes as JPEG through `QImage`/`QBuffer` — no new dependency, and
QImage is safe off the GUI thread. Keyed on album artist + album, so a
twelve-track record costs one mutagen open and one file. Cached under
`$XDG_CACHE_HOME/lintunes/andtunes-art`, invalidated by the audio file
being newer — which is exactly right, since embedding new art rewrites
the file and moves its mtime.
- [x] **A sibling worker, not a flag on the old one.**
`andtunes/sync.py` follows the `plan_export`/`ExportWorker` shape
exactly: pure planner, then a daemon thread emitting KiB progress, with
the same MTP caveats (no copystat, diff by name+size). It is separate
because it nests directories, has an album-art pass, and writes several
trailing files instead of one m3u. Planning deliberately does *not*
render art — that's a mutagen open per album and planning runs on the
GUI thread.
- [x] **The index is written last, and always describes what's really there.**
Cancel mid-sync and the manifest and m3us are rewritten listing only
tracks whose files landed, so andTunes opens a smaller working library
rather than a broken one. Re-syncing completes it.
- [x] **A containment guard, because this is the first code that deletes a
directory on the Rabbit.** Every delete goes through
`layout.assert_inside`, which refuses anything that isn't strictly
inside the andTunes root — so the old `Music/<Playlist>/` folders are
structurally unreachable, not merely un-referenced. `Buttons/` (the
user's own menu artwork) and `plays/` (written by the app) are skipped
by the scan entirely: sync reads those, it doesn't own them. Emptied
folders are pruned bottom-up, asking the filesystem rather than
`os.walk`'s stale listing.
- [x] **Unticking a playlist is how it comes off the device.** New
`Connections → Sync to Rabbit` (everything ticked, no longer caring
which view is open) and `Rabbit Sync Settings…`
(`gui/device_sync_dialog.py` — a checkbox list with a filter box,
because 487 playlists is a normal library here). The selection lives in
`preferences.json` under `device_sync`, so it rides the Syncthing share
and both machines agree on what the Rabbit is carrying; keeping it off
the `Playlist` keeps it out of the conflict-merge machinery.
- [x] **The old per-playlist sync stays, renamed "(Auxio)".** Removing it in
the same round that adds the new tree would leave the device full of
files and nothing able to open them until round 49. It goes when
andTunes can actually play a song.
Not done, deliberately: the Android app (rounds 48–50), shipping default
button images, the format gate for files Android can't decode, and reading
play counts back off the device. Verified against the real 21,531-track
library into a scratch device root — a re-sync copies nothing, and unticking
a playlist removes its media, its m3u, its now-unused cover and every emptied
folder.
### Round 46 (2026-09-02) — The file already told you (v0.16.0)
Round 45 shipped and trav broke it in two places within the hour, both real.