Commit Graph
87 Commits
Author SHA1 Message Date
travandClaude Opus 5.5 43d7b91aa6 v0.31.3: header row moves up beside the seek bar; knolled top row
The Library button, the view's header (search box or playlist name) and
the seek row now share one band under the transport boxes, instead of the
seek row hanging alone with a header strip below it. The now-playing panel
caps at 560px and the gaps between controls share the rest evenly; more
room under the menu bar. Long playlist names shrink up to 2pt, then elide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:43:21 -07:00
travandClaude Opus 5.5 8797e63bd8 v0.31.2: panels pushed in, buttons pushed out; black bpm text
- The now-playing panel and the visualizer are debossed by exactly as
  much as the buttons are embossed: the same rims and outline, flipped
  (theme.deboss_qss / theme.paint_deboss), so things you press stand out
  and things that show you something sit in.
- The bpm button's text is black, enabled or not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:23:34 -07:00
travandClaude Opus 5.5 c8c90b830a v0.31.1: one button look — crisper emboss, darken on hover
- The emboss hugs the edges: thin lit and shaded rims, a defined outline
  that's a touch darker along the bottom, and a nearly flat face — shape
  without depth.
- Hover darkens the whole button slightly instead of Qt's pale panel on
  top of it.
- One face for every button (theme.button_face_qss): regular buttons,
  Library and its ▾, the purple friend-mode buttons, and the transport
  boxes. Shuffle and bpm are boxes that are one button, so the whole box
  darkens (bpm's whole box is now its tap target); the play box's three
  tiles each darken on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:17:05 -07:00
travandClaude Opus 5.5 051312d91d README: new features, and a Cassette guide for first-timers
Adds the features shipped since the list was written (Identify Track,
Import from URL, andTunes, art sources, New Tracks, merge improvements,
Cassette) without touching the existing lines, and a step-by-step guide
at the end: installing Syncthing, inviting a friend, choosing what to
share, cassetting songs, following playlists and troubleshooting. Icons
and screenshots in docs/cassette/ are rendered from the app's own
painting code against the synthetic dev library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:06:35 -07:00
travandClaude Opus 5.5 87d43f8e0c v0.31.0: Cassette followed playlists — a friend's playlist in your sidebar
- A followed playlist (the UFO in friend mode) appears in my sidebar with
  a purple UFO: mixed in with my playlists alphabetically, or inside a
  folder named after the friend (per-friend, in Sync Settings). It's never
  a Playlist of mine, so it stays out of the library and its merges.
- It plays from the cache. Player's queue accepts friend-track keys,
  resolved through Cassette: they scrobble to Last.fm but don't count in
  my play journal. Songs still on their way are grey and skipped.
- The cassette column there is hidden by default (right-click the
  header); pressing one takes the song into my library — straight from
  the cache if it's there, otherwise by asking for it.
- Sync Settings per friend: keep followed songs separate (default) or add
  them to my library. Unfollowing (right-click) frees their cache.
- The bottom bar says when the friend's folder last synced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:40:59 -07:00
travandClaude Opus 5.5 ea7f57e38b v0.30.0: Cassette delivery — requested songs arrive in your library
- 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>
2026-09-28 21:32:25 -07:00
travandClaude Opus 5.5 8099cfc85f v0.29.0: Cassette friend mode — browse a friend's library, cassette songs
- 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>
2026-09-28 21:27:06 -07:00
travandClaude Opus 5.5 759fa85153 v0.28.0: Cassette share selector — choose what friends can cassette
- 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>
2026-09-28 21:18:14 -07:00
travandClaude Opus 5.5 f04e69f9b2 v0.27.0: Cassette invites — one paste connects two friends
- 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>
2026-09-28 21:13:07 -07:00
travandClaude Opus 5.5 0dad4cc95e v0.26.0: Cassette footing — Syncthing client, host machine, Sync Settings
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>
2026-09-28 21:01:17 -07:00
travandClaude Opus 5.5 6d65ca4050 v0.25.0: New Tracks, a soft emboss, date added pinned
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>
2026-09-28 20:56:28 -07:00
trav da9186420b moved tasks to projects board 2026-09-28 19:37:11 -07:00
travandClaude Opus 5.5 720f5c10a2 v0.24.1: bpm box in line with the now-playing panel
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>
2026-09-25 23:56:19 -07:00
travandClaude Opus 5.5 372946c79f v0.24.0: click the blank cover
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>
2026-09-24 23:52:09 -07:00
travandClaude Opus 5 3150df0f89 v0.23.0: covers for the whole album
- 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>
2026-09-22 09:38:39 -07:00
travandClaude Opus 5 d448f64c2b v0.22.1: not a bot
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>
2026-09-19 00:43:01 -07:00
travandClaude Opus 5 ec008fc1e9 v0.22.0: lookup on youtube
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>
2026-09-18 23:06:38 -07:00
travandClaude Opus 5 d736b24c8e v0.21.0: click to rename
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>
2026-09-18 20:53:09 -07:00
travandClaude Opus 5 0e726af0da v0.20.6: one press, sixteen hundred songs
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>
2026-09-17 21:52:45 -07:00
travandClaude Opus 5 f091a545f5 v0.20.5: the wheel learns to coast
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
2026-09-14 13:51:42 -07:00
travandClaude Opus 5 efa07b05c6 v0.20.4: the song that wasn't there
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
2026-09-14 13:25:15 -07:00
travandClaude Opus 5 def7635a87 v0.20.3: one artist spelled two ways
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
2026-09-14 12:51:40 -07:00
travandClaude Opus 5 370ea498cc v0.20.2: the cancel button stops flying around
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
2026-09-11 21:08:32 -05:00
travandClaude Opus 5 c797cf570c v0.20.1: one fingerprint, six artists
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
2026-09-11 20:41:33 -05:00
travandClaude Opus 5 81b7f6767c 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
2026-09-11 15:20:44 -05:00
travandClaude Opus 5 9483d8951b v0.19.0: andTunes plays music on the Rabbit
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
2026-09-11 14:25:55 -05:00
travandClaude Opus 5 8188a5b389 v0.18.0: paste a link, get the song
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
2026-09-10 23:44:45 -05:00
travandClaude Opus 5 bcba621f3f v0.17.1: a library to break
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
2026-09-10 00:49:26 -04:00
travandClaude Opus 5 cc1ca5ef78 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
2026-09-06 03:25:40 -04:00
travandClaude Opus 5 4acc57b648 v0.16.0: the file already told you
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
2026-09-02 17:24:07 -04:00
travandClaude Opus 5 f0d4d1eff2 v0.15.0: ask the song what it is
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
2026-09-02 15:40:52 -04:00
travandClaude Opus 5 8c097faacc v0.14.0: a removal you make sticks
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
2026-08-27 18:31:33 -04:00
travandClaude Opus 5 c8de124543 v0.13.0: the merge report says who, what, and where
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
2026-08-27 15:16:49 -04:00
travandClaude Opus 5 37b3744aa4 Name the sender behind every MPRIS call
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
2026-08-26 22:21:17 -04:00
travandClaude Opus 5 7df5ecf868 v0.12.1: stop asking where the music is when config.json already says
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
2026-08-25 15:06:13 -04:00
travandClaude Opus 5 521db2f81d v0.12.0: the merge report stops reporting things you didn't do
"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
2026-08-25 14:53:37 -04:00
travandClaude Opus 5 a61cbaa369 v0.11.1: a web mix stops shipping an .m3u
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
2026-08-22 15:22:01 -04:00
travandClaude Opus 5 800e8ffb5d v0.11.0: web mixes pick their own color
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
2026-08-22 15:14:36 -04:00
trav 2a317bcae9 some readme stuff 2026-08-22 14:07:47 -04:00
trav d75b2a966a some readme stuff 2026-08-22 14:05:43 -04:00
travandClaude Opus 5 4b510318c6 v0.10.1: ending a cast no longer freezes the window
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
2026-08-22 13:15:42 -04:00
travandClaude Opus 5 9ef59dd3b2 v0.10.0: a first launch that welcomes you
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
2026-08-22 13:04:24 -04:00
travandClaude Opus 5 574e476dc3 v0.9.1: playlist merges stop losing your track order
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
2026-08-20 21:29:03 -04:00
travandClaude Opus 5 2d961c0c9e v0.9.0: play counts that can't conflict
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
2026-08-20 20:42:07 -04:00
travandClaude Opus 5 5df0af0bae One merge window, not fifteen
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
2026-08-20 20:33:14 -04:00
travandClaude Opus 5 afadf31a99 v0.8.0: export a playlist — as a folder, or as a whole website
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>
2026-08-19 19:03:01 -04:00
travandClaude Opus 5 211cb50a9e v0.7.0: startup speed — the 21k-track library stops freezing
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>
2026-08-19 17:09:07 -04:00
travandClaude Opus 5 f1a949810e v0.6.1: the parking brake — stop playing audio at 3am
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>
2026-08-17 14:01:36 -04:00
travandClaude Opus 5 6fad0247ee v0.6.0: delete songs from the library
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>
2026-08-14 11:21:50 -04:00
travandClaude Opus 5 01996d5fe2 v0.5.2: custom start times survive a track change
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>
2026-08-14 09:28:50 -04:00
travandClaude Opus 5 bcc17a7542 v0.5.1: one cast control instead of two
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>
2026-08-13 22:24:31 -04:00
travandClaude Opus 5 6c746c3330 v0.5.0: send album art to the Chromecast
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>
2026-08-13 22:06:27 -04:00
travandClaude Opus 5 087c4bf103 v0.4.0: cast to a Chromecast from the Connections menu
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>
2026-08-13 21:49:53 -04:00
travandClaude Fable 5 b6b6dc01f7 v0.3.0: cancelable device sync
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>
2026-08-07 18:08:41 -04:00
travandClaude Fable 5 c9c8971243 v0.2.2: device-sync guards — quit warning, sleep/shutdown inhibition
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>
2026-08-07 17:59:25 -04:00
travandClaude Fable 5 d5ea4b30bf v0.2.1: sync status readable in the corner; Device menu right of Track
The QProgressBar overlay text didn't fit at 160-220px and clipped to two
broken lines. Split it: a plain label "Copying to Rabbit R1 · 1.2 GB /
3.4 GB" next to a textless bar, both permanent right-side widgets hidden
when idle. And the Device menu now sits to the right of Track (trav's
intended spot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:39:03 -04:00
travandClaude Fable 5 62fadd0a2d v0.2.0: Device menu — sync a playlist to the Rabbit R1
New Device menu (left of Track) with "Sync Playlist to Rabbit", enabled
only when a playlist is showing and the Rabbit is plugged in. The Rabbit
mounts over MTP/gvfs (not mass storage), so device_sync.py drives it
with plain file I/O honoring the MTP caveats: no copystat, diff by
name+size. One-way mirror into Music/<Playlist>/ on the device — stale
files deleted (that folder only), Auxio-importable .m3u carries the
order, free space checked up front with a needed-vs-available alert, and
a right-justified status-bar progress bar tracks the chunked copies.
Verified live against the real Rabbit end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:23:24 -04:00
trav d6639710fd v0.1.6: highlight the folder a dragged playlist would drop into 2026-07-17 13:23:51 -04:00
trav 2820e6df8a v0.1.5: sidebar playlist tree auto-scrolls when dragging near its edges 2026-07-17 13:05:52 -04:00
travandClaude Fable 5 663d5e5a08 v0.1.4: playback-control provenance log
LinTunes has spontaneously resumed playback for ~4s (then paused) four
times while trav was away — suspected phantom media-key events from the
Audioengine USB DAC's HID 'keyboard', but the pathway (MPRIS vs the
focused-window key filter) is unproven. Every control path now drops a
timestamped line in ~/.cache/lintunes/control-events.log: MPRIS Player
methods, the media-key eventFilter (with source input device where the
compositor exposes it), and Player.toggle_play/pause with position. New
lintunes/eventlog.py, 1MB rotation, never raises; conftest autouse
fixture keeps tests off the real log file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:09:21 -04:00
travandClaude Fable 5 a50c39fa43 v0.1.3: start time honored when replaying the already-loaded track
QMediaPlayer.setSource() no-ops on an unchanged URL, so replaying the
track that was already the loaded source never re-fired LoadedMedia and
the armed _pending_start_ms was silently dropped — a freshly edited
start time appeared to save but playback began at 0:00. _load_current
now detects the unchanged source and stop()+seeks directly (a forced
clear+reload was tried and races the FFmpeg backend, snapping the seek
back to 0). previous()-restart likewise rewinds to the custom start
time instead of 0:00.

Verified live via an offscreen GUI harness: replay-while-playing,
replay-after-EndOfMedia, and cleared-start-time replay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 06:28:21 -04:00
travandClaude Fable 5 f94a66963b v0.1.2: transport buttons fill their bubble as equal tap targets
Prev/play/next (and shuffle) used to hug their ~20px glyphs in the middle
of the 62px rounded box, so most of the bubble was dead space. _box() now
takes split=True: the buttons tile the bubble interior in equal shares,
expanding to the (tightened) margins with the glyph centered in each.
Minimum button widths keep the bubbles at their old footprint — no bigger,
no smaller. Round 21 tests cover the equal split, vertical fill, edge
spans, and the preserved footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:03:54 -04:00
travandClaude Fable 5 332b47a018 v0.1.1: perf timings + single-save tag writes + coalesced browser rebuild
Diagnosing the Ctrl+I save hang on the Debian machine (GUI thread blocks
past mutter's 5s check-alive):

- lintunes/perf.py: timed() context manager logging wall-clock ms at INFO
  on lintunes.perf — instruments tag/artwork saves, file moves,
  edit_track(s)_fields, the browser rebuild, smart recompute, and the
  debounced JSON flush, so the slow machine can tell us which stage
  eats the time (journalctl --user or a terminal run).
- tagging.write_tags: ONE parse + ONE save per edit. grouping/
  compilation/bpm ride the main save via registered Easy keys
  (GRP1/TCMP/TBPM on EasyID3, cpil on EasyMP4) instead of
  _write_extra_tags re-parsing and re-saving the audio file.
- LibraryView: browser rebuilds coalesce through a 0ms single-shot
  timer — an N-track Get Info edit rebuilds the genre/artist/album
  cascade once instead of N times over the whole library.

Moving the writes off the GUI thread is deliberately deferred until the
Debian timings say which stage dominates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:25:56 -04:00
travandClaude Fable 5 2d02ccefd6 CLAUDE.md: every round ends with commit AND push (keep machines synced)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:31:37 -04:00
travandClaude Fable 5 1bf346229c Task board: Round 19 section; versioning convention in CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:30:37 -04:00
travandClaude Fable 5 502a6106f4 v0.1.0: version in the status bar + one-click self-update via git pull
__version__ in lintunes/__init__.py (setup.py regex-reads it) shows
bottom-left via the new VersionButton; the tooltip carries the git short
hash so machines on the same version but different commits are
distinguishable. The new Updater fetches upstream 10s after launch and
every 4h on daemon threads (lastfm.py pattern); commits behind put a *
on the button, and clicking it confirms, runs `git pull --ff-only`,
quits cleanly (library flush + player shutdown), and re-execs
`python -m lintunes.main` on the new code. Positional file args are
stripped from the re-exec so they don't re-import as duplicates. Pull
failures surface the git error: line in the status bar and leave the
running app untouched; outside a git checkout the button is a plain
label.

Also fixes a Round-5 regression found while verifying: the totals label
was a permanent status-bar widget with stretch=1, which squeezed the
transient-message area to zero width — every showMessage (scrobbles,
tag-write errors, import status) has been invisible since. Both
readouts are now non-permanent widgets, so a transient message
temporarily replaces them and they return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:30:37 -04:00
travandClaude Fable 5 407c5669c5 BPM tap button averages the whole session, not the last 8 taps
TapTempo kept a rolling window of 8 taps, so a long tapping session
chased the most recent taps instead of converging on the overall tempo
(trav noticed the estimate never settled). Every tap since the session
started now counts; a >2.5s gap still begins a fresh session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:30:37 -04:00
travandClaude Fable 5 405250dc4e Task board: Round 18 section, equalizer parked with design note
CLAUDE.md's never-move rule rewritten for the Round 18 rename-move
feature; TASKS.md New backlog folded into a checked-off Round 18
section (plus trav's by-eye verify list). The equalizer item moves to
Parked/deferred with trav's bass/mid/treble idea and the QMediaPlayer
no-effects-hooks blocker recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:05:47 -04:00
travandClaude Fable 5 40b951ca1a Renaming artist/album moves the file: music folder stays organized
Editing artist/album_artist/album now relocates the file to its new
<music_folder>/Music/Artist/Album/ home (folders created as needed,
"Unknown Artist" fallback via the same organized_destination the
importer uses), deliberately relaxing the old never-move rule:

- LibraryManager.organize_root() is the shared definition of the
  managed tree; MainWindow._music_import_dir delegates to it.
- Only files already inside the organize root are managed; external
  files get their tags edited but are never pulled into the tree.
- The move rides the existing undo Command maps as a "location" field:
  Ctrl+Z moves the file back, redo re-moves, both best-effort (a failed
  physical move drops "location" so memory tracks the real file).
- A failed move applies the edit, keeps the old path, and reports via
  the new file_move_failed signal (status bar), mirroring
  tag_write_failed. Collisions get the importer's " 1" suffix.
- Directories left empty are pruned up to (never including) the root.
- conflict_resolver._merge_track_fields now carries "location" with
  the newest-date_modified rule, so a rename-move on one machine
  propagates through both the sync-conflict merge and the live
  reload_from_disk reconcile on the other.
- The on-disk basename is kept verbatim (no re-sanitizing), so files
  named on macOS aren't gratuitously renamed by a tag edit.

Same-filesystem rename keeps the player's open fd valid, so moving the
currently-playing track is safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:59:59 -04:00
travandClaude Fable 5 7e8359d872 File drops from the file manager insert at the drop position
The table already computed and emitted the drop row for external URL
drops, but PlaylistView._on_files_dropped threw it away, so imported
files always appended. Thread it through: playlist_view.files_dropped
gains a row slot (None = append), MainWindow.import_files and
file_importer.import_paths take an optional position and hand it to
add_tracks_to_playlist, which already supported positional insert.
Files already in the library dedup to the existing track and insert at
the drop position without duplicating the library entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:54:54 -04:00
travandClaude Fable 5 0d8c9dd033 Visualizer: gray-mode color slider + mode persists across launches
- New prefs: visualizer_mode (on/dim/off, saved on every click) and
  color_visualizer_gray (None = keep the derived alternateBase look).
- VisualizerWidget takes prefs (optional, so existing call sites/tests
  keep working); restores its mode at startup and validates junk values.
- New "Visualizer (gray mode):" row in the Preferences color sliders;
  theme.default_gray mirrors the widget's 0.88-lightness derivation so
  an untouched slider shows the current appearance.
- TransportBar.refresh_theme() repaints the visualizer so slider drags
  preview live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:52:59 -04:00
travandClaude Fable 5 d46374967d Fix silent resume after walking away: seek-in-place re-primes the sink
Rediagnosed (was thought Bluetooth-specific): on the Debian 13 machine,
resuming after the machine sat idle plays nothing even though decoding
runs (visualizer moves) — the OS suspends the audio sink and it comes
back dead until a seek re-primes it. Resuming after a pause of 30s+ now
does a seek-in-place before play(), automating the manual "rewind
slightly" workaround without losing the position. The volume re-apply
from the earlier fix stays (harmless, covers device swaps).

Also fold the new backlog items into the TASKS.md board.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:56:11 -04:00
travandClaude Fable 5 b69a25fa51 Album art download: one-click apply to every song on the album
The confirmation dialog gains a "Use for All N Songs in Album" button
(shown when the library's album has more songs than the selection); it
embeds the chosen art into every track matching the album, not just the
right-clicked ones. Album membership matches case-insensitively on album
name + album artist (falling back to track artist), the same grouping the
search already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:54:24 -04:00
trav 649302551d tysm fable 2026-07-02 12:44:58 -04:00
travandClaude Fable 5 8dc33fa65e Round 17: task batch + cruft sweep (ratings, art download, MPRIS/exit/BT fixes)
Features:
- Rating hover dots: hovering a rating cell shows 5 clickable slots
  (RatingDelegate); click slot k sets k stars, clicking the current count
  clears. Undoable, library-only (ratings never rewrite music files).
- Search bar moved into the Library header strip, right of the Library
  button, so the tracklist top aligns with the playlist tree.
- Right-click "Download Album Art…": iTunes Search API (no key), off-thread
  fetch, preview/confirm dialog with Next Result, embeds via write_artwork
  + size refresh, invalidates the MPRIS art cache.

Fixes:
- MPRIS media keys: PropertiesChanged sent invalidated_properties as "av"
  instead of "as", so gsd-media-keys dropped it and never MRU-bumped
  lintunes (why the play/pause key kept waking stale players). Now an
  explicit QDBusArgument string array; loopback-verified sa{sv}as.
- Exit segfault: ordered Player.shutdown() (stop, clear source, detach
  buffer/audio outputs) from closeEvent/aboutToQuit; scripted run exits 0.
- BT zero-volume after pause/resume: volume re-applied on resume, device
  swap, and BufferedMedia (needs verify on the affected machine).

Cruft sweep:
- Tag writes filtered to EDITABLE_FIELDS; failures logged + surfaced in
  the status bar (was a swallowed print).
- O(n²) import fixed (location index + cached max track id); O(1)
  refresh/reveal via TrackTableModel row index.
- lastfm: scrobble-queue thread lock, login prefs write marshalled to the
  GUI thread, JSON/raise_for_status order fixed.
- Shared read_json/write_json; TableSettingsMixin dedupes view settings.

TASKS.md rewritten as a resumable board; tests in tests/test_round17.py
(251 total pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:36:11 -04:00
travandClaude Opus 4.8 9575f0400f Live multi-machine sync: watch files, reconcile, alert on conflicts
Both machines now reflect each other's changes within a couple seconds, and
genuine Syncthing conflicts auto-merge with a backup and an alert.

- sync_watcher.py: QFileSystemWatcher (debounced, re-arms after atomic renames)
  emits a single `changed`; the manager decides if it was external.
- library_manager: `reload_from_disk()` re-reads and reconciles disk into memory
  (max play/skip counts, newest edit wins, playlist membership unioned), keeping
  local unsaved edits and object identity so open views stay valid, then refreshes
  the UI without touching the player. `flush()` records each file's (mtime, size)
  so our own writes are never mistaken for an external change. New signals
  library_reloaded / conflict_resolved.
- conflict_resolver: back up BOTH sides into .resolved/<ts>/{original,incoming}/
  before merging, return list[ConflictSummary], add restore_backup(); runs at
  startup and live.
- gui/conflict_dialog.py: modeless summary with Open/Restore backup; main_window
  shows it from a status-bar notice and preserves scroll+selection on reload.

Tests: tests/test_round16.py (9) + full suite green (225).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:55:45 -04:00
travandClaude Opus 4.8 94e7c0713b README: install routes (pip/apt/venv) + screenshot
Document three ways to get the dependencies since Debian's system pip is locked
down (PEP 668 externally-managed-environment): pip, apt (no pip), or a venv, with
the note that `python3 -m lintunes.main` runs the app without pip or the
`lintunes` command at all. Make the second-machine steps install-agnostic and add
the app screenshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:51:48 -04:00
travandClaude Opus 4.8 8237fe0fd2 README: document second-machine (Syncthing) setup
Add a "Running on a second machine" section: clone + install, point the
per-machine ~/.config/lintunes/config.json at the synced folder via --save-config
(no re-import needed since paths are stored relative to the data dir), and run
packaging/install-desktop.sh to install + pin the launcher. Note the desktop
launcher follows the saved config, and add smart playlists + multi-machine sync
to the feature list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 17:00:17 -04:00
travandClaude Opus 4.8 2c5b5a575f Store track paths relative to the data dir for multi-machine sync
The library is going live as the canonical store, synced to a second machine
via Syncthing. Track locations were stored absolute and only remapped at import,
so on a second machine (where Syncthing mounts the folder at a different path)
every location would break.

- lintunes/paths.py: to_relative/to_absolute. Locations are stored relative to
  the data dir and resolved back on load, applied only at the json_storage
  boundary (save_tracks/load_library). Track.location stays absolute in memory,
  so the player, tagging, and art code are unchanged. The data dir and the music
  move together inside one synced tree, so paths resolve wherever it's mounted —
  no per-machine music_root config. Absolute paths in older library.json files
  still load (back-compat).
- tests/test_round15.py: helper round-trips, back-compat, and a machine-2
  scenario (save under root A, load the copied tree under root B).
- TASKS.md/tasks-done.md: mark the data-dir move + real import done; log the
  benign exit-time Qt/FFmpeg teardown segfault.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:43:57 -04:00
travandClaude Opus 4.8 7e8266c14c Add smart playlists with full iTunes 12 import (Phase 1)
Auto-populating, criteria-driven playlists that import faithfully from iTunes.

- lintunes/smart.py: recursive SmartCriteria/SmartGroup/SmartRule/SmartLimit
  model (JSON round-tripping), a shared FIELD_REGISTRY used by both the
  evaluator and the editor, and evaluate() (match all/any, nested groups,
  string/int/duration/rating/date/bool ops, "in the last N", limit by
  items/time/size). Null play/skip dates are treated as the distant past,
  matching iTunes.
- iTunes import via a vendored MIT-licensed binary parser
  (lintunes/itunes_smart/, from cvzi/itunes_smartplaylist). Nested groups
  parse and evaluate; blobs we can't represent (MediaKind/iCloud/etc.) flag
  unsupported and keep the imported snapshot. "loved" is dropped per user pref.
- library_manager: create/set/recompute smart playlists (undoable), field-scoped
  coalesced live recompute hooked into the edit/play/skip/add funnels, a no-op
  equality guard to avoid Syncthing churn, and manual-edit guards. main.py
  recomputes on load; conflict_resolver keeps newest criteria for smart lists.
- GUI: ❧ glyph painted in the sidebar branch column, read-only track table for
  smart playlists, New/Edit Smart Playlist menus, and SmartPlaylistEditorDialog
  (per-field rule rows, match all/any, limits, live updating).

Tests: tests/test_round14.py (real captured blobs in tests/smart_blobs.json).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:43:39 -04:00
travandClaude Opus 4.8 b6d1805962 Cut removes immediately; scrollbar click-to-jump
Ctrl-X now removes the track from the playlist right away (so it visibly
leaves) and a paste re-inserts it at the target — replacing the earlier
deferred-move design (dropped move_tracks_between_playlists and the MIME
cut flag). A cut from the library is still just a copy. Separately, a
left-click on any scrollbar trough now jumps to that spot instead of
paging, via an app-wide ClickToJumpScrollStyle proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:12:27 -04:00
travandClaude Opus 4.8 3e43c81302 Add drag auto-scroll near the list edges
While a track drag hovers within 28px of the track list's top/bottom
edge, the list scrolls that way (40ms timer) so the user can drop onto
off-screen rows. Qt's built-in autoscroll never fires because our
dragMoveEvent accepts the event itself, so this is driven manually; the
drop line refreshes as content scrolls under a stationary cursor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:58:30 -04:00
travandClaude Opus 4.8 2af0d91a76 Add Ctrl-X cut and paste-above-selection
Cut (Ctrl-X) marks the selection on the clipboard with a `cut` flag; a
paste of a cut from a real playlist is a *move* (single-undo across
playlists via move_tracks_between_playlists; same-playlist reorders),
while a copy — or a cut from the library, which has nothing to remove —
just adds. Paste now inserts *above the selected track* rather than
appending. Cut/Paste added to the track context menu; parse_tracks_mime
now tolerates a None/empty clipboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:55:38 -04:00
travandClaude Opus 4.8 1dd128b7f4 Add per-area color preference sliders
Five grayscale (black->white) sliders in Preferences that update live:
outer-chrome background (QPalette.Window), the rounded transport button
boxes, the now-playing panel, the now-playing text, and the row stripes
(QPalette.AlternateBase). Defaults are None ("inherit current") so nothing
changes until the user drags. Decouples the button boxes from the stripe
color (they used to share palette alternate-base). Live preview via
Preferences.set_live (no disk write); persisted ~0.4s after the drag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:41:49 -04:00
travandClaude Opus 4.8 bb31e9e5cc Add volume slider to the transport bar
A slim horizontal master-volume slider in its own slot between the
visualizer and the timeline (TASKS.md). Perceptual->linear gain so it
ramps like iTunes; persists to preferences.json (debounced so it doesn't
thrash the theme). Clicking jumps to the spot via a new ClickJumpSlider
base extracted from SeekSlider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:21:00 -04:00
travandClaude Opus 4.8 45bc8518a3 Add CLAUDE.md
Codebase guide for future Claude Code sessions: commands, the
storage→model→manager→GUI architecture, and Qt/Wayland gotchas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:13:06 -04:00
travandClaude Opus 4.8 f6d35fa594 Import existing LinTunes project
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>
2026-06-26 21:12:01 -04:00