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>
162 lines
6.9 KiB
Python
162 lines
6.9 KiB
Python
"""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"
|