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
+79
View File
@@ -0,0 +1,79 @@
"""A read-only summary of Syncthing conflicts LinTunes auto-merged, with the
option to open or restore the pre-merge backup."""
from pathlib import Path
from PyQt6.QtCore import QUrl
from PyQt6.QtGui import QDesktopServices
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QPushButton,
QMessageBox)
class ConflictSummaryDialog(QDialog):
def __init__(self, summaries, manager, parent=None):
super().__init__(parent)
self._summaries = summaries
self._manager = manager
self._backup_dir = summaries[0].backup_dir if summaries else ""
self.setWindowTitle("Synced changes merged")
self.resize(560, 440)
layout = QVBoxLayout(self)
intro = QLabel(
"LinTunes found changes made on more than one machine and merged "
"them (play counts kept highest, newest edits win, nothing removed). "
"Both versions were backed up first — restore them if a merge isn't "
"what you wanted.")
intro.setWordWrap(True)
layout.addWidget(intro)
body = QTextEdit()
body.setReadOnly(True)
body.setPlainText(self._format(summaries))
layout.addWidget(body, 1)
buttons = QHBoxLayout()
open_btn = QPushButton("Open backup folder")
open_btn.clicked.connect(self._open_backup)
restore_btn = QPushButton("Restore pre-merge backup")
restore_btn.clicked.connect(self._restore_backup)
keep_btn = QPushButton("Keep merged")
keep_btn.setDefault(True)
keep_btn.clicked.connect(self.accept)
buttons.addWidget(open_btn)
buttons.addWidget(restore_btn)
buttons.addStretch(1)
buttons.addWidget(keep_btn)
layout.addLayout(buttons)
open_btn.setEnabled(bool(self._backup_dir))
restore_btn.setEnabled(bool(self._backup_dir))
@staticmethod
def _format(summaries) -> str:
blocks = []
for s in summaries:
lines = [f"● {s.file}"]
lines.extend(f" {line}" for line in s.lines)
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
def _open_backup(self):
if self._backup_dir:
QDesktopServices.openUrl(QUrl.fromLocalFile(self._backup_dir))
def _restore_backup(self):
if not self._backup_dir:
return
if QMessageBox.question(
self, "Restore backup",
"Replace the current files with the pre-merge versions from this "
"backup? The other machine's changes may re-sync as a new "
"conflict later.") != QMessageBox.StandardButton.Yes:
return
from lintunes.storage import conflict_resolver
conflict_resolver.restore_backup(Path(self._backup_dir), self._manager.data_dir)
self._manager.reload_from_disk()
QMessageBox.information(self, "Restored", "Restored the pre-merge backup.")
self.accept()
+30
View File
@@ -94,7 +94,10 @@ class MainWindow(QMainWindow):
self.player.playing_changed.connect(self._update_now_playing_icon)
self.player.playing_changed.connect(self._on_playing_changed)
manager.track_updated.connect(self._on_track_meta_updated)
manager.library_reloaded.connect(self._on_library_reloaded)
manager.conflict_resolved.connect(self._on_conflict_resolved)
prefs.changed.connect(self._on_prefs_changed)
self._conflict_dialog = None
if lastfm is not None:
self.player.track_changed.connect(lastfm.handle_track_started)
self.player.track_finished.connect(lastfm.handle_track_finished)
@@ -245,6 +248,33 @@ class MainWindow(QMainWindow):
self._sidebar.reflect_playlist(pid)
self._playlist_view.reveal_track(track_id)
# ---- multi-machine sync ----
def _on_library_reloaded(self):
"""Another machine's changes were reconciled in. Refresh the library
view (the sidebar and open playlist refresh via playlists_changed),
keeping the user's scroll and selection. Never touches the player."""
table = self._library_view.table
selected = table.selected_track_ids()
scroll = table.verticalScrollBar().value()
self._library_view.reload()
if selected:
table.reveal_track(selected[0])
table.verticalScrollBar().setValue(scroll)
def _on_conflict_resolved(self, summaries):
self.statusBar().showMessage(
f"Merged changes from your other machine "
f"({len(summaries)} item(s)) — see details", 8000)
self.show_conflict_summary(summaries)
def show_conflict_summary(self, summaries):
from lintunes.gui.conflict_dialog import ConflictSummaryDialog
# Modeless so a merge never blocks playback or what you're doing.
self._conflict_dialog = ConflictSummaryDialog(summaries, self._manager, self)
self._conflict_dialog.show()
self._conflict_dialog.raise_()
# ---- info dialog ----
def _show_info(self, track_ids: list[int]):