File → Export Playlist…, and the same item on a playlist's right-click
menu. A sibling of device_sync: pure plan_export() first, then a worker
on a daemon thread sharing the status-bar progress widgets. The manifest
(index.html / .m3u) is written last, so an interrupted export never
leaves a page naming files that aren't there.
Two destinations. A folder gives the audio under "Artist - Title" names
plus an extended .m3u. A web mix gives a self-contained static site
reproducing the hand-made yearly mixes, with a dialog for title,
description and hero image.
audio.js and jQuery are gone. The old pages shipped ~293 KB: a build of
audio.js whose upstream hasn't moved since 2012, plus jQuery 3.2.1
(CVE-2019-11358, CVE-2020-11022, CVE-2020-11023 — unexploitable on a
static page, since nothing untrusted reaches a jQuery HTML sink, but
dead weight regardless). audio.js never used jQuery; jQuery was there
for ~25 lines of tracklist glue. Both are replaced by a dependency-free
player.js plus a player.css transcribed from the customized audio.js
skin, so the page looks identical — same 250px #c7b563 bar, same
player-graphics.gif (byte-identical: it's an animated GIF whose loading
frame is a spinner), same shortcuts. ~293 KB → ~6 KB. The Flash
fallback went too; audio.js gated it on !canPlayType("audio/mpeg;"),
unreachable since ~2010.
Conversion happens only when a browser genuinely can't decode a file,
never because of bitrate — a 320 kbps MP3 is copied verbatim. Everything
that does convert targets FLAC, so a conversion cannot cost a bit; a
test asserts the exported FLAC's decoded PCM hashes identical to the
ALAC source. Against the real library that's 105 Apple Lossless and 8
AIFF out of 21,382 tracks. DRM'd tracks are reported in a confirmation
dialog, never silently dropped and never attempted. ffmpeg is the CLI
binary here, not Qt's ffmpeg backend, so it's detected at runtime.
Verified in Chromium 151 and Firefox 153 over CDP/Marionette: every
exported format decodes including the converted FLAC, the player builds,
click / space / arrows / scrubber-seek / autoplay-next all work, and the
network log shows no request for jquery, audio.min.js or any .swf.
mix-example/ removed; the template reproduces it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
449 lines
25 KiB
Markdown
449 lines
25 KiB
Markdown
# 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 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 36 below is still open.
|
||
|
||
## Round 36 — the merge rework (planned, Round 35 covered the speed half)
|
||
|
||
The three symptoms in Round 35 shared a root cause; that round fixed the
|
||
performance half. This is the correctness half.
|
||
|
||
- [ ] **Playlist merges lose position.** `_merge_playlist` is a 2-way union with
|
||
no common ancestor: it takes one side's order wholesale and *appends* the
|
||
other side's extras, so a track inserted in the middle on one machine
|
||
arrives at the tail on the other (this is what happened to `nissa one`).
|
||
Replace with an anchor-based merge — insert each side-only track after its
|
||
nearest preceding common anchor. Same in `_reconcile_playlist`.
|
||
- [ ] **mtime is a lie for playlists.** `mark_playlist_settings_dirty` rewrites
|
||
the whole playlist file for UI-only changes, and because the last column
|
||
is stretch-sized, *resizing the window* rewrites the open playlist's JSON.
|
||
So "most recently edited" often means "most recently resized". Give
|
||
`Playlist` a `date_modified` bumped only in `_set_track_ids`, and merge on
|
||
that.
|
||
- [ ] **`max()` play counts discard concurrent plays.** Base 100, one machine
|
||
plays 5 (105), the other plays 3 (103) → merge keeps 105 and those 3 are
|
||
gone. Per-machine `plays/<machine-id>.json` journal (machine id kept
|
||
outside the synced dir): only its owner ever writes it, so play data can
|
||
never conflict, effective count = base + sum of journals, and `library.json`
|
||
stops being rewritten every 3 s during playback — which is what generates
|
||
the conflicts in the first place.
|
||
- [ ] **Quiet the merge dialog.** Lossless merges (counts and dates only) should
|
||
be a status-bar line, not a window; keep the dialog for lossy cases, and
|
||
reuse one instance so six can never stack up again.
|
||
- [ ] 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)
|
||
|
||
- [x] "✕" button left of the sync progress group; expands to "cancel transfer"
|
||
on hover; confirm dialog (Cancel Transfer / Keep Copying) before cancelling.
|
||
- [x] `DeviceSyncWorker.cancel()`: stops at the next chunk boundary, removes
|
||
the in-flight partial file, emits `cancelled` with copied/total counts.
|
||
- [x] 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).
|
||
- [x] 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)
|
||
|
||
- [x] 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).
|
||
- [x] Machine can't sleep or shutdown/restart mid-transfer: second
|
||
SleepInhibitor (suspend+logout flags, its own reason text for GNOME's dialog).
|
||
- [x] 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.
|
||
- [x] 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)
|
||
|
||
- [x] 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.
|
||
- [x] `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.
|
||
- [x] Up-front free-space check with a needed-vs-available alert.
|
||
- [x] 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.
|
||
|
||
- [x] `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.
|
||
|
||
- [x] `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.
|
||
|
||
- [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 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.
|
||
**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.
|