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).
This commit is contained in:
2026-07-02 00:55:45 -04:00
parent 765448f5c5
commit 3f94fb9854
9 changed files with 691 additions and 99 deletions
+132
View File
@@ -30,6 +30,8 @@ class LibraryManager(QObject):
playlist_content_changed = pyqtSignal(str) # persistent_id
track_updated = pyqtSignal(int) # track_id
track_fields_edited = pyqtSignal(int, list) # track_id, changed field names
library_reloaded = pyqtSignal() # library re-read from disk (sync)
conflict_resolved = pyqtSignal(list) # list[ConflictSummary] just merged
def __init__(self, library: Library, data_dir: Path, parent=None):
super().__init__(parent)
@@ -58,6 +60,11 @@ class LibraryManager(QObject):
self._recompute_timer.setInterval(0)
self._recompute_timer.timeout.connect(self._flush_recompute)
# Signatures (mtime, size) of the data files as we last wrote/read them,
# so the sync watcher can tell our own writes from another machine's.
self._own_sigs: dict[str, tuple | None] = {}
self.snapshot_sync_state()
# ---- id generation ----
def new_track_id(self) -> int:
@@ -548,17 +555,142 @@ class LibraryManager(QObject):
if self._dirty_tracks:
json_storage.save_tracks(self.library, self.data_dir)
self._dirty_tracks = False
self._record_sig(self.data_dir / "library.json")
for pid in list(self._dirty_playlists):
playlist = self.library.playlists.get(pid)
if playlist:
json_storage.save_playlist(playlist, self.data_dir)
self._record_sig(self.data_dir / "playlists" / f"{pid}.json")
self._dirty_playlists.clear()
for pid in list(self._deleted_playlists):
json_storage.delete_playlist_file(pid, self.data_dir)
self._own_sigs.pop(str(self.data_dir / "playlists" / f"{pid}.json"), None)
self._deleted_playlists.clear()
if self._dirty_metadata:
json_storage.save_metadata(self.library, self.data_dir)
self._dirty_metadata = False
self._record_sig(self.data_dir / "library_metadata.json")
# ---- multi-machine sync (watch for external changes, reconcile) ----
def _data_files(self) -> list[Path]:
files = [self.data_dir / "library.json",
self.data_dir / "library_metadata.json"]
playlists_dir = self.data_dir / "playlists"
if playlists_dir.exists():
files.extend(sorted(playlists_dir.glob("*.json")))
return files
@staticmethod
def _sig(path: Path):
try:
st = path.stat()
return (st.st_mtime_ns, st.st_size)
except OSError:
return None
def _record_sig(self, path: Path):
self._own_sigs[str(path)] = self._sig(path)
def snapshot_sync_state(self):
"""Baseline the data files' signatures to their current on-disk state, so
only genuinely external (Syncthing) changes look external afterwards."""
self._own_sigs = {str(p): self._sig(p) for p in self._data_files()}
def _has_external_changes(self) -> bool:
current = {str(p): self._sig(p) for p in self._data_files()}
if set(current) != set(self._own_sigs):
return True
return any(current[k] != self._own_sigs.get(k) for k in current)
def check_for_external_changes(self):
"""Entry point for the sync watcher (debounced). Merges any Syncthing
conflict files, and if the data changed underneath us (another machine
synced in), reloads and reconciles it into the running app."""
from lintunes.storage import conflict_resolver
summaries = conflict_resolver.resolve_conflicts(self.data_dir)
if not summaries and not self._has_external_changes():
return # our own write, or a no-op event
self.reload_from_disk()
if summaries:
self.conflict_resolved.emit(summaries)
def reload_from_disk(self):
"""Re-read the library from disk and reconcile it into memory, keeping
any local unsaved edits that are newer (play counts take the max, the
newest metadata edit wins, playlist membership is unioned). Preserves
object identity so open views stay valid, then refreshes the UI. Never
touches the player."""
was_dirty = (self._dirty_tracks or bool(self._dirty_playlists)
or self._dirty_metadata)
dirty_pids = set(self._dirty_playlists)
disk = json_storage.load_library(self.data_dir)
for tid, disk_track in disk.tracks.items():
mem_track = self.library.tracks.get(tid)
if mem_track is None:
self.library.tracks[tid] = disk_track
else:
_reconcile_track(mem_track, disk_track)
for tid in list(self.library.tracks):
if tid not in disk.tracks:
del self.library.tracks[tid]
for pid, disk_pl in disk.playlists.items():
mem_pl = self.library.playlists.get(pid)
if mem_pl is None:
self.library.playlists[pid] = disk_pl
elif pid in dirty_pids:
_reconcile_playlist(mem_pl, disk_pl) # keep local edits, union in
else:
mem_pl.__dict__.update(disk_pl.__dict__) # adopt disk (identity kept)
for pid in list(self.library.playlists):
if pid not in disk.playlists and pid not in dirty_pids:
self.library.playlists.pop(pid, None)
if not self._dirty_metadata:
self.library.music_folder = disk.music_folder
self.library.import_date = disk.import_date
self.library.library_settings = disk.library_settings
self._recomputing = True
try:
self.recompute_all_smart()
finally:
self._recomputing = False
# Persist the reconciled result only when we had local edits to fold in
# (or a recompute diverged); a pure adopt leaves disk untouched.
if (was_dirty or self._dirty_tracks or self._dirty_playlists
or self._dirty_metadata):
self.flush()
# Re-baseline to whatever is now on disk so our own/adopted state isn't
# re-flagged as external next tick.
self.snapshot_sync_state()
self.playlists_changed.emit()
self.library_reloaded.emit()
def _reconcile_track(mem_track, disk_track):
"""Fold the disk copy of a track into the in-memory one using the same rules
as the conflict resolver (max play/skip counts, newest date_modified wins),
mutating mem_track in place so any view holding it stays valid."""
from lintunes.storage.conflict_resolver import _merge_track_fields
merged = mem_track.to_dict()
_merge_track_fields(merged, disk_track.to_dict()) # disk wins where newer
for key, value in merged.items():
setattr(mem_track, key, value)
def _reconcile_playlist(mem_pl, disk_pl):
"""Reconcile a playlist we were locally editing with the disk copy: keep our
order and append any tracks that exist only on the other machine (a merge
never drops tracks)."""
local = list(mem_pl.track_ids)
local_set = set(local)
mem_pl.track_ids = local + [tid for tid in disk_pl.track_ids
if tid not in local_set]
def _utc_now_iso() -> str: