Files
lintunes/TASKS.md
T
travandClaude Opus 5 8188a5b389 v0.18.0: paste a link, get the song
File > Import from URL... runs trav's `song` helper (yt-dlp --extract-audio
--audio-format mp3) from inside the app. Everything at the link downloads to
a private temp dir and is imported as each song lands. "and add to current
playlist?" puts the batch above the selected song, else at the end of the
playing playlist, else at the end of the shown one, in the link's order.
Each song goes straight into Identify Track. With no AcoustID key, the
proposal comes from the filename alone.

Album art now opens on a single click, in a window shaped like the cover
and as big as the screen allows.

Also fixed the dev fixture's play journal, which crashed startup (wrong
shape) and would have been ignored anyway (wrong keys). The loader now
skips a journal it can't read instead of aborting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5m9mXFPHNro78BdD69mG2
2026-09-10 23:44:45 -05:00

32 KiB
Raw Blame History

LinTunes — task board

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

tasks

  • archive the done tasks in here to another file, this is crufty....

Round 49 (2026-09-10) — Import from URL: done, see tasks-done.md

File ▸ Import from URL… (the song helper in-process), plus the album art opening on one click, art-shaped and screen-tall. Left on the table:

  • Machine 2 needs yt-dlp. A runtime CLI tool, not a pip dep: pip install --user yt-dlp (and ffmpeg, which it already has for Qt). The menu says so if it's missing.
  • Importing the same link twice makes a second copy. The importer dedups by file location, and a fresh download has a fresh path. The yt-dlp id (the [jNQXAC9IVRw] in the filename) is a stable key to check against before downloading.
  • "Stop Identifying (N left)" doesn't count songs still downloading. The queue only knows about songs that have landed.
  • The art window's title-bar allowance is a fixed 48 px. Wayland doesn't report frame size before the window is mapped. If GNOME's title bar is taller at some scale, the bottom of the cover can clip by a few pixels.

Round 48 (2026-09-10) — A library to break: done, see tasks-done.md

scripts/make_dev_library.py + the andTunes resume docs. Left on the table:

  • The fixture has no iTunes XML. test_itunes_importer.py still leans on the gitignored itunes-test-library/. A generator flag that emits a matching iTunes Library.xml would make the import path developable without trav's own export too.
  • No fixture for a Syncthing conflict. The merge rules are the subtlest thing in the codebase and the generator could plant a *.sync-conflict-* pair on demand — two copies with different date_modified, one stamped and one not — instead of every conflict test hand-rolling one.

Round 47 (2026-09-06) — LinTunes writes the device's library: done, see tasks-done.md

The desktop half of andTunes: a de-duplicated Music/andTunes/ tree, a library.json the R1 app reads instead of scanning, per-album cover export, and a ticked-playlist selection that makes unticking the way to remove one. The app itself is rounds 48–50 — its board is andtunes/TASKS.md. Left on the table:

  • Ship default button images. The spec wants the six menu tiles to be replaceable artwork "stored in an easy spot". The device side is designed (Buttons/<name>.png, app falls back to a bundled drawable) but LinTunes doesn't write the defaults yet — and when it does it must only fill in files that are missing, never overwrite trav's own.
  • A format gate for what Android can't decode. Sync copies every suffix verbatim today. AIFF and protected AAC will simply fail to play on the device. Wants the export/web_support.py shape: deny-by-default on the suffix with the iTunes kind breaking the .m4a tie, and FLAC-only conversion so it can never cost a bit.
  • Clean up the old Music/<Playlist>/ folders. They're outside the andTunes root and the containment guard makes them unreachable on purpose, so this has to be a separate, explicitly confirmed action — and only once andTunes has replaced Auxio.
  • Art export is the slow part of a sync. One mutagen open + JPEG encode per album, on the worker thread, uncounted in the progress bar's byte total (the bar sits full while the label says "Album art"). Fine for a few hundred albums; if trav syncs a thousand it wants a second progress phase with its own range.

Round 46 (2026-09-02) — The file already told you: done, see tasks-done.md

Filename-derived proposals when no database knows the song, plus using the file's own tags/name/duration to rank real lookup results. Left on the table:

  • Submit fingerprints back to AcoustID. The Zuuso case is unfixable by lookup because nobody ever submitted it — and LinTunes is holding a good fingerprint plus (after an identify) confirmed tags. /v2/submit takes exactly that. It would make the library better for everyone and fix trav's own second machine. Needs a user API key (not just the app key) and must be explicitly opt-in — never submit tags the user hasn't confirmed.
  • An LLM pass for the genuinely ambiguous ones. Raised by trav 2026-09-02. Worth being precise about where it helps: not the Zuuso case (no data exists to reason over) and not the Alhambra year (1961 isn't in the response either). It helps where there are many plausible candidates and the signal is semantic — an odd filename convention, or deciding whether a number is a performance year or a release year. Would be the app's first LLM dependency; keep it behind the same "nothing is written until accepted" gate.
  • A "clean up this filename" action independent of identify. Half of what round 46 built is useful with no network at all: 474 files in Unknown Artist/Unknown Album could have artist/title/track filled from their names in one pass. Wants the worker+progress split, not a dialog per track.

Round 45 (2026-09-02) — Ask the song what it is: done, see tasks-done.md

Right-click → Identify Track…: Chromaprint fingerprint + AcoustID lookup, a proposal dialog, oldest-year-wins. Left on the table:

  • Batch identify without a dialog per track. The queue reviews one track at a time, which is right for a handful and tedious for a hundred. A "fill only what's empty, above 0.9 confidence, no questions asked" pass would want the plan_export()-then-worker split from export/exporter.py (progress on the status bar, a cancel point) rather than the current one-at-a-time _identify_next chain, plus a summary at the end naming what it changed. Worth doing only if trav actually has a big pile of badly-tagged files.
  • Fetch the cover in the same pass. An identification hands back MusicBrainz release IDs, and the Cover Art Archive serves art by release ID — so a confirmed match could offer artwork without the separate artist/album text search that art_search.py does today. Needs meta=releaseids and a second endpoint; keep the deny-by-default "nothing is written until accepted" shape.
  • Genre is still untouched. AcoustID/MusicBrainz return tags, but they're folksonomy noise (dozens of overlapping user tags per recording) and iTunes-style genre is a single word. Deliberately skipped; revisit only with a mapping trav trusts.
  • Tracks under ~3 seconds can't be fingerprinted — Chromaprint returns "Empty fingerprint" and the status bar says so. Fine for music, would matter if the library ever holds sound effects.

Round 40 (2026-08-22) — welcoming the newcomers: done, see tasks-done.md

No startup font modal, a first-run music-folder question, and a portable music_folder. Left on the table:

  • Offer to move the files when the music folder changes. Today changing it warns that existing songs stay put and only new additions go to the new folder — honest, but someone genuinely relocating a library has to move it by hand. Constraints already worked out, so this needn't be re-derived: _move_file uses Path.rename, which is same-filesystem only, so a move to another drive needs a chunked copy + unlink (never shutil.move in one shot — no progress, no cancel point); every moved track's location must be repointed through LibraryManager, and its date_modified bumped or _merge_track_fields won't carry the new path across a sync; files outside the organize root must not be touched; and it wants the plan_export()-then-worker split from export/exporter.py so it can show progress and be cancelled. Not undoable — an UndoStack command is a synchronous closure, and a 21k-file move can't run on the GUI thread. Tabled 2026-08-22: trav has no plans to move his own library, so this was not worth the round.

Round 37 (2026-08-19) — Export Playlist: done, see tasks-done.md

Folder + web-mix export, audio.js/jQuery dropped for a dependency-free player, lossless-only conversion.

Round 38 (2026-08-20) — merge windows + play journals: done, see tasks-done.md

Round 39 (2026-08-20) — playlist order + honest date_modified: done, see tasks-done.md

Round 36 — the merge rework (closed out by Rounds 38 and 39)

The three symptoms in Round 35 shared a root cause; that round fixed the performance half. Round 38 took the play-count and dialog items, Round 39 the two playlist ones. Only the .stignore note below is left.

  • Playlist merges lose position. Done in Round 39 — merge_track_order anchors each side-only track to the nearest track both copies share. Replayed against the real a nissa one: the reorder survives and a mid-list insert lands at index 5, not 26.
  • mtime is a lie for playlists. Done in Round 39 — Playlist.date_modified is bumped only in _set_track_ids, and _on_section_resized now ignores the stretched last column, so resizing the window doesn't rewrite the playlist file at all.
  • max() play counts discard concurrent plays. Done as described — per-machine plays/<machine-id>.json journals, machine id in the config dir. Three plays on the real library now write 83 bytes instead of 15 MB, and library.json isn't touched at all.
  • Quiet the merge dialog. One dialog per session: each merge is folded in as a timestamped entry (newest first) instead of opening another window. Fifteen had stacked up. Kept the window for every merge rather than demoting lossless ones to the status bar — with journals landing, a merge stops being routine and is worth seeing.
  • Consider a .stignore for .resolved so merge backups stop syncing (Round 35 bounded the folder to 10 snapshots, but it still replicates).

old

Round 28 — cancelable device sync (v0.3.0)

  • "✕" button left of the sync progress group; expands to "cancel transfer" on hover; confirm dialog (Cancel Transfer / Keep Copying) before cancelling.
  • DeviceSyncWorker.cancel(): stops at the next chunk boundary, removes the in-flight partial file, emits cancelled with copied/total counts.
  • Cancelled syncs always leave a coherent device folder: the m3u is rewritten to list only tracks actually present (fixes the pre-existing gap where stale files deleted before a cancel could leave dangling m3u refs).
  • Recoverability verified: cancel-then-resync produces byte-identical results to an uninterrupted sync (test + live run against the real Rabbit).

Tests in tests/test_round28.py. Feature round → minor bump 0.3.0.

Round 27 — device-sync guards (v0.2.2)

  • Quit warning while a transfer is copying (closeEvent; covers X button, Ctrl+Q, MPRIS Quit, and the self-update restart, which used to bypass closeEvent entirely).
  • Machine can't sleep or shutdown/restart mid-transfer: second SleepInhibitor (suspend+logout flags, its own reason text for GNOME's dialog).
  • Found & fixed: the GNOME Inhibit D-Bus call sent signed ints against a (susu) signature, so the playback sleep inhibitor had silently never worked — _uint marshalling repairs both.
  • Investigated Syncthing changes landing mid-copy: safe by design (the SyncPlan is a click-time snapshot; the worker never reads live library state). Hardened the one gap: a source file relocated under the queue is skipped + dropped from the m3u + reported, instead of aborting the sync.

Tests in tests/test_round27.py. Fix round → patch bump 0.2.2.

Round 26 — Device menu: sync playlist to Rabbit R1 (v0.2.0)

  • Device menu (left of Track) with "Sync Playlist to Rabbit"; grayed out on the Library view, while syncing, or when no Rabbit is plugged in.
  • device_sync.py: MTP/gvfs detection (the Rabbit mounts via MTP, not mass storage), name+size incremental diff, stale-file deletion scoped to the playlist's own folder, Auxio-importable .m3u, chunked-copy worker thread.
  • Up-front free-space check with a needed-vs-available alert.
  • Right-justified sync progress bar in the status bar (version left, totals middle, sync right).

Tests in tests/test_round26.py. Feature round → minor bump 0.2.0. Follow-up polish (0.2.1): sync status split into a plain "Copying to Rabbit R1 · X / Y" label + textless bar (overlay text didn't fit), and the Device menu moved to the right of Track per trav.

Round 25 — folder highlight while dragging a playlist (v0.1.6)

Tests in tests/test_round25.py. Fix round → patch bump 0.1.6.

Dragging a playlist over a folder gave no indication the drop would land inside it. PlaylistTree now highlights the hovered folder mid-drag with the same treatment track drops give playlist rows, via a shared _highlight_drop_target(pos, kind) helper ("folder" for internal moves, "playlist" for track drops). A folder in the dragged row's own branch is never highlighted — move_playlist rejects that drop as a cycle, so lighting it up would lie. The Round 24 autoscroll tick's highlight refresh became kind-aware (_drag_target_kind) so a folder highlight survives edge auto-scrolling instead of being cleared by the track-only check.

  • lintunes/gui/sidebar.py — _highlight_drop_target / _is_own_branch helpers; _drag_target_kind tracked through dragMoveEvent, reset in _stop_autoscroll.

Round 24 — sidebar drag auto-scroll (v0.1.5)

Tests in tests/test_round24.py. Fix round → patch bump 0.1.5.

Dragging a playlist near the top/bottom edge of the sidebar tree scrolled nothing: PlaylistTree.dragMoveEvent fully overrides the base implementation, so Qt's built-in autoscroll (in QAbstractItemView.dragMoveEvent) never ran — the same root cause Round 13 fixed in the track table. Ported that machinery (autoscroll_direction shared from track_table, 40 ms timer, one unit per tick) into PlaylistTree. Playlist moves keep the scroll live at the edge even over non-folder rows (the tree always accepts an internal move somewhere); track drags gate on hovering a real playlist row, matching the drop-target highlight, and the highlight re-tracks the row under a held-still cursor as it scrolls.

  • lintunes/gui/sidebar.py — _update_autoscroll / _autoscroll_tick / _stop_autoscroll wired into dragMoveEvent, dragLeaveEvent, dropEvent.

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.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.
  • 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_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).
  • 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 2–4 — 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 5–7 — 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. See the backend note below — a GStreamer sink would make this a drop-in equalizer-3bands element instead of a redesign.

  • Swap the local sink from Qt Multimedia to GStreamer (GstSink). Assessed 2026-08-15 while fixing the Round 34 audio leak. Not urgent — the parking brake handles the leak — but the case is real and it should be the plan whenever the equalizer or gapless comes up, since those are what make it pay for itself.

    Why: Qt Multimedia's FFmpeg backend has cost us five workarounds now, all
    of them in `LocalSink` — the stale-`LoadedMedia` URL tagging, the
    `setSource` no-op dance, the resume nudge, the shutdown-ordering segfault
    guard, and now the parking brake. GStreamer is what every other Linux
    music player uses (Rhythmbox, Lollypop, Amberol), corks properly on
    pause, and unlocks three parked/impossible features: a 3-band EQ
    (`equalizer-3bands`), true gapless (`playbin3` `about-to-finish`), and
    ReplayGain (`rgvolume`).
    
    Cost: `LocalSink` is ~180 lines and `PlaybackSink` is already a real
    seam (proven by `CastSink`), so the sink itself is bounded. The bigger
    cost is the tests — 10+ files stub Qt Multimedia *by name* via
    `patch.multiple(player_module, QMediaPlayer=…, QAudioOutput=…,
    QAudioBufferOutput=…, QMediaDevices=…)`. Also: new system dep
    (`python3-gi` + `gstreamer1.0-*` via apt here, `python3-gobject` +
    `gstreamer1-plugins-*` via dnf on the Fedora machine — the self-updater
    only does `git pull`, so both machines need it installed by hand before
    the push lands), GLib bus pumped from a `QTimer` rather than a GLib main
    loop, and the visualizer's PCM tee rebuilt on `appsink` instead of
    `QAudioBufferOutput`.
    
    Do it incrementally: write `GstSink` alongside `LocalSink`, put it behind
    a preference, run it for a week, delete the Qt one when it's trusted.
    Already available here: GStreamer 1.26.2 + Python bindings + libav,
    pipewire and good/base plugin sets.
    
  • 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.