Live multi-machine sync: watch files, reconcile, alert on conflicts

Both machines now reflect each other's changes within a couple seconds, and
genuine Syncthing conflicts auto-merge with a backup and an alert.

- sync_watcher.py: QFileSystemWatcher (debounced, re-arms after atomic renames)
  emits a single `changed`; the manager decides if it was external.
- library_manager: `reload_from_disk()` re-reads and reconciles disk into memory
  (max play/skip counts, newest edit wins, playlist membership unioned), keeping
  local unsaved edits and object identity so open views stay valid, then refreshes
  the UI without touching the player. `flush()` records each file's (mtime, size)
  so our own writes are never mistaken for an external change. New signals
  library_reloaded / conflict_resolved.
- conflict_resolver: back up BOTH sides into .resolved/<ts>/{original,incoming}/
  before merging, return list[ConflictSummary], add restore_backup(); runs at
  startup and live.
- gui/conflict_dialog.py: modeless summary with Open/Restore backup; main_window
  shows it from a status-bar notice and preserves scroll+selection on reload.

Tests: tests/test_round16.py (9) + full suite green (225).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 00:55:45 -04:00
parent 94e7c0713b
commit 9575f0400f
9 changed files with 691 additions and 99 deletions

161
tests/test_round16.py Normal file
View File

@ -0,0 +1,161 @@
"""Round 16: live multi-machine sync — conflict merge summaries + backups, and
the LibraryManager reloading/reconciling external (Syncthing) changes without
losing local unsaved edits.
"""
import json
from lintunes.storage import json_storage, conflict_resolver
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track, Playlist, PlaylistType
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _save(tmp_path, tracks=(), playlists=()):
lib = Library()
for t in tracks:
lib.tracks[t.track_id] = t
for p in playlists:
lib.playlists[p.persistent_id] = p
json_storage.save_library(lib, tmp_path)
return lib
def _write_library_json(tmp_path, tracks_dict):
(tmp_path / "library.json").write_text(
json.dumps(tracks_dict, indent=2), encoding="utf-8")
def _read_library_json(tmp_path):
return json.loads((tmp_path / "library.json").read_text())
def _conflict_name(original: str) -> str:
# Syncthing inserts before the extension: library.json -> library.sync-...json
stem, _, ext = original.rpartition(".")
return f"{stem}.sync-conflict-20240101-120000-ABCDEFG.{ext}"
# --------------------------------------------------------------------------- #
# conflict_resolver: merges, summaries, backups, restore
# --------------------------------------------------------------------------- #
class TestConflictResolver:
def test_library_playcount_max_and_summary(self, tmp_path):
_save(tmp_path, [Track(track_id=1, name="A", play_count=5)])
# Other machine's copy has a higher play count.
conflict = {"1": {"track_id": 1, "name": "A", "play_count": 9}}
(tmp_path / _conflict_name("library.json")).write_text(json.dumps(conflict))
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert _read_library_json(tmp_path)["1"]["play_count"] == 9
assert len(summaries) == 1
assert summaries[0].kind == "library"
assert any("9" in line for line in summaries[0].lines)
def test_backs_up_both_sides_and_removes_conflict(self, tmp_path):
_save(tmp_path, [Track(track_id=1, name="A", play_count=5)])
conflict_path = tmp_path / _conflict_name("library.json")
conflict_path.write_text(json.dumps({"1": {"track_id": 1, "play_count": 9}}))
summaries = conflict_resolver.resolve_conflicts(tmp_path)
backup_dir = tmp_path / ".resolved"
assert (backup_dir).exists()
# both the pre-merge original and the incoming copy were kept
assert list(backup_dir.rglob("original/library.json"))
assert list(backup_dir.rglob("incoming/library.json"))
assert not conflict_path.exists() # conflict file cleared from the data dir
assert summaries[0].backup_dir
def test_playlist_union_keeps_all_tracks(self, tmp_path):
pl = Playlist(name="Road", persistent_id="AAAA1111",
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2])
_save(tmp_path, playlists=[pl])
pfile = tmp_path / "playlists" / "AAAA1111.json"
base = json.loads(pfile.read_text())
other = dict(base)
other["track_ids"] = [2, 3] # other machine added 3
(tmp_path / "playlists" / _conflict_name("AAAA1111.json")).write_text(
json.dumps(other))
conflict_resolver.resolve_conflicts(tmp_path)
merged = set(json.loads(pfile.read_text())["track_ids"])
assert merged == {1, 2, 3} # nothing dropped
def test_restore_backup_reverts(self, tmp_path):
_save(tmp_path, [Track(track_id=1, name="A", play_count=5)])
(tmp_path / _conflict_name("library.json")).write_text(
json.dumps({"1": {"track_id": 1, "play_count": 9}}))
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert _read_library_json(tmp_path)["1"]["play_count"] == 9
conflict_resolver.restore_backup(summaries[0].backup_dir, tmp_path)
assert _read_library_json(tmp_path)["1"]["play_count"] == 5 # pre-merge
# --------------------------------------------------------------------------- #
# LibraryManager: self-write suppression + reconciling reload
# --------------------------------------------------------------------------- #
class TestManagerSync:
def _mgr(self, tmp_path, tracks=(), playlists=()):
lib = _save(tmp_path, tracks, playlists)
return LibraryManager(lib, tmp_path)
def test_own_write_not_flagged_external(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [Track(track_id=1, name="A", rating=0)])
assert mgr._has_external_changes() is False
mgr.update_track_fields(1, {"rating": 80})
mgr.flush()
assert mgr._has_external_changes() is False # our own save
def test_external_write_flagged(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [Track(track_id=1, name="A")])
data = _read_library_json(tmp_path)
data["1"]["name"] = "A much longer name (changes size + mtime)"
_write_library_json(tmp_path, data)
assert mgr._has_external_changes() is True
def test_reload_adopts_external_and_keeps_local_edit(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [
Track(track_id=1, name="One"), Track(track_id=2, name="Two")])
# Local unsaved edit (bumps date_modified to now → should win for t1).
mgr.update_track_fields(1, {"rating": 100})
# Other machine edited t2 with a clearly-newer timestamp.
data = _read_library_json(tmp_path)
data["2"]["rating"] = 80
data["2"]["date_modified"] = "2999-01-01T00:00:00"
_write_library_json(tmp_path, data)
mgr.reload_from_disk()
assert mgr.library.tracks[1].rating == 100 # local edit preserved
assert mgr.library.tracks[2].rating == 80 # external change adopted
def test_check_for_external_merges_conflict_and_emits(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [Track(track_id=1, name="A", play_count=2)])
seen = []
mgr.conflict_resolved.connect(lambda s: seen.append(s))
(tmp_path / _conflict_name("library.json")).write_text(
json.dumps({"1": {"track_id": 1, "play_count": 7}}))
mgr.check_for_external_changes()
assert mgr.library.tracks[1].play_count == 7 # merged into memory
assert len(seen) == 1 and seen[0][0].kind == "library"
def test_reload_picks_up_new_playlist(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [Track(track_id=1, name="A")])
# Other machine created a playlist file.
newpl = Playlist(name="Fresh", persistent_id="BBBB2222",
playlist_type=PlaylistType.REGULAR, track_ids=[1])
json_storage.save_playlist(newpl, tmp_path)
mgr.check_for_external_changes()
assert "BBBB2222" in mgr.library.playlists
assert mgr.library.playlists["BBBB2222"].name == "Fresh"