- Rabbit Sync Settings shows the sidebar's folder tree. A folder has its own
tristate checkbox; a fully ticked folder is stored as device_sync.folder_ids
and follows the folder, so playlists added to it later sync too.
- The album-art pass compares covers against the sizes the planner's device
scan already read (plan.art_sizes) instead of an MTP stat per album, caches
"no art" as a .none marker, and only unlinks covers that are really there.
The summary reports covers actually updated rather than every cover.
- A quiet tink when a Rabbit sync, export or URL import finishes, with an
on/off switch (sound_effects, on by default).
- Preferences is now General / Appearance / Integrations, a list on the left
and the page on the right, opened big enough for the tallest page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Connections ▸ Share Library with a Friend… makes a one-time invite code
(device id, display name, token) and the inviter's send-only folder.
- Connections ▸ Add Friend Library… takes the pasted code: adds the
inviter, shares our folder, and accepts theirs receive-only when it's
offered back.
- Syncthing hides an unknown device's folders (verified on 1.30), so the
inviter probes a new knock only while an invite is open: added with
nothing shared, completed on the right token, otherwise removed and
never probed again. Used and cancelled codes go nowhere.
- Sync Settings lists open invites (Cancel Invite) and gives each friend a
page: avatar, plain-language status with a next step, last seen, last
synced, Remove Friend. Removals are staged until OK.
- scripts/cassette_pair.py runs throwaway Syncthings on loopback;
pytest --syncthing drives a real three-machine handshake.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sync was fine. Everything it put on the Rabbit was there and correct --
2,432 media files, 208 covers, a manifest whose every field type-checks
against the parser -- and andTunes still answered "No library / Couldn't read
library.json". Fifteen tracks took all 2,439 songs down with them.
Library.group() keyed albumsByKey case-folded and artistsByName raw-case, and
only ever built the Artist inside `if (album == null)`. So the second track of
an album whose artist is spelled differently found the album already there,
skipped the block that would have made the artist, and dereferenced the null
that came back. Six artists in trav's library are spelled two ways: RJD2/Rjd2,
Toro Y Moi/Toro y Moi, FatBoy Slim/Fatboy Slim, LOVING/Loving, Land Of The
Loops/Land of the Loops, Salami Rose Joe Louis/salami rose joe louis. It had
worked until the 12th because that sync was the first to carry both spellings
of one of those albums.
Both maps fold case now, and the artist is fetched-or-made before the album
block and held, so no lookup left in group() can come back null. Artist gains
a key the way Album always had one, and ListActivity navigates by it --
ALBUM_ROW already passed album.key, so artists just stopped being the
exception.
The device filesystem is case-insensitive, so those two spellings are one
folder there. plan_andtunes_sync compared exact strings and saw every such
file as stale *and* missing, deleting and re-copying it over MTP on every
sync forever; the diff and the collision rule both fold now. plan.stale still
carries the device's own spelling, since that is what _delete_stale unlinks by.
Verified on the Rabbit: 2,439 songs listed, RJD2 one row of 12 songs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
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
File > Import from URL... runs trav's `song` helper (yt-dlp --extract-audio
--audio-format mp3) from inside the app. Everything at the link downloads to
a private temp dir and is imported as each song lands. "and add to current
playlist?" puts the batch above the selected song, else at the end of the
playing playlist, else at the end of the shown one, in the link's order.
Each song goes straight into Identify Track. With no AcoustID key, the
proposal comes from the filename alone.
Album art now opens on a single click, in a window shaped like the cover
and as big as the screen allows.
Also fixed the dev fixture's play journal, which crashed startup (wrong
shape) and would have been ignored anyway (wrong keys). The loader now
skips a journal it can't read instead of aborting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5m9mXFPHNro78BdD69mG2
Round 47 was verified by loading trav's real 21,531-track library. Nothing was
written and nothing was at risk, but it's the wrong habit and he said so. The
alternative is better for development anyway: reproducible, in-repo, and full
of the awkward cases on purpose rather than by luck.
scripts/make_dev_library.py builds a complete synthetic library — data dir and
music tree — from ffmpeg sine waves in six containers, with art on some albums
and not others, every playlist type (regular, folder, live/non-live/
unsupported/limited+nested smart, system, empty, duplicates), a play journal
and a tombstone. ~4 MB, gitignored; the generator is the artifact worth
keeping, not the sine waves.
The edge cases are the point. A collision pair identical in artist/album/title/
number, slashes and colons in every name, a track with no artist, a multi-disc
release, a compilation whose album_artist differs, a dangling location, a
208-character title, and non-ASCII plus an emoji all the way out to the m3u
filename. CLAUDE.md now carries the rule: never develop against the real
library, and when a feature needs a shape the fixture lacks, add it here.
Using it found two bugs in it — a relative --out made every location resolve
against the wrong root, and four-minute uncompressed clips made it 62 MB.
andTunes is paused on a weak connection (the Android SDK is a ~1-1.5 GB
one-time download; after that --offline builds need nothing). andtunes/
README.md now carries everything needed to resume cold: measured device facts,
the dp trap, exact toolchain commands, locked design decisions, and the
library.json contract. JDK 21 is installed and ticked off.
Also answers the scroll wheel question: it reads as volume because the ROM's
key layout maps the wheel's KEY_UP/KEY_DOWN to KEYCODE_VOLUME_UP/DOWN, but a
focused activity sees key events first — so andTunes can claim it by consuming
both those and DPAD_UP/DOWN, in onKeyDown and onKeyUp. No system file touched,
no other app affected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
control-events.log records that a [mpris] PlayPause arrived but not who sent
it — PyQt6 has no QDBusContext, so the adaptor can't see the caller. This tails
dbus-monitor, filters MPRIS method calls, and resolves each sender's unique bus
name to a pid and cmdline, which is the other half of the picture.
It has already earned its place: two weeks of capture show every
playback-affecting call into LinTunes coming from gsd-media-keys during waking
hours, and not one between midnight and 7am — so the phantom overnight audio
was not a stray Play, which is consistent with it being the paused-but-live
PipeWire stream fixed in v0.6.1. Read-only; touches no library or music file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
Snapshot of the existing codebase before working through the TASKS.md
backlog. Real library data (data/) and the iTunes import fixture
(itunes-test-library/) are gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>