Files
lintunes/TASKS.md
trav 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

16 KiB
Raw Permalink Blame History

LinTunes — task board

Legend: [ ] todo · [~] in progress · [x] done. When a round closes, move its finished items to tasks-done.md.

Round 23 — playback-control provenance log (v0.1.4)

Tests in tests/test_round23.py. Diagnostic round → patch bump 0.1.4.

Background: LinTunes has resumed playback by itself for ~4s (then paused) four times while trav was away/asleep (Jul 5, 7, 8?, 10). Fingerprint each time: current track resumed from its paused position, paused ~4s later. Suspected phantom media-key events (the Audioengine 2+ USB DAC registers a HID keyboard), but the pathway is unproven — hence instrumentation.

  • New lintunes/eventlog.pylog_control(source, action, detail) appends timestamped one-liners to ~/.cache/lintunes/control-events.log (1 MB rotate, never raises) and mirrors to the lintunes.control logger.
  • Provenance hooks: every MPRIS Player method (mpris.py; PyQt6 lacks QDBusContext so no sender — run dbus-monitor alongside when the sender matters), the media-key eventFilter in main_window.py (logs the source input device where the compositor exposes it), and Player.toggle_play/pause (state + position).
  • tests/conftest.py: autouse fixture isolates the log path so tests never write the real ~/.cache file.

Round 22 — start time honored on same-source replay (v0.1.3)

Plan reference: ~/.claude/plans/could-you-look-into-generic-balloon.md. Tests in tests/test_round22.py. Fix-only round → patch bump 0.1.3.

Background: editing a track's start time saved fine but replaying it still started at 0:00 whenever that track was already the loaded source — Qt's QMediaPlayer.setSource() no-ops on an unchanged URL, so the LoadedMedia status change that consumes the armed _pending_start_ms never re-fired. (A forced clear+reload was tried first and races the FFmpeg backend: the seek lands, then the pipeline restart snaps position back to 0.)

  • Player._load_current: when the source URL is unchanged, skip setSource entirely — the media is already loaded — and stop() + seek straight to the armed start time. Verified live (offscreen GUI harness): replay-while-playing, replay-after-EndOfMedia, and cleared-start-time replay all land where they should.
  • Player.previous(): the ">3 s in restarts the track" path now seeks to the track's custom start time instead of 0:00.

Round 21 — transport buttons fill their bubble (v0.1.2)

Plan reference: ~/.claude/plans/tap-targets-on-the-snazzy-minsky.md. Tests in tests/test_round21.py. Fix-only round → patch bump 0.1.2.

  • Prev/play/next (and shuffle) tap targets now tile their rounded box: _box(split=True) in gui/transport.py gives each button an equal, full-height share of the bubble with the glyph centered; minimum button widths keep the bubbles at their old footprint (no bigger).

Round 20 — Ctrl+I save hang on the Debian machine (v0.1.1)

Plan reference: ~/.claude/plans/i-m-having-some-trouble-eager-gosling.md. Tests in tests/test_round20.py. Fix-only round → patch bump 0.1.1.

Background: on the Debian machine, OK in Get Info froze the GUI thread past mutter's ~5 s check-alive → "Force Quit / Wait" dialog. Happens on single-track edits. Whole save path is synchronous on the GUI thread.

  • Perf instrumentation: new lintunes/perf.py (timed() context manager, INFO on the lintunes.perf logger → stderr/journal). Times tag saves, artwork saves, file moves, edit_track(s)_fields, browser rebuild, smart recompute, and the debounced JSON flush.
  • tagging.write_tags now parses + saves the file ONCE per edit: grouping/compilation/bpm ride the same save via registered Easy keys (GRP1/TCMP/TBPM on EasyID3, cpil on EasyMP4) instead of _write_extra_tags doing a second full parse+save.
  • LibraryView coalesces browser rebuilds through a 0 ms single-shot timer: an N-track Get Info edit rebuilds the genre/artist/album cascade once, not N times (was O(edited × library)).
  • Diagnose on the Debian machine: self-update, reproduce a Ctrl+I edit, read lintunes.perf timings (terminal run or journalctl --user) to see which stage eats the ~5 s — likely the audio-file rewrite or the 15 MB library.json flush. Then decide on moving that stage off the GUI thread (deliberately deferred).

Round 19 — version display, git self-update, BPM fix

Plan reference: ~/.claude/plans/some-fixes-features-for-lintues-merry-journal.md. Tests land in tests/test_round19.py. First versioned release: 0.1.0.

  • Version number bottom-left in the status bar: __version__ in lintunes/__init__.py is the single source of truth (setup.py regex-reads it); shown by the new gui/version_button.py; tooltip carries the git short hash so two machines on the same version but different commits are distinguishable.
  • Git self-update: new lintunes/updater.py fetches upstream ~10 s after launch and every 4 h (daemon threads, lastfm.py pattern). Commits behind → a * on the version button; click → confirm dialog → git pull --ff-only → clean quit (flush + player shutdown) → os.execv relaunch on the new code (positional file args stripped so they don't re-import). Pull failures (offline / conflicting local edits) surface in the status bar and leave the running app untouched. Not a git checkout / no upstream → button is just a static label. Limitation: a round that adds a pip dependency still needs a manual pip install -e . per machine. Pushing master is now effectively "releasing" to the other machines.
  • Status-bar bug found while verifying: the totals label was added with addPermanentWidget(…, stretch=1), which squeezed the transient message area to zero width — every showMessage (scrobbles, tag-write errors, import status, sync notices) has been invisible since the label landed in Round 5. Both readouts are now non-permanent widgets: a transient message temporarily replaces them, then they return.
  • BPM tap button: whole-session averaging — removed the 8-tap rolling window in tap_tempo.py (trav's "no rhythm" suspicion was the window, not him). All taps since the session started are averaged; a >2.5 s gap still begins a new session. Verified live under Xvfb: 8 fast + 8 slow taps read the blended overall average, not just the recent 8.
  • verify by eye in the running app: version reads bottom-left; after the next git push, machine 2 shows the * within ~10 s of launch and click-to-update pulls + restarts; tap out a real song's BPM and sanity-check the number.

Round 18 — the 2026-07-02 backlog batch

Plan reference: ~/.claude/plans/can-you-knock-out-synthetic-horizon.md. Tests land in tests/test_round18.py. (The equalizer backlog item moved to Parked / deferred — see there.)

  • Drag tracks from the GNOME file browser into a playlist/Library: the copy-to-Artist/Album/ + unknown-artist machinery already existed; the gap was that playlist_view._on_files_dropped discarded the drop row. Position now threads through files_droppedMainWindow.import_filesimport_pathsadd_tracks_to_playlist(position), so dropped files land at the drop line (a file already in the library dedups to its existing track and still inserts in place).
  • Renaming artist/album/album_artist moves the file so the music folder stays organized like the library (LibraryManager._maybe_move_file under organize_root() = <music_folder>/Music): folders created as needed, Unknown Artist fallback, collision " 1" suffixes, empty dirs pruned (never the root), undo/redo moves the file back/forward, a failed move keeps the edit + old path and reports via file_move_failed in the status bar. Files outside the organize root are never moved. Cross-machine: location now merges newest-date_modified-wins in conflict_resolver, covering both sync-conflict files and live reload_from_disk. CLAUDE.md's never-move rule updated accordingly.
  • Visualizer: new "Visualizer (gray mode)" slider in the Preferences gray adjustments sets the dim-mode bar color (untouched = the old derived look); the color/gray/off click cycle is unchanged and the mode now persists across launches (visualizer_mode pref).
  • verify by eye in the running app: drop files from Nautilus into the middle of a manual-sort playlist (land at the drop line, Ctrl+Z removes); Get Info an Unknown Artist track → set artist → watch the file move in Nautilus (Ctrl+Z moves it back; try once while a track is playing); bulk album rename + one Ctrl+Z restores all; drag the visualizer slider while playing in gray mode; cycle modes + relaunch. (A scripted offscreen run of all of the above passed 2026-07-02.)

Round 17 — TASKS.md batch + cruft cleanup

Plan reference: ~/.claude/plans/can-you-take-a-drifting-sonnet.md. Tests land in tests/test_round17.py.

Phase 1 — cruft & correctness quick wins

  • 1a. Tag-write filter: _write_track_tags only writes fields in tagging.EDITABLE_FIELDS; rating/size edits become library-JSON-only and no longer rewrite music files (library_manager.py)
  • 1b. Replace print() with logging; new tag_write_failed signal surfaced in the status bar (library_manager.py, file_importer.py, main_window.py, main.py)
  • 1c. O(n²) import fix: {location: track} index per import_paths + cached _max_track_id for O(1) new_track_id (file_importer.py, library_manager.py)
  • 1d. TrackTableModel._row_by_id index → O(1) refresh_track / reveal_track (track_table.py)
  • 1e. threading.Lock around the scrobble-queue load/mutate/save (lastfm.py)
  • 1f. Last.fm login: prefs write marshalled to the GUI thread via an internal signal (lastfm.py)
  • 1g. Shared read_json/write_json in json_storage.py; drop the duplicates in conflict_resolver.py
  • 1h. lastfm._call: try JSON first, fall back to raise_for_status() on non-JSON bodies

Phases 24 — player & desktop integration

  • Task F: silent resume after pause — REDIAGNOSED per trav 2026-07-02: not Bluetooth-specific; happens on the Debian 13 machine whenever he walks away, resumes, and gets no sound despite the visualizer moving (decoding runs, the idle-suspended audio sink comes back dead; a slight rewind fixed it). Two-part fix in player.py: (1) _apply_volume() re-applied on resume / setDevice / BufferedMedia; (2) resume after a pause ≥30 s (RESUME_NUDGE_THRESHOLD_S) does a seek-in-place first — the automated version of the manual rewind, without losing the playback position.
  • verify Task F on the Debian 13 machine: play, pause, walk away ≥1 min, resume — must be audible without manually seeking
  • Task E: exit segfault — idempotent Player.shutdown() (stop → clear source → detach buffer/audio outputs → disconnect QMediaDevices), called from closeEvent + aboutToQuit. Verified 2026-07-02: scripted run (import → play to end → close) exits 0, no segfault.
  • Task C: MPRIS play/pause commandeering — root cause found & fixed: _notify sent PropertiesChanged with invalidated_properties marshalled as av instead of as, so gsd-media-keys dropped it and never bumped LinTunes in its media-key MRU. Now an explicit empty string-array via QDBusArgument (mpris.py). Verified 2026-07-02 by D-Bus loopback: old code's signature was sa{sv}av, new is sa{sv}as (test_round17).
  • verify Task C in practice: play in LinTunes with a stale YouTube tab around; the media key should control only LinTunes. Fallbacks if GNOME still misroutes (documented, not built): org.gnome.SettingsDaemon.MediaKeys.GrabMediaPlayerKeys, bus-name re-registration on play.

Phases 57 — features

  • Task B: search bar moved into the Library header strip (_top_strip), directly right of the sidebar Library button, so the browse/tracklist top aligns with the playlist tree (library_view.py)
  • Task A: rating hover dots — hovering a rating cell shows 5 slots (★ where rated, • where not); click slot k sets k stars; clicking the current rating clears it. RatingDelegate + rating_edited signal, wired to manager.edit_track_fields in both views (track_table.py, library_view.py, playlist_view.py). Rating is library-only (no music-file rewrite), undoable with Ctrl+Z.
  • Task D: right-click "Download Album Art…" — iTunes Search API (no key; upscale artworkUrl100 → 600x600), off-thread fetch, confirmation dialog with preview + Next Result, embed via tagging.write_artwork + size refresh, invalidate MPRIS art cache (new art_search.py, new gui/album_art_dialog.py, track_table.py, main_window.py, mpris.py). Follow-up 2026-07-02: "Use for All N Songs in Album" button applies the art to every library track on that album, not just the selection.
  • verify Tasks A/B/D by eye in the running app (hover/click ratings, search-bar alignment, art download on a real album)

Phase 8

  • TableSettingsMixin: dedupe sort/columns/width persistence between LibraryView and PlaylistView (new gui/table_settings.py)

Standalone maintenance (manual, not code work)

  • Artwork recovery against the LIVE data dir (embeds the 790 album-propagated covers; gets art-bearing coverage to 100%): 1. python3 scripts/audit_artwork.py --data-dir <data> (read-only re-check) 2. python3 scripts/recover_artwork.py --data-dir <data> --dry-run → review 3. re-run with --write; audit_artwork.py again to confirm Note: lintunes/itc.py is a live dependency of recover_artwork.py (tests in test_round9.py) — do not delete as "unused".

Parked / deferred

  • Equalizer ("maybe just some bass/mid/treble sliders in preferences" — trav 2026-07-02). Deferred from Round 18: QMediaPlayer has no audio-effects hooks, so even a 3-band EQ means either a custom decode→filter→output pipeline (replaces playback; risky for seek/formats) or leaning on the system (PipeWire filter-chain / EasyEffects). Needs a design decision before building.
  • Smart playlists Phase 2: nested-group editing UI in the criteria dialog (import + evaluation of nested groups already works; imported nested playlists are read-only until then). Big change, on hold.
  • Live-sync v2: per-playlist tombstones (so a delete on one machine isn't resurrected by the union merge during a simultaneous edit) + optional per-change accept/refuse review. Low priority — trav doesn't edit on both machines at once; v1 handles sequential use.
  • Data-dir location as an in-app preference (currently only --data-dir / --save-config).
  • Album-art grid view: a browsable grid of album covers for the library (the artwork-coverage investigation that motivated it is answered & tooled — see Standalone maintenance above).
  • Off-thread artwork reads: SidebarArt.set_track and the Get Info dialog read embedded art synchronously on the GUI thread (fine for local files; worth revisiting for large FLACs).
  • Progress UI (or background thread) for multi-selection tag writes in edit_tracks_fields — a big batch currently blocks the UI.
  • More aggressive media-key commandeering beyond the Task C fix, if the real-machine verification shows GNOME still routing keys elsewhere.