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:
@@ -1,5 +1,55 @@
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user