v0.14.0: a removal you make sticks

Round 43 made the merge report honest, and in doing so 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.

The union was there for a real reason — two copies with no common ancestor
cannot tell "A added this" from "B removed it" — so the missing evidence is
written down instead of inferred. New lintunes/tombstones.py: a playlist keeps
track_events {track id: [when, add|remove]}, the library keeps deleted_tracks
{track id: when} in library_metadata.json, and a merge applies the newest event
per track across both copies. 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 stays the safe behavior where there is no
evidence either way.

Deliberately not "the newer copy wins wholesale": that one-liner silently drops
a song the other machine added while you were removing one, which
test_an_unrelated_addition_is_not_lost pins.

Events are recorded in the funnels that already exist — _set_track_ids diffs
before/after so a reorder records nothing, _remove_tracks stamps the library,
_restore_tracks clears it so Ctrl+Z takes the tombstone back — and pruned after
30 days at the save boundary.

Three edges worth naming:

* Track ids are never reused. The next id came from max(library.tracks), so
  deleting the highest-numbered track freed its id, and the next import would be
  dropped on sight by the dead id's own tombstone on every machine.
* library_metadata.json is merged before library.json, since it carries the
  record the library merge is filtered against and rglob order is not a plan.
* A merge applying a deletion never touches a music file — it drops the library
  entry only, and test_a_merge_never_touches_a_music_file fails the run if
  send_to_trash is so much as called. Applied removals grade WARNING and name
  the song.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
This commit is contained in:
2026-08-27 18:31:33 -04:00
co-authored by Claude Opus 5
parent c8de124543
commit 8c097faacc
12 changed files with 594 additions and 26 deletions
+21 -1
View File
@@ -57,7 +57,8 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
`plays/<machine-id>.json` per machine. Writes are atomic (`*.json.tmp`
→ rename). **`storage/conflict_resolver.py`** merges Syncthing
`*.sync-conflict-*` files on startup: play counts take the max, edited fields
take the newest, playlist membership takes the union. Since Round 39 that
take the newest, playlist membership takes the union **except where a removal
was recorded** (Round 44, see `tombstones.py`). Since Round 39 that
union is **anchor-based** (`merge_track_order`): a track only one copy has is
re-inserted after the nearest track both share, not appended at the tail, so
a middle insert stays in the middle. Whose order wins is decided by
@@ -169,6 +170,25 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
back to `Path("Music")`, which resolved against a working directory GNOME's
dash doesn't set predictably.
- **`lintunes/tombstones.py`** — why a deletion sticks. Two copies with no
common ancestor cannot tell "A added this" from "B removed it", which is why
every merge was a union — and why a song removed on one machine came back from
the other forever. So removals are *recorded*: `Playlist.track_events`
(`{track id: [when, "add"|"remove"]}`) and `Library.deleted_tracks`
(`{track id: when}`, in `library_metadata.json`). A merge takes the **newest
event per track** across both copies and applies it, so a removal beats a copy
that merely still had the song and a later re-add beats the removal; a track
with no event still merges as a union. Events are recorded in the same single
funnels as everything else — `_set_track_ids` for playlists, `_remove_tracks`
for the library — and pruned after `RETENTION_DAYS` (30) at the save boundary,
so a machine offline longer than that can resurrect something. Three rules:
**track ids are never reused** (`_highest_track_id` counts deletions, or a new
track would be dropped on sight by the dead id's own tombstone),
`library_metadata.json` is merged **before** `library.json` (it carries the
record the library merge is filtered against — `resolve_conflicts` sorts for
it), and a merge applying a deletion **never touches a music file**; it only
drops the library entry, and reports at WARNING.
- **`lintunes/sync_identity.py`** — turns a conflict filename's 7-char device
token into a device name, by reading Syncthing's `config.xml` and deriving
*our own* device ID from `cert.pem` (base32 of the SHA-256 of the DER cert).