Commit Graph
16 Commits
Author SHA1 Message Date
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
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 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 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 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 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