The sidebar gets 8px of bare window on its left, and the Library button
spans exactly the playlist list below it, scrollbar included. The Library
button, search box, playlist name, times and playhead all stand on one
floor 5px above the lists (text by its baseline). The top row floats:
equal space before, between and after the controls, with the now-playing
panel a fixed share of the width (700px at 1920). The header row grows if
the app font makes the Library button taller than the strip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three reported symptoms, one shape: every operation was whole-library,
whole-file, on the Qt main thread. Measured on the real library (21,482
tracks, 506 playlists, 15 MB library.json).
The track table sorted through a QSortFilterProxyModel, which asks data()
for a value on every comparison — 580k Python round trips, 8.5 s per table
load, paid again on every reload. TrackTableModel now keeps _tracks in
canonical (playlist) order plus an _order index list and sorts a key list
computed once per track. Nothing used the proxy's filtering. Sorting by #
is the identity order, so "source row" still means "playlist position" for
drag-reorder. 8.5 s -> 0.13 s; MainWindow() 18.1 s -> 0.79 s.
Smart rules compile to closures once per evaluate() instead of being
re-dispatched per track — the operator lookup, casefolding the query and
parsing the rule's own date constants all leave the 21k-iteration loop
(_match_date was re-parsing its own constant 21,482 times per rule).
Verified identical membership against the old evaluator on all 20 real
smart playlists. 2.54 s -> 0.72 s, and it now runs after the first paint.
Also: _reconcile_track skips two to_dict() round trips per unchanged track
(MERGEABLE_FIELDS in the resolver is the single source of truth for what a
merge touches); no git subprocess on the startup path (Updater.enabled is
probed lazily on the background thread, version-button tooltip deferred);
and .resolved/ is pruned to the newest 10 snapshots — it had reached 1.7 GB
across 73 snapshots inside the Syncthing share.
Time to interactive window ~15 s -> 3.2 s. The mid-session freeze when
Syncthing delivers a change — a full reload + recompute + table rebuild
with the window already on screen, which is what GNOME was offering to
force-quit — ~16 s -> 3.8 s.
The merge rework itself (playlist ordering, per-machine play journal,
quieting the dialog) is queued in TASKS.md as Round 36.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>