v0.13.0: the merge report says who, what, and where

The merge window kept reporting "Order kept from this machine (most recently
edited)" for playlists edited on the other machine. Three defects, confirmed
against the real snapshots in .resolved/:

* "this machine" was inferred from which copy held the plain filename. That is
  Syncthing's call, not a statement about authorship — it sets the local copy
  aside as readily as a remote one. 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,
  so the label was exactly backwards. The 7-char device ID in the conflict
  filename — the only real evidence — was matched by a bare \w+ and deleted with
  the file. New sync_identity.py decodes it against Syncthing's config.xml and
  works out which device is us from cert.pem.

* The decision leaned local. date_modified was only consulted when *both* copies
  had one, and an iTunes playlist never reordered here has none — so the honest
  comparison was skipped exactly when one machine had edited and the other
  hadn't. A stamped copy now beats an unstamped one; mtime is the fallback only
  when neither side has ever been edited. And every merge used to rewrite the
  file it kept whether or not anything changed, freshening its mtime while the
  conflict file kept its origin's: a ratchet. No-op merges write nothing, and a
  merge whose result is a union neither copy had stamps date_modified, so the
  other machine adopts it instead of trading the same 19 tracks back and forth.

* Nothing was actionable. Re-inserted tracks are now named with their position
  ("Pola — Abeille -> position 24, after ..."), six in the window and all of
  them in what-changed.txt at the top of the backup snapshot, alongside the real
  conflict filename and its device. Tracks only this copy has are reported too
  rather than resurrected in silence.

Also fixed while in here: a rename or folder move made elsewhere was discarded
by every merge (only track_ids and settings were adopted); _reconcile_playlist
asserted the local edit was newer and never checked, so a reorder synced in from
the other machine was undone and flushed back to disk, and the branch reaching
it was gated on a dirty flag that a column drag sets; and _merge_metadata
decided the music folder from whichever copy an mtime coin flip had kept.

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 15:16:49 -04:00
co-authored by Claude Opus 5
parent 37b3744aa4
commit c8de124543
10 changed files with 978 additions and 71 deletions
+27 -5
View File
@@ -61,16 +61,32 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
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
`Playlist.date_modified` — bumped *only* in
`LibraryManager._set_track_ids` — not by the file's mtime, which moves for
cosmetic reasons. `library_manager._reconcile_playlist` uses the same helper
for the live-reload path. Since Round 42 every `ConflictSummary` carries a
`Playlist.date_modified`, not by the file's mtime, which moves for cosmetic
reasons. Since Round 43 a *stamped* copy also beats an *unstamped* one (a
stamp exists only once LinTunes recorded an edit, so that is real evidence);
mtime is the fallback only when neither side has ever been edited. Two rules
follow: a merge that changes nothing writes nothing (an unconditional write
reset the kept file's mtime and biased that fallback a little more every
round), and a merge whose result is a **union neither copy had** stamps
`date_modified` — that content is newer than both, and saying so is what stops
two machines trading the same tracks back and forth.
`library_manager._reconcile_playlist` is the same decision on the live-reload
path and must read the stamps too; it is reached only for playlists in
`_dirty_playlist_content` (real content edits), never for one that is merely
cosmetically dirty from a column drag. Since Round 42 every `ConflictSummary` carries a
**`level`** (`WARNING` / `CHANGE` / `INFO`): a merge that only reconciled
column widths, or a smart playlist whose rules are byte-identical on both
sides, is `INFO` and must never read as an edit the user made. The dialog
(`gui/conflict_dialog.py`) shows one level *and above* and opens at the
highest level in the batch, so a routine merge never steals focus but a
blank window is impossible either.
blank window is impossible either. Round 43 made the summaries say something
actionable: which copy won and when it was edited, who wrote the copy
Syncthing set aside (the 7-char device token in the conflict filename, named
via `sync_identity.py`), and every re-inserted track by `Artist — Title` and
position — six in the window, all of them in `what-changed.txt` at the top of
the backup snapshot. **Never label a copy "this machine" from which file
holds the plain name**: that is Syncthing's choice, and it sets the local copy
aside as readily as a remote one.
- **`lintunes/storage/play_journal.py`** — why play counts can't conflict. Since
Round 38 `library.json` holds only a **base** count and each machine owns
@@ -153,6 +169,12 @@ 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/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).
Stdlib only, cached, and every failure path returns `None` — a machine with no
Syncthing must still merge, just without naming anyone.
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
so the desktop's media keys / now-playing popup control playback. Spacebar and
arrow keys are handled locally via `MainWindow.eventFilter`.