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
+44 -1
View File
@@ -265,7 +265,42 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
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.
only ever *reads* local library files. Since Round 47 this is the *older*
of two syncs — kept on the menu as "Sync Playlist to Rabbit (Auxio)" until
the andTunes app can play a song — and its `sanitize_name` / `track_display`
/ `track_filename` / `build_m3u` / `CHUNK` are imported by both
`export/exporter.py` and `andtunes/`, so their signatures are load-bearing.
- **`lintunes/andtunes/`** — the desktop half of andTunes, a music player for
the Rabbit R1 (Round 47; the app itself lives in `andtunes/` at the repo
root, board in `andtunes/TASKS.md`). The premise: **the app never scans
anything.** Auxio re-reads Android's MediaStore on every launch, which is
the "your songs will show up here" hang — but 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 parses one file instead of
indexing a filesystem. `layout.py` is the on-device shape (`Media/<Artist>/
<Album>/04 Song.mp3` — one copy per song however many playlists hold it —
plus `Art/`, `Playlists/`, `library.json`); `manifest.py` is pure and
Qt-free; `art.py` renders one 480 px JPEG **per album** (not per track) via
QImage — safe off the GUI thread, unlike QPixmap — cached under
`$XDG_CACHE_HOME/lintunes/andtunes-art` and invalidated by the audio file
being newer, which is exactly what embedding new art does. `sync.py` is a
third sibling of `plan_export`/`ExportWorker`: pure planner, then a daemon
thread with KiB progress. Three rules that bite: **planning must not render
art** (a mutagen open per album, and planning runs on the GUI thread — the
worker does it, and those bytes are absent from the progress total); the
index (m3us then `library.json`) is written **last** and after a cancel is
rewritten to list only tracks whose files actually landed, so the app never
opens a manifest with holes in it; and every delete goes through
`layout.assert_inside`, which refuses anything not strictly inside the
andTunes root. That guard is why the old `Music/<Playlist>/` folders are
structurally unreachable rather than merely un-referenced, and `Buttons/`
(the user's own menu artwork) and `plays/` (written by the app) are skipped
by the device scan entirely — sync reads those, it doesn't own them.
Which playlists go on the device lives in `preferences.json` under
`device_sync` (so it rides the Syncthing share and both machines agree);
keeping it off the `Playlist` keeps it out of the conflict-merge machinery,
and **unticking is the only way a playlist comes off the device**.
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
right-click menu). A sibling of `device_sync`, reusing its filename helpers
@@ -323,6 +358,14 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
## Conventions & gotchas
- **The Rabbit R1's screen is not what the internet says.** Measured:
**480 × 640 px**, physical density 320, **override density 160** — so an app
sees a 480 × 640 *dp* canvas on a 2.88" panel (~278 real ppi). One dp is
about half its usual physical size, so andTunes doubles every stock value
(rows ≥ 88 dp, text 28–32 sp). Don't "fix" it by changing the device
density; trav has it where he wants it. Android 13 / API 33, arm64-v8a.
The scroll wheel emits KEY_UP/KEY_DOWN (`KEYCODE_DPAD_UP`/`DOWN`).
- **Tests are organized as `tests/test_roundN.py`** — each development round adds
a new `test_roundN.py` alongside the topical files (`test_models.py`,
`test_itunes_importer.py`, etc.). New feature work follows the same pattern.