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).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,56 +1,103 @@
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from lintunes.models import Track, Playlist
|
||||
|
||||
|
||||
# Syncthing conflict file pattern: filename.sync-conflict-20240101-123456-ABCDEFG.json
|
||||
CONFLICT_PATTERN = re.compile(r"^(.+)\.sync-conflict-(\d{8}-\d{6})-\w+(\.\w+)$")
|
||||
|
||||
|
||||
def resolve_conflicts(data_dir: Path):
|
||||
@dataclass
|
||||
class ConflictSummary:
|
||||
"""A human-readable record of one merged Syncthing conflict, surfaced to the
|
||||
user and used to offer a restore from the backed-up pre-merge copies."""
|
||||
file: str # display name, e.g. "library.json" or a playlist name
|
||||
kind: str # "library" | "playlist" | "metadata" | "other"
|
||||
lines: list[str] = field(default_factory=list) # what was reconciled
|
||||
backup_dir: str = "" # <data_dir>/.resolved/<timestamp>
|
||||
|
||||
|
||||
def resolve_conflicts(data_dir: Path) -> list[ConflictSummary]:
|
||||
"""Merge any Syncthing ``*.sync-conflict-*`` files into their originals.
|
||||
|
||||
Before touching anything, both the current (original) file and the incoming
|
||||
conflict file are copied into ``<data_dir>/.resolved/<timestamp>/`` so the
|
||||
merge is always reversible. Returns one ConflictSummary per conflict (empty
|
||||
list when there was nothing to do)."""
|
||||
if not data_dir.exists():
|
||||
return
|
||||
return []
|
||||
|
||||
resolved_dir = data_dir / ".resolved"
|
||||
conflict_files = _find_conflict_files(data_dir)
|
||||
|
||||
if not conflict_files:
|
||||
return
|
||||
return []
|
||||
|
||||
print(f"Found {len(conflict_files)} Syncthing conflict(s), resolving...")
|
||||
resolved_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_dir = data_dir / ".resolved" / stamp
|
||||
(backup_dir / "original").mkdir(parents=True, exist_ok=True)
|
||||
(backup_dir / "incoming").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
summaries = []
|
||||
for conflict_path, original_path in conflict_files:
|
||||
if not original_path.exists():
|
||||
# Original was deleted; just archive the conflict
|
||||
_archive(conflict_path, resolved_dir)
|
||||
continue
|
||||
rel = original_path.relative_to(data_dir)
|
||||
# Back up both sides first (belt and suspenders).
|
||||
if original_path.exists():
|
||||
_copy(original_path, backup_dir / "original" / rel)
|
||||
_copy(conflict_path, backup_dir / "incoming" / rel)
|
||||
|
||||
try:
|
||||
if original_path.name == "library.json":
|
||||
_merge_library(original_path, conflict_path)
|
||||
if not original_path.exists():
|
||||
summary = ConflictSummary(
|
||||
str(rel), "other",
|
||||
["Deleted here but edited elsewhere — kept the deletion; "
|
||||
"the other copy is in the backup."])
|
||||
elif original_path.name == "library.json":
|
||||
summary = _merge_library(original_path, conflict_path)
|
||||
elif original_path.name == "library_metadata.json":
|
||||
_merge_metadata(original_path, conflict_path)
|
||||
summary = _merge_metadata(original_path, conflict_path)
|
||||
elif original_path.parent.name == "playlists":
|
||||
_merge_playlist(original_path, conflict_path)
|
||||
summary = _merge_playlist(original_path, conflict_path)
|
||||
else:
|
||||
# Unknown file type — keep the newer one
|
||||
_keep_newer(original_path, conflict_path)
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to merge {conflict_path.name}: {e}")
|
||||
summary = _keep_newer(original_path, conflict_path)
|
||||
except Exception as e: # never let a bad file abort startup/live reload
|
||||
summary = ConflictSummary(
|
||||
str(rel), "other",
|
||||
[f"Could not merge automatically ({e}); both versions are in the "
|
||||
"backup, current copy left as-is."])
|
||||
|
||||
_archive(conflict_path, resolved_dir)
|
||||
summary.backup_dir = str(backup_dir)
|
||||
summaries.append(summary)
|
||||
# Remove the conflict file from the data dir (its copy is in the backup)
|
||||
# so it isn't reprocessed and Syncthing stops flagging it.
|
||||
conflict_path.unlink(missing_ok=True)
|
||||
|
||||
print(f" Resolved {len(conflict_files)} conflict(s)")
|
||||
return summaries
|
||||
|
||||
|
||||
def restore_backup(backup_dir: Path, data_dir: Path) -> list[str]:
|
||||
"""Undo a merge: copy the pre-merge ``original/`` snapshot back over the
|
||||
current files. Returns the list of restored relative paths."""
|
||||
original_root = Path(backup_dir) / "original"
|
||||
restored = []
|
||||
if not original_root.exists():
|
||||
return restored
|
||||
for src in original_root.rglob("*"):
|
||||
if src.is_file():
|
||||
rel = src.relative_to(original_root)
|
||||
dest = Path(data_dir) / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
restored.append(str(rel))
|
||||
return restored
|
||||
|
||||
|
||||
def _find_conflict_files(data_dir: Path) -> list[tuple[Path, Path]]:
|
||||
results = []
|
||||
for path in data_dir.rglob("*.sync-conflict-*"):
|
||||
if ".resolved" in path.parts: # don't reprocess our own backups
|
||||
continue
|
||||
match = CONFLICT_PATTERN.match(path.name)
|
||||
if match:
|
||||
original_name = match.group(1) + match.group(3)
|
||||
@ -59,35 +106,57 @@ def _find_conflict_files(data_dir: Path) -> list[tuple[Path, Path]]:
|
||||
return results
|
||||
|
||||
|
||||
def _merge_library(original_path: Path, conflict_path: Path):
|
||||
def _merge_library(original_path: Path, conflict_path: Path) -> ConflictSummary:
|
||||
original = _load_json(original_path)
|
||||
conflict = _load_json(conflict_path)
|
||||
|
||||
added = 0
|
||||
changed = 0
|
||||
examples: list[str] = []
|
||||
for tid, conflict_track_data in conflict.items():
|
||||
if tid not in original:
|
||||
# New track from conflict side
|
||||
original[tid] = conflict_track_data
|
||||
added += 1
|
||||
continue
|
||||
|
||||
orig_track = original[tid]
|
||||
_merge_track_fields(orig_track, conflict_track_data)
|
||||
notes = _merge_track_fields(original[tid], conflict_track_data)
|
||||
if notes:
|
||||
changed += 1
|
||||
if len(examples) < 8:
|
||||
name = original[tid].get("name") or f"track {tid}"
|
||||
examples.append(f"“{name}”: {notes[0]}")
|
||||
|
||||
_save_json(original_path, original)
|
||||
|
||||
lines = []
|
||||
if changed:
|
||||
lines.append(f"{changed} track(s) reconciled "
|
||||
"(play counts kept highest, newest edits win).")
|
||||
if added:
|
||||
lines.append(f"{added} track(s) present only on the other machine were kept.")
|
||||
if not lines:
|
||||
lines.append("No differences needed reconciling.")
|
||||
lines.extend(examples)
|
||||
return ConflictSummary("library.json", "library", lines)
|
||||
|
||||
def _merge_track_fields(orig: dict, conflict: dict):
|
||||
|
||||
def _merge_track_fields(orig: dict, conflict: dict) -> list[str]:
|
||||
"""Merge conflict's track fields into orig in place; return change notes."""
|
||||
notes = []
|
||||
# play_count, skip_count: take max
|
||||
for field in ("play_count", "skip_count"):
|
||||
if field in conflict:
|
||||
orig[field] = max(orig.get(field, 0), conflict[field])
|
||||
for field_name, label in (("play_count", "play count"), ("skip_count", "skip count")):
|
||||
if field_name in conflict:
|
||||
new = max(orig.get(field_name, 0), conflict[field_name])
|
||||
if new != orig.get(field_name, 0):
|
||||
notes.append(f"{label} {orig.get(field_name, 0)} → {new}")
|
||||
orig[field_name] = new
|
||||
|
||||
# play_date_utc: take most recent
|
||||
for field in ("play_date_utc", "skip_date"):
|
||||
if field in conflict:
|
||||
orig_val = orig.get(field)
|
||||
conf_val = conflict[field]
|
||||
# play_date_utc, skip_date: take most recent
|
||||
for field_name in ("play_date_utc", "skip_date"):
|
||||
if field_name in conflict:
|
||||
orig_val = orig.get(field_name)
|
||||
conf_val = conflict[field_name]
|
||||
if orig_val is None or (conf_val and conf_val > orig_val):
|
||||
orig[field] = conf_val
|
||||
orig[field_name] = conf_val
|
||||
|
||||
# date_added: take earliest
|
||||
if "date_added" in conflict:
|
||||
@ -96,70 +165,93 @@ def _merge_track_fields(orig: dict, conflict: dict):
|
||||
if orig_val is None or (conf_val and conf_val < orig_val):
|
||||
orig["date_added"] = conf_val
|
||||
|
||||
# loved: OR (true wins)
|
||||
# loved: OR (true wins). Legacy field — unused by LinTunes but preserved so a
|
||||
# merge never silently clears it.
|
||||
if conflict.get("loved", False):
|
||||
orig["loved"] = True
|
||||
|
||||
# rating, metadata: newest date_modified wins
|
||||
# rating + editable metadata: newest date_modified wins
|
||||
orig_mod = orig.get("date_modified")
|
||||
conf_mod = conflict.get("date_modified")
|
||||
if conf_mod and (not orig_mod or conf_mod > orig_mod):
|
||||
# Conflict is newer — take its metadata fields
|
||||
for field in ("rating", "name", "artist", "album_artist", "album",
|
||||
"genre", "composer", "grouping", "comments",
|
||||
"sort_name", "sort_artist", "sort_album_artist",
|
||||
"sort_album", "sort_composer", "date_modified"):
|
||||
if field in conflict:
|
||||
orig[field] = conflict[field]
|
||||
if conflict.get("rating") != orig.get("rating") and "rating" in conflict:
|
||||
notes.append(f"rating {_stars(orig.get('rating'))} → "
|
||||
f"{_stars(conflict.get('rating'))} (newer)")
|
||||
for field_name in ("rating", "name", "artist", "album_artist", "album",
|
||||
"genre", "composer", "grouping", "comments",
|
||||
"sort_name", "sort_artist", "sort_album_artist",
|
||||
"sort_album", "sort_composer", "date_modified"):
|
||||
if field_name in conflict:
|
||||
orig[field_name] = conflict[field_name]
|
||||
return notes
|
||||
|
||||
|
||||
def _merge_playlist(original_path: Path, conflict_path: Path):
|
||||
def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary:
|
||||
original = _load_json(original_path)
|
||||
conflict = _load_json(conflict_path)
|
||||
name = original.get("name") or conflict.get("name") or original_path.stem
|
||||
|
||||
# Smart playlists: membership is derived, so unioning track_ids would
|
||||
# resurrect tracks that no longer match. Keep the newer file's criteria;
|
||||
# membership is recomputed from it at the next launch.
|
||||
# Smart playlists: membership is derived, so keep the newer criteria and let
|
||||
# it recompute (unioning derived track_ids would resurrect non-matches).
|
||||
if original.get("playlist_type") == "smart" or conflict.get("playlist_type") == "smart":
|
||||
_keep_newer(original_path, conflict_path)
|
||||
return
|
||||
newer = _keep_newer(original_path, conflict_path)
|
||||
newer.file, newer.kind = name, "playlist"
|
||||
newer.lines = ["Smart-playlist rules taken from the most recently edited copy."]
|
||||
return newer
|
||||
|
||||
# Union track_ids, order from the newer file
|
||||
orig_ids = set(original.get("track_ids", []))
|
||||
conf_ids = set(conflict.get("track_ids", []))
|
||||
all_ids = orig_ids | conf_ids
|
||||
orig_ids = list(original.get("track_ids", []))
|
||||
conf_ids = list(conflict.get("track_ids", []))
|
||||
orig_set, conf_set = set(orig_ids), set(conf_ids)
|
||||
|
||||
# Determine which file is newer by checking file mtime
|
||||
if conflict_path.stat().st_mtime > original_path.stat().st_mtime:
|
||||
# Use conflict's order as base, append any missing from original
|
||||
base_order = conflict.get("track_ids", [])
|
||||
extra = [tid for tid in original.get("track_ids", []) if tid not in conf_ids]
|
||||
conflict_newer = conflict_path.stat().st_mtime > original_path.stat().st_mtime
|
||||
if conflict_newer:
|
||||
base_order = conf_ids
|
||||
extra = [tid for tid in orig_ids if tid not in conf_set]
|
||||
original["track_ids"] = base_order + extra
|
||||
# Settings from newer file
|
||||
if "settings" in conflict:
|
||||
original["settings"] = conflict["settings"]
|
||||
order_src = "the other machine"
|
||||
else:
|
||||
# Original is newer — append missing from conflict
|
||||
extra = [tid for tid in conflict.get("track_ids", []) if tid not in orig_ids]
|
||||
original["track_ids"] = original.get("track_ids", []) + extra
|
||||
extra = [tid for tid in conf_ids if tid not in orig_set]
|
||||
original["track_ids"] = orig_ids + extra
|
||||
order_src = "this machine"
|
||||
|
||||
_save_json(original_path, original)
|
||||
|
||||
|
||||
def _merge_metadata(original_path: Path, conflict_path: Path):
|
||||
# Simple: keep the newer file's content
|
||||
_keep_newer(original_path, conflict_path)
|
||||
added = len(set(original["track_ids"]) - orig_set)
|
||||
lines = [f"Order kept from {order_src} (most recently edited)."]
|
||||
if added:
|
||||
lines.append(f"{added} track(s) that were only in the other copy were kept "
|
||||
"(nothing is removed on a merge).")
|
||||
return ConflictSummary(name, "playlist", lines)
|
||||
|
||||
|
||||
def _keep_newer(original_path: Path, conflict_path: Path):
|
||||
def _merge_metadata(original_path: Path, conflict_path: Path) -> ConflictSummary:
|
||||
summary = _keep_newer(original_path, conflict_path)
|
||||
summary.file, summary.kind = "library_metadata.json", "metadata"
|
||||
summary.lines = ["Library columns/settings taken from the most recently edited copy."]
|
||||
return summary
|
||||
|
||||
|
||||
def _keep_newer(original_path: Path, conflict_path: Path) -> ConflictSummary:
|
||||
took = "this machine"
|
||||
if conflict_path.stat().st_mtime > original_path.stat().st_mtime:
|
||||
shutil.copy2(conflict_path, original_path)
|
||||
took = "the other machine"
|
||||
return ConflictSummary(original_path.name, "other",
|
||||
[f"Kept the copy from {took} (most recently edited)."])
|
||||
|
||||
|
||||
def _archive(conflict_path: Path, resolved_dir: Path):
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
archive_name = f"{timestamp}_{conflict_path.name}"
|
||||
shutil.move(str(conflict_path), str(resolved_dir / archive_name))
|
||||
def _stars(rating) -> str:
|
||||
try:
|
||||
return f"{int(rating) // 20}★"
|
||||
except (TypeError, ValueError):
|
||||
return "0★"
|
||||
|
||||
|
||||
def _copy(src: Path, dest: Path):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
@ -168,5 +260,7 @@ def _load_json(path: Path) -> dict:
|
||||
|
||||
|
||||
def _save_json(path: Path, data: dict):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
tmp.replace(path)
|
||||
|
||||
Reference in New Issue
Block a user