198 lines
12 KiB
Markdown
198 lines
12 KiB
Markdown
# LinTunes — task board
|
||
|
||
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||
When a round closes, move its finished items to `tasks-done.md`.
|
||
|
||
## Round 19 (current) — 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 2–4 — 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 5–7 — 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.
|