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
368 lines
16 KiB
Python
368 lines
16 KiB
Python
"""Round 42: the merge report stops shouting, and smart playlists stop
|
|
conflicting.
|
|
|
|
Two halves of one complaint — the merge window was full of entries for things
|
|
nobody did:
|
|
|
|
* A live smart playlist's membership is derived from its rules, but it was being
|
|
*persisted*, and `recompute_smart_playlist` rewrites it (and bumped
|
|
`date_modified`) every time a play count moves. Both machines did that
|
|
continuously against the same file, so `unplayed` handed Syncthing a conflict
|
|
once a song — for a list the next load throws away and recomputes anyway.
|
|
* Every summary was presented at the same weight, so a column resize on the
|
|
other machine popped and raised the same window as a 21-track reconciliation.
|
|
Summaries are now graded WARNING / CHANGE / INFO and the dialog shows one
|
|
level and above, opening at the highest level in the batch.
|
|
"""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from lintunes.library_manager import LibraryManager
|
|
from lintunes.models import Library, Playlist, PlaylistType, Track
|
|
from lintunes.smart import SmartCriteria, SmartGroup, SmartRule
|
|
from lintunes.storage import json_storage, conflict_resolver
|
|
from lintunes.storage.conflict_resolver import (
|
|
WARNING, CHANGE, INFO, ConflictSummary, at_least, max_level)
|
|
|
|
|
|
def _rock_criteria(**kwargs) -> SmartCriteria:
|
|
return SmartCriteria(
|
|
root=SmartGroup("all", [SmartRule("genre", "is", "Rock")]), **kwargs)
|
|
|
|
|
|
def _library(*genres) -> Library:
|
|
lib = Library()
|
|
for i, genre in enumerate(genres, start=1):
|
|
lib.tracks[i] = Track(track_id=i, name=f"t{i}", genre=genre)
|
|
return lib
|
|
|
|
|
|
def _conflict_name(original: str) -> str:
|
|
stem, _, ext = original.rpartition(".")
|
|
return f"{stem}.sync-conflict-20240101-120000-ABCDEFG.{ext}"
|
|
|
|
|
|
def _playlist_file(tmp_path, pid="AAAA1111"):
|
|
return tmp_path / "playlists" / f"{pid}.json"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Derived membership is never persisted
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
class TestDerivedMembership:
|
|
def _saved(self, tmp_path, criteria, track_ids=(1, 2)):
|
|
pl = Playlist(name="unplayed", persistent_id="AAAA1111",
|
|
playlist_type=PlaylistType.SMART,
|
|
track_ids=list(track_ids), smart_criteria=criteria)
|
|
lib = Library()
|
|
lib.playlists[pl.persistent_id] = pl
|
|
json_storage.save_library(lib, tmp_path)
|
|
return json.loads(_playlist_file(tmp_path).read_text())
|
|
|
|
def test_live_smart_playlist_stores_no_track_ids(self, tmp_path):
|
|
data = self._saved(tmp_path, _rock_criteria())
|
|
assert data["track_ids"] == []
|
|
assert data["derived_membership"] is True
|
|
|
|
def test_non_live_smart_playlist_keeps_its_snapshot(self, tmp_path):
|
|
data = self._saved(tmp_path, _rock_criteria(live_update=False))
|
|
assert data["track_ids"] == [1, 2]
|
|
assert "derived_membership" not in data
|
|
|
|
def test_unsupported_criteria_keep_the_imported_snapshot(self, tmp_path):
|
|
data = self._saved(tmp_path, _rock_criteria(unsupported=True))
|
|
assert data["track_ids"] == [1, 2]
|
|
|
|
def test_regular_playlist_is_untouched(self, tmp_path):
|
|
pl = Playlist(name="mix", persistent_id="BBBB2222",
|
|
playlist_type=PlaylistType.REGULAR, track_ids=[3, 1])
|
|
lib = Library()
|
|
lib.playlists[pl.persistent_id] = pl
|
|
json_storage.save_library(lib, tmp_path)
|
|
data = json.loads(_playlist_file(tmp_path, "BBBB2222").read_text())
|
|
assert data["track_ids"] == [3, 1]
|
|
|
|
def test_membership_survives_a_save_load_roundtrip(self, qapp, tmp_path):
|
|
library = _library("Rock", "Jazz", "Rock")
|
|
json_storage.save_library(library, tmp_path) # library.json for the reload
|
|
mgr = LibraryManager(library, tmp_path)
|
|
pl = mgr.create_smart_playlist("rockers", _rock_criteria())
|
|
assert pl.track_ids == [1, 3]
|
|
mgr.flush()
|
|
|
|
# Nothing on disk, but a fresh start rebuilds it from the rules.
|
|
assert json.loads(
|
|
_playlist_file(tmp_path, pl.persistent_id).read_text())["track_ids"] == []
|
|
restarted = LibraryManager(json_storage.load_library(tmp_path), tmp_path)
|
|
assert restarted.library.playlists[pl.persistent_id].track_ids == []
|
|
restarted.recompute_all_smart()
|
|
assert restarted.library.playlists[pl.persistent_id].track_ids == [1, 3]
|
|
|
|
|
|
class TestRecomputeIsNotAnEdit:
|
|
"""A recompute must leave no trace: no dirty file, no timestamp. Bumping
|
|
date_modified there made Round 39's "who edited last" tiebreaker meaningless
|
|
for every smart playlist."""
|
|
|
|
def _manager(self, tmp_path, criteria):
|
|
mgr = LibraryManager(_library("Rock", "Jazz"), tmp_path)
|
|
pl = mgr.create_smart_playlist("rockers", criteria)
|
|
mgr.flush()
|
|
mgr._dirty_playlists.clear()
|
|
return mgr, pl
|
|
|
|
def test_derived_recompute_leaves_no_trace(self, qapp, tmp_path):
|
|
mgr, pl = self._manager(tmp_path, _rock_criteria())
|
|
stamp = pl.date_modified
|
|
|
|
mgr.library.tracks[2].genre = "Rock" # track 2 now matches
|
|
assert mgr.recompute_smart_playlist(pl.persistent_id) is True
|
|
|
|
assert pl.track_ids == [1, 2] # in memory, recomputed
|
|
assert pl.date_modified == stamp # not an edit
|
|
assert pl.persistent_id not in mgr._dirty_playlists
|
|
|
|
def test_derived_recompute_still_announces_itself(self, qapp, tmp_path):
|
|
"""No file write, but the views must still redraw."""
|
|
mgr, pl = self._manager(tmp_path, _rock_criteria())
|
|
seen = []
|
|
mgr.playlist_content_changed.connect(seen.append)
|
|
|
|
mgr.library.tracks[2].genre = "Rock"
|
|
mgr.recompute_smart_playlist(pl.persistent_id)
|
|
|
|
assert seen == [pl.persistent_id]
|
|
|
|
def test_a_snapshot_playlist_still_persists_its_recompute(self, qapp, tmp_path):
|
|
mgr, pl = self._manager(tmp_path, _rock_criteria(live_update=False))
|
|
stamp = pl.date_modified
|
|
|
|
mgr.library.tracks[2].genre = "Rock"
|
|
assert mgr.recompute_smart_playlist(pl.persistent_id, force=True) is True
|
|
|
|
assert pl.date_modified != stamp
|
|
assert pl.persistent_id in mgr._dirty_playlists
|
|
|
|
def test_a_real_edit_still_counts_as_one(self, qapp, tmp_path):
|
|
"""The touch=False path must not leak into user edits."""
|
|
mgr = LibraryManager(_library("Rock", "Jazz"), tmp_path)
|
|
pl = mgr.create_playlist("mix")
|
|
mgr.flush()
|
|
mgr._dirty_playlists.clear()
|
|
stamp = pl.date_modified
|
|
|
|
mgr.add_tracks_to_playlist(pl.persistent_id, [1])
|
|
|
|
assert pl.date_modified != stamp
|
|
assert pl.persistent_id in mgr._dirty_playlists
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Severity
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
class TestLevels:
|
|
def test_ordering(self):
|
|
assert at_least(WARNING, INFO) and at_least(INFO, INFO)
|
|
assert not at_least(INFO, CHANGE)
|
|
assert max_level([ConflictSummary("a", "other", level=INFO),
|
|
ConflictSummary("b", "other", level=WARNING)]) == WARNING
|
|
assert max_level([]) == INFO
|
|
|
|
def _write_conflict(self, tmp_path, name, payload):
|
|
path = tmp_path / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
cfile = path.parent / _conflict_name(path.name)
|
|
cfile.write_text(json.dumps(payload))
|
|
return cfile
|
|
|
|
def _smart_pair(self, tmp_path, their_criteria, their_stamp):
|
|
pl = Playlist(name="unplayed", persistent_id="AAAA1111",
|
|
playlist_type=PlaylistType.SMART,
|
|
smart_criteria=_rock_criteria(),
|
|
date_modified="2026-08-01T00:00:00")
|
|
lib = Library()
|
|
lib.playlists[pl.persistent_id] = pl
|
|
json_storage.save_library(lib, tmp_path)
|
|
theirs = json.loads(_playlist_file(tmp_path).read_text())
|
|
theirs["smart_criteria"] = their_criteria.to_dict()
|
|
theirs["date_modified"] = their_stamp
|
|
theirs["track_ids"] = [7, 8, 9] # their derived membership drifted
|
|
self._write_conflict(tmp_path, "playlists/AAAA1111.json", theirs)
|
|
return conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
def test_identical_smart_rules_are_info(self, tmp_path):
|
|
summaries = self._smart_pair(tmp_path, _rock_criteria(),
|
|
"2026-08-09T00:00:00")
|
|
assert summaries[0].level == INFO
|
|
assert "identical" in summaries[0].lines[0]
|
|
|
|
def test_genuinely_edited_smart_rules_are_a_change(self, tmp_path):
|
|
other = SmartCriteria(
|
|
root=SmartGroup("all", [SmartRule("genre", "is", "Jazz")]))
|
|
summaries = self._smart_pair(tmp_path, other, "2026-08-09T00:00:00")
|
|
assert summaries[0].level == CHANGE
|
|
assert "the copy Syncthing set aside" in summaries[0].lines[0]
|
|
|
|
def test_metadata_is_always_info_and_says_what_differed(self, tmp_path):
|
|
lib = Library()
|
|
lib.music_folder = "/media/muzak"
|
|
json_storage.save_library(lib, tmp_path)
|
|
theirs = json.loads((tmp_path / "library_metadata.json").read_text())
|
|
theirs["library_settings"]["column_widths"] = {"name": 400}
|
|
self._write_conflict(tmp_path, "library_metadata.json", theirs)
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert summaries[0].level == INFO
|
|
assert "column layout" in summaries[0].lines[0]
|
|
|
|
def test_but_adopting_a_music_folder_is_a_change(self, tmp_path):
|
|
"""The one thing in this file nobody sets by resizing a window. Round 40
|
|
made the music folder win on its own stamp rather than the file's mtime;
|
|
when that fires, the merge changed a deliberate setting."""
|
|
lib = Library()
|
|
lib.music_folder = "/media/muzak"
|
|
lib.music_folder_set_at = "2026-08-01T00:00:00"
|
|
json_storage.save_library(lib, tmp_path)
|
|
theirs = json.loads((tmp_path / "library_metadata.json").read_text())
|
|
theirs["music_folder"] = "/media/elsewhere"
|
|
theirs["music_folder_rel"] = "../elsewhere"
|
|
theirs["music_folder_set_at"] = "2026-08-09T00:00:00"
|
|
self._write_conflict(tmp_path, "library_metadata.json", theirs)
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert summaries[0].level == CHANGE
|
|
assert "Music folder" in summaries[0].lines[-1]
|
|
assert json.loads((tmp_path / "library_metadata.json").read_text())[
|
|
"music_folder"] == "/media/elsewhere"
|
|
|
|
def test_a_cosmetic_only_playlist_merge_is_info(self, tmp_path):
|
|
pl = Playlist(name="mix", persistent_id="BBBB2222",
|
|
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2, 3],
|
|
date_modified="2026-08-01T00:00:00")
|
|
lib = Library()
|
|
lib.playlists[pl.persistent_id] = pl
|
|
json_storage.save_library(lib, tmp_path)
|
|
theirs = json.loads(_playlist_file(tmp_path, "BBBB2222").read_text())
|
|
theirs["settings"]["column_widths"] = {"name": 500}
|
|
theirs["date_modified"] = "2026-08-09T00:00:00"
|
|
self._write_conflict(tmp_path, "playlists/BBBB2222.json", theirs)
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert summaries[0].level == INFO
|
|
assert "column layout" in summaries[0].lines[0]
|
|
|
|
def test_a_real_order_merge_is_a_change(self, tmp_path):
|
|
pl = Playlist(name="mix", persistent_id="BBBB2222",
|
|
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2, 3],
|
|
date_modified="2026-08-01T00:00:00")
|
|
lib = Library()
|
|
lib.playlists[pl.persistent_id] = pl
|
|
json_storage.save_library(lib, tmp_path)
|
|
theirs = json.loads(_playlist_file(tmp_path, "BBBB2222").read_text())
|
|
theirs["track_ids"] = [1, 99, 2, 3]
|
|
theirs["date_modified"] = "2026-08-09T00:00:00"
|
|
self._write_conflict(tmp_path, "playlists/BBBB2222.json", theirs)
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert summaries[0].level == CHANGE
|
|
assert json.loads(
|
|
_playlist_file(tmp_path, "BBBB2222").read_text())["track_ids"] == [1, 99, 2, 3]
|
|
|
|
def test_a_deleted_file_edited_elsewhere_is_a_warning(self, tmp_path):
|
|
json_storage.save_library(Library(), tmp_path)
|
|
self._write_conflict(tmp_path, "playlists/CCCC3333.json",
|
|
{"name": "gone", "persistent_id": "CCCC3333"})
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert summaries[0].level == WARNING
|
|
|
|
def test_an_unmergeable_file_is_a_warning(self, tmp_path):
|
|
json_storage.save_library(Library(), tmp_path)
|
|
(tmp_path / "something.json").write_text("{}")
|
|
self._write_conflict(tmp_path, "something.json", {"a": 1})
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert summaries[0].level == WARNING
|
|
|
|
def test_a_play_journal_conflict_is_a_warning(self, tmp_path):
|
|
json_storage.save_library(Library(), tmp_path)
|
|
(tmp_path / "plays").mkdir(exist_ok=True)
|
|
(tmp_path / "plays" / "boxA.json").write_text(
|
|
json.dumps({"1": {"plays": 2}}))
|
|
self._write_conflict(tmp_path, "plays/boxA.json", {"1": {"plays": 5}})
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert summaries[0].level == WARNING
|
|
assert json.loads(
|
|
(tmp_path / "plays" / "boxA.json").read_text())["1"]["plays"] == 5
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The dialog
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
@pytest.fixture
|
|
def dialog_cls():
|
|
from lintunes.gui.conflict_dialog import ConflictSummaryDialog
|
|
return ConflictSummaryDialog
|
|
|
|
|
|
def _summary(name, level, line="something happened"):
|
|
return ConflictSummary(name, "playlist", [line], backup_dir="/tmp/b",
|
|
level=level)
|
|
|
|
|
|
class TestConflictDialog:
|
|
def test_an_all_info_batch_opens_on_the_info_view(self, qapp, dialog_cls):
|
|
dlg = dialog_cls([_summary("library_metadata.json", INFO)], None)
|
|
assert dlg.level == INFO
|
|
assert "library_metadata.json" in dlg._body.toPlainText()
|
|
assert "Nothing at this level" not in dlg._body.toPlainText()
|
|
|
|
def test_a_warning_batch_hides_the_routine_entries(self, qapp, dialog_cls):
|
|
dlg = dialog_cls([_summary("library_metadata.json", INFO),
|
|
_summary("plays/boxA.json", WARNING)], None)
|
|
body = dlg._body.toPlainText()
|
|
assert dlg.level == WARNING
|
|
assert "plays/boxA.json" in body
|
|
assert "library_metadata.json" not in body
|
|
assert "1 routine entry hidden" in body
|
|
|
|
def test_a_later_warning_pulls_the_view_back_up(self, qapp, dialog_cls):
|
|
dlg = dialog_cls([_summary("library_metadata.json", INFO)], None)
|
|
assert dlg.add_event([_summary("plays/boxA.json", WARNING)]) is True
|
|
assert dlg.level == WARNING
|
|
assert "library_metadata.json" not in dlg._body.toPlainText()
|
|
|
|
def test_routine_events_do_not_ask_to_be_raised(self, qapp, dialog_cls):
|
|
dlg = dialog_cls([_summary("mix", CHANGE)], None)
|
|
assert dlg.level == CHANGE
|
|
assert dlg.add_event([_summary("library_metadata.json", INFO)]) is False
|
|
# Still recorded — it's there once the user dials the filter down.
|
|
dlg._on_level_picked(dlg._level_box.findData(INFO))
|
|
assert "library_metadata.json" in dlg._body.toPlainText()
|
|
|
|
def test_a_user_choice_survives_a_quieter_batch(self, qapp, dialog_cls):
|
|
dlg = dialog_cls([_summary("mix", CHANGE)], None)
|
|
dlg._on_level_picked(dlg._level_box.findData(WARNING))
|
|
dlg.add_event([_summary("other mix", CHANGE)])
|
|
assert dlg.level == WARNING
|
|
assert "Nothing at this level" in dlg._body.toPlainText()
|
|
|
|
def test_filtered_out_events_leave_no_orphan_headers(self, qapp, dialog_cls):
|
|
dlg = dialog_cls([_summary("mix", CHANGE)], None)
|
|
dlg.add_event([_summary("library_metadata.json", INFO)])
|
|
headers = [ln for ln in dlg._body.toPlainText().splitlines()
|
|
if ln.startswith("── ")]
|
|
assert len(headers) == 1 # only the event that still has content
|