v0.12.0: the merge report stops reporting things you didn't do

"unplayed", "missed and never skipped" and "top 100 in past year" showed up in
the merge window constantly, and nobody had touched their rules. A live smart
playlist's membership is derived, 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 against the same file after every
song, so Syncthing conflicted on a list the next load throws away and rebuilds
anyway. Membership now stays in memory: save_playlist writes track_ids: [] for
a live smart playlist, and the recompute passes touch=False so it marks nothing
dirty and moves no timestamp. live_update=False and unsupported criteria are
unchanged — their track_ids are a snapshot, which is real content. Since
_mark_playlist also dirties the metadata, library_metadata.json stops being
rewritten every song too.

The rest was presentation. Every summary read at the same weight, so someone
resizing a column on the other machine popped and raised the same window as a
21-track reconciliation, described as "Library columns/settings taken from the
most recently edited copy." Summaries now carry a level — WARNING for a merge
that couldn't resolve cleanly or discarded a side, CHANGE for real content,
INFO for cosmetic or derived — and the dialog has a Show: selector that filters
to one level and above. It opens at the highest level in the batch, so nothing
routine steals focus and a blank window can't happen; a later warning always
pulls the view back up to it. The wording says what happened instead of how the
merge works: a metadata merge names the keys that actually differed, and
adopting the other machine's music folder grades CHANGE rather than INFO,
because that one is a setting somebody chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
This commit is contained in:
2026-08-25 14:53:37 -04:00
co-authored by Claude Opus 5
parent a61cbaa369
commit 521db2f81d
9 changed files with 631 additions and 35 deletions
+367
View File
@@ -0,0 +1,367 @@
"""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 other machine" 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