## Done ### 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 `