v0.20.0: the Rabbit's plays come home

andTunes phase 3. The R1 is now one more machine in the Round 38 play-count
model, and nothing lands on it that Android can't play.

Play counts: andTunes 0.2.0 counts a play on a natural finish (LinTunes'
rule) and keeps Music/andTunes/plays/andtunes-<install id>.json in exactly
the per-machine-totals journal shape, written beside the old one and renamed
over it. Each sync first folds it into <data_dir>/plays/ with a new
play_journal.merge_totals, the per-track max: the file now has two homes and
both desktops may bring it back, so the max converges and an older copy can
never pull a count down. Nothing is written when nothing moved, and
PlayJournal.load needed no change.

Format gate: the planner reuses export/web_support.conversion_for outright,
since Android's MediaPlayer decodes the browser's set. FairPlay is refused
and reported; ALAC, AIFF and oddities land as FLAC, converted once into a
per-track cache and size-diffed after that. With no ffmpeg, the export's
"sync without them?" question. A file ffmpeg can't read costs that song,
not the sync.

"Sync Playlist to Rabbit (Auxio)" is retired from the menu; device_sync's
helpers stay because export and andTunes import them.

The dev fixture grew a real ALAC track, a Protected AAC .m4p and an "Odd
Formats" playlist. Verified on the R1 with it: AIFF and ALAC arrived as
FLAC and played, the FairPlay track was refused, four plays came back on the
next sync and each track's effective count rose by exactly one, and a third
sync copied nothing.

Found along the way: USB re-enumeration can wedge gvfsd-mtp, after which
anything touching the mount (find_device, the GUI tests) hangs in an
uninterruptible wait. CLAUDE.md now has the recovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv
This commit is contained in:
2026-09-11 15:20:44 -05:00
co-authored by Claude Opus 5
parent 9483d8951b
commit 81b7f6767c
17 changed files with 864 additions and 127 deletions
+23 -5
View File
@@ -289,11 +289,12 @@ 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. 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.
only ever *reads* local library files. Its menu item ("Sync Playlist to
Rabbit (Auxio)") was **retired in Round 51**, once andTunes could play what
it syncs; the module stays because `find_device` 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
@@ -343,6 +344,23 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
where the file is missing** — those names are a contract with the app's
`Ui.button()`, which prefers the device copy. `proguard.pro` keeps no
debug attributes because R8 8.2 NPEs on javac 21's.
Round 51 closed the loop. The planner runs every track through
`export/web_support.conversion_for` (Android decodes the browser set), so
FairPlay is refused and ALAC/AIFF land as `.flac` — converted once into
`$XDG_CACHE_HOME/lintunes/andtunes-flac/<track id>.flac` and size-diffed
from there on, so a converted song isn't re-copied every sync. And the app
keeps `plays/andtunes-<install id>.json` in the play-journal shape, counted
on a natural finish like `Player`; `plays.bring_back` folds it into
`<data_dir>/plays/` with `play_journal.merge_totals` (per-track max — that
file has two homes and both desktops may bring it back) **before** anything
is copied, writing nothing when nothing moved. `PlayJournal.load` then
treats the R1 as one more machine.
**A wedged gvfs-MTP mount blocks in uninterruptible FUSE waits**: `timeout`
can't kill a process stuck on it, and `find_device()` — called on the GUI
thread every time the Connections menu opens, and by the GUI tests —
freezes with it. USB re-enumeration (lock/unlock with `mtp,adb`, an
`adb install`) is what wedged it in Round 51. Recovery: `kill` the
`gvfsd-mtp` process, then `gio mount mtp://<device>/`.
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
right-click menu). A sibling of `device_sync`, reusing its filename helpers