- Sending: a friend's requests ∩ what I still offer is copied into my
outbox (via a .part name Syncthing never sees); whatever they no longer
request is deleted, so the outbox cleans itself up.
- Receiving: a finished file I cassetted is imported exactly like Add to
Library — organized into Artist/Album, tags read, date added now, none
of their listening history — and a song wanted only for a followed
playlist lands in the cache. Then requests.json is rewritten without
what arrived and without what they stopped offering.
- Safe to repeat: Syncthing temp files ignored, Syncthing asked whether a
file is whole, and an exact match already in my library is never
imported twice.
- Folders are watched (re-armed after each rename), with a slow fallback.
- file_importer split into stage_file (off the GUI thread) and
track_from_file (date added = now).
- pytest --syncthing now runs a real invite → share → request → deliver →
cleanup between two Syncthing instances.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A ▾ cut from the right end of the Library button (shown once there's a
friend) opens a popup of friends. Choosing one enters friend mode: the
button becomes "<Name>'s Library" in light purple, the sidebar lists
their shared playlists with a UFO follow toggle on each, and the content
area is their library — browse only, nothing plays or edits.
- A cassette column on the far left and cassette buttons on every artist
and album: pressed grey = send me this, medium grey on hover to let go.
An artist cassettes all its albums and tracks (a snapshot).
- Bottom bar: Hide tracks I have (exact matches: artist, album, title,
length within 2 s); close matches show light grey. Size of what's
cassetted against free space: orange when Syncthing would stall at its
free-space floor, red when it won't fit.
- Save and Close writes requests.json; Cancel discards; the ▾ or quitting
with changes asks Save / Don't Save / Cancel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Sync Settings gets a share selector on each friend page (and on General
when "Share the same selection with all friends" is on; each friend's
own selection is kept, greyed, and comes back when it's turned off).
- Library: a genre/artist/album column browser with a checkbox beside
every artist, album and track, search, Select All / Select None and
"Share whole library". Playlists: a checklist with "Share all
playlists". Nothing is shared by default.
- Checking an artist or album is a snapshot of its tracks; the whole
library and shared playlists are live. A shared playlist's tracks are
checked and locked, with a tooltip naming the playlist(s).
- cassette/share.py writes the trimmed copy (library.json, playlists.json)
into each friend's folder: tags yes, listening history and local paths
no. Written on OK and after library edits (debounced), and only when the
bytes would change.
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>
First step of friend library sharing (Cassette):
- cassette/syncthing_api.py: a typed wrapper over the *local* Syncthing's
REST API. Key and address come from Syncthing's config.xml (config.json
may override); every failure is a SyncthingError naming the step, what
Syncthing said, and whether it's not running / a bad key / a refusal.
- cassette/state.py: friends, invites and share selections, kept in the
machine-local ~/.local/share/lintunes/cassette, never the synced data dir.
- cassette/host.py: one machine runs Cassette. config.json flags it,
preferences.json names it, and the other machine greys the menu group
out and says where friend sharing lives.
- Connections ▸ Sync Settings…: VLC-style list + page, editing a copy
that only OK writes. General shows Syncthing's status with a next step
for every failure, the display name and an avatar picker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Groundwork for Cassette (friend library sharing) that stands on its own:
- A brand-new library (fresh data dir, or a fresh iTunes import) starts
with a "New Tracks" smart playlist: added in the last 3 months, newest
first. Fixed persistent id, so two machines seeding one unsynced share
merge into a single playlist.
- Every non-flat button, the sidebar Library button and the transport
boxes get a very slight top-lit gradient computed from the live palette,
with the chosen color kept exactly at its midpoint.
- Tests pin that individual imports stamp date added = now even for an
old file, and that the iTunes migration keeps the XML's dates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The boxed controls were centered on the whole bar while the now-playing
panel hung from its top, so the bpm box sat ~11px below the panel beside
it. The panel is now CONTROL_HEIGHT tall and every control shares its top
edge; the seek row hangs under the panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking the gray sidebar art square (playing track has no embedded art)
now opens Download Album Art for that track, same as the right-click menu.
Once art is embedded the square refreshes and a click opens the big view
again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Multi-select Get Info keeps the art square; a pasted cover goes into
every selected track (shared cover shown, else "mixed artwork").
- Paste Artwork button + Ctrl+V anywhere outside a text field; a caption
says what happened. Clipboard reads that don't decode are retried and
never staged; every attempt is logged at INFO.
- Album art search queries Deezer alongside iTunes and ranks by album
match (iTunes has no copy of Digable Planets' Reachin' at all).
- embed_artwork shared by Get Info and Download Album Art; single-track
Get Info now invalidates the MPRIS art cache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Import from URL now gets past YouTube's "Sign in to confirm you're not a
bot": a run that downloads nothing for that reason retries once with
yt-dlp's --cookies-from-browser (Firefox first), and a browser that worked
is remembered in this machine's config.json so later imports send the
cookies from the start.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Right-click a song -> "lookup on youtube" opens a YouTube search for
"<title> <artist>" in the default browser. Single selection only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking the already-selected playlist in the sidebar opens inline rename,
and renaming an existing playlist starts with the cursor at the end rather
than the whole name selected. New playlists still select all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single Right-arrow press skipped 1,676 tracks in 81 s and hard-froze the
desktop. On Wayland key repeat is generated by the client until the
compositor delivers the release, and the key filter treated every repeat as
a new press. Each skip sent gnome-shell an MPRIS Metadata whose xesam:artist
was a plain Python list, which marshals as "av" instead of "as". gnome-shell
logged an error for every one (50k/min) and reloaded the cover, and that load
is what kept it from ever delivering the release.
Space/Left/Right now act once per press and swallow the repeats.
xesam:artist goes out through mpris.string_array, which build_properties_changed
now shares for invalidated_properties. The new test round-trips the signal
into GDBus, which is what gnome-shell validates with, and fails 'av' == 'as'
against the old code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getevent settled what the wheel can and can't tell us: och1970_holl_key
advertises KEY_UP and KEY_DOWN and nothing else -- no REL_WHEEL, no
resolution, because the och1970 is a Hall latch the driver quantises into
clicks. There is no sub-detent position to read without a kernel driver
change, so all the nuance has to come from *when* the detents arrive.
So a detent is now an impulse into a velocity rather than a jump. The list
coasts and eases out, and an impulse is worth up to 8x more when detents come
30 ms apart than when they come 220 ms apart -- squared, so the gain stays out
of the way while you're hunting for one row. Measured on the device: one
unhurried detent moves about half a row, twelve fast ones move forty-odd.
Turning back the other way kills the coast first, so a correction bites
instead of fighting the glide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
trav couldn't play the first track of his playlist. Shuffle off gave him song
two; shuffle on gave him a random one. The player was right both times:
library.json named a song whose file was not on the device, and skipping an
unplayable file advances one slot. Tapping any song that *is* there plays
exactly that song, shuffle or not.
The file was missing because a gvfs-MTP mount can hold a phantom directory --
one it lists happily while the device has no such folder. Every write into it
fails EIO, and mkdir(exist_ok=True) sees the phantom and does nothing, so it
never heals; only remounting clears it. That folder was new because Round 52's
retag moved the file under a new artist.
So a copy that raises OSError now costs that song, not the sync, and the song
is left out of the m3u and library.json. Aborting cost 2,400 songs for one
folder; counting it present anyway put a song in the manifest that isn't on
the device, which is the bug you could hear. The names come back in the
summary and a dialog rather than vanishing.
Planning is a worker now. plan_andtunes_sync is pure and writes nothing, but
it walks every file on the device, and over MTP that is thousands of round
trips -- on the GUI thread it froze the window and GNOME offered to kill
LinTunes, which is how a sync got force-quit halfway through.
And the sync stopped going quiet at the end. "Album art 501/501" is emitted
before the last album, and then _write_index, _ship_buttons and
_prune_empty_dirs ran silently: 501 exists() stats over MTP, ~840 KB of
writes and a full tree walk, with the progress line frozen. They report now,
and _write_index reads Art/ in one listing instead of a stat per album.
andTunes 0.2.2: the wheel scrolls the other way in lists, and smoothly --
a detent adds to a pixel debt that a Choreographer callback pays off a
fraction per frame, so one detent eases to a stop and a fast spin blends into
one movement instead of teleporting a row at a time. Volume is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
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
The status bar's transfer widgets are permanent widgets: Qt lays them out
left to right but justifies the group right, so whichever is added last
owns the corner and never moves, and everything before it slides whenever
the group's total width changes. The label carries the song title of the
moment, so it changed width on nearly every track — dragging the ✕ across
the bar, which is the one widget you're aiming at.
So the ✕ is added last instead of first. It sits in the corner, the label
absorbs the movement, and the progress bar stops jittering too. Hovering
still expands it to "cancel transfer" and pushes the row left, which is
the one shift trav asked to keep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
trav's "Dionne Farris - I Know" kept identifying as Jay-Z, against ID3 tags
that plainly said otherwise. AcoustID was right; the ranking threw the answer
away.
The fingerprint matched one AcoustID result at 0.97, and six recordings hang
off it: Dionne Farris twice, plus Jay-Z, Marisela, New Atlantic and David
Essex, all of whom recorded a song called "I Know". A result's score belongs
to the *audio*, so every linked recording carries it however wrong the link
is. With the scores tied, ranking fell through to the duration bucket, where
Jay-Z's 222.7 s beat Dionne's 227.3 s against a 224 s file. The tags never got
a vote: the hint sat below duration in the sort key.
So the lookup now asks who submitted each link. `sources` joins LOOKUP_META —
475 people linked that audio to Dionne Farris, 6 to Jay-Z, 1 each to the rest
— and _link_tier sinks anything under a tenth of the strongest link in the
same result. The share is relative, never an absolute count, and a missing
count ranks as real: an obscure song's true link may have two submissions
against a stray's one, and rounds 45-46's payloads rank unchanged.
And it asks what the file already says. artist_hint_for gathers the artist
tag, the album artist and the artist in the filename; _artist_agreement counts
the words shared with a candidate's credit, placeholders dropped. Like every
hint since round 46 it only chooses among what AcoustID returned.
New key order: stray tier, artist agreement, duration bucket, hint overlap,
release rank — who, which take, which release. Artist above duration is the
whole fix; duration still separates two takes by one artist.
Verified live against the reported file: the proposal is now I Know — Dionne
Farris — Wild Seed - Wild Flower (1994), track 1, with all five mis-tagged
artists off the dropdown. The real response is pinned in tests/test_round52.py.
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
The Android half of andTunes had been paused on a 1 GB toolchain download
(Gradle wrapper, Android Gradle Plugin, Kotlin). None of that was needed:
platform 33 and build-tools 34 were already in ~/Android/Sdk, and an app that
links no libraries needs five SDK steps, not a build system. andtunes/build.py
runs aapt2 -> javac -> R8 -> zipalign -> apksigner and produces a 61 KiB APK.
andTunes 0.1.0 is framework-only Java: a six-tile menu that loads nothing, a
streaming library.json parse grouped into artists and albums in one pass, one
ListActivity for every list (songs and artist songs open with Shuffle all,
albums carry art thumbs, search as you type), a Now playing bar under every
list, and a white Now Playing screen with square art, a slim scrubber,
prev/play/next, shuffle and repeat, and no back button. A foreground
PlaybackService owns the queue: audio focus, becoming-noisy, MediaSession and
a MediaStyle notification, end of queue pauses, missing files skip, and the
last queue and position come back on launch. Black on white, and every size
doubled for the R1's override density. Cold start to the menu: 313-338 ms.
The scroll wheel turned out to be DPAD_UP/DOWN (stock Generic.kl), not
volume. The app takes both in dispatchKeyEvent: lists scroll a row per
detent, and the menu and Now Playing get volume.
LinTunes side: Connections > Install andTunes on Rabbit... installs the
committed APK over adb and grants all-files access, or copies it to Download/
over MTP when there's no adb. Sync fills Buttons/ with default artwork only
where a file is missing, so trav's own art is never overwritten.
Verified against the dev library only, synced to the R1 and driven with
adb input + screencap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
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
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
Two real failures from the first hour of round 45, both fixed by treating
the file itself as evidence.
"B. Clem - Zuuso [1025657891]" fingerprinted fine and matched nothing.
Checked before building: AcoustID returns zero results, a MusicBrainz text
search for "Zuuso" returns zero, iTunes returns zero. The track is in no
metadata service on earth, so no smarter lookup could have answered it —
but its filename said exactly what it was. New filename_tags.py reads that,
validated against all 474 files in the untagged folder rather than invented
examples: it strips yt-dlp ids, drops "(Official Video)" noise, splits
Artist - Title, reads a leading 1-04, and falls back to the iTunes tree.
It is careful about what it removes — an 11-char trailing token counts as a
YouTube id only if it carries a digit, an underscore or flipping case, or
"Cold Draft-Underground" loses a word, and a hyphen only splits when it has
spaces around it so Jay-Z survives.
A guess says it is one: candidates carry a source, and the dialog quotes a
confidence only for real fingerprint matches.
The file now ranks lookup results too, which fixes the Alhambra case: token
overlap (words double, bare numbers single), a duration bucket against the
recording lengths AcoustID returns, and a filename year that predates what
the database knows. Live also stops counting as a demerit — it was lumped in
with Compilation and buried a live album for a file whose name said "Live At
The Alhambra". That track now proposes the right album with year 1961
instead of a compilation with 2002.
The year genuinely needed the filename: MusicBrainz's own first-release-date
for "Ahmad Jamal's Alhambra" is 2002, because its three original 1961
pressings are stored undated. The obvious fix of a second MusicBrainz call
returns the same wrong answer.
Not done on purpose: trav rejected "never propose a shorter title" —
truncating is wanted, since the garbage is usually the part being dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsiFHdyVg1UhBSxDJfTNRm
Right-click → Identify Track… fingerprints the file with Chromaprint's
fpcalc and asks AcoustID who it is, then proposes the tags it found.
The year is the point. A 60s song kept getting stamped with the year its
CD reissue came out, which sorts the library wrong, so every candidate
carries the recording's *original* year — the earliest release across all
of its release groups — including the candidate that proposes a later
greatest-hits as the album. Ranking separately prefers a plain studio
Album over EP/Single over anything wearing a Compilation or Live
secondary type, so the default proposal is the real album as well.
Nothing is written until accepted: the dialog shows each current value
beside an editable proposed one, and a row starts checked only where the
proposal actually differs from what the file already has, so an untouched
field can never quietly blank a tag. Applying goes through
edit_track_fields, which is what buys the tag write, the abort-if-it-fails
rule, artist/album file relocation and one undo step without re-deriving
any of them.
A selection is a queue reviewed one track at a time; a failure is a
status-bar message that moves on rather than ending the batch. fpcalc is
detected at runtime like ffmpeg and the AcoustID key lives in preferences
(so it syncs, and stays out of git) — the round adds no new dependency.
Also fixes a pre-existing round 40 test that used the real tummult mount
point as its stand-in for an unreachable path, so it failed whenever that
drive was plugged in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NsiFHdyVg1UhBSxDJfTNRm
Round 43 made the merge report honest, and in doing so made its one real
limitation impossible to miss: every merge was a union, so a song removed from a
playlist on one machine was handed straight back by the other on the next sync,
and a track deleted from the library came back with it. A deletion you cannot
make stick is not a deletion.
The union was there for a real reason — two copies with no common ancestor
cannot tell "A added this" from "B removed it" — so the missing evidence is
written down instead of inferred. New lintunes/tombstones.py: a playlist keeps
track_events {track id: [when, add|remove]}, the library keeps deleted_tracks
{track id: when} in library_metadata.json, and a merge applies the newest event
per track across both copies. A removal beats a copy that merely still had the
song; a deliberate re-add afterwards beats the removal; a track nobody touched
still merges as a union, which stays the safe behavior where there is no
evidence either way.
Deliberately not "the newer copy wins wholesale": that one-liner silently drops
a song the other machine added while you were removing one, which
test_an_unrelated_addition_is_not_lost pins.
Events are recorded in the funnels that already exist — _set_track_ids diffs
before/after so a reorder records nothing, _remove_tracks stamps the library,
_restore_tracks clears it so Ctrl+Z takes the tombstone back — and pruned after
30 days at the save boundary.
Three edges worth naming:
* Track ids are never reused. The next id came from max(library.tracks), so
deleting the highest-numbered track freed its id, and the next import would be
dropped on sight by the dead id's own tombstone on every machine.
* library_metadata.json is merged before library.json, since it carries the
record the library merge is filtered against and rglob order is not a plan.
* A merge applying a deletion never touches a music file — it drops the library
entry only, and test_a_merge_never_touches_a_music_file fails the run if
send_to_trash is so much as called. Applied removals grade WARNING and name
the song.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
The merge window kept reporting "Order kept from this machine (most recently
edited)" for playlists edited on the other machine. Three defects, confirmed
against the real snapshots in .resolved/:
* "this machine" was inferred from which copy held the plain filename. That is
Syncthing's call, not a statement about authorship — it sets the local copy
aside as readily as a remote one. In the 9:34 PM `* a fresh master` merge the
copy labelled "the other machine" was this machine's own 3:15 PM merge output,
so the label was exactly backwards. The 7-char device ID in the conflict
filename — the only real evidence — was matched by a bare \w+ and deleted with
the file. New sync_identity.py decodes it against Syncthing's config.xml and
works out which device is us from cert.pem.
* The decision leaned local. date_modified was only consulted when *both* copies
had one, and an iTunes playlist never reordered here has none — so the honest
comparison was skipped exactly when one machine had edited and the other
hadn't. A stamped copy now beats an unstamped one; mtime is the fallback only
when neither side has ever been edited. And every merge used to rewrite the
file it kept whether or not anything changed, freshening its mtime while the
conflict file kept its origin's: a ratchet. No-op merges write nothing, and a
merge whose result is a union neither copy had stamps date_modified, so the
other machine adopts it instead of trading the same 19 tracks back and forth.
* Nothing was actionable. Re-inserted tracks are now named with their position
("Pola — Abeille -> position 24, after ..."), six in the window and all of
them in what-changed.txt at the top of the backup snapshot, alongside the real
conflict filename and its device. Tracks only this copy has are reported too
rather than resurrected in silence.
Also fixed while in here: a rename or folder move made elsewhere was discarded
by every merge (only track_ids and settings were adopted); _reconcile_playlist
asserted the local edit was newer and never checked, so a reorder synced in from
the other machine was undone and flushed back to disk, and the branch reaching
it was gated on a dirty flag that a column drag sets; and _merge_metadata
decided the music folder from whichever copy an mtime coin flip had kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
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
Restarting on the machine that mounts the drive at /media/trav/muzak opened
"LinTunes can't find your music folder", offering ~/Music. The library's only
music_folder is the other machine's mount point (/run/media/trav/tummult/...)
because this library predates 0.10 and has no music_folder_rel yet — but
config.json has held the right path all along under music_root, which
`--music-root … --save-config` writes and the importer stores as
library.music_folder. resolve() only looked for music_folder_override, a key
that exists solely because the prompt writes it, so a library imported the
normal way could never satisfy the check that decides whether to prompt.
music_root is now a machine-local candidate alongside music_folder_override,
consulted in the same last-resort position and subject to the same must-exist
rule, so a stale one still reports "missing" rather than resolving to a phantom
tree. Read-only: nothing writes it back, and the newer key still wins when both
are present.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
"unplayed", "missed and never skipped" and "top 100 in past year" showed up in
the merge window constantly, and nobody had touched their rules. A live smart
playlist's membership is derived, but it was being persisted — and
recompute_smart_playlist rewrites it (and bumped date_modified) every time a
play count moves. Both machines did that against the same file after every
song, so Syncthing conflicted on a list the next load throws away and rebuilds
anyway. Membership now stays in memory: save_playlist writes track_ids: [] for
a live smart playlist, and the recompute passes touch=False so it marks nothing
dirty and moves no timestamp. live_update=False and unsupported criteria are
unchanged — their track_ids are a snapshot, which is real content. Since
_mark_playlist also dirties the metadata, library_metadata.json stops being
rewritten every song too.
The rest was presentation. Every summary read at the same weight, so someone
resizing a column on the other machine popped and raised the same window as a
21-track reconciliation, described as "Library columns/settings taken from the
most recently edited copy." Summaries now carry a level — WARNING for a merge
that couldn't resolve cleanly or discarded a side, CHANGE for real content,
INFO for cosmetic or derived — and the dialog has a Show: selector that filters
to one level and above. It opens at the highest level in the batch, so nothing
routine steals focus and a blank window can't happen; a later warning always
pulls the view back up to it. The wording says what happened instead of how the
merge works: a metadata merge names the keys that actually differed, and
adopting the other machine's music folder grades CHANGE rather than INFO,
because that one is a setting somebody chose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
It was there on the theory that it cost nothing and let the folder double as a
plain music folder. But a web mix is a folder you upload, and a stray playlist
file next to index.html is one more thing to explain to whoever receives it.
plan.m3u_name is now "" for WEB, so the plan doesn't name a file it won't
write, and index.html is simply the last thing written — which is what the
manifest-last invariant always meant for that branch. Folder exports keep
their m3u unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
The export dialog gains an accent color — the hover background on links and
tracklist rows, and the player's progress fill — and the rest of the bar loses
its 2010 gold so that choice is the only color in it.
The accent travels as a `:root { --accent }` custom property declared in
index.html, which player.css reads as `var(--accent, #8c764a)`. That keeps the
stylesheet in the verbatim copyfile loop: index.html is still the only rendered
template. `normalize_accent` is the injection gate — the value lands raw inside
a <style> block and string.Template escapes nothing — and `contrast_text` flips
the hover text black or white, since the old page hard-coded white and a pale
accent made it unreadable.
The bar itself is now fixed light gray with black text. Its sprite glyphs are
pale lavender and yellow, drawn for the dark gold bar, so they're recolored
with `filter: brightness(0)` rather than by editing the GIF — which still ships
byte-identical, spinner and all.
Not persisted: the picker opens on #8c764a every time, so an export nobody
touches looks exactly like the mixes already online.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBSM2bFC6UToiEg8BE4dqj
serve_forever's poll_interval is how long shutdown() blocks waiting for the
loop to notice it should stop, and the default is 0.5 s. That whole half
second was paid on the GUI thread every time a cast session ended: the
click on the cast indicator goes disconnect() → _teardown → sink.shutdown()
→ server.stop() → httpd.shutdown(), all synchronous. Measured here: 450 ms
of frozen window, now 10 ms.
The cost is 100 select() wakeups a second instead of 2, on a thread that
only exists for the life of a cast session.
Found uncommitted in the working tree, left over from the cast round.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4Z46BMQYS57bcbxWbSS3C
Three things that only ever hurt new users.
The startup font modal is gone. _ensure_now_playing_font ran before
MainWindow existed, so on a machine without Century Gothic the very first
thing LinTunes did was open a parentless dialog demanding a font decision.
theme.NOW_PLAYING_FALLBACKS now picks the closest geometric sans installed
(URW Gothic, the Avant Garde clone CG derives from, leads the chain), and
the choice moved to Preferences ▸ Now-playing font. An uninstalled saved
family falls back to automatic instead of the app default.
A non-iTunes user can finally set their music folder. library.music_folder
was written in exactly one place — the iTunes importer — and with it unset
_music_import_dir fell back to a *relative* Path("Music"), resolved against
a working directory GNOME's dash does not set predictably (see
packaging/install-desktop.sh). Music scattered somewhere unfindable. Now
the first launch asks one plain-language question, Preferences can change
it later, and an import with no folder set refuses rather than guessing.
Picking ~/Music files into ~/Music, not ~/Music/Music.
music_folder is portable at last. It was the only path in the library
stored raw absolute in the *synced* metadata, so machine 2 inherited
machine 1's paths. It is now also stored relative to the data dir, the
same trick Track.location has used all along. The absolute key stays
forever as the shared floor between versions: old code reads it and
behaves exactly as before, and old code that writes the file just drops
the new keys, so no version combination hard-fails.
Two traps worth naming. set_music_folder must call
mark_library_settings_dirty() or reload_from_disk reverts the change on
the next sync tick. And the dirty flag only guards until flush, so a
music_folder_set_at stamp decides adoption semantically — _merge_metadata
picks the whole file by mtime, which moves when someone resizes a column
(the Round 39 lesson, applied to metadata).
Also: correct CLAUDE.md's claim that the importer skips smart playlists —
it imports them; system playlists are what's skipped. README gains a
"never used a terminal?" on-ramp and loses the instruction to hand-write
library_metadata.json before first launch.
Verified against the live 21,490-track library: still resolves (via the
legacy key), metadata untouched, 646 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4Z46BMQYS57bcbxWbSS3C
What scrambled `a nissa one` took three defects at once. Reorder it on machine
A; open it on machine B and resize the window; B's file is now newer, so the
merge takes B's order — the old one — wholesale.
_merge_playlist was a 2-way union with no common ancestor: one side's order
wholesale, the other side's extras appended at the tail, so a track inserted in
the middle on one machine arrived at the bottom on the other. merge_track_order
re-inserts each side-only track after the nearest track both copies share
instead. _reconcile_playlist (the live-reload path) uses the same helper. The
union invariant is unchanged and property-tested over 300 random pairs — a
merge never drops a track.
Whose order wins is now Playlist.date_modified, bumped only in _set_track_ids
(add / remove / reorder / undo) and deliberately not by
mark_playlist_settings_dirty, with a file-mtime fallback for playlists written
before the field existed. The file's mtime was a lie: the last column is
stretch-sized, so Qt re-fires sectionResized whenever the viewport width
changes, and a window resize or splitter drag rewrote the open playlist's JSON
— moving its mtime and handing Syncthing another conflict — for a width
apply_settings overrides on load anyway. _on_section_resized now skips that
section; genuine drags on every other column still persist.
Replayed on a copy of the real playlists: `a nissa one` keeps its reorder
against a newer-by-mtime opponent, and `a nissa ideas` takes the other
machine's two mid-list inserts at 5 and 12 rather than 26 and 27.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
The merge windows kept coming because finishing a track rewrote all 15 MB of
library.json. Both machines did that, so Syncthing saw two edits to one big
file between syncs and produced a conflict file roughly once per song — and
the merge then took max() of the two counts, discarding whichever side had
played less. The .resolved/ backups showed the last nine library.json merges
were ~98% play counts plus exactly one real edit, with the same ~45 tracks
disagreeing every time and the count only creeping down over a day.
library.json now holds only a base count. Each machine owns
plays/<machine-id>.json with its own per-track totals, and the effective count
is base + the sum of every journal. Only the owner writes its journal, so play
data can't conflict; the journal stores totals rather than an append log, so
there's no compaction step to double-count in; and a machine still on older
code keeps bumping its own base, which stays additive with our journal.
Two places carry the whole hazard, and both are pinned by tests: save_tracks
writes journal.base_fields(), never the Track's effective count, and
PlayJournal.load must be given a base-valued library — which is why
reload_from_disk folds the journals onto the disk copy before reconciling.
On the real 21k library: three plays write 83 bytes and leave library.json
untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
show_conflict_summary() built a fresh ConflictSummaryDialog every time and
only reassigned self._conflict_dialog — the old dialog was still a child of
the window, so it stayed on screen. check_for_external_changes() runs
resolve_conflicts() on every Syncthing watch tick, so that was one window per
merge, forever. Fifteen had piled up on trav's desktop.
The dialog is now a session-long log: add_event() folds each merge in as its
own timestamped entry, newest first, and the restore button names the merge
it would undo. MainWindow reuses the open dialog and clears its reference on
finished, so closing it lets the next merge open a fresh one instead of
touching a deleted C++ object.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
File → Export Playlist…, and the same item on a playlist's right-click
menu. A sibling of device_sync: pure plan_export() first, then a worker
on a daemon thread sharing the status-bar progress widgets. The manifest
(index.html / .m3u) is written last, so an interrupted export never
leaves a page naming files that aren't there.
Two destinations. A folder gives the audio under "Artist - Title" names
plus an extended .m3u. A web mix gives a self-contained static site
reproducing the hand-made yearly mixes, with a dialog for title,
description and hero image.
audio.js and jQuery are gone. The old pages shipped ~293 KB: a build of
audio.js whose upstream hasn't moved since 2012, plus jQuery 3.2.1
(CVE-2019-11358, CVE-2020-11022, CVE-2020-11023 — unexploitable on a
static page, since nothing untrusted reaches a jQuery HTML sink, but
dead weight regardless). audio.js never used jQuery; jQuery was there
for ~25 lines of tracklist glue. Both are replaced by a dependency-free
player.js plus a player.css transcribed from the customized audio.js
skin, so the page looks identical — same 250px #c7b563 bar, same
player-graphics.gif (byte-identical: it's an animated GIF whose loading
frame is a spinner), same shortcuts. ~293 KB → ~6 KB. The Flash
fallback went too; audio.js gated it on !canPlayType("audio/mpeg;"),
unreachable since ~2010.
Conversion happens only when a browser genuinely can't decode a file,
never because of bitrate — a 320 kbps MP3 is copied verbatim. Everything
that does convert targets FLAC, so a conversion cannot cost a bit; a
test asserts the exported FLAC's decoded PCM hashes identical to the
ALAC source. Against the real library that's 105 Apple Lossless and 8
AIFF out of 21,382 tracks. DRM'd tracks are reported in a confirmation
dialog, never silently dropped and never attempted. ffmpeg is the CLI
binary here, not Qt's ffmpeg backend, so it's detected at runtime.
Verified in Chromium 151 and Firefox 153 over CDP/Marionette: every
exported format decodes including the converted FLAC, the player builds,
click / space / arrows / scrubber-seek / autoplay-next all work, and the
network log shows no request for jquery, audio.min.js or any .swf.
mix-example/ removed; the template reproduces it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reported symptoms, one shape: every operation was whole-library,
whole-file, on the Qt main thread. Measured on the real library (21,482
tracks, 506 playlists, 15 MB library.json).
The track table sorted through a QSortFilterProxyModel, which asks data()
for a value on every comparison — 580k Python round trips, 8.5 s per table
load, paid again on every reload. TrackTableModel now keeps _tracks in
canonical (playlist) order plus an _order index list and sorts a key list
computed once per track. Nothing used the proxy's filtering. Sorting by #
is the identity order, so "source row" still means "playlist position" for
drag-reorder. 8.5 s -> 0.13 s; MainWindow() 18.1 s -> 0.79 s.
Smart rules compile to closures once per evaluate() instead of being
re-dispatched per track — the operator lookup, casefolding the query and
parsing the rule's own date constants all leave the 21k-iteration loop
(_match_date was re-parsing its own constant 21,482 times per rule).
Verified identical membership against the old evaluator on all 20 real
smart playlists. 2.54 s -> 0.72 s, and it now runs after the first paint.
Also: _reconcile_track skips two to_dict() round trips per unchanged track
(MERGEABLE_FIELDS in the resolver is the single source of truth for what a
merge touches); no git subprocess on the startup path (Updater.enabled is
probed lazily on the background thread, version-button tooltip deferred);
and .resolved/ is pruned to the newest 10 snapshots — it had reached 1.7 GB
across 73 snapshots inside the Syncthing share.
Time to interactive window ~15 s -> 3.2 s. The mid-session freeze when
Syncthing delivers a change — a full reload + recompute + table rebuild
with the window already on screen, which is what GNOME was offering to
force-quit — ~16 s -> 3.8 s.
The merge rework itself (playlist ordering, per-machine play journal,
quieting the dialog) is queued in TASKS.md as Round 36.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A paused or stopped QMediaPlayer on Qt's FFmpeg backend keeps its PipeWire
stream open and never corks or drains it, so the few seconds the backend
decoded ahead sit there live. A later audio-graph change — a USB DAC waking
from idle suspend, a device appearing — flushes that stale buffer to the
speakers, hours after the app was last touched.
That is the "LinTunes plays by itself" haunting: the v0.1.4 provenance log
recorded zero control events between the 17:48 pause and the 00:30 incident,
while MPRIS still reported Paused at exactly 6974000us — the position it was
paused at seven hours earlier — and PipeWire showed the stream state=running.
It explains the whole shape of it: always a short burst (only what was
buffered), always mid-song, never a recorded play count.
After 30s idle, release the pipeline: stop() then setSource(QUrl()), which
removes the PipeWire node outright, so no buffer survives to leak. Gain drops
to zero first as an independent second layer. stop() arms the brake too — Qt
leaves the source loaded there, and _advance() takes that path when a playlist
runs out. Resume rebuilds the source and seeks back via the existing
custom-start-time machinery.
Verified end-to-end against the real LocalSink with a silent WAV, watched
through pw-dump: present while playing, still present right after pause, gone
once parked, rebuilt on resume with position and duration intact.
This is a workaround for an upstream Qt Multimedia bug; TASKS.md now carries
the assessment for moving the local sink to GStreamer, which gets correct
pause behavior for free and would unlock the parked equalizer and gapless.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Right-click a track (or a multi-selection) for "Remove from Library" or
"Remove from Library and Delete File". The second moves the file to the
desktop trash rather than unlinking it, so it stays recoverable by Ctrl+Z
in-app and by "Restore" from the file manager afterwards.
The trash is per-filesystem: music lives on a mounted volume, so the file
belongs in <topdir>/.Trash-<uid> with a topdir-relative, percent-encoded
Path. Using ~/.local/share/Trash would be a cross-device copy recording an
original path "Restore" can't reach. lintunes/trash.py implements the
freedesktop spec directly rather than adding a dependency that would need
a manual reinstall on the other machine.
Playlist cleanup is synchronous with the removal: playlist edits address
tracks by row index into track_ids while the view skips ids missing from
the library, so a dangling id would desync the two and make a later
"Remove from Playlist" hit the wrong track.
A file that can't be trashed keeps its track — better a song to delete
again than a library entry orphaned from a file still on disk. Player
gains drop_tracks() so deleting the playing track stops cleanly instead
of erroring out when the queue walk later reaches a dead id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playing a track with an iTunes start time (e.g. "Pretty Girls is a
Motherfucker", 0:40) began at 0:00 whenever another track was already
loaded. QMediaPlayer.setSource() synchronously emits two status changes
before returning: a LoadedMedia still reporting the *outgoing* source,
then LoadingMedia for the new one. The stale first event consumed the
one-shot _pending_start_ms armed in Round 22 and seeked the dying
pipeline, so the new track's real LoadedMedia ~5 ms later found nothing
armed. Only the first track after launch worked.
The armed seek now carries the URL it belongs to and is consumed only
when source() matches. tests/test_round32.py models the real Qt event
sequence, which Round 22's plain MagicMock never emitted; the
test_round8/test_round22 stubs now report the new source by the time its
LoadedMedia arrives, as Qt does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cast glyph showed up both under the volume slider and inside the
visualizer, which was redundant. Dropped the volume-slider indicator
(gui/cast_indicator.py deleted) and made the visualizer panel the single
cast control — the volume slider goes back to sitting on its own, and
nothing shifts position when a session starts.
The panel's glyph is bigger (20 -> 40px) and always painted in the theme
highlight rather than following the brightness mode. Clicking it now stops
casting instead of cycling on/dim/off, which meant nothing when there are
no bars to dim; that click was the useful behavior the volume-slider icon
had, so it moves here rather than being lost.
transport_icon() takes a size and scales the painter, so the bigger glyph
is repainted crisply rather than being a blown-up 20px pixmap; size joins
the cache key. The cast/cast_connected glyph pair collapses into one,
since only the connected state was ever drawn.
450 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The device is on a TV, so it should show the cover. play_media now carries
thumb=, which pychromecast folds into metadata["images"] — the field the
receiver paints full-screen. Verified against the real device: it fetches
both the audio and the artwork URL from us on every track change.
Album art lives in the audio file's tags rather than as a file of its own,
so TrackServer tokens now resolve to an _Asset that is either a path or a
blob held in memory. Audio and art get separate eviction rings so a cover
can't push out the previous track's audio while the device is still
fetching it; Range and HEAD work on both.
The image type is sniffed from the cover's magic bytes rather than trusted
from the tag — ID3 APIC mimes are routinely wrong or blank, and the
receiver silently drops an image whose declared type doesn't match its
content. Anything unrecognized is treated as "no cover".
Best-effort throughout: no art, junk where the art should be, or an
unreadable file all just play without a cover rather than failing the
load. Also sends albumArtist and trackNumber in the metadata.
tests/test_round29.py: 74 tests; 447 pass overall.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Device menu becomes Connections, with "Connect to Chromecast…" beside
the Rabbit sync. It opens a dialog that spins while it searches and lists
devices as they appear; picking one hands playback over, puts a small cast
glyph under the volume slider, and clicking that glyph disconnects.
Media-receiver model: lintunes serves the original file over the LAN on an
ephemeral port and the Chromecast decodes it itself. Bit-exact — no
transcode, no second lossy encode — and the device buffers for itself. The
cost is that lintunes is a remote control while connected: no local PCM, so
the visualizer shows the cast glyph instead of bars, and transport actions
land with about a second of round trip.
Player now walks its queue through a swappable PlaybackSink. It keeps
owning the queue, shuffle walk, start/stop times and the play-count and
scrobble bookkeeping, so casting counts plays and scrobbles exactly like
local playback. set_sink() carries track, position and playing-state both
ways, so connecting and disconnecting pick up mid-song. LocalSink stays in
player.py because the Player tests stub Qt Multimedia in that namespace.
The URL carries an opaque random token, never a path, so there is nothing
to traverse with; only the last few played tracks stay resolvable and the
whole map dies with the session. Range and HEAD are implemented because
the device seeks by re-requesting ranges and won't report a duration
without them.
Failure handling, verified against a real device: a dropped socket gets a
15s grace period, since pychromecast retries on its own and a Wi-Fi blip
heals itself. A real loss, another app taking the device, or a network
change falls back to local playback still playing, at the same position —
the sink reports the state lintunes last asked for rather than the IDLE
status a dying connection pushes just before it goes. Quitting stops the
device instead of leaving it fetching from a server that just died. The
~119 Apple Lossless / AIFF / protected-AAC tracks are skipped with a
status-bar message; the other 21,000+ MP3 and AAC files cast natively.
New dependency: pychromecast>=14.0.10, imported lazily so the app still
launches where it isn't installed (the menu item then explains the
install), and python_requires raised to >=3.11 to match its floor.
NOTE: the other machine needs `pip install -e .` before casting appears.
tests/test_round29.py: 67 tests; 440 pass overall.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A "✕" button sits left of the sync progress group and expands to
"cancel transfer" on hover; clicking asks Cancel Transfer / Keep
Copying (the copy keeps running under the dialog). Cancel stops the
worker at the next chunk boundary, removes the in-flight partial file,
and rewrites the m3u to list only tracks actually on the device — so a
cancelled sync always leaves a coherent partial playlist. That last bit
also closes a pre-existing gap: stale files are deleted before copying,
so an interruption could previously leave the old m3u pointing at
deleted files.
Recoverability proven both ways: a test asserts cancel-then-resync ends
byte-identical to an uninterrupted sync, and a live run against the
real Rabbit (cancel mid-file-2 of 3) recovered with kept=1/copies=2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Quitting mid-transfer now asks (Keep Syncing / Quit Anyway) from
closeEvent, which every quit path hits — including the self-update
restart, which previously bypassed closeEvent via a bare quit() and now
routes through close(). A running sync holds its own GNOME inhibitor
(suspend + logout, "Syncing a playlist to a device") so the machine
won't sleep or shutdown/restart under a copy.
Along the way: the Inhibit D-Bus call marshaled Python ints as signed
against GNOME's (susu) signature, so every call was rejected and the
playback sleep inhibitor had silently never worked — _uint fixes both
holders (verified live: cookies taken, IsInhibited flips, releases
clean).
Also investigated trav's mid-copy Syncthing question: the transfer works
from a click-time snapshot and never reads live library state, so remote
changes can't corrupt it — no state freeze needed. Hardened the one real
gap: a source file relocated under the queue (remote metadata edit
moving files) is now skipped, dropped from the m3u, and reported,
instead of aborting the whole sync.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>