v0.9.1: playlist merges stop losing your track order

What scrambled `a nissa one` took three defects at once. Reorder it on machine
A; open it on machine B and resize the window; B's file is now newer, so the
merge takes B's order — the old one — wholesale.

_merge_playlist was a 2-way union with no common ancestor: one side's order
wholesale, the other side's extras appended at the tail, so a track inserted in
the middle on one machine arrived at the bottom on the other. merge_track_order
re-inserts each side-only track after the nearest track both copies share
instead. _reconcile_playlist (the live-reload path) uses the same helper. The
union invariant is unchanged and property-tested over 300 random pairs — a
merge never drops a track.

Whose order wins is now Playlist.date_modified, bumped only in _set_track_ids
(add / remove / reorder / undo) and deliberately not by
mark_playlist_settings_dirty, with a file-mtime fallback for playlists written
before the field existed. The file's mtime was a lie: the last column is
stretch-sized, so Qt re-fires sectionResized whenever the viewport width
changes, and a window resize or splitter drag rewrote the open playlist's JSON
— moving its mtime and handing Syncthing another conflict — for a width
apply_settings overrides on load anyway. _on_section_resized now skips that
section; genuine drags on every other column still persist.

Replayed on a copy of the real playlists: `a nissa one` keeps its reorder
against a newer-by-mtime opponent, and `a nissa ideas` takes the other
machine's two mid-list inserts at 5 and 12 rather than 26 and 27.

Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
This commit is contained in:
2026-08-20 21:29:03 -04:00
parent 3214bb7d96
commit 19aa816537
9 changed files with 403 additions and 36 deletions
+11 -6
View File
@@ -313,6 +313,11 @@ class LibraryManager(QObject):
if playlist is None:
return
playlist.track_ids = list(ids)
# The single funnel for every content change (add / remove / reorder /
# undo), and deliberately the *only* place date_modified moves — a
# column resize must not look like an edit to the merge. See
# conflict_resolver._playlist_newer.
playlist.date_modified = _utc_now_iso()
self._mark_playlist(pid)
self.playlist_content_changed.emit(pid)
@@ -974,13 +979,13 @@ def _reconcile_track(mem_track, disk_track):
def _reconcile_playlist(mem_pl, disk_pl):
"""Reconcile a playlist we were locally editing with the disk copy: keep our
order and append any tracks that exist only on the other machine (a merge
"""Reconcile a playlist we were locally editing with the disk copy: our order
wins (the local edit is the more recent one), and tracks that exist only on
the other machine are merged back into position rather than appended (a merge
never drops tracks)."""
local = list(mem_pl.track_ids)
local_set = set(local)
mem_pl.track_ids = local + [tid for tid in disk_pl.track_ids
if tid not in local_set]
from lintunes.storage.conflict_resolver import merge_track_order
mem_pl.track_ids = merge_track_order(list(mem_pl.track_ids),
list(disk_pl.track_ids))
def _utc_now_iso() -> str: