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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user