## Done ### 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/.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/ //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/.jpg`, `Playlists/.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//` 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 `