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:
2026-08-20 21:29:03 -04:00
co-authored by Claude Opus 5
parent 2d961c0c9e
commit 574e476dc3
9 changed files with 403 additions and 36 deletions
+14 -1
View File
@@ -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` `plays/<machine-id>.json` per machine. Writes are atomic (`*.json.tmp`
→ rename). **`storage/conflict_resolver.py`** merges Syncthing → rename). **`storage/conflict_resolver.py`** merges Syncthing
`*.sync-conflict-*` files on startup: play counts take the max, edited fields `*.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 - **`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 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** — - **Keep `Player` and `track_table` manager-free where they already are** —
cross-cutting data is injected via callbacks/signals (e.g. `track_table` takes cross-cutting data is injected via callbacks/signals (e.g. `track_table` takes
a `playlists_for_track` callback rather than importing the manager). 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` / - **Qt/Wayland gotchas (GNOME/Mutter):** `QDrag.setPixmap` / `setDragCursor` /
`QCursor.pos()` are unreliable during a drag — `gui/drag_ghost.py` paints its `QCursor.pos()` are unreliable during a drag — `gui/drag_ghost.py` paints its
own child-widget overlay instead. `QAudioOutput` must not be constructed before own child-widget overlay instead. `QAudioOutput` must not be constructed before
+13 -16
View File
@@ -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 ## Round 37 (2026-08-19) — Export Playlist: done, see tasks-done.md
Folder + web-mix export, audio.js/jQuery dropped for a dependency-free 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 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 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 performance half. Round 38 took the play-count and dialog items, Round 39 the
playlist items below are what's left. two playlist ones. Only the `.stignore` note below is left.
- [ ] **Playlist merges lose position.** `_merge_playlist` is a 2-way union with - [x] **Playlist merges lose position.** Done in Round 39 — `merge_track_order`
no common ancestor: it takes one side's order wholesale and *appends* the anchors each side-only track to the nearest track both copies share.
other side's extras, so a track inserted in the middle on one machine Replayed against the real `a nissa one`: the reorder survives and a
arrives at the tail on the other (this is what happened to `nissa one`). mid-list insert lands at index 5, not 26.
Replace with an anchor-based merge — insert each side-only track after its - [x] **mtime is a lie for playlists.** Done in Round 39 — `Playlist.date_modified`
nearest preceding common anchor. Same in `_reconcile_playlist`. is bumped only in `_set_track_ids`, and `_on_section_resized` now ignores
- [ ] **mtime is a lie for playlists.** `mark_playlist_settings_dirty` rewrites the stretched last column, so resizing the window doesn't rewrite the
the whole playlist file for UI-only changes, and because the last column playlist file at all.
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] **`max()` play counts discard concurrent plays.** Done as described — - [x] **`max()` play counts discard concurrent plays.** Done as described —
per-machine `plays/<machine-id>.json` journals, machine id in the config 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, dir. Three plays on the real library now write 83 bytes instead of 15 MB,
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.9.0" __version__ = "0.9.1"
+9
View File
@@ -714,6 +714,15 @@ class TrackTableView(QTableView):
def _on_section_resized(self, section, _old, new_width): def _on_section_resized(self, section, _old, new_width):
if self._suppress_signals or section >= len(self.model_.fields): if self._suppress_signals or section >= len(self.model_.fields):
return 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] field = self.model_.fields[section]
if field != INDEX_FIELD: if field != INDEX_FIELD:
self.column_width_changed.emit(field, new_width) self.column_width_changed.emit(field, new_width)
+11 -6
View File
@@ -313,6 +313,11 @@ class LibraryManager(QObject):
if playlist is None: if playlist is None:
return return
playlist.track_ids = list(ids) 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._mark_playlist(pid)
self.playlist_content_changed.emit(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): def _reconcile_playlist(mem_pl, disk_pl):
"""Reconcile a playlist we were locally editing with the disk copy: keep our """Reconcile a playlist we were locally editing with the disk copy: our order
order and append any tracks that exist only on the other machine (a merge 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).""" never drops tracks)."""
local = list(mem_pl.track_ids) from lintunes.storage.conflict_resolver import merge_track_order
local_set = set(local) mem_pl.track_ids = merge_track_order(list(mem_pl.track_ids),
mem_pl.track_ids = local + [tid for tid in disk_pl.track_ids list(disk_pl.track_ids))
if tid not in local_set]
def _utc_now_iso() -> str: def _utc_now_iso() -> str:
+7
View File
@@ -58,6 +58,10 @@ class Playlist:
track_ids: list[int] = field(default_factory=list) track_ids: list[int] = field(default_factory=list)
settings: PlaylistSettings = field(default_factory=PlaylistSettings) settings: PlaylistSettings = field(default_factory=PlaylistSettings)
is_system: bool = False 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 # Set only for SMART playlists. Membership (track_ids) is derived from this
# by the LibraryManager; for imported playlists track_ids also doubles as a # by the LibraryManager; for imported playlists track_ids also doubles as a
# snapshot fallback when the criteria couldn't be fully parsed. # snapshot fallback when the criteria couldn't be fully parsed.
@@ -78,6 +82,8 @@ class Playlist:
} }
if self.parent_persistent_id: if self.parent_persistent_id:
d["parent_persistent_id"] = 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: if self.smart_criteria is not None:
d["smart_criteria"] = self.smart_criteria.to_dict() d["smart_criteria"] = self.smart_criteria.to_dict()
return d return d
@@ -96,6 +102,7 @@ class Playlist:
track_ids=d.get("track_ids", []), track_ids=d.get("track_ids", []),
settings=PlaylistSettings.from_dict(d.get("settings", {})), settings=PlaylistSettings.from_dict(d.get("settings", {})),
is_system=d.get("is_system", False), is_system=d.get("is_system", False),
date_modified=d.get("date_modified"),
smart_criteria=smart_criteria, smart_criteria=smart_criteria,
) )
+71 -12
View File
@@ -226,43 +226,102 @@ def _merge_track_fields(orig: dict, conflict: dict) -> list[str]:
return notes 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: def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary:
original = read_json(original_path) original = read_json(original_path)
conflict = read_json(conflict_path) conflict = read_json(conflict_path)
name = original.get("name") or conflict.get("name") or original_path.stem 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 # Smart playlists: membership is derived, so keep the newer criteria and let
# it recompute (unioning derived track_ids would resurrect non-matches). # it recompute (unioning derived track_ids would resurrect non-matches).
if original.get("playlist_type") == "smart" or conflict.get("playlist_type") == "smart": if original.get("playlist_type") == "smart" or conflict.get("playlist_type") == "smart":
newer = _keep_newer(original_path, conflict_path) if conflict_newer:
newer.file, newer.kind = name, "playlist" write_json(original_path, conflict)
newer.lines = ["Smart-playlist rules taken from the most recently edited copy."] return ConflictSummary(
return newer name, "playlist",
["Smart-playlist rules taken from the most recently edited copy."])
orig_ids = list(original.get("track_ids", [])) orig_ids = list(original.get("track_ids", []))
conf_ids = list(conflict.get("track_ids", [])) conf_ids = list(conflict.get("track_ids", []))
orig_set, conf_set = set(orig_ids), set(conf_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: if conflict_newer:
base_order = conf_ids # The other copy was edited last, so it sets the order; anything only we
extra = [tid for tid in orig_ids if tid not in conf_set] # had is re-inserted at its anchor rather than dumped at the tail.
original["track_ids"] = base_order + extra original["track_ids"] = merge_track_order(conf_ids, orig_ids)
if "settings" in conflict: if "settings" in conflict:
original["settings"] = conflict["settings"] original["settings"] = conflict["settings"]
if conflict.get("date_modified"):
original["date_modified"] = conflict["date_modified"]
order_src = "the other machine" order_src = "the other machine"
moved_in = len(conf_set - orig_set)
else: else:
extra = [tid for tid in conf_ids if tid not in orig_set] original["track_ids"] = merge_track_order(orig_ids, conf_ids)
original["track_ids"] = orig_ids + extra
order_src = "this machine" order_src = "this machine"
moved_in = len(orig_set - conf_set)
write_json(original_path, original) write_json(original_path, original)
added = len(set(original["track_ids"]) - orig_set) added = len(set(original["track_ids"]) - orig_set)
lines = [f"Order kept from {order_src} (most recently edited)."] lines = [f"Order kept from {order_src} (most recently edited)."]
if added: if added:
lines.append(f"{added} track(s) that were only in the other copy were kept " lines.append(f"{added} track(s) that were only in the other copy were kept, "
"(nothing is removed on a merge).") "back in position (nothing is removed on a merge).")
return ConflictSummary(name, "playlist", lines) return ConflictSummary(name, "playlist", lines)
+30
View File
@@ -1,5 +1,35 @@
## Done ## 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) ### 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 Fifteen "Synced changes merged" windows were stacked on the desktop. Two
+247
View File
@@ -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 == []