From da9186420bf78f7a872db856d5d0f640190d7dbb Mon Sep 17 00:00:00 2001 From: trav Date: Mon, 28 Sep 2026 19:37:11 -0700 Subject: [PATCH] moved tasks to projects board --- CLAUDE.md | 7 +- TASKS.md | 582 ------------------- andtunes/DEVICE.md | 28 + andtunes/README.md | 4 +- andtunes/TASKS.md | 120 ---- cassette-feature.txt | 91 +++ tasks-done.md | 1315 ------------------------------------------ 7 files changed, 125 insertions(+), 2022 deletions(-) delete mode 100644 TASKS.md create mode 100644 andtunes/DEVICE.md delete mode 100644 andtunes/TASKS.md create mode 100644 cassette-feature.txt delete mode 100644 tasks-done.md diff --git a/CLAUDE.md b/CLAUDE.md index 5bce986..ab120e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,9 @@ LinTunes is an iTunes-replacement music library manager and player for Linux, built with PyQt6 / Qt Multimedia. It imports an iTunes 12 library, stores the library as plain JSON (syncable via Syncthing), and reproduces the iTunes UI (left sidebar + right playlist view, column browser, customizable per-playlist -columns). `spec.md` is the original design brief; `TASKS.md` is the live backlog -and `tasks-done.md` records completed work. +columns). `spec.md` is the original design brief. Tasks live on the kanban board at +`~/Documents/projtracker/projects/lintunes/` (use the `tasks` skill); the old +`TASKS.md`/`tasks-done.md` are archived there — don't recreate them. ## Commands @@ -336,7 +337,7 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal - **`lintunes/andtunes/`** — the desktop half of andTunes, a music player for the Rabbit R1 (Round 47; the app itself lives in `andtunes/` at the repo - root, board in `andtunes/TASKS.md`). The premise: **the app never scans + root, R1 measurements in `andtunes/DEVICE.md`, tasks tagged `andtunes` on the board). The premise: **the app never scans anything.** Auxio re-reads Android's MediaStore on every launch, which is the "your songs will show up here" hang — but LinTunes already knows the artist, album and year of every file it just copied, so it writes them into diff --git a/TASKS.md b/TASKS.md deleted file mode 100644 index c207245..0000000 --- a/TASKS.md +++ /dev/null @@ -1,582 +0,0 @@ -# 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/.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//` 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. - -- [x] **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. -- [x] **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. -- [x] **`max()` play counts discard concurrent plays.** Done as described — - per-machine `plays/.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. -- [x] **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) - -- [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`): 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 ` (read-only re-check) - 2. `python3 scripts/recover_artwork.py --data-dir --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. diff --git a/andtunes/DEVICE.md b/andtunes/DEVICE.md new file mode 100644 index 0000000..d8bd846 --- /dev/null +++ b/andtunes/DEVICE.md @@ -0,0 +1,28 @@ +# andTunes — device facts + +Measured facts about the Rabbit R1 that the app is built around. +(Moved out of the old andtunes/TASKS.md board on 2026-09-24.) + + +- Panel **480 × 640 px**, physical density 320, **override density 160** — so + the app sees a 480 × 640 **dp** canvas on a 2.88" screen (~278 real ppi). +- **One dp is about half its usual physical size here.** Double every stock + value: body text 28–32 sp, list rows ≥ 88 dp, menu tiles ~213 dp. A 48 dp + row would be 4.4 mm tall. Do not "fix" this by changing the device density — + trav likes it where it is. +- Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free. +- **The wheel can only ever give us detents.** `getevent -pl` (2026-09-14): + `och1970_holl_key` advertises `KEY (0001): KEY_UP KEY_DOWN` and *nothing + else* — no `EV_REL`/`REL_WHEEL`, no `EV_ABS`, no resolution. The `och1970` + is a Hall latch, so the driver quantises the magnet into clicks and hands + userspace one key press each. There is no sub-detent position to read + without a kernel driver change, so every bit of nuance has to be derived + from the *timing* between detents (see `WheelScroll`). +- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**, + mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`) + to **`KEYCODE_DPAD_UP` / `DPAD_DOWN`** — not volume. Side/PTT button sits + on `mtk-kpd` with KEY_VOLUMEDOWN + KEY_POWER. Headset exposes + KEY_PLAYPAUSE, so a MediaSession gets media keys for free. +- USB: set to come up as **`mtp,adb`** whenever the screen is unlocked + (`adb shell svc usb setScreenUnlockedFunctions mtp`, 2026-09-11), so the + gvfs mount sync uses and the adb install uses are both there at once. diff --git a/andtunes/README.md b/andtunes/README.md index 5885197..50ff558 100644 --- a/andtunes/README.md +++ b/andtunes/README.md @@ -209,6 +209,6 @@ push onto any Android device or emulator with `adb push`. That tree includes the awkward cases on purpose — an emoji album name, a 208-character title, two tracks that differ only by id, a track with no artist. -## Board +## Device facts -`TASKS.md` in this directory. +`DEVICE.md` in this directory: screen density, wheel keycodes, USB modes. diff --git a/andtunes/TASKS.md b/andtunes/TASKS.md deleted file mode 100644 index 8a275f4..0000000 --- a/andtunes/TASKS.md +++ /dev/null @@ -1,120 +0,0 @@ -# andTunes — task board - -A music player for the Rabbit R1 that never scans anything: LinTunes writes -`Music/andTunes/library.json` during sync and the app just reads it. See -`../andTunes spec.txt` for the original brief. - -Legend: `[ ]` todo · `[~]` in progress · `[x]` done. - -**Status: phases 1–3 done (2026-09-11, LinTunes rounds 50–51, andTunes -0.2.0); 0.2.1 (LinTunes round 54, 2026-09-14) fixed the grouping crash — -`Library.group()` keyed albums case-folded but artists raw-case, so one artist -spelled two ways on one album ("RJD2" / "Rjd2") took the whole library down -with `Couldn't read library.json`. Both maps fold case now, and `Artist` has a -`key` the way `Album` always did. 0.2.2 (round 55, 2026-09-14) reversed the -wheel in lists and made it scroll smoothly (`WheelScroll` — a pixel debt paid -off per frame, not a row per detent); volume on the other screens is -unchanged.** What's left is the *Parked* list, pulled in -as trav finds he needs it. Built with -`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see -`README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round -each lands in is decided when it starts. - -## Device facts (measured 2026-09-06, `adb shell wm size` etc.) - -- Panel **480 × 640 px**, physical density 320, **override density 160** — so - the app sees a 480 × 640 **dp** canvas on a 2.88" screen (~278 real ppi). -- **One dp is about half its usual physical size here.** Double every stock - value: body text 28–32 sp, list rows ≥ 88 dp, menu tiles ~213 dp. A 48 dp - row would be 4.4 mm tall. Do not "fix" this by changing the device density — - trav likes it where it is. -- Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free. -- **The wheel can only ever give us detents.** `getevent -pl` (2026-09-14): - `och1970_holl_key` advertises `KEY (0001): KEY_UP KEY_DOWN` and *nothing - else* — no `EV_REL`/`REL_WHEEL`, no `EV_ABS`, no resolution. The `och1970` - is a Hall latch, so the driver quantises the magnet into clicks and hands - userspace one key press each. There is no sub-detent position to read - without a kernel driver change, so every bit of nuance has to be derived - from the *timing* between detents (see `WheelScroll`). -- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**, - mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`) - to **`KEYCODE_DPAD_UP` / `DPAD_DOWN`** — not volume. Side/PTT button sits - on `mtk-kpd` with KEY_VOLUMEDOWN + KEY_POWER. Headset exposes - KEY_PLAYPAUSE, so a MediaSession gets media keys for free. -- USB: set to come up as **`mtp,adb`** whenever the screen is unlocked - (`adb shell svc usb setScreenUnlockedFunctions mtp`, 2026-09-11), so the - gvfs mount sync uses and the adb install uses are both there at once. - -## Phase 1 — plays a song (v0.1.0) ✔ - -- [x] `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also - present; `build.py` points at 21.) -- [x] Android SDK: platform 33 + build-tools 34.0.0 were already in - `~/Android/Sdk`. **No Gradle, no Kotlin** — `build.py` runs aapt2 → - javac → R8 → zipalign → apksigner directly (trav, 2026-09-11: don't - pull a GB over cell if it isn't needed; it isn't). -- [x] Project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33, - framework only: `Activity`, `ListView`, `MediaPlayer`, `MediaSession`, - `Notification.MediaStyle`, R8. The APK is 61 KiB. -- [x] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends the user to - the toggle (LinTunes' installer grants it over adb, so it rarely shows). -- [x] Library loader: `android.util.JsonReader` streaming parse off the main - thread into a process singleton; artists/albums grouped in one pass; - an unknown `format` gets a readable message; reloads when - `library.json` changes (a re-sync) the next time the menu resumes. -- [x] `MenuActivity`: six tiles, 2 × 3, **loads nothing**. The sixth reads - "Now Playing" or "Shuffle All" depending on the service. -- [x] `ListActivity` in songs mode + "Shuffle all" header row. -- [x] `PlaybackService` (foreground) + `NowPlayingActivity`: white - background, square art, slim scrubber, ◀ ⏯ ▶, shuffle, repeat. - **No back button.** -- [x] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification. -- [x] End of queue **pauses** unless repeat is on. Repeat: off / all / one. -- [x] `adb shell am start -W`: **313–338 ms** cold to the menu (551 ms on the - very first launch after install, which includes dex verification). - -## Phase 2 — the rest of the screens ✔ - -- [x] Playlists, artists (→ "All songs" + albums; all songs has its own - shuffle), albums, search (artists, albums and songs as you type). -- [x] Album art thumbs in album rows, full art on now playing. -- [x] The "Now playing" bar at the bottom of every list screen; the playing - song is drawn inverted in any list it appears in. -- [x] Scroll wheel → list scrolling (one row per detent), volume on the menu - and now-playing screens. Taken in `dispatchKeyEvent`, DPAD *and* - VOLUME codes, down *and* up, so the search box can't swallow it. -- [x] Replaceable button images: `Buttons/.png` beats the bundled - drawable, for the six menu tiles plus ◀ ▶ ⏯. LinTunes ships the - defaults into `Buttons/` only where the file is missing. -- [x] Missing file (synced then deleted) skips with a toast, doesn't stop - the queue. -- [x] Resume last queue + track + position on launch (paused). - -## Phase 3 — install from LinTunes, counts coming back - -- [x] `Connections → Install andTunes on Rabbit…` (LinTunes 0.19.0): - `adb install -r` + the two permission grants; says Install / Update / - Reinstall from the version on the device; with no adb, copies the APK - to `Download/` over MTP for a manual tap. APK committed at - `lintunes/android/andTunes.apk` (+ `andTunes.json`, its version). -- [x] Format gate (LinTunes 0.20.0): sync reuses - `export/web_support.conversion_for` outright, because Android's - MediaPlayer decodes the same set a browser does. Protected AAC is - refused and reported; ALAC, AIFF and anything else unplayable becomes - **FLAC** (never a lossy re-encode). Conversions are cached per track, so - they're converted once and size-diffed after that. -- [x] Play counts back (andTunes 0.2.0): a natural finish counts a play, as - it does in LinTunes. The app keeps `plays/andtunes-.json` in - the per-machine-totals shape of `storage/play_journal.py`. Sync folds it - into `/plays/` with the per-track max *before* copying - anything, so the R1 is one more machine in the Round 38 model and there - are no new merge rules. -- [x] Retired `Sync Playlist to Rabbit (Auxio)` from the menu. (trav's R1 - has no old `Music//` folders, so no cleanup was needed.) - -## Parked - -- Gapless, crossfade, EQ, ratings, folder browsing, sleep timer, Android Auto. -- Deleting the old per-playlist folders automatically. They're outside the - andTunes root and the containment guard makes them unreachable on purpose; - removing them should be an explicit, separately-confirmed action. diff --git a/cassette-feature.txt b/cassette-feature.txt new file mode 100644 index 0000000..499c6b4 --- /dev/null +++ b/cassette-feature.txt @@ -0,0 +1,91 @@ +social/napster layer to lintunes + +what is the user journey??? hehe + +want to subscribe to playlists +don't want any central servers I run +syncthing perhaps, per person +each person has a folder they share +and you sync it down +and you can copy it to your own machine +and it's lowkey a gossip protocol bc if you're on the same network with someone else who follows that friend you can give them the tracks +how to limit total size? + +OH what if you sync a COPY of that person's library file +that knows what tracks they have +and you can peruse it +and mark certain ones as wanted +so maybe you have a want list + +each person's share folder is read only to you + + + + +2026-09-28 +what are necessary features? +what features does syncthing afford? +let's do the venn diagram of those +then do the ones that it afford but aren't necessary + + + + +follow someone by pasting a link? +do it under Connections menu +it would have "add friend library" or "share library with a friend" +add friend library gives you a pop up with a text field where you can paste in the syncthing link that's read-only to you +you also create a folder that's read-only for them and it outputs that link for you to give the friend +they can paste this link into the same "add friend library" +there should also be a menu item that says "sync settings" +all these menu items can be grouped together under Connections between a couple horizonal lines. + +sync settings can have a list on the left of 'general settings', and then in the list each friend you're synced with +this should look and behave like the settings window in vlc with the list on the left and settings that pop up on the right. +for each one it'll give you status of Syncthing generally and each friend connection. Whether you've made contact with the other machine. +The way the friend sync should work is that each user chooses what of their library they want to sync. This is in sync settings for each friend. +by default it should have checked 'share whole library +if that button is unchecked then a browser (like the browser in the app, genre, artist, album) shows up as well as 2 buttons, 'select all' and 'select none' +we need to make sure there's a cancel button on this window in case people press one of these buttons by accident, thus clearing their selectionn. +each artist, album and song has a little checkmark next to it +whatever has a checkmark, these artists, albums are made available to the other user +in the folder that we share with them, that's read-only to them, contains shrunken library database copies. If someone has selected to share all artists and all playlists, it's basically just a copy of the local library. +if it's pared down on either the playlists or artits, etc then those are removed from the copy of the library in the share folder +we only need to write the file when we hit 'ok' in the settings menu. +we can set what we share with each friend in each friend settings +OR +you can set a setting in the general sync settings that you want to share the same thing to all your friends and then you pick in the general settings. + +share all playlists is default unchecked with none of the playlists selected +thus defaulting to a user opting in to sharing all their playlists +in general settings there's a setting for what your name shows up as to other users. + +in the main window we now need to cut out a small square of the Library button to put a little down-arrow triangle library selector button +hierarchically this button should appear as a smaller part of the library button, resting right next to it and not being sloped on the corners where they meet. +clicking that button causes a tiny popup window to show below it where you can select your friends libraries by name +when switching in to their library it now shows all of their library and playlists as if you're using their lintunes +but the files aren't all there +there is now another column that shows up by default all the way to the left of the other columns +this is a download request button +each artist, album and song will have a little embossed button with a cassette tape icon on it +if you press it it goes gray (as does the icon next to any sub-items, ie, clicking the cassette for an artist will transitively click the cassette for all albums and tracks of that artist) +if you hover over a grayed-out pressed cassete button it should go medium-gray indicating it can be pressed to swap back from selected to un-selected +they're esentially all checkboxes. + +If any new artists/albums/tracks are selected or unselected, 'cancel' and 'save' buttons will appear in the bottom menu to go back to your own library and update your requests file +this requests file is read by the other lintunes and if it sees a song that has been 'cassetted', ie, selected by the friend lintunes, then the subsequent file is saved to the out-box that is read-only synced back to the friend. +the other friend's lintunes tracks that folder and when files are synced in there that we have 'cassetted' then we copy them into our library as if the 'add to library' menu item had been selected +ie, sort into the right folder. Import the song. +we then now update the list of tracks we have 'cassetted' to a specific friend because we no longer cassette it, we have it now. + +when viewing a friend's library you can say 'see only tracks I don't have' or 'see all tracks' in the bottom menu. + +I imagine this will require some delta-library folders or ways to keep track of state. We want to be resilient if sync goes wonky. +also we want it to be fast so we want to limit disk writes and reads. It should be fast because it's all local. We let syncthing handle everything that goes over the network. + +I now also want to by default on new installs to add a smart playlist called 'new tracks' which shows stuff that has been added to your library in the past 3 months +this can be deleted just like any other smart playlist + + + +additionally I'd like to add some emboss to all of the buttons. Just a real slight emboss, soft. diff --git a/tasks-done.md b/tasks-done.md deleted file mode 100644 index 852edb8..0000000 --- a/tasks-done.md +++ /dev/null @@ -1,1315 +0,0 @@ -## Done - -### Round 60 (2026-09-22) — covers for the whole album (v0.23.0) - -- [x] **Paste artwork onto a multi-selection.** Multi-track Get Info keeps - the art square. It shows the shared cover if every track (≤100) has - the same one, otherwise "mixed artwork". A pasted cover goes into - every selected track on OK; untouched, nothing is written. -- [x] **Paste that takes the first time.** There's now a Paste Artwork - button, and Ctrl+V anywhere in the dialog outside a text field pastes - the cover, so no click has to land on the square first. A caption - says what happened ("New artwork — saved when you click OK", "The - clipboard doesn't hold an image", …). A read that doesn't decode is - retried 3× at 300 ms and is **never staged**; before this, a - half-read PNG would blank the square and could have been embedded on - OK. The root cause of the flaky paste is unconfirmed: the journal - shows no Qt Wayland clipboard timeouts. So each attempt now logs its - formats, byte counts and decode result at INFO, where the journal - will show the next failure. -- [x] **Deezer as a second art source.** iTunes has no *Reachin'* at all; - Deezer has it at 1000 px. Results are merged and ranked by album - match; the dialog says "via iTunes/Deezer". iTunes retries with the - title minus its parenthetical ("Reachin'") when the full one finds - nothing. -- [x] `embed_artwork` shared by Get Info and Download Album Art. Single-track - Get Info now invalidates the MPRIS art cache too; it never did. -- [x] Drag an image straight from a browser onto the square (image data, - not only local files). - -### Round 58 (2026-09-18) — lookup on youtube (v0.22.0) - -- [x] **"lookup on youtube" in a song's right-click menu.** Opens the default - browser on a YouTube search for " <artist>" - (`track_table.youtube_search_url`, encoded via `QUrlQuery`). Offered for - a single selected track only, and without needing a file on disk. - -### Round 57 (2026-09-18) — click to rename (v0.21.0) - -- [x] **Click a selected playlist to rename it.** The sidebar tree gains - Qt's `SelectedClicked` trigger alongside double-click/F2, so the - second click on the current playlist opens inline rename (after the - double-click interval; a press that becomes a drag never opens it). -- [x] **Rename opens with the cursor at the end, nothing selected.** Qt - selects the editor's text inside `QAbstractItemView::edit()`, after - the delegate runs, so `PlaylistTree.edit()` undoes it afterwards — - every route (click, double-click, F2, context menu) goes through it. - A brand-new playlist still selects all (one-shot - `_select_all_next_edit`), so typing replaces "untitled playlist". - -### Round 56 (2026-09-17) — one press, sixteen hundred songs (v0.20.6) - -One Right-arrow press skipped 1,676 tracks in 81 s and hard-froze GNOME badly -enough to need SysRq (incident log `~/claude-diag/logs/issue-log-2026-09-17.md`). -It was a feedback loop, and both halves were ours. - -- [x] **Transport keys act once per press.** On Wayland key repeat is - generated by the *client* until the compositor delivers the release, and - `MainWindow.eventFilter` treated every repeat as a new press. Space, Left - and Right now swallow auto-repeats (swallow, not pass through, so a held - Right doesn't walk the table cursor instead). -- [x] **`xesam:artist` goes out as `as`.** A plain `[str]` inside `Metadata` - marshals as `av`; gnome-shell logged an error for it (32k–50k/min during - the incident) and reloaded the cover per track change. That load is what - kept it from delivering the key release, so the repeats never stopped. - `mpris.string_array` builds the typed array, shared with - `build_properties_changed`'s `invalidated_properties` (Round 17's same - pitfall). The test round-trips the signal into GDBus, which is what - gnome-shell validates with, and fails `'av' == 'as'` on the old code. - -### Round 55 (2026-09-14) — the song that wasn't there (v0.20.4, andTunes 0.2.2) - -trav couldn't play the first track of his playlist: shuffle off gave him song -2, shuffle on gave him a random one. The player was right both times — -`library.json` named a song whose file was not on the device, and skipping an -unplayable file advances one slot. Verified on the Rabbit: tapping any song -that *is* there plays exactly that song, shuffle or not. - -- [x] **The phantom directory.** `Media/Dionne Farris/` existed as far as gvfs - was concerned — it would list the album inside it — and did not exist at - all per `adb`. Every write into it failed `EIO`, and - `mkdir(exist_ok=True)` saw the phantom and did nothing, so it could never - heal while the mount lived. Freshly made folders worked fine, so it was - stale cache, not a broken mount. Only remounting clears it. That folder - was new because Round 52's retag moved the file under a new artist. -- [x] **A refused copy costs that song, not the sync**, and the song is left - out of the m3u and `library.json`. Aborting cost 2,400 songs for one - folder; counting it present anyway put a song in the manifest that isn't - on the device, which is what trav was hearing. The names come back in - the summary and a dialog, so it is never silent. -- [x] **Planning is a worker** (`AndTunesPlanWorker`). It is pure and writes - nothing, but it walks every file on the device, and over MTP that is - thousands of round trips — on the GUI thread it froze the window and - GNOME offered to kill LinTunes, which is how a sync got force-quit - halfway. `disk_usage` rides along, being one more MTP call. -- [x] **The tail of the sync says what it's doing.** "Album art 501/501" is - emitted *before* the last album, and then `_write_index`, - `_ship_buttons` and `_prune_empty_dirs` ran silently — 501 `exists()` - stats over MTP, ~840 KB of writes and a full tree walk with the progress - line frozen. They report now, and `_write_index` reads `Art/` in one - listing instead of a stat per album. -- [x] **The wheel scrolls kinetically, and the other way** (`WheelScroll`; - v0.20.5 / andTunes 0.2.3 refined the first pass). `getevent -pl` settled - what's possible: `och1970_holl_key` advertises `KEY_UP KEY_DOWN` and - nothing else — no `REL_WHEEL`, no resolution, because the `och1970` is a - Hall latch the driver quantises into clicks. There is no sub-detent - position to read, so every bit of nuance comes from the *timing*. A - detent is an impulse into a velocity rather than a jump: the list coasts - and eases out, and an impulse is worth up to 8× more when detents arrive - 30 ms apart instead of 220 ms. Measured on the device: one unhurried - detent moves about half a row, twelve fast ones move forty-odd. Turning - back the other way kills the coast first so a correction bites. Direction - flipped in lists only; volume on the other screens is unchanged. - -### Round 54 (2026-09-14) — one artist spelled two ways (v0.20.3, andTunes 0.2.1) - -trav synced to the Rabbit, the sync reported success, and andTunes answered -with `No library / Couldn't read library.json`. The sync was fine. Everything -it wrote was on the device and correct — 2,432 media files, 208 covers, a -manifest whose every field type-checks against the parser. The app was the bug, -and it took all 2,439 songs down over fifteen of them. - -- [x] **The diagnosis**, read off the device: a `NullPointerException` on - `Library$Artist.tracks` inside `load` (R8 inlines `group()` into it). - `group()` keyed `albumsByKey` **case-folded** and `artistsByName` - **raw-case**, and only ever built the `Artist` inside - `if (album == null)`. So the second track of an album whose artist is - spelled differently found the album already there, skipped the block that - would have made the artist, and dereferenced the null that came back. - Replaying the algorithm over the real manifest: 15 tracks, six artists - spelled two ways — `RJD2`/`Rjd2`, `Toro Y Moi`/`Toro y Moi`, - `FatBoy Slim`/`Fatboy Slim`, `LOVING`/`Loving`, - `Land Of The Loops`/`Land of the Loops`, - `Salami Rose Joe Louis`/`salami rose joe louis`. It worked on the 11th - because the 12th's sync was the first to carry both spellings of one of - those albums. -- [x] **Both maps fold case now**, and the artist is fetched-or-made *before* - the album block and held in a local, so there is no lookup left in - `group()` that can return null. `Artist` gains a `key` (the folded name), - the way `Album` already had one, and `ListActivity` navigates by it — - `ALBUM_ROW` already passed `album.key`, so artists just stopped being the - exception. `playlistIds.get` in `load()` got the same treatment. -- [x] **The device diff folds case too.** `/sdcard` is case-insensitive, so - those two spellings are one folder there; `plan_andtunes_sync` compared - exact strings and saw every such file as stale **and** missing, deleting - and re-copying it over MTP on every sync forever. The collision rule - folds as well, so two songs whose paths differ only by case get the - `[track_id]` suffix instead of one silently overwriting the other. - `plan.stale` still carries the device's own spelling — that is what - `_delete_stale` unlinks by. -- [x] **The fixture grew the shape** (`scripts/make_dev_library.py`): one - artist spelled two ways on one shared album, next to the other naming - edge cases. `tests/test_round54.py` pins the desktop half (the Java - grouping has no harness — it was verified on the device: 2,439 songs - listed, and RJD2 one row of 12 songs where there had been a crash). - -### Round 52 (2026-09-11) — one fingerprint, six artists (v0.20.1) - -trav's "Dionne Farris - I Know" kept being identified as Jay-Z, with ID3 tags -that plainly said otherwise. AcoustID was right all along; the ranking threw -the answer away. - -- [x] **The diagnosis.** The fingerprint matched one AcoustID result at 0.97, - and *six* recordings hang off it — Dionne Farris twice, plus Jay-Z, - Marisela, New Atlantic and David Essex, all of whom recorded a song - called "I Know". A result's score belongs to the **audio**, so every - linked recording carries it however wrong the link is. With the scores - tied, ranking fell through to the duration bucket, and Jay-Z's 222.7 s - beat Dionne's 227.3 s against a 224 s file. The tags were never consulted - — the hint sat *below* duration in the sort key. -- [x] **Ask who submitted the link.** `sources` joins `LOOKUP_META`: 475 people - linked that audio to Dionne Farris, 6 to Jay-Z, and 1 each to the other - three. `_link_tier` sinks anything under a tenth of the strongest link in - the same result. The share is **relative**, never an absolute count, and - a missing count ranks as real — an obscure song's true link may have two - submissions against a stray's one, and nothing is demoted on missing - evidence (every canned payload from rounds 45–46 ranks unchanged). -- [x] **Ask what the file already says.** `artist_hint_for` gathers the artist - tag, the album artist and the artist in the filename; `_artist_agreement` - counts the words it shares with a candidate's artist credit, with - placeholders ("Unknown Artist", "Various") dropped. Like every hint since - round 46 it only chooses *among* what AcoustID returned — it can't invent - a candidate. -- [x] **New key order: who, which take, which release** — stray tier, artist - agreement, duration bucket, hint overlap, release rank. Artist above - duration is the whole fix; duration still separates two takes by one - artist, so round 46's rule is intact. -- [x] Verified live against the reported file: the proposal is now - **I Know — Dionne Farris — Wild Seed - Wild Flower (1994)**, track 1, and - all five mis-tagged artists are off the eight-candidate dropdown. The - real response is pinned as a canned payload in `tests/test_round52.py`. -- [x] **Noticed while verifying**: the full suite took 550 s and - `test_round33.py::…::test_stops_when_the_playing_track_is_deleted` hung - outright, with the R1 mounted. Not the Round 51 wedge — no uninterruptible - FUSE waits, and it cleared on its own: a later run was 932 passed in - 12.2 s with that test green, and it passes 5/5 in 0.19 s alone. So an - MTP mount that is merely *busy* (not wedged) slows the suite ~45× and can - stall a Qt test outright. Worth suspecting before believing a hang. - -### Round 51 (2026-09-11) — counts come home, and only playable files go (v0.20.0) - -andTunes phase 3. The Rabbit is now one more machine in the play-count -model, and nothing lands on it that Android can't play. - -- [x] **Play counts come back** (andTunes 0.2.0). A natural finish counts a - play, which is LinTunes' rule, and LinTunes records no skips either. - The app keeps `Music/andTunes/plays/andtunes-<install id>.json` in the - Round 38 per-machine-totals shape and writes it beside the old one then - renames it over, so a sync never reads half a file. Each sync first - folds it into `<data_dir>/plays/` (`andtunes/plays.py`), using a new - `play_journal.merge_totals` (the per-track max). That file now has two - homes and both desktops may bring it back, so the max converges and an - older copy can never pull a count down. Nothing is written when nothing - moved, and `PlayJournal.load` needed no change at all. -- [x] **Format gate.** The planner reuses `export/web_support.conversion_for` - outright, because Android's MediaPlayer decodes the browser's set. - FairPlay is refused and reported, and ALAC, AIFF and oddities land as - `.flac`. Each conversion is cached at - `$XDG_CACHE_HOME/lintunes/andtunes-flac/<track id>.flac`, so after the - first sync it's an ordinary size diff and is never re-copied. With no - ffmpeg, the same "sync without them?" question as export. A file - ffmpeg can't read costs that song, not the sync. -- [x] **"Sync Playlist to Rabbit (Auxio)" retired** from the Connections - menu, along with its handlers and worker plumbing. `device_sync`'s - helpers stay, because export and andTunes import them. -- [x] **The dev fixture grew the shapes the gate needs**: a real ALAC - `.m4a`, a "Protected AAC" `.m4p`, and an "Odd Formats" playlist (AIFF, - WAV, OGG, ALAC, FairPlay), ticked for sync by default. -- [x] **Found while verifying: a wedged gvfs-MTP mount hangs everything that - touches it.** A USB re-enumeration left `gvfsd-mtp` holding a dead - session. Every `ls` then sat in an uninterruptible FUSE wait that even - `timeout` couldn't kill. The dev sync and the GUI tests (which call the - real `find_device()`) both hung with it. Recovery was killing - `gvfsd-mtp` and running `gio mount mtp://…/`; the steps are now in - CLAUDE.md. It also explains the earlier 3-minute test runs: the full - suite takes 11 s against a healthy mount. -- [x] Verified with the dev library on the R1: - - AIFF → `01 Reel One.flac` and ALAC → `03 Reel Three.flac` on the - device; the FairPlay track was refused; 18 stale files from the old - fixture were pruned; the default buttons were shipped. - - All four Odd Formats songs played through on the R1. - - The next sync brought back `plays: 4`, and each track's effective - count went up by exactly one, with the device's `last_played`. - - A third sync copied nothing. - - Cold start 338–347 ms. - `tests/test_round51.py` covers the gate, the cache, `bring_back` and - the fold; the full suite passes. - -### Round 50 (2026-09-11) — andTunes plays music on the Rabbit (v0.19.0) - -The Android half of andTunes had been paused on a 1 GB toolchain download. -It turned out not to need one. - -- [x] **No Gradle, no Kotlin, no download.** Platform 33 + build-tools 34 were - already in `~/Android/Sdk`, and an app that links nothing needs five SDK - steps, not a build system. `andtunes/build.py` runs aapt2 → javac → R8 - → zipalign → apksigner. The APK is 61 KiB. trav's call, after asking - what Kotlin and Gradle even buy: nothing the app needs. -- [x] **andTunes 0.1.0, framework-only Java** (`andtunes/app/`): - - a six-tile menu that loads nothing - - a streaming `library.json` parse on a background thread, grouped into - artists and albums in one pass - - one `ListActivity` for playlists, artists (→ All songs + albums with - art thumbs), albums, songs (Shuffle all first) and search - - a Now playing bar on every list, with the playing song drawn inverted - - Now Playing: white, square art, slim scrubber, ◀ ⏯ ▶, shuffle, repeat - off/all/one, **no back button** - - a foreground `PlaybackService` with audio focus, becoming-noisy, - MediaSession and a MediaStyle notification - - end of queue pauses, missing files skip with a toast, and the last - queue and position come back on launch - Black on white throughout for the dim panel, and every size doubled - for the R1's override density. -- [x] **Cold start 313–338 ms** to the menu, measured with `am start -W` on - the R1 (target < 400; the first launch after install is 551 ms, - because of dex verification). -- [x] **The scroll wheel is DPAD, not volume.** `dumpsys input` shows - `och1970_holl_key` uses the stock `Generic.kl` (KEY_UP/DOWN → - DPAD_UP/DOWN). The app takes both DPAD and VOLUME codes in - `dispatchKeyEvent`, down and up. Lists scroll a row per detent; the - menu and Now Playing get volume. -- [x] **Replaceable buttons.** `andtunes/tools/make_buttons.py` renders the - defaults (menu tiles + ◀ ▶ ⏯). Sync copies them into the device's - `Buttons/` **only where the file is missing**, and the app prefers - whatever is there. -- [x] **Connections ▸ Install andTunes on Rabbit…** (`lintunes/andtunes/ - install.py`): adb install -r, then the all-files and notification - grants. The prompt says Install / Update / Reinstall based on the - version already on the device. With no adb, it copies the APK to - `Download/` over MTP. The APK is committed in `lintunes/android/` and - ships with the self-updater. -- [x] **The R1 now comes up as `mtp,adb`** whenever it's unlocked - (`svc usb setScreenUnlockedFunctions mtp`), so sync's gvfs mount and - adb are both there with no mode switching. -- [x] Found while building: R8 8.2 dies with an internal NPE on javac 21's - debug attributes for an anonymous class, so `proguard.pro` keeps none. -- [x] Verified with the **dev library** only: synced headless to the real - gvfs mount (21 tracks, 5 playlists, 4 covers). Then driven on the R1 - with `input tap`/`keyevent` + `screencap` through menu → songs → shuffle - → now playing, artists → artist → album thumbs, the wheel, search, and - resume after a force-stop. `tests/test_round50.py` covers button - shipping and the installer; the full suite passes. - -### Round 49 (2026-09-10) — Import from URL (v0.18.0) - -trav's `song` shell helper is `yt-dlp --extract-audio --audio-format mp3`, -followed by a drag into LinTunes. Now it's one menu item. - -- [x] **File ▸ Import from URL…** sits under Add Files to Library. You paste a - link (it's prefilled from the clipboard). Everything at the link - downloads, whether one song or a playlist, with the `song` flags - verbatim (`lintunes/url_import.py`). Output is read from `--print` - markers, not scraped. Downloads go to a private temp dir, each song is - imported the moment it lands, and the temp dir is removed at the end. - Progress and cancel use the status-bar transfer widgets. -- [x] **"and add to current playlist?"** puts the songs above the selected - song in the shown playlist, else at the end of the playing one, else at - the end of the shown one. With none of those the checkbox is grayed. A - hint under the checkbox names the actual target, so the rule is never a - surprise. A batch keeps the link's order, and smart and folder - playlists are never targets. -- [x] **Every imported song goes through Identify Track** with the usual - checkbox dialog, song 1 while the rest still download (the Identify - queue can now grow mid-run). With no AcoustID key the proposal comes - from the filename alone, with no fpcalc and no network. yt-dlp's - `Title [id].mp3` is exactly what the filename parser was built for. -- [x] **Album art opens on a single click**, in a window shaped like the - cover and as big as the screen allows (`art_window.fit_size`): square - for a square cover, no black bars, and a small cover scales up. -- [x] **Found while verifying: the dev fixture's play journal crashed - startup.** `make_dev_library.py` got it wrong twice. First, it wrapped - the journal as `{"machine", "tracks"}`, but `PlayJournal` reads a flat - `{track id: totals}` map, and `int("tracks")` raised straight through - a loader whose own comment says a bad journal must never abort - startup. Second, even unwrapped, its entries used the `library.json` - names (`play_count`/`play_date_utc`) instead of `record_play()`'s - (`plays`/`last_played`), so the fixture's plays would have been - silently ignored. The generator now writes the real shape and keys - (checked: base + journal = the effective count on every journaled - track), and the loader skips shapes it doesn't know. -- [x] Verified end to end against a copy of the dev library, with real - yt-dlp on a 19-second video. The song landed above the selected row in - “Inside B”, the Identify dialog proposed a name from the filename, and - the temp dir was gone afterwards. - -### Round 48 (2026-09-10) — A library to break (v0.17.1) - -Round 47 was verified by loading trav's real 21,531-track library. Nothing was -written to it and nothing was at risk, but that is the wrong habit and he said -so: "it's too precious… mistakes happen and it makes me nervous." He's right — -the safety of a given run isn't the point when the alternative is a fixture -that's better for development anyway: reproducible, in-repo, and full of the -awkward cases on purpose rather than by luck. - -- [x] **`scripts/make_dev_library.py`.** Builds a complete synthetic library — - data dir *and* music tree — from ffmpeg sine waves in six containers - (mp3, m4a, flac, wav, aiff, ogg), with embedded art on some albums and - none on others, every playlist type (regular, folder + children, - live/non-live/unsupported/limited+nested smart, system, empty, - duplicates), a per-machine play journal and a tombstone. ~4 MB, a few - seconds to build, `--force` to rebuild, gitignored — the generator is - the artifact worth keeping, not the sine waves. -- [x] **The edge cases are the point.** Each one has bitten something: a - collision pair identical in artist/album/title/number (the `[track_id]` - suffix), slashes and colons in every name (`sanitize_name`), a track - with no artist and none with an album (Unknown Artist/Album), a - multi-disc release (the `2-04` prefix), a compilation whose - `album_artist` differs from every track artist (album grouping and the - art key), a dangling `location` (the "skipped, no local file" counters), - a 208-character title (the 150-char truncation — and the manifest still - carries it in full, which is what the app displays), and non-ASCII plus - an emoji all the way out to the device's m3u filename. -- [x] **Two bugs in the generator, found by using it.** Passing a relative - `--out` made every `Track.location` resolve against the wrong root on - load, because locations are absolute in memory and stored relative to - the data dir; and four-minute uncompressed clips made the fixture 62 MB. - Durations only need to *differ* (sorting, smart-playlist limits, - `#EXTINF`), so they're now 2–20 s and the whole thing is 4.4 MB. -- [x] **Round 47 re-verified against the fixture**, not the real library: - first sync, an idempotent re-sync (0 copied, 6 kept), unticking, and the - awkward playlist end-to-end — `Song [19].mp3` / `Song [20].mp3` for the - collision pair, `mix summer 2003 🎧.m3u`, the dangling track counted as - skipped rather than fatal. -- [x] **A standing rule in CLAUDE.md**: never develop or verify against the - real library; when a feature needs a shape the fixture lacks, add it to - the generator. -- [x] **andTunes documented for a cold resume.** `andtunes/README.md` now - carries the measured device facts, the dp trap, the exact toolchain - commands, the locked design decisions and the `library.json` contract; - `andtunes/TASKS.md` renumbered to andTunes' own phases. Work is paused - on a weak connection — the Android SDK is a ~1–1.5 GB one-time download, - after which `./gradlew --offline` needs no connection at all. JDK 21 is - already installed and ticked off. -- [x] **The scroll wheel question answered.** It reads as volume in every app - because the ROM's key layout maps the wheel's KEY_UP/KEY_DOWN to - `KEYCODE_VOLUME_UP/DOWN` — but a focused activity sees key events first, - so andTunes can claim it by consuming both those *and* `DPAD_UP/DOWN` - (the mapping is unconfirmed) in `onKeyDown` **and** `onKeyUp`. No system - file touched, no other app affected. - -Not done, deliberately: no iTunes XML in the fixture (the importer tests still -use the gitignored real export), and no synthetic Syncthing conflict pair. - -### Round 47 (2026-09-06) — LinTunes writes the device's library (v0.17.0) - -The first half of andTunes, a music player for the Rabbit R1 that never scans -anything. Auxio re-reads Android's MediaStore on every launch, which is what -leaves the Rabbit sitting on "your songs will show up here" — so the answer -isn't a faster scanner, it's not scanning: LinTunes already knows the artist, -album and year of every file it just copied, so it writes them down and the -app reads one JSON file. This round builds the desktop side; the app itself is -rounds 48–50 (`andtunes/TASKS.md`). - -- [x] **A de-duplicated tree instead of a folder per playlist.** - `lintunes/andtunes/layout.py` lays out `Music/andTunes/` — `Media/ - <Artist>/<Album>/04 Song.mp3` (the iTunes shape `filename_tags` already - reads back, with a `2-04` prefix once a release has more than one disc), - `Art/<key>.jpg`, `Playlists/<Name>.m3u`, `library.json`. A song in three - playlists is stored once, which the Artists/Albums/Songs screens need - anyway and which saves the space three copies cost. -- [x] **`library.json`, the reason the app opens fast.** - `andtunes/manifest.py` is pure — no Qt, no device, no I/O. Rows are - sparse the way `Track.to_dict` is sparse, because that file is what the - R1 parses before it can draw anything. Artists and albums are *not* - shipped: grouping a flat list on device beats parsing three redundant - ones. A playlist's ids are filtered to tracks that actually landed, so - the app never resolves a dangling reference. -- [x] **One cover per album, not per song.** `andtunes/art.py` reads the - embedded artwork with mutagen, scales to 480 px (the panel's width) and - re-encodes as JPEG through `QImage`/`QBuffer` — no new dependency, and - QImage is safe off the GUI thread. Keyed on album artist + album, so a - twelve-track record costs one mutagen open and one file. Cached under - `$XDG_CACHE_HOME/lintunes/andtunes-art`, invalidated by the audio file - being newer — which is exactly right, since embedding new art rewrites - the file and moves its mtime. -- [x] **A sibling worker, not a flag on the old one.** - `andtunes/sync.py` follows the `plan_export`/`ExportWorker` shape - exactly: pure planner, then a daemon thread emitting KiB progress, with - the same MTP caveats (no copystat, diff by name+size). It is separate - because it nests directories, has an album-art pass, and writes several - trailing files instead of one m3u. Planning deliberately does *not* - render art — that's a mutagen open per album and planning runs on the - GUI thread. -- [x] **The index is written last, and always describes what's really there.** - Cancel mid-sync and the manifest and m3us are rewritten listing only - tracks whose files landed, so andTunes opens a smaller working library - rather than a broken one. Re-syncing completes it. -- [x] **A containment guard, because this is the first code that deletes a - directory on the Rabbit.** Every delete goes through - `layout.assert_inside`, which refuses anything that isn't strictly - inside the andTunes root — so the old `Music/<Playlist>/` folders are - structurally unreachable, not merely un-referenced. `Buttons/` (the - user's own menu artwork) and `plays/` (written by the app) are skipped - by the scan entirely: sync reads those, it doesn't own them. Emptied - folders are pruned bottom-up, asking the filesystem rather than - `os.walk`'s stale listing. -- [x] **Unticking a playlist is how it comes off the device.** New - `Connections → Sync to Rabbit` (everything ticked, no longer caring - which view is open) and `Rabbit Sync Settings…` - (`gui/device_sync_dialog.py` — a checkbox list with a filter box, - because 487 playlists is a normal library here). The selection lives in - `preferences.json` under `device_sync`, so it rides the Syncthing share - and both machines agree on what the Rabbit is carrying; keeping it off - the `Playlist` keeps it out of the conflict-merge machinery. -- [x] **The old per-playlist sync stays, renamed "(Auxio)".** Removing it in - the same round that adds the new tree would leave the device full of - files and nothing able to open them until round 49. It goes when - andTunes can actually play a song. - -Not done, deliberately: the Android app (rounds 48–50), shipping default -button images, the format gate for files Android can't decode, and reading -play counts back off the device. Verified against the real 21,531-track -library into a scratch device root — a re-sync copies nothing, and unticking -a playlist removes its media, its m3u, its now-unused cover and every emptied -folder. - -### Round 46 (2026-09-02) — The file already told you (v0.16.0) - -Round 45 shipped and trav broke it in two places within the hour, both real. -"B. Clem - Zuuso [1025657891]" fingerprinted fine and matched **nothing**, and -"Snowfall (Live At The Alhambra_1961)" matched but proposed a 2002 reissue year -and ranked a compilation above the album its own filename named. - -- [x] **The limit is the database, not the fingerprint.** Verified before - building anything: that Zuuso fingerprint returns zero AcoustID results, - a MusicBrainz text search for "Zuuso" returns zero, and iTunes returns - zero (and irrelevant fuzz for "B. Clem"). The track exists in no metadata - service on earth. So no smarter lookup — LLM or otherwise — could have - answered it, because there was nothing to answer with. -- [x] **But the filename knew.** New `lintunes/filename_tags.py` reads tags out - of the name yt-dlp gave the file. "B. Clem - Zuuso [1025657891]" → artist - "B. Clem", name "Zuuso", which is exactly what trav said it was. Checked - against all 474 files in the untagged folder, not against invented - examples. -- [x] **Careful about what it strips.** An 11-character trailing token is only - a YouTube id if it carries a digit, an underscore, or case that flips - repeatedly — otherwise "Cold Draft-Underground" loses a word. A hyphen - only splits artist from title when it has spaces around it, so "Jay-Z" - and "350-440-DialTone" survive. A 4-digit year is never a numeric id. -- [x] **A guess is labelled a guess.** `IdentifyCandidate.source` is - "acoustid" or "filename"; the dialog quotes a match confidence only for - the former and says "No database knows this — read from the file's name" - for the latter, rather than inventing a percentage. -- [x] **The file now ranks the results too.** `hint_for(track)` feeds tag + - filename tokens into `parse_lookup`: overlap scoring (words count double, - bare numbers single — "alhambra" identifies a release, "1961" appears in - every compilation spanning it), a coarse duration bucket against the - recording lengths AcoustID returns, and a filename year that predates - what the database knows. Alhambra now ranks the right album first with - year 1961 instead of a compilation with 2002. -- [x] **Live is no longer a demerit.** It was lumped in with Compilation, which - buried a live album for a file whose name said "Live At The Alhambra". - Only compilations, remixes, interviews and the like are demoted now. -- [x] **Why the year needed the filename at all:** MusicBrainz's own - `first-release-date` for "Ahmad Jamal's Alhambra" is *2002* — its three - original 1961 pressings are in the database undated. A second MusicBrainz - call, the obvious fix, returns the same wrong answer. 1961 exists only in - trav's filename. -- [x] Not done, deliberately: trav rejected "never propose a shorter title" — - truncating is *wanted*, because the garbage in a title is usually the - part being dropped. - -### Round 45 (2026-09-02) — Ask the song what it is (v0.15.0) - -Some files arrive with the title right and everything else missing or wrong, -and there was no way to fix that except typing. Now the audio itself is the -question: right-click → **Identify Track…** fingerprints the file and asks -AcoustID who it is. - -- [x] **Fingerprint, not filename guessing.** New `lintunes/fingerprint.py`: - `fpcalc -json` (Chromaprint's CLI, detected with `shutil.which` like - `ffmpeg_available()` — a runtime tool, not a pip dependency) produces a - duration + fingerprint, which `lookup_fingerprint` POSTs to the AcoustID - web API with `meta=recordings releasegroups releases tracks compress`. - `requests` was already a dep, so the round adds none. -- [x] **The year is the song's, not the pressing's.** trav's actual complaint: - "I don't care when the CD of something from the 60s came out." So - `parse_lookup` proposes the *original* release year — the earliest date - across every release group the recording appears on — and every candidate - carries it, including the one proposing a 1998 greatest-hits as the album. - Years sort by when the song came out. `_releasegroup_sort_key` separately - ranks a plain studio Album above EP/Single above anything wearing a - Compilation/Live secondary type, so the *default* proposal is the real - album too. -- [x] **Nothing is written until you say so.** `gui/identify_dialog.py` is - passive the way `AlbumArtDialog` is — candidates arrive pre-parsed, and - the caller applies the result afterwards. Each row shows the current value - beside an editable proposed one, with a checkbox that starts checked only - where the proposal actually differs from what the file already has (a row - the lookup knows nothing about is disabled, so it can't quietly blank a - tag). A dropdown switches between alternate releases. -- [x] **Applied through the one funnel.** `LibraryManager.edit_track_fields` - does it, so tag writes, the abort-if-the-write-fails rule, artist/album - file relocation and a single undo step all come free rather than being - re-implemented. -- [x] **A selection is a queue, one dialog at a time.** Each track is reviewed - on its own; "Stop Identifying (N left)" abandons the rest, and a failure - (unfingerprintable file, network error, no match) is a status-bar message - that moves on to the next rather than ending the batch. -- [x] **The key is trav's, and stays out of git.** `preferences.acoustid` - + a Preferences row linking acoustid.org/new-application, following the - Last.fm precedent — preferences.json rides the Syncthing share, so - pasting it once covers both machines. -- [x] Fixed an unrelated pre-existing test failure: round 40's - `test_the_pre_010_music_root_key_counts_as_an_override` used the real - tummult mount point as its stand-in for an unreachable path, so it failed - whenever that drive was actually plugged in. It now builds the path under - `tmp_path` and never creates it. - -### Round 44 (2026-08-27) — A removal you make sticks (v0.14.0) - -Round 43 made the merge report honest, which made its one real limitation -impossible to miss: every merge was a union, so a song removed from a playlist -on one machine was handed straight back by the other on the next sync, and a -track deleted from the library came back with it. A deletion you cannot make -stick is not a deletion. - -- [x] **Removals are recorded, not inferred.** The union was there for a real - reason — two copies and no common ancestor cannot tell "A added this" from - "B removed it" — so the missing evidence is now written down. New - `lintunes/tombstones.py`: `Playlist.track_events` holds - `{track id: [when, "add"|"remove"]}` and `Library.deleted_tracks` holds - `{track id: when}` in `library_metadata.json`. A merge takes the newest - event per track across both copies and applies it. A removal beats a copy - that merely still had the song; a deliberate re-add afterwards beats the - removal; a track nobody touched still merges as a union, which is the safe - old behavior for everything that has no evidence either way. -- [x] **Not "the newer copy wins wholesale".** That was the tempting one-line - version and it silently drops a song the other machine added while you - were removing one. `test_an_unrelated_addition_is_not_lost` pins it. -- [x] **Recorded in the existing funnels.** `_set_track_ids` diffs before/after - (so a reorder records nothing), `_remove_tracks` stamps - `deleted_tracks`, and `_restore_tracks` clears it, so Ctrl+Z on a delete - takes the tombstone back with it. -- [x] **Track ids are never reused.** The sharpest edge in the whole design: the - next id came from `max(library.tracks)`, so deleting the highest-numbered - track freed its id for the next import — and that new track would be - dropped on sight by the dead id's own tombstone, on every machine. - `_highest_track_id` counts the deletions too. -- [x] **`library_metadata.json` merges before `library.json`.** It carries the - record the library merge filters against, and `rglob` order is not a plan. - `_merge_metadata` unions the two copies' `deleted_tracks` regardless of - which whole copy the mtime pick kept. -- [x] **A merge applying a deletion never touches a music file.** It drops the - library entry only — the file was already trashed on the machine where the - delete happened, and the music folder is its own Syncthing share. - `test_a_merge_never_touches_a_music_file` fails the run if `send_to_trash` - is so much as called. -- [x] **Reported as a warning, with the song named.** An applied removal grades - WARNING so it opens the merge window, names each track, and says Ctrl+Z - does not reach a merge. `_Labeler.remember` keeps the name of a track - library.json is about to lose, so the playlist merge can still say which - song it dropped rather than "track 4". -- [x] **Events expire after 30 days** (`RETENTION_DAYS`), pruned at the save - boundary like the derived-membership gate. The trade is stated in the - module docstring: a machine offline longer than that can resurrect a song - it never learned was deleted. - - -### Round 43 (2026-08-27) — The merge report says who, what, and where (v0.13.0) - -The merge window kept saying `Order kept from this machine (most recently -edited)` for playlists trav had edited on the *other* machine, and offered a -backup folder holding two directories with one hex-named JSON each. Confirmed -against the real snapshots in `.resolved/`: in the 9:34 PM `* a fresh master` -merge, the copy labelled "the other machine" was this machine's own 3:15 PM -merge output (2472 tracks, `date_modified 19:15:26` — the previous merge's own -stamp), so the label was exactly backwards. - -- [x] **Stop guessing which machine wrote which copy.** "this machine" was - inferred from which copy held the plain filename, and that is Syncthing's - call, not a statement about authorship — it sets the local copy aside as - readily as a remote one. The report now names the two copies for what - they factually are ("the copy that was already here" / "the copy - Syncthing set aside") and attributes the set-aside one from the 7-char - device ID in the conflict filename, which `CONFLICT_PATTERN` used to - match with a bare `\w+` and delete along with the file. New - `lintunes/sync_identity.py` maps that token to a device name out of - Syncthing's `config.xml` and works out which one is us by deriving our own - device ID from `cert.pem` (base32 of the SHA-256 of the DER cert, the way - Syncthing does). Stdlib only, best-effort: no Syncthing, no config or an - unreadable cert all mean the report simply names nobody. -- [x] **`date_modified` is consulted when only one copy has one.** It used to - need *both* stamps (`if ours and theirs`) and otherwise fell back to file - mtime — and a playlist imported from iTunes and never reordered on this - machine has no stamp at all, so the honest comparison was skipped exactly - when one machine had edited and the other hadn't. A stamp only exists once - LinTunes recorded an edit, so stamped-vs-unstamped is evidence: the - stamped copy wins. mtime is the fallback only when neither side has ever - been edited, and the report says so when it happens. -- [x] **No more mtime ratchet.** `_merge_playlist` rewrote the file it kept on - every merge, whether or not anything changed, while Syncthing preserves - the origin's mtime on the conflict file — so each merge made the copy in - place harder to beat on the next one. A no-op merge now writes nothing. A - merge that produces a true union stamps `date_modified`, because content - neither copy had is genuinely newer than both; that is what stops two - machines trading the same 19 tracks back and forth (three times in one - evening, in the snapshots). -- [x] **The report names the songs.** `merge_track_order` knew every - re-inserted track's position and threw it away. Each re-inserted track is - now listed as `Artist — Title → position 24, after "…"`, six in the window - and all of them in the backup. Titles come from a lazy read of - `library.json` (the 15 MB file, so only when a playlist merge actually - needs a name). -- [x] **Tracks the other copy didn't have are reported, not resurrected in - silence.** The union policy is unchanged — nothing is ever removed — but a - track only this copy has is now named, with both readings offered ("if you - added it here, that's all this is; if you deleted it on the other machine, - delete it here too"), since two lists give no way to tell which it was. -- [x] **`what-changed.txt` in the backup folder.** The same merge in words, - uncapped, beside `original/` and `incoming/` — including the real - Syncthing conflict filename and its device, which the merge otherwise - destroys. The dialog button is now "Open merge report". -- [x] **A rename or folder move made elsewhere survives a merge.** Only - `track_ids` and `settings` were ever taken from the winning copy, so a - playlist renamed on the other machine was renamed back by every merge. - `name` and `parent_persistent_id` come from the winner now, and - `_apply_rename`/`_apply_reparent` stamp `date_modified` so a rename is - comparable across machines at all. -- [x] **The same revert, on the sync path with no conflict file.** - `_reconcile_playlist` asserted in its docstring that the local edit was - the more recent one and never checked — so a reorder synced in from the - other machine was undone and then flushed straight back to disk. It reads - the stamps now, and adopts `name`/`parent`/`settings` when the disk copy - wins. The branch that reaches it is also gated properly: `_dirty_playlists` - is set by a column drag or a sort click too, so a sync landing in the 3 s - after a cosmetic click routed through the keep-local path. New - `_dirty_playlist_content` marks only real content edits — the Round 42 - rule ("cosmetic table settings must not look like edits") one level up. -- [x] **The music folder no longer rides a coin flip.** `_merge_metadata` - inferred which copy had lost from whichever one `_keep_newer`'s mtime pick - had kept; when both files land in the same instant that pick is arbitrary, - which is why `test_but_adopting_a_music_folder_is_a_change` passed about - half the time. It decides from the two inputs' `music_folder_set_at` - directly, and grades CHANGE when the folder actually differs from the one - we started with. - - -### Round 42 (2026-08-22) — A web mix stops shipping an .m3u (v0.11.1) - -- [x] **No `.m3u` beside `index.html`.** It shipped on the theory that it cost - nothing and let the folder double as a plain music folder, but a web mix - is a folder you upload — a stray playlist file next to the page is one - more thing to explain to whoever receives it. `plan.m3u_name` is now `""` - for `WEB` so the plan says so rather than naming a file it won't write, - and `index.html` is simply the last thing written, which is what the - manifest-last invariant always meant for that branch. Folder exports are - untouched. - -### Round 41 (2026-08-22) — Web mixes pick their own color (v0.11.0) - -Every exported mix came out in the same 2010 gold-and-brown skin: a `#c7b563` -player bar, `#ccc` progress fill, and a hard-coded `#8c764a` behind every hover. -Now the hover backgrounds and the progress fill are one color the user picks in -the export dialog, and the rest of the bar is deliberately colorless so that -choice is the only color in it. - -- [x] **One accent, chosen per export.** A swatch button in `WebMixDialog` - opening `QColorDialog`, plus Reset. Deliberately *not* persisted — it - opens on `exporter.DEFAULT_ACCENT` (`#8c764a`) every time, so an export - nobody touches still looks exactly like the mixes already online. - `exporter.normalize_accent` is the injection gate, not a nicety: the value - is substituted raw into the page's `<style>` block and `string.Template` - escapes nothing, so anything that isn't `#rgb`/`#rrggbb` falls back to the - default. -- [x] **Delivered as a CSS custom property**, `:root { --accent }` declared in - `index.html`, which `player.css` reads as `var(--accent, #8c764a)`. That is - what lets the stylesheet stay in the verbatim `shutil.copyfile` loop — - `index.html` is still the only thing rendered. -- [x] **Hover text flips black or white** against the accent - (`exporter.contrast_text`, the same luminance rule `theme.apply_theme` - uses). The old page hard-coded white, which vanished the moment anyone - picked a pale color. -- [x] **The player bar is fixed light gray with black text**: `#eee` behind the - transport, `#000` for both elapsed and duration, a gray buffered segment, - and a dark box-shadow in place of the near-white one that was invisible on - the white page it always sat on. -- [x] **Black glyphs without touching the GIF.** The sprite's play/pause are - pale lavender and the spinner is yellow — drawn for the dark gold bar and - invisible on light gray. `filter: brightness(0)` zeroes every channel and - leaves alpha alone, so they paint solid black and the loading frame still - animates. `player-graphics.gif` still ships byte-identical. -- [x] `theme.swatch_icon()` — `preferences_dialog`'s private `_swatch` promoted - so the export dialog isn't a second copy of it. - -Verified in Chrome against two generated mixes: a dark teal (`#0f8a7e`) accent -gives white hover text, a pale yellow (`#f2e96b`) gives black, and both paint -the progress fill while the bar around it stays gray with a visible black -play/pause. - -### Round 39 (2026-08-20) — Playlist merges stop losing your track order (v0.9.1) - -The playlist half of the Round 36 rework, and what actually scrambled -`a nissa one`. It took three defects together: reorder it on machine A, open it -on machine B and resize the window, and B's file is newer, so the merge takes -B's order — the old one — wholesale. - -- [x] **Anchor-based merge.** `merge_track_order` in `conflict_resolver` unions - two orderings by re-inserting each side-only track after the nearest track - both copies share, instead of appending it at the tail. `_merge_playlist` - and `library_manager._reconcile_playlist` (the live-reload path) both use - it. The union invariant is unchanged and property-tested over 300 random - pairs: a merge never drops a track, duplicates and disjoint lists included. -- [x] **`Playlist.date_modified`, bumped only in `_set_track_ids`** — the single - funnel for add / remove / reorder / undo, and deliberately *not* - `mark_playlist_settings_dirty`. `_playlist_newer` prefers it over the - file's mtime, falling back to mtime for playlists written before the field - existed. The smart-playlist branch uses the same test, which told the same - lie. -- [x] **A window resize no longer rewrites the playlist.** The last column is - stretch-sized, so Qt re-fires `sectionResized` for it whenever the viewport - width changes — a resize or a splitter drag rewrote the open playlist's - JSON, moved its mtime, and handed Syncthing another conflict, all for a - width `apply_settings` overrides on load. `_on_section_resized` skips that - section; genuine drags on every other column still persist. - -Replayed against the real playlists on a copy: on `a nissa one` (23 tracks) the -reorder survives a newer-by-mtime opponent, and on `a nissa ideas` (26 tracks) -the other machine's two mid-list inserts land at 5 and 12 rather than 26 and 27. - -### Round 38 (2026-08-20) — One merge window, and play counts that can't conflict (v0.9.0) - -Fifteen "Synced changes merged" windows were stacked on the desktop. Two -independent bugs, and the `.resolved/` backups said which mattered: every one of -the last nine `library.json` merges was ~98% play counts (43-47 of ~45 changed -tracks) plus exactly one real edit — the same single rating each time. The same -~45 tracks disagreed in *every* merge and the count only crept down (48 → 44 over -a day), so `max()` had been quietly discarding plays for at least that long. - -- [x] **One window, not one per merge.** `show_conflict_summary` built a fresh - `ConflictSummaryDialog` each time and only reassigned the attribute — the - old dialog stayed a live child of the window. Since - `check_for_external_changes` runs `resolve_conflicts` on every Syncthing - watch tick, that was one window per merge for the life of the process. The - dialog is now a session log: `add_event()` folds each merge in as its own - timestamped entry (newest first), the restore button names the merge it - would undo, and `finished` clears MainWindow's reference so a close lets - the next merge open a fresh one. -- [x] **Per-machine play journals.** `library.json` now holds only a base count; - each machine owns `plays/<machine-id>.json` with its own per-track totals, - and the effective count is base + the sum of all journals. Only the owner - writes its journal, so play data can't conflict; totals rather than an - append log, so there's no compaction step to double-count in; and a machine - still on older code keeps bumping its own base, which stays additive with - our journal, so the version-skew window is safe too. On the real 21k - library: three plays wrote **83 bytes** and left `library.json` untouched, - where before each one rewrote 15 MB. -- [x] **The two hazards, pinned by tests.** `save_tracks` must write the base and - `PlayJournal.load` must be handed a base-valued library — the second is why - `reload_from_disk` folds the journals onto the `disk` copy *before* - reconciling, or the max() against the effective in-memory count would win - by exactly this machine's journal. -- [x] Journal conflicts merge by highest total rather than newest-wins. They - shouldn't be possible; if a filesystem oddity makes one, falling through to - `_keep_newer` would discard a machine's whole history. - -Deliberately not done: lossless merges still open the window rather than -demoting to a status-bar line. With journals landing, a merge stops being -routine — the ones left are real edits, and worth seeing. - -### Round 37 (2026-08-19) — Export a playlist: a folder, or a whole website (v0.8.0) - -`File → Export Playlist…`, and the same item on a playlist's right-click menu. -Two destinations behind one planner, built as a sibling of `device_sync` (same -shape: a pure `plan_export`, then a worker on a daemon thread reporting through -signals, sharing the status-bar progress widgets and the cancel button). - -- [x] **Folder export** — the audio files under `Artist - Title.ext` names next - to an extended `.m3u`. No `.pls`; nothing reads it any more. Files are - copied byte-for-byte, never re-encoded. -- [x] **Web mix export** — a self-contained static site reproducing the - hand-made yearly mixes: `index.html`, `audios/`, a hero image, and a - dialog for title / description / image. The description is deliberately - passed through raw (the mixes lean on inline links right there); the title - and track labels are escaped. Per-track liner notes stay hand-edited — - `index.html` carries a commented-out block in the right spot. -- [x] **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 — not exploitable on a - static page, since nothing untrusted ever reaches a jQuery HTML sink, but - dead weight either way). The decisive detail: **audio.js never used - jQuery** — jQuery was there for ~25 lines of tracklist glue. Replaced by a - ~180-line dependency-free `player.js` + a `player.css` transcribed from - the customized audio.js skin, so the page looks the same: same 250px - `#c7b563` bar, same `player-graphics.gif` (shipped byte-identical — it's - an *animated* GIF, the loading state is a spinner), same click-to-play, - autoplay-next and ←/→/space shortcuts. **~293 KB → ~6 KB.** The Flash - fallback went with it; audio.js gated it on `!canPlayType("audio/mpeg;")`, - unreachable in any browser since ~2010. -- [x] **Conversion only when a browser genuinely can't play it, and never lossy.** - `export/web_support.py` is a deny-by-default gate modelled on - `cast/support.py`, using the iTunes `kind` string to disambiguate `.m4a` - (plain AAC / Apple Lossless / FairPlay all wear that suffix). Bitrate is - **not** a trigger — 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 is 105 Apple Lossless + 8 AIFF out - of 21,382 tracks. DRM'd tracks (6 Protected AAC) are reported in a - confirmation dialog, never silently dropped and never attempted. -- [x] **ffmpeg is detected, not assumed** — it's the CLI binary, which is a - different thing from Qt Multimedia's ffmpeg backend. Missing, the export - offers to go ahead without the affected tracks. -- [x] **The manifest is written last.** A cancelled or interrupted export leaves - no `index.html` and no `.m3u`, so a half-finished folder never advertises - files that aren't there. Cancel also removes the in-flight partial file. -- [x] Verified in **Chromium 151 and Firefox 153** driven over CDP/Marionette: - all four exported formats decode (including the converted FLAC), the - player builds, the gold bar and sprite render, click / space / ←→ / - scrubber-seek / autoplay-next all work, and the network log shows no - request for jquery, audio.min.js or any `.swf`. -- [x] `mix-example/` removed — it was the reference for the template and the - template now reproduces it. - -### Round 35 (2026-08-19) — Startup speed: the 21k-track library stops freezing (v0.7.0) - -Reported as three separate complaints — "startup takes a minute or two and GNOME -offers to force quit", a playlist insert that jumped to the end, and the pile of -merge dialogs — which turned out to share one shape: every operation was -whole-library, whole-file, on the Qt main thread. Measured against the real -library (21,482 tracks, 506 playlists, 15 MB `library.json`). - -- [x] **The track table sorts itself.** `TrackTableView` ran its model through a - `QSortFilterProxyModel`, which asks `data()` for a value on *every - comparison* — 580k Python round trips, **8.5 s per table load**, paid again - on every reload. The model now keeps `_tracks` in canonical (playlist) - order plus an `_order` index list, and sorts a key list computed once per - track. Nothing used the proxy's filtering (the column browser filters by - handing `set_tracks` a shorter list). Sorting by `#` is the identity order, - so "source row" still means "playlist position" for drag-reorder. - **8.5 s → 0.13 s.** `MainWindow()` **18.1 s → 0.79 s.** -- [x] **Smart rules compile to closures once per `evaluate()`** instead of being - re-dispatched per track: the operator lookup, casefolding the query, and - parsing the rule's own date constants all leave the 21k-iteration loop - (`_match_date` was re-parsing its own constant 21,482 times per rule). - Verified against the previous implementation on all 20 real smart - playlists: **identical membership, 0 mismatches**. **2.54 s → 0.72 s.** -- [x] Smart recompute deferred until after the first paint (`main.py`), so the - compositor always has a window that answers. -- [x] `_reconcile_track` skips the two `to_dict()` round trips when a track is - unchanged (`MERGEABLE_FIELDS` in the resolver is now the single source of - truth for what a merge can touch). A sync changes a handful of tracks out - of 21k; this was most of the cost of a reload. -- [x] No git subprocess on the startup path: `Updater.enabled` is probed lazily - on the background check thread (it was two `rev-parse` calls with 15 s - timeouts in `__init__`), and the version button loads its commit-hash - tooltip after the window is up. -- [x] `.resolved/` is pruned to the newest 10 snapshots. It had reached **1.7 GB - across 73 snapshots**, never pruned — and it lives inside the Syncthing - share, so every 15 MB pre-merge copy was being replicated to the other - machine. - -Net: **time-to-interactive-window ~15 s → 3.2 s**, and the mid-session freeze -when Syncthing delivers a change (which is what GNOME was actually offering to -force-quit — a full reload + recompute + table rebuild with the window already -on screen) **~16 s → 3.8 s**. - -Not in this round, deliberately: the merge rework itself (playlist ordering, -per-machine play journal, quieting the dialog) — see TASKS.md. - -### Round 34 (2026-08-15) — The parking brake: stop playing audio at 3am (v0.6.1) - -The long-running "LinTunes plays by itself" haunting, finally attributed and -fixed. Five-plus incidents since July, always the same shape: a few seconds of -audio from the middle of a song, at some random hour, hours after the app was -last touched, never recording a play count. - -It was never a control event. The v0.1.4 provenance log had **zero** entries -between the 17:48 pause and the 00:30 incident — no MPRIS, no media key, no -AVRCP ghost — while MPRIS still reported `Paused` at exactly `6974000µs`, the -same position it was paused at seven hours earlier. Meanwhile PipeWire showed -LinTunes owning a stream in state `running`, linked to the DAC, gain 1.0. - -Cause: a paused (or stopped) `QMediaPlayer` on the FFmpeg backend keeps its -PipeWire stream open and never corks or drains it, so the few seconds the -backend had decoded ahead sit there live. When the audio graph is later rewired -— a USB DAC waking from idle suspend, a device appearing — that stale buffer -flushes to the speakers. It explains every observation: always a short burst -(it can only be what was buffered), always mid-song, never a play count (nothing -resumed), and why the Fedora machine never does it (no USB DAC to wake up). -Reproduced standalone on Qt 6.8.2 / FFmpeg 7.1.5 in ~20 lines. - -- [x] `PARK_AFTER_IDLE_S` (30s) + `LocalSink._park()` / `_unpark()`. After that - long idle, release the pipeline: `stop()` then `setSource(QUrl())`, which - is verified to remove the PipeWire node outright — there is no buffer left - that could ever leak. Resume rebuilds the source and rides the existing - custom-start-time machinery to seek back. -- [x] Two independent layers, deliberately: gain goes to 0 *before* the - teardown, so anything still buffered downstream drains silently even if - the release misbehaves. `_apply_volume()` forces silence while parked, so - the volume slider can't re-arm it. -- [x] `stop()` arms the brake too — Qt's `stop()` leaves the source loaded, and - `Player._advance()` calls it when a playlist runs out, so "playlist ended - at 1am" was a second, equally real leak path. -- [x] Parked state serves `position_ms()`/`duration_ms()` from a cache and - suppresses the media object's position/duration/state/status signals, so - the teardown never reaches the UI as a jump to 0:00 and a stray - `EndOfMedia` can't advance the queue. -- [x] `seek()` while parked moves the resume point instead of poking a dead - pipeline; `load()` discards parked state; `shutdown()` disarms the timer. -- [x] Verified end-to-end against the real `LocalSink` (silent WAV, zero gain, - watched via `pw-dump`): stream present while playing, still present right - after pause, **gone** once parked, rebuilt on resume with position and - duration intact. Tests in `tests/test_round34.py` (17). - -Note: this is a workaround for an upstream Qt Multimedia bug, not a LinTunes -design flaw — GStreamer-based players get correct pause behavior for free by -corking. See the backend note under "Parked / deferred". - -### Round 33 (2026-08-14) — Delete songs from the library (v0.6.0) - -An iTunes 12 habit LinTunes had no answer for: getting rid of a song you don't -want to keep. `LibraryManager` had `add_track` but no inverse, and nothing in the -app had ever deleted a local music file. Deletion goes to the desktop trash -rather than `unlink`, so it's recoverable both by Ctrl+Z and from the file -manager afterwards. - -Two constraints shaped it. The trash is **per-filesystem** — the music sits on a -mounted volume, so the right destination is `<topdir>/.Trash-<uid>`, and using -the home trash would mean a cross-device copy plus a recorded original path -"Restore" can't reach. And playlist cleanup has to be **synchronous with the -removal**: playlist edits address tracks by row index into `track_ids` while the -view skips ids missing from the library, so a dangling id would desync the two -and make a later "Remove from Playlist" hit the wrong track. - -- [x] `lintunes/trash.py` — freedesktop Trash spec 1.0 by hand, no new - dependency (a new pip dep would need a manual `pip install -e .` on the - other machine). Picks the trash by the file's own mount, writes the - percent-encoded `.trashinfo` with `O_EXCL` first to claim the name, then - renames the file in; a failed rename cleans up the info file. `TrashError` - leaves the file untouched. -- [x] `LibraryManager.delete_tracks(ids, delete_files=)` — the single funnel. - Removes from `library.tracks` and every non-smart playlist, prunes the - dirs it emptied, invalidates the exported artwork, and pushes one undo - Command covering the whole selection. A file that can't be trashed - **keeps its track** rather than leaving an orphaned entry. New - `tracks_removed` / `tracks_restored` signals. -- [x] Undo restores the file from the trash *and* the track's original position - in each playlist; redo re-trashes (re-recording paths, since a redo can - land on a different collision-suffixed name). If the trash was emptied - behind us, the library entry is restored anyway — a visible broken track - beats a silent second loss. -- [x] `Player.drop_tracks()` — stops if the deleted track is playing, otherwise - keeps the current track's place in the shortened queue. Previously a dead - id in the queue made `_load_current` stop everything with an error dialog - once the walk reached it. -- [x] Context menu gains "Remove from Library" and "Remove from Library and - Delete File", in **both** the library and playlist views. No keyboard - shortcut — `Del` still means "remove from playlist". Confirmation uses the - existing destructive-dialog idiom (DestructiveRole button, Cancel as - default) and names the playlists affected. -- [x] `tests/test_round33.py` (26 tests) covers the spec details (volume vs home - trash, percent-encoding, collision suffixes, no orphaned info file on - failure), the manager (playlist cleanup, pruning, partial failure, undo of - position, emptied-trash undo) and `drop_tracks`. Verified for real against - `/run/media/trav/tummult/.Trash-1000` with a scratch file. - -### Round 32 (2026-08-14) — Custom start times survive a track change (v0.5.2) - -trav reported "Pretty Girls is a Motherfucker" starting at 0:00 despite its -imported 0:40 start time. Import and storage were fine (70 tracks carry a start -time, 72 a stop time); the seek was being thrown away. `QMediaPlayer.setSource()` -synchronously emits *two* status changes before it returns — a `LoadedMedia` -still reporting the **outgoing** source, then `LoadingMedia` for the new one — -and the stale first event consumed the one-shot `_pending_start_ms` armed by -Round 22, leaving nothing for the new track's real `LoadedMedia` ~5 ms later. -So it misfired on every track change; only the first track after launch worked, -which is why Round 22 looked green. - -- [x] The armed seek is tagged with the URL it belongs to - (`_pending_start_url`), and `_on_media_status` consumes it only when - `source()` matches — a status change fired for other media can't eat it. -- [x] `LocalSink.load` restructured so the pending seek is armed only on the - new-source branch; Round 22's same-source replay path still seeks directly - and arms nothing. -- [x] `tests/test_round32.py` models the real Qt event sequence (a `setSource` - side effect that fires the stale `LoadedMedia` before switching `source()`), - which Round 22's plain `MagicMock` never did. All three behavioral tests fail - on the old code. `test_round8` / `test_round22` stubs updated to report the - new source by the time its `LoadedMedia` arrives, as real Qt does. -- [x] Verified against the real files headless: switching from a playing track - to the Psychic Vagina mp3 with `start_ms=40000` now lands at 40.1 s and - climbs; a track with no start time still starts at 0. - -### Round 31 (2026-08-13) — One cast control, not two (v0.5.1) - -The cast glyph appeared both under the volume slider and inside the visualizer, -which was redundant. Dropped the volume-slider indicator (`gui/cast_indicator.py` -deleted); the visualizer panel is now the single cast control. - -- [x] **The visualizer panel is the indicator** — bigger glyph (20 → 40 px), - always painted in the theme highlight rather than following the brightness - mode, and clicking it stops casting instead of cycling on/dim/off (which - meant nothing with no bars to dim). New `cast_stop_requested` signal. -- [x] **`transport_icon(kind, color, size=…)`** scales the painter instead of - upscaling a 20px pixmap, so the panel-sized glyph is crisp; `size` joins the - cache key. -- [x] Collapsed the `cast`/`cast_connected` glyph pair into one — only the - connected state was ever drawn. - -### Round 30 (2026-08-13) — Album art on the cast device (v0.5.0) - -The Chromecast is on a TV, so it should show the cover. `play_media` now -carries `thumb=`, which pychromecast folds into `metadata["images"]` — the -field the receiver paints full-screen. Verified against the real device: it -fetches both the audio and the artwork URL from us on every track change. - -- [x] **`TrackServer` serves in-memory blobs** — album art lives in the audio - file's tags, not as a file of its own, so a token now resolves to an - `_Asset` that is either a path or bytes. Audio and art get **separate - eviction rings**, so a cover can't push out the previous track's audio while - a device is still fetching it. Range and HEAD work on both. -- [x] **`support.image_type_for`** sniffs the cover's type from its magic bytes - rather than trusting the tag — ID3 APIC mimes are routinely wrong or blank, - and the receiver silently drops an image whose type doesn't match. -- [x] **Best-effort throughout** — no cover, junk where the cover should be, or - an unreadable file all just play without art rather than failing the load. -- [x] Also sends `albumArtist` and `trackNumber` in the metadata. - -### Round 29 (2026-08-13) — Cast to Chromecast (v0.4.0) - -The Device menu is now **Connections**, with "Connect to Chromecast…" alongside -the Rabbit sync. Picking a device from the search dialog hands playback to it; -a small cast glyph appears under the volume slider and clicking it disconnects. -See `tests/test_round29.py` (67 tests). - -Uses the **media-receiver model**: lintunes serves the original file over the -LAN and the Chromecast decodes it. Bit-exact (no transcode, no double-lossy on -already-lossy files) and the device buffers for itself; the cost is that -lintunes becomes a remote control while connected — no local PCM, so the -visualizer shows the cast glyph instead of bars, and transport actions land -with ~1 s of round trip. - -- [x] **`lintunes/cast/`** — `support.py` (format gate, source-address - selection, range parsing), `server.py` (token-addressed - `ThreadingHTTPServer` with Range/HEAD), `discovery.py` (`CastBrowser` behind - Qt signals), `sink.py` (`CastSink`), `controller.py` (session lifecycle + - its own `SleepInhibitor`). -- [x] **`Player` sink split** — new `PlaybackSink` base with `LocalSink` and - `CastSink`; `set_sink()` carries track/position/playing-state both ways, so - connecting and disconnecting pick up mid-song. Play counts and Last.fm - scrobbles fire identically on both paths. -- [x] **Format handling** — 12,610 MP3 + ~8,750 AAC cast natively; the ~119 - Apple Lossless / AIFF / protected-AAC tracks are skipped with a status-bar - message rather than stalling the device. -- [x] **Failure handling** — a dropped socket gets a 15 s grace period - (pychromecast retries on its own, so a Wi-Fi blip heals itself); a real loss, - a foreign app taking the device, or a network change falls back to local - playback **still playing**, at the same position. Quitting stops the device - rather than leaving it fetching from a dead server. -- [x] **New dependency: `pychromecast>=14.0.10`**, imported lazily so the app - still launches where it isn't installed; `python_requires` raised to >=3.11 - (its floor). The menu item explains the `pip install -e .` when missing. - -### Misc (recorded 2026-07-02, moved from TASKS.md during the Round 17 rewrite) - -- [x] Confirm Last.fm scrobbling works -- [x] Volume slider between the visualizer and the timeline -- [x] Preferences: per-area color adjustments (background, button - round-rects, now-playing text + its backing, tracklist stripe gray), each - with a live black↔white slider -- [x] Visualizer: discrete light-gray-on-light-gray mode -- [x] Cut/copy/paste tracks (Ctrl+X/C/V); paste inserts above the selected - track, not at the end -- [x] Auto-scroll the track list when dragging a track above/below the - visible area -- [x] Move data dir to `/run/media/trav/tummult/music/lintunes/` + - relative track paths (see Round 15) -- [x] Real library import: 21,382 tracks, 462 playlists + 21 folders, - 18 smart playlists (3 kept as snapshot), 5 missing files, 1,324 - case/unicode path fixes (see Round 15) - -### Round 16 (2026-07-02) — Live multi-machine sync + conflict alerts - -Both machines now reflect each other's changes within a couple seconds, with -genuine conflicts auto-merged (backed up first) and surfaced in an alert. See -`tests/test_round16.py` (9 tests) + the known limitations below. - -- [x] **Live external-change reload** — new `lintunes/sync_watcher.py` - (`QFileSystemWatcher`, 600 ms debounce, re-arms after atomic-rename replaces). - `LibraryManager.reload_from_disk()` re-reads and **reconciles** disk into memory - (max play/skip counts, newest edit wins, playlist membership unioned), keeping - local unsaved edits and object identity so open views stay valid; then refreshes - the UI without touching the player. **Self-write suppression:** `flush()` records - each file's `(mtime, size)` in `_own_sigs`; the watcher ignores changes matching - our own writes so a save never triggers a reload. -- [x] **Auto-merge + backups + summaries** — `storage/conflict_resolver.py` now - backs up *both* sides into `<data>/.resolved/<timestamp>/{original,incoming}/` - before merging, returns `list[ConflictSummary]`, and adds `restore_backup()`. - Conflicts are resolved at startup **and live** (via the watcher). -- [x] **Alert UI** — `gui/conflict_dialog.py` `ConflictSummaryDialog` (scrollable - summary, Open backup folder, Restore pre-merge backup), shown modeless from a - status-bar notice so a merge never interrupts playback. Wired in `main_window` - (`_on_library_reloaded` preserves scroll+selection; `_on_conflict_resolved`). - -**Known limitations (v2):** a *simultaneous* conflicting playlist edit unions -membership, so a deletion made on one side while the other edits the same file can -be resurrected (clean, non-simultaneous deletes reload fine) — proper fix is -per-playlist tombstones. No per-change accept/refuse yet (whole-file restore only). - -### Round 15 (2026-07-01) — Go live: portable multi-machine paths + final import - -Prepared LinTunes to become the canonical library, synced to a second machine via -Syncthing. See `tests/test_round15.py` (10 tests). - -- [x] **Portable track paths** (`lintunes/paths.py`) — track locations are stored - **relative to the data dir** (`to_relative`) and resolved back to absolute on load - (`to_absolute`), applied only at the `json_storage` boundary (`save_tracks` / - `load_library`). `Track.location` stays absolute in memory, so the player, tagging, - and art code are untouched. Because the data dir and the music live inside one - synced tree and move together, the library now resolves on any machine no matter - where Syncthing mounts the folder — no per-machine `music_root` config. Absolute - paths in older `library.json` files still load (back-compat). -- [x] **Final import + go live** — fresh import of the current `iTunes Library.xml` - into the synced data dir `/run/media/trav/tummult/music/lintunes/` (inside the - verified "music" Syncthing share), config saved via `--save-config`, preferences - (UI tuning + Last.fm) carried over from the old `./data`, which is retained as a - rollback backup. Git remote set to `git.autonomic.zone:2222/trav/lintunes` so the - second machine can clone. - -### Round 14 (2026-06-26) — Smart playlists (Phase 1) - -Auto-populating, criteria-driven playlists with full iTunes 12 import. See -`tests/test_round14.py` (27 tests, incl. real captured blobs in -`tests/smart_blobs.json`). - -- [x] **Criteria model + evaluator** (`lintunes/smart.py`) — recursive - `SmartRule`/`SmartGroup`/`SmartLimit`/`SmartCriteria` (JSON round-tripping), - a shared `FIELD_REGISTRY` (field → Track attr, type, operators) used by both - evaluator and editor, and `evaluate()` (match all/any, nested groups, - string/int/duration/rating/date/bool ops, relative "in the last N", and - limit-by items/time/size with a "selected by" sort). -- [x] **iTunes import** — the binary "Smart Info"/"Smart Criteria" blobs are - decoded by a **vendored, MIT-licensed parser** (`lintunes/itunes_smart/`, - from github.com/cvzi/itunes_smartplaylist) and converted to our model by - `parse_itunes_smart()`. Nested rule groups **parse and evaluate** correctly; - blobs we can't represent (MediaKind/iCloud/etc.) flag `unsupported` and keep - the imported track snapshot. Validated against all 14 real smart playlists. - `loved` is deliberately dropped per user preference. -- [x] **Manager** (`library_manager.py`) — `create_smart_playlist` / - `set_smart_criteria` (undoable) / `recompute_smart_playlist` / - `recompute_all_smart`; field-scoped, coalesced live recompute hooked into the - track-edit/play/skip/add funnels (honours each playlist's `live_update`); a - no-op equality guard to avoid Syncthing churn; manual add/remove/reorder - blocked on smart playlists. `main.py` recomputes on load; - `conflict_resolver.py` takes newest criteria (no track_id union) for smart. -- [x] **GUI** — `❧ ` glyph on smart rows in the sidebar (with a rename-strip - delegate), distinct context menu (New/Edit Smart Playlist), read-only track - table for smart playlists, and `SmartPlaylistEditorDialog` - (`gui/smart_playlist_dialog.py`): per-field rule rows, match all/any, limits, - live-updating. New Smart Playlist on File menu (Ctrl+Alt+N). - -**Phase 2 TODO:** nested-group **editing** UI in the dialog (imported nested -playlists are currently shown read-only with a banner; they still update/play). - -### Round 8 (2026-06-19) - -All four implemented in Round 8 (2026-06-19); see tests/test_round8.py. - -- [x] the ability to right click on any track and within the right-click menu that comes up there's an item that says "show in playlist..." with a right arrow. Hover that and you get a list of all the playlists that that track appears in. Select a playlist and the app jumps to that playlist and highlights the first instance of that song in that playlist. -- [x] ability to edit the ID3 tags of multiple files at once and have it intelligently only modify only the id3 tags that the files share. Say we're editing all songs in an albumb: the song title isn't editable because it isn't the same between the tracks, but the album title would show and the artist. So we could make those changes to all songs at once. _(iTunes-style: differing fields show a "Mixed" placeholder but stay editable; only fields you actually touch are written to all selected tracks.)_ -- [x] search, we need search. It only needs to appear in the library view. This should be a text box that takes up a third of that empty horizontal space between the tracks view and the top control panel. It should be a textbox that has in very light gray text that says "search". When you click into it that the "search" text disappears. As soon as you start typing it starts filtering the music. This should be compatible with the browser columns so the browser columns still show genres, artists and albums that match the search term. The search should search all fields for the search term(s). I could see this being really slow having to search all fields of all tracks at every keypress. So we can pause between key-presses if we want. So it's a search but really more like a filter. _(250 ms debounce.)_ -- [x] I want support for custom start and end times of tracks like itunes has. We need to be able to import this data from how it's stored in the itunes library and we need to be able to respect it during playback. There should also be fields in the 'get info' screen that allows us to turn custom start/end times of the track on and off and set the value of how far into the song it starts/ends. This is a feature in itunes we're implementing. _(Import was already wired; added Get Info fields + playback seek-to-start/stop-at-stop. Stored in library JSON only — no standard ID3 frame, same as iTunes.)_ - -- [x] Search box over the library. _(Round 8.)_ - - - -### Rounds 1–4 (2026-06-12) - -- **Spec 1–14** — full iTunes-replacement baseline: - - iTunes XML import with Mac→local location remap, play counts/ratings/dates - preserved, smart + system playlists skipped (`importers/itunes_importer.py`). - - Syncthing-friendly JSON storage, conflict-file merge on startup - (`storage/`); data dir lives in the synced music folder. - - iTunes-style layout: sidebar + track view; genre/artist/album browser - (Ctrl+B); per-playlist columns/sort/manual order; playlist folders. - - PyQt6 GUI; playback (mp3/m4a/flac via Qt Multimedia); scrubbing timeline + - transport buttons; Space/←/→ keys + MPRIS2 media keys. - - Drag/copy-paste tracks; drop-to-import files/folders; Get Info tag editing - via mutagen. -- **Round 2** — art paste in Get Info; BPM tap button; guessed ratings dropped - + migrated; big-art window; 60fps EQ visualizer; cue-don't-play arrows; - now-playing speaker icon. -- **Round 3** — Preferences (`preferences.py`, Edit ▸ Preferences, Ctrl+,); - Last.fm scrobbling (`lastfm.py`, full-play-through only); theming - (`theme.py`: 5 highlight colors, 3 UI scales); shuffle (walk-order); - sidebar restructure (Library button + bottom art); MPRIS artUrl; painted - transport glyphs; drag fix; app icon + `install-desktop.sh`. -- **Round 4** — drag chip + glowing drop line; Library button border; 8px BPM - box; click-to-jump seek slider; vertical browser splitter; fixed-baseline - visualizer (this also resolved the "vibrating bar bottoms" complaint). - -### Round 5 (2026-06-13) — UI polish + status bar + browser normalization - -- [x] **Visualizer rounded corners** — rounded gray panel + 1px `palette(mid)` - border, bars clipped to the rounded shape (`gui/visualizer.py`). -- [x] **BPM button** flat inside its box, matching the transport icons - (`gui/transport.py`). -- [x] **Bottom status bar** — permanent totals readout `N tracks · DD:HH:MM:SS` - (trimmed leading units) for the visible track set; reflects browser - genre/artist/album selection and playlist contents; transient - import/scrobble/error messages still shown; height scales with UI size - (`gui/track_table.py` `format_total_time`/`tracks_changed`/`total_stats`, - `gui/main_window.py`, `theme.py` `status_height`). -- [x] **Browser normalization** — merge case variants (display the - most-tracks spelling; ties: more capitals, then alphabetical) for genres, - artists, albums; ignore leading "The" when sorting artists; case-insensitive - filtering so a canonical entry matches all variants (`gui/library_view.py`). -- [x] **Header bold bug** — `setHighlightSections(False)`; column headers no - longer bold when playback sets the current cell (`gui/track_table.py`). -- [x] **Library button** — light-gray rounded button, not bold - (`gui/sidebar.py`). -- [x] **Playlist title** — name only; count/time moved to the status bar - (`gui/playlist_view.py`). -- [x] Tests: `tests/test_round5.py` (format_total_time, _canonical_values, - _artist_sort_key). - -### 2026-06-18 — top control panel tweaks - -- [x] **Faster tap-BPM commit** — the tapped tempo now saves to the track 3s - after the cursor leaves the button, down from 6s (`gui/transport.py` - `BpmButton.SAVE_DELAY_MS`). -- [x] **Control panel redesign** — bar height floored at `BAR_HEIGHT` (84px, - ~10% under the old ~95px) via `setMinimumHeight`, giving the center column - vertical slack; the title/artist block is centered with **equal** top/bottom - borders (verified ~11px vs ~10px) using equal `addStretch` above and below, - while the seek row stays pinned to the bottom so the timeline sits low. Key - fix: the **bpm box was moved out of the seek row** into its own box on the far - right (`layout.addWidget(_box(...))`). It was a 37px-tall box that inflated the - timeline row, floating the 15px slider with a lopsided phantom gap below the - artist; out of that row the seek row collapses to slider/label height so the - stretches split evenly. Note: `BAR_HEIGHT` is a *floor* — setting it below the - natural stacked height (~69px) has no effect. EQ visualizer panel background - now matches the transport boxes (`palette(alternate-base)` instead of - `window().darker(115)`). (`gui/transport.py`, `gui/visualizer.py`.) Verified - by offscreen `QWidget.grab()` render + widget-geometry measurement - (gnome-screenshot is blocked under Wayland). - - - - - -- [ ] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` once trav - confirms the real import looks right (re-run import or copy `./data`, update - config with `--save-config`). -- [ ] Run the real import on trav's library and eyeball the result (fixture in - `./itunes-test-library/`). -- [ ] Album art beyond now-playing (grid/album view); embedded tags only - (decision: no parsing of iTunes' .itc artwork cache). -- [ ] Search box over the library. -- [ ] Background/threaded bulk file import (current import is synchronous). -- [ ] Better duplicate handling on import-by-drop (currently matched by path). -- [ ] Volume control in the transport bar (MPRIS exposes a fixed 1.0). -- [ ] Smart playlists, if ever (skipped at import for now). -- [ ] Wayland edge-drag resize — needs trav's verification; if still bad, try - `lintunes -platform xcb` and switch the .desktop Exec if that fixes it.