## Done
### 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 "
"
(`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-.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 `/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/