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

276 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
- [x] New `lintunes/eventlog.py``log_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.
- [x] 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).
- [x] `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.)
- [x] `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.
- [x] `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**.
- [x] 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.
- [x] 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.
- [x] `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.
- [x] `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**.
- [x] 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.
- [x] 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.
- [x] 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.
- [x] 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.)
- [x] 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_dropped`
`MainWindow.import_files``import_paths`
`add_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).
- [x] 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.
- [x] 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
- [x] 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`)
- [x] 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`)
- [x] 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`)
- [x] 1d. `TrackTableModel._row_by_id` index → O(1) `refresh_track` /
`reveal_track` (`track_table.py`)
- [x] 1e. `threading.Lock` around the scrobble-queue load/mutate/save
(`lastfm.py`)
- [x] 1f. Last.fm login: prefs write marshalled to the GUI thread via an
internal signal (`lastfm.py`)
- [x] 1g. Shared `read_json`/`write_json` in `json_storage.py`; drop the
duplicates in `conflict_resolver.py`
- [x] 1h. `lastfm._call`: try JSON first, fall back to `raise_for_status()`
on non-JSON bodies
### Phases 24 — player & desktop integration
- [x] 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
- [x] 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.
- [x] 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
- [x] 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`)
- [x] 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.
- [x] 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
- [x] `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.