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