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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
This commit is contained in:
@@ -57,7 +57,14 @@ 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.
|
||||
take the newest, playlist membership takes the union. 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
|
||||
`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.
|
||||
|
||||
- **`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
|
||||
@@ -187,6 +194,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
|
||||
- **Keep `Player` and `track_table` manager-free where they already are** —
|
||||
cross-cutting data is injected via callbacks/signals (e.g. `track_table` takes
|
||||
a `playlists_for_track` callback rather than importing the manager).
|
||||
- **Cosmetic table settings must not look like edits.** The last column is
|
||||
stretch-sized, so Qt re-fires `sectionResized` for it on every viewport width
|
||||
change; `track_table._on_section_resized` ignores that section, or resizing
|
||||
the window would rewrite the open playlist's JSON (and hand Syncthing a
|
||||
conflict) purely for a width `apply_settings` overrides on load anyway.
|
||||
|
||||
- **Qt/Wayland gotchas (GNOME/Mutter):** `QDrag.setPixmap` / `setDragCursor` /
|
||||
`QCursor.pos()` are unreliable during a drag — `gui/drag_ghost.py` paints its
|
||||
own child-widget overlay instead. `QAudioOutput` must not be constructed before
|
||||
|
||||
@@ -12,28 +12,25 @@ When a round closes, move its finished items to `tasks-done.md`.
|
||||
## Round 37 (2026-08-19) — Export Playlist: done, see tasks-done.md
|
||||
|
||||
Folder + web-mix export, audio.js/jQuery dropped for a dependency-free
|
||||
player, lossless-only conversion. Round 36 below is still open.
|
||||
player, lossless-only conversion.
|
||||
|
||||
## Round 38 (2026-08-20) — merge windows + play journals: done, see tasks-done.md
|
||||
## Round 39 (2026-08-20) — playlist order + honest date_modified: done, see tasks-done.md
|
||||
|
||||
## Round 36 — the merge rework (the playlist half is still open)
|
||||
## Round 36 — the merge rework (closed out by Rounds 38 and 39)
|
||||
|
||||
The three symptoms in Round 35 shared a root cause; that round fixed the
|
||||
performance half. Round 38 took the play-count and dialog items; the two
|
||||
playlist items below are what's left.
|
||||
performance half. Round 38 took the play-count and dialog items, Round 39 the
|
||||
two playlist ones. Only the `.stignore` note below is left.
|
||||
|
||||
- [ ] **Playlist merges lose position.** `_merge_playlist` is a 2-way union with
|
||||
no common ancestor: it takes one side's order wholesale and *appends* the
|
||||
other side's extras, so a track inserted in the middle on one machine
|
||||
arrives at the tail on the other (this is what happened to `nissa one`).
|
||||
Replace with an anchor-based merge — insert each side-only track after its
|
||||
nearest preceding common anchor. Same in `_reconcile_playlist`.
|
||||
- [ ] **mtime is a lie for playlists.** `mark_playlist_settings_dirty` rewrites
|
||||
the whole playlist file for UI-only changes, and because the last column
|
||||
is stretch-sized, *resizing the window* rewrites the open playlist's JSON.
|
||||
So "most recently edited" often means "most recently resized". Give
|
||||
`Playlist` a `date_modified` bumped only in `_set_track_ids`, and merge on
|
||||
that.
|
||||
- [x] **Playlist merges lose position.** Done in Round 39 — `merge_track_order`
|
||||
anchors each side-only track to the nearest track both copies share.
|
||||
Replayed against the real `a nissa one`: the reorder survives and a
|
||||
mid-list insert lands at index 5, not 26.
|
||||
- [x] **mtime is a lie for playlists.** Done in Round 39 — `Playlist.date_modified`
|
||||
is bumped only in `_set_track_ids`, and `_on_section_resized` now ignores
|
||||
the stretched last column, so resizing the window doesn't rewrite the
|
||||
playlist file at all.
|
||||
- [x] **`max()` play counts discard concurrent plays.** Done as described —
|
||||
per-machine `plays/<machine-id>.json` journals, machine id in the config
|
||||
dir. Three plays on the real library now write 83 bytes instead of 15 MB,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.9.0"
|
||||
__version__ = "0.9.1"
|
||||
|
||||
@@ -714,6 +714,15 @@ class TrackTableView(QTableView):
|
||||
def _on_section_resized(self, section, _old, new_width):
|
||||
if self._suppress_signals or section >= len(self.model_.fields):
|
||||
return
|
||||
header = self.horizontalHeader()
|
||||
if (header.stretchLastSection()
|
||||
and section == header.logicalIndex(header.count() - 1)):
|
||||
# The last column is stretch-sized, so Qt re-fires this every time
|
||||
# the viewport changes width — resizing the window or dragging the
|
||||
# splitter would otherwise rewrite the whole playlist JSON (and hand
|
||||
# Syncthing a conflict). Its width is derived, not chosen: there's no
|
||||
# right edge to drag, and apply_settings overrides it on load.
|
||||
return
|
||||
field = self.model_.fields[section]
|
||||
if field != INDEX_FIELD:
|
||||
self.column_width_changed.emit(field, new_width)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -58,6 +58,10 @@ class Playlist:
|
||||
track_ids: list[int] = field(default_factory=list)
|
||||
settings: PlaylistSettings = field(default_factory=PlaylistSettings)
|
||||
is_system: bool = False
|
||||
# Bumped only when track_ids change (LibraryManager._set_track_ids), never
|
||||
# for column widths or sort order — the conflict merge uses it to decide
|
||||
# whose order wins, and the file's mtime moves for cosmetic reasons.
|
||||
date_modified: Optional[str] = None
|
||||
# Set only for SMART playlists. Membership (track_ids) is derived from this
|
||||
# by the LibraryManager; for imported playlists track_ids also doubles as a
|
||||
# snapshot fallback when the criteria couldn't be fully parsed.
|
||||
@@ -78,6 +82,8 @@ class Playlist:
|
||||
}
|
||||
if self.parent_persistent_id:
|
||||
d["parent_persistent_id"] = self.parent_persistent_id
|
||||
if self.date_modified:
|
||||
d["date_modified"] = self.date_modified
|
||||
if self.smart_criteria is not None:
|
||||
d["smart_criteria"] = self.smart_criteria.to_dict()
|
||||
return d
|
||||
@@ -96,6 +102,7 @@ class Playlist:
|
||||
track_ids=d.get("track_ids", []),
|
||||
settings=PlaylistSettings.from_dict(d.get("settings", {})),
|
||||
is_system=d.get("is_system", False),
|
||||
date_modified=d.get("date_modified"),
|
||||
smart_criteria=smart_criteria,
|
||||
)
|
||||
|
||||
|
||||
@@ -226,43 +226,102 @@ def _merge_track_fields(orig: dict, conflict: dict) -> list[str]:
|
||||
return notes
|
||||
|
||||
|
||||
def merge_track_order(primary: list[int], secondary: list[int]) -> list[int]:
|
||||
"""Union two orderings of a playlist without losing position.
|
||||
|
||||
A plain union appends whatever the other copy had at the tail, so a track
|
||||
inserted in the *middle* on one machine arrives at the *bottom* on the other
|
||||
— which is what scrambled `a nissa one`. Instead, every track that exists
|
||||
only in ``secondary`` is re-inserted after its nearest preceding track that
|
||||
both copies share (its "anchor"); ``primary`` decides the order of everything
|
||||
the two have in common.
|
||||
|
||||
The result is always the union of the two lists: a merge must never drop a
|
||||
track, so anything whose anchor never turns up is flushed at the tail.
|
||||
"""
|
||||
common = set(primary) & set(secondary)
|
||||
|
||||
# Walk secondary once: each secondary-only track hangs off the last shared
|
||||
# track before it (None = it led the list).
|
||||
pending: dict[object, list[int]] = {}
|
||||
anchor = None
|
||||
for tid in secondary:
|
||||
if tid in common:
|
||||
anchor = tid
|
||||
else:
|
||||
pending.setdefault(anchor, []).append(tid)
|
||||
|
||||
merged = list(pending.pop(None, []))
|
||||
seen = set()
|
||||
for tid in primary:
|
||||
merged.append(tid)
|
||||
# First occurrence only — a playlist may hold the same track twice.
|
||||
if tid in common and tid not in seen:
|
||||
seen.add(tid)
|
||||
merged.extend(pending.pop(tid, []))
|
||||
for leftover in pending.values(): # anchor never appeared; never drop it
|
||||
merged.extend(leftover)
|
||||
return merged
|
||||
|
||||
|
||||
def _playlist_newer(original: dict, conflict: dict,
|
||||
original_path: Path, conflict_path: Path) -> bool:
|
||||
"""Is the incoming copy the more recently *edited* one?
|
||||
|
||||
Prefer the playlist's own ``date_modified``, which only moves when its
|
||||
contents change. File mtime is a lie: the last column is stretch-sized, so
|
||||
resizing the window rewrites the open playlist's JSON — "most recently
|
||||
edited" used to mean "most recently resized". Falls back to mtime for
|
||||
playlists written before ``date_modified`` existed.
|
||||
"""
|
||||
ours, theirs = original.get("date_modified"), conflict.get("date_modified")
|
||||
if ours and theirs:
|
||||
return theirs > ours
|
||||
return conflict_path.stat().st_mtime > original_path.stat().st_mtime
|
||||
|
||||
|
||||
def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary:
|
||||
original = read_json(original_path)
|
||||
conflict = read_json(conflict_path)
|
||||
name = original.get("name") or conflict.get("name") or original_path.stem
|
||||
|
||||
conflict_newer = _playlist_newer(original, conflict, original_path, conflict_path)
|
||||
|
||||
# Smart playlists: membership is derived, so keep the newer criteria and let
|
||||
# it recompute (unioning derived track_ids would resurrect non-matches).
|
||||
if original.get("playlist_type") == "smart" or conflict.get("playlist_type") == "smart":
|
||||
newer = _keep_newer(original_path, conflict_path)
|
||||
newer.file, newer.kind = name, "playlist"
|
||||
newer.lines = ["Smart-playlist rules taken from the most recently edited copy."]
|
||||
return newer
|
||||
if conflict_newer:
|
||||
write_json(original_path, conflict)
|
||||
return ConflictSummary(
|
||||
name, "playlist",
|
||||
["Smart-playlist rules taken from the most recently edited copy."])
|
||||
|
||||
orig_ids = list(original.get("track_ids", []))
|
||||
conf_ids = list(conflict.get("track_ids", []))
|
||||
orig_set, conf_set = set(orig_ids), set(conf_ids)
|
||||
|
||||
conflict_newer = conflict_path.stat().st_mtime > original_path.stat().st_mtime
|
||||
if conflict_newer:
|
||||
base_order = conf_ids
|
||||
extra = [tid for tid in orig_ids if tid not in conf_set]
|
||||
original["track_ids"] = base_order + extra
|
||||
# The other copy was edited last, so it sets the order; anything only we
|
||||
# had is re-inserted at its anchor rather than dumped at the tail.
|
||||
original["track_ids"] = merge_track_order(conf_ids, orig_ids)
|
||||
if "settings" in conflict:
|
||||
original["settings"] = conflict["settings"]
|
||||
if conflict.get("date_modified"):
|
||||
original["date_modified"] = conflict["date_modified"]
|
||||
order_src = "the other machine"
|
||||
moved_in = len(conf_set - orig_set)
|
||||
else:
|
||||
extra = [tid for tid in conf_ids if tid not in orig_set]
|
||||
original["track_ids"] = orig_ids + extra
|
||||
original["track_ids"] = merge_track_order(orig_ids, conf_ids)
|
||||
order_src = "this machine"
|
||||
moved_in = len(orig_set - conf_set)
|
||||
|
||||
write_json(original_path, original)
|
||||
|
||||
added = len(set(original["track_ids"]) - orig_set)
|
||||
lines = [f"Order kept from {order_src} (most recently edited)."]
|
||||
if added:
|
||||
lines.append(f"{added} track(s) that were only in the other copy were kept "
|
||||
"(nothing is removed on a merge).")
|
||||
lines.append(f"{added} track(s) that were only in the other copy were kept, "
|
||||
"back in position (nothing is removed on a merge).")
|
||||
return ConflictSummary(name, "playlist", lines)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
## Done
|
||||
|
||||
### Round 39 (2026-08-20) — Playlist merges stop losing your track order (v0.9.1)
|
||||
|
||||
The playlist half of the Round 36 rework, and what actually scrambled
|
||||
`a nissa one`. It took three defects together: reorder it on machine A, open it
|
||||
on machine B and resize the window, and B's file is newer, so the merge takes
|
||||
B's order — the old one — wholesale.
|
||||
|
||||
- [x] **Anchor-based merge.** `merge_track_order` in `conflict_resolver` unions
|
||||
two orderings by re-inserting each side-only track after the nearest track
|
||||
both copies share, instead of appending it at the tail. `_merge_playlist`
|
||||
and `library_manager._reconcile_playlist` (the live-reload path) both use
|
||||
it. The union invariant is unchanged and property-tested over 300 random
|
||||
pairs: a merge never drops a track, duplicates and disjoint lists included.
|
||||
- [x] **`Playlist.date_modified`, bumped only in `_set_track_ids`** — the single
|
||||
funnel for add / remove / reorder / undo, and deliberately *not*
|
||||
`mark_playlist_settings_dirty`. `_playlist_newer` prefers it over the
|
||||
file's mtime, falling back to mtime for playlists written before the field
|
||||
existed. The smart-playlist branch uses the same test, which told the same
|
||||
lie.
|
||||
- [x] **A window resize no longer rewrites the playlist.** The last column is
|
||||
stretch-sized, so Qt re-fires `sectionResized` for it whenever the viewport
|
||||
width changes — a resize or a splitter drag rewrote the open playlist's
|
||||
JSON, moved its mtime, and handed Syncthing another conflict, all for a
|
||||
width `apply_settings` overrides on load. `_on_section_resized` skips that
|
||||
section; genuine drags on every other column still persist.
|
||||
|
||||
Replayed against the real playlists on a copy: on `a nissa one` (23 tracks) the
|
||||
reorder survives a newer-by-mtime opponent, and on `a nissa ideas` (26 tracks)
|
||||
the other machine's two mid-list inserts land at 5 and 12 rather than 26 and 27.
|
||||
|
||||
### Round 38 (2026-08-20) — One merge window, and play counts that can't conflict (v0.9.0)
|
||||
|
||||
Fifteen "Synced changes merged" windows were stacked on the desktop. Two
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Round 39: playlist merges stop losing your track order.
|
||||
|
||||
Three defects, and it took all three to scramble `a nissa one`:
|
||||
|
||||
* `_merge_playlist` was a 2-way union with no common ancestor — it took one
|
||||
side's order wholesale and *appended* the other side's extras, so a track
|
||||
inserted in the middle on one machine arrived at the bottom on the other.
|
||||
* Whose order won was decided by the file's mtime, and the last column is
|
||||
stretch-sized — so resizing the window rewrote the open playlist's JSON and
|
||||
"most recently edited" meant "most recently resized".
|
||||
* Those cosmetic rewrites were themselves handing Syncthing conflicts.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from lintunes.models import Library, Playlist, PlaylistType
|
||||
from lintunes.storage import json_storage, conflict_resolver
|
||||
from lintunes.storage.conflict_resolver import merge_track_order
|
||||
|
||||
|
||||
def _save(tmp_path, playlists=()):
|
||||
lib = Library()
|
||||
for p in playlists:
|
||||
lib.playlists[p.persistent_id] = p
|
||||
json_storage.save_library(lib, tmp_path)
|
||||
return lib
|
||||
|
||||
|
||||
def _conflict_name(original: str) -> str:
|
||||
stem, _, ext = original.rpartition(".")
|
||||
return f"{stem}.sync-conflict-20240101-120000-ABCDEFG.{ext}"
|
||||
|
||||
|
||||
class TestAnchoredOrder:
|
||||
"""merge_track_order: a track inserted in the middle stays in the middle."""
|
||||
|
||||
def test_middle_insert_is_not_appended(self):
|
||||
# The other copy slipped 99 between 2 and 3. It belongs there, not last.
|
||||
assert merge_track_order([1, 2, 3, 4], [1, 2, 99, 3, 4]) == [1, 2, 99, 3, 4]
|
||||
|
||||
def test_both_sides_inserted_at_different_points(self):
|
||||
assert merge_track_order([1, 7, 2, 3], [1, 2, 8, 3]) == [1, 7, 2, 8, 3]
|
||||
|
||||
def test_consecutive_inserts_keep_their_relative_order(self):
|
||||
assert merge_track_order([1, 2], [1, 8, 9, 2]) == [1, 8, 9, 2]
|
||||
|
||||
def test_a_leading_insert_goes_to_the_head(self):
|
||||
assert merge_track_order([1, 2, 3], [7, 1, 2, 3]) == [7, 1, 2, 3]
|
||||
|
||||
def test_primary_decides_the_order_of_shared_tracks(self):
|
||||
# Both copies hold 1-2-3; primary reversed them. Primary wins.
|
||||
assert merge_track_order([3, 2, 1], [1, 2, 3]) == [3, 2, 1]
|
||||
|
||||
def test_trailing_insert_still_lands_after_its_anchor(self):
|
||||
assert merge_track_order([1, 2, 3], [1, 2, 3, 4]) == [1, 2, 3, 4]
|
||||
|
||||
@pytest.mark.parametrize("primary,secondary", [
|
||||
([], []),
|
||||
([1, 2, 3], []),
|
||||
([], [1, 2, 3]),
|
||||
([1, 2, 3], [4, 5, 6]), # disjoint
|
||||
([1, 2, 2, 3], [1, 2, 3, 3]), # the same track twice
|
||||
])
|
||||
def test_edge_cases_never_drop_a_track(self, primary, secondary):
|
||||
merged = merge_track_order(primary, secondary)
|
||||
assert set(merged) == set(primary) | set(secondary)
|
||||
|
||||
def test_nothing_is_ever_dropped(self):
|
||||
"""The invariant that matters most: a merge is a union, always."""
|
||||
rng = random.Random(39)
|
||||
for _ in range(300):
|
||||
base = rng.sample(range(20), rng.randint(0, 10))
|
||||
a = [t for t in base if rng.random() > 0.3] + rng.sample(range(20, 30), 2)
|
||||
b = [t for t in base if rng.random() > 0.3] + rng.sample(range(30, 40), 2)
|
||||
rng.shuffle(a)
|
||||
merged = merge_track_order(a, b)
|
||||
assert set(merged) == set(a) | set(b)
|
||||
# Primary's entries keep primary's exact sequence — only tracks it
|
||||
# never had get woven in between them.
|
||||
assert [t for t in merged if t in set(a)] == a
|
||||
|
||||
|
||||
class TestHonestDateModified:
|
||||
"""The merge decides on the playlist's own date_modified, not file mtime."""
|
||||
|
||||
def _setup(self, tmp_path, ours, theirs, our_stamp, their_stamp):
|
||||
pl = Playlist(name="a nissa one", persistent_id="AAAA1111",
|
||||
playlist_type=PlaylistType.REGULAR, track_ids=ours,
|
||||
date_modified=our_stamp)
|
||||
_save(tmp_path, [pl])
|
||||
pfile = tmp_path / "playlists" / "AAAA1111.json"
|
||||
other = json.loads(pfile.read_text())
|
||||
other["track_ids"] = theirs
|
||||
if their_stamp is None:
|
||||
other.pop("date_modified", None)
|
||||
else:
|
||||
other["date_modified"] = their_stamp
|
||||
cfile = tmp_path / "playlists" / _conflict_name("AAAA1111.json")
|
||||
cfile.write_text(json.dumps(other))
|
||||
return pfile, cfile
|
||||
|
||||
def test_a_resize_no_longer_beats_a_reorder(self, tmp_path):
|
||||
"""The `a nissa one` regression. We reordered; the other machine only
|
||||
opened it and resized the window, so its *file* is newer — but its
|
||||
date_modified is older, and our order must survive."""
|
||||
pfile, cfile = self._setup(
|
||||
tmp_path,
|
||||
ours=[3, 1, 2], # we carefully reordered
|
||||
theirs=[1, 2, 3], # they never touched the contents
|
||||
our_stamp="2026-08-20T18:00:00",
|
||||
their_stamp="2026-08-19T09:00:00")
|
||||
import os, time
|
||||
os.utime(cfile, (time.time() + 100, time.time() + 100)) # their file is newer
|
||||
|
||||
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
||||
|
||||
assert json.loads(pfile.read_text())["track_ids"] == [3, 1, 2]
|
||||
assert "this machine" in summaries[0].lines[0]
|
||||
|
||||
def test_the_genuinely_newer_edit_still_wins(self, tmp_path):
|
||||
pfile, _ = self._setup(
|
||||
tmp_path, ours=[1, 2, 3], theirs=[3, 2, 1],
|
||||
our_stamp="2026-08-19T09:00:00",
|
||||
their_stamp="2026-08-20T18:00:00")
|
||||
|
||||
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
||||
|
||||
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
|
||||
assert "the other machine" in summaries[0].lines[0]
|
||||
|
||||
def test_their_insert_lands_in_position_not_at_the_tail(self, tmp_path):
|
||||
pfile, _ = self._setup(
|
||||
tmp_path, ours=[1, 2, 3], theirs=[1, 2, 99, 3],
|
||||
our_stamp="2026-08-20T18:00:00",
|
||||
their_stamp="2026-08-19T09:00:00")
|
||||
|
||||
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
||||
|
||||
assert json.loads(pfile.read_text())["track_ids"] == [1, 2, 99, 3]
|
||||
assert "back in position" in summaries[0].lines[1]
|
||||
|
||||
def test_falls_back_to_mtime_before_the_field_existed(self, tmp_path):
|
||||
"""Playlists written by an older version carry no stamp."""
|
||||
pfile, cfile = self._setup(
|
||||
tmp_path, ours=[1, 2, 3], theirs=[3, 2, 1],
|
||||
our_stamp=None, their_stamp=None)
|
||||
import os, time
|
||||
os.utime(cfile, (time.time() + 100, time.time() + 100))
|
||||
|
||||
conflict_resolver.resolve_conflicts(tmp_path)
|
||||
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
|
||||
|
||||
def test_smart_playlist_rules_also_use_date_modified(self, tmp_path):
|
||||
from lintunes.smart import SmartCriteria
|
||||
pl = Playlist(name="Recent", persistent_id="BBBB2222",
|
||||
playlist_type=PlaylistType.SMART, track_ids=[1],
|
||||
smart_criteria=SmartCriteria(), date_modified="2026-08-20T18:00:00")
|
||||
_save(tmp_path, [pl])
|
||||
pfile = tmp_path / "playlists" / "BBBB2222.json"
|
||||
ours = json.loads(pfile.read_text())
|
||||
other = dict(ours)
|
||||
other["name"] = "Stale"
|
||||
other["date_modified"] = "2026-08-19T09:00:00"
|
||||
cfile = tmp_path / "playlists" / _conflict_name("BBBB2222.json")
|
||||
cfile.write_text(json.dumps(other))
|
||||
import os, time
|
||||
os.utime(cfile, (time.time() + 100, time.time() + 100))
|
||||
|
||||
conflict_resolver.resolve_conflicts(tmp_path)
|
||||
assert json.loads(pfile.read_text())["name"] == "Recent"
|
||||
|
||||
|
||||
class TestDateModifiedMovesOnlyForContent:
|
||||
@pytest.fixture
|
||||
def manager(self, tmp_path, qapp, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
from lintunes.library_manager import LibraryManager
|
||||
from lintunes.models import Track
|
||||
lib = Library()
|
||||
for tid in (1, 2, 3):
|
||||
lib.tracks[tid] = Track(track_id=tid, name=f"T{tid}")
|
||||
lib.playlists["AAAA1111"] = Playlist(
|
||||
name="a nissa one", persistent_id="AAAA1111",
|
||||
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2, 3])
|
||||
return LibraryManager(lib, tmp_path)
|
||||
|
||||
def _stamp(self, manager):
|
||||
return manager.library.playlists["AAAA1111"].date_modified
|
||||
|
||||
def test_reorder_bumps_it(self, manager):
|
||||
assert self._stamp(manager) is None
|
||||
manager.move_tracks_in_playlist("AAAA1111", [2], 0)
|
||||
assert self._stamp(manager) is not None
|
||||
|
||||
def test_add_and_remove_bump_it(self, manager):
|
||||
manager.remove_tracks_from_playlist("AAAA1111", [0])
|
||||
first = self._stamp(manager)
|
||||
assert first is not None
|
||||
manager.add_tracks_to_playlist("AAAA1111", [1])
|
||||
assert self._stamp(manager) >= first
|
||||
|
||||
def test_undo_bumps_it_too(self, manager):
|
||||
manager.move_tracks_in_playlist("AAAA1111", [2], 0)
|
||||
before = self._stamp(manager)
|
||||
manager.undo_stack.undo()
|
||||
assert manager.library.playlists["AAAA1111"].track_ids == [1, 2, 3]
|
||||
assert self._stamp(manager) >= before
|
||||
|
||||
def test_a_column_resize_does_not(self, manager):
|
||||
"""The whole point: cosmetic settings must not look like an edit."""
|
||||
manager.mark_playlist_settings_dirty("AAAA1111")
|
||||
assert self._stamp(manager) is None
|
||||
|
||||
|
||||
class TestStretchedColumnIsNotPersisted:
|
||||
@pytest.fixture
|
||||
def table(self, qapp):
|
||||
from lintunes.gui.track_table import TrackTableView
|
||||
from lintunes.models import Track
|
||||
view = TrackTableView()
|
||||
view.set_tracks([Track(track_id=1, name="A", artist="B")])
|
||||
return view
|
||||
|
||||
def test_a_middle_column_still_persists(self, table):
|
||||
seen = []
|
||||
table.column_width_changed.connect(lambda f, w: seen.append((f, w)))
|
||||
table.horizontalHeader().resizeSection(1, 123)
|
||||
assert seen and seen[0][1] == 123
|
||||
|
||||
def test_the_stretched_last_column_does_not(self, table):
|
||||
"""Qt re-fires sectionResized for the stretched column on every viewport
|
||||
width change — a window resize used to rewrite the playlist JSON."""
|
||||
header = table.horizontalHeader()
|
||||
last = header.logicalIndex(header.count() - 1)
|
||||
seen = []
|
||||
table.column_width_changed.connect(lambda f, w: seen.append((f, w)))
|
||||
header.resizeSection(last, 321)
|
||||
assert seen == []
|
||||
|
||||
def test_resizing_the_widget_is_silent(self, table):
|
||||
seen = []
|
||||
table.column_width_changed.connect(lambda f, w: seen.append((f, w)))
|
||||
table.resize(400, 300)
|
||||
table.resize(900, 300)
|
||||
assert seen == []
|
||||
Reference in New Issue
Block a user