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:
2026-07-02 00:55:45 -04:00
parent 94e7c0713b
commit 9575f0400f
9 changed files with 691 additions and 99 deletions

View File

@ -3,27 +3,27 @@
## Open / future
- [x] confirm last fm works
- [ ] when hovering the cursor over a song's rating field in the tracklist view it should light up 5 dots. Whichever song is currently hovered over there should show 5 dots and if I click on one it rates it that many stars. And if it already has stars it should only show dots where there aren't stars (so if it's rated 4 stars then there's a dot on the right of the 4 stars). I can click on already rated songs to set the rating.
- [x] do do a volume slider, this can some space between the visualizer and the timeline.
- [x] I'd like in the preferences to have a colors adjustment for each area setting: background, round-rec of buttons, the (currently white) behind the track/artist/album text, the color of that text itself, and the color of the gray of the stripes in the tracklisting. Each of those should have a slider from black to white that appears immediately so the user can tweak it to their liking.
- [x] visualizer should have more discrete mode that's a light gray on light gray
- [ ] move search to the right of library and the browse comes up to be in line with the top of the playlist list
- [~] we need to be able to make smart playlists. They should also be properly imported from itunes. We need all the fields that itunes 12 is able to work with when creating a smart playlist. This is a complicated feature! We also need to be able to edit the criteria of a smart playlist once it is created. Smart playlists should have a little Rotated Floral Heart Bullet (❧) to the left of their title in the playlist list on the left. _(Round 14 — Phase 1 done: full iTunes import (incl. nested groups, which parse + evaluate) and a flat-rule editor. **Phase 2 TODO:** nested-group editing UI in the dialog — until then, imported nested playlists are shown read-only. See tasks-done.md.)_
- [x] the ability to not just copy/paste a song but also to ctrl-x cut a song from one space and ctrl-v paste it somewhere else. AND when pasting a song(s) it should paste above the currently selected track. That's where it pastes to, not to the end of the playlist.
- [x] if the user is dragging a track around and they drag above the top of the track list, the track list should scroll up. Same if they hover the track below the track list at the bottom it scrolls down. The track list should scroll in the direction the user is hovering the track. This helps the user move a track to a place in a playlist that isn't currently seen.
- [ ] the app should be a little more agressive about comandeering the play/pause button from other media playing on the system. If I was playing a youtube video and that was associated with play/pause, but it's been hours since I played it, and I started playing music in lintunes, I would expect the play/pause button to pause the lintunes music when I hit it— not also start playing a youtube video, you know what I mean?
- [ ] ability to right-click a song and download album art. Is there a repo we can search to get it? Wikipedia? It should ask for confirmation the art is correct.
- [ ] Minor: LinTunes segfaults during Qt-Multimedia/FFmpeg pipeline teardown on
app **exit** (fires after the GUI is gone — cosmetic, no data risk since writes
are atomic). Tidy the shutdown so the media objects are released cleanly.
- [ ] Live-sync v2 (Round 16 shipped v1): per-playlist **tombstones** so a
deletion made on one machine while the other edits the same playlist isn't
resurrected by the union merge; optional **per-change accept/refuse** review
(v1 offers whole-file restore only). Also consider a lighter per-file reload for
very large libraries (v1 does a full reload + smart recompute on each external
change).
## when we're ready to go live
- [ ] (`tagging.read_embedded_artwork`); it is NOT stored in library.json.
@ -47,21 +47,9 @@
via tagging.write_artwork). scripts/audit_artwork.py re-checks coverage.
- `.itc` decoder lives in lintunes/itc.py (tests/test_round9.py). -->
- [x] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` — done
2026-07-01 (fresh import written there, config saved with `--save-config`).
Track paths are now stored **relative to the data dir** (`lintunes/paths.py`,
applied at the `json_storage` boundary) so the Syncthing-shared library is
portable to the second machine regardless of its mount point. See tasks-done.md
Round 15.
- [ ] Make the data dir location a **preference** (currently only settable via
`--data-dir` / `--save-config`).
- [x] Run the real import on trav's library — done 2026-07-01: 21,382 tracks,
462 playlists + 21 folders, 18 smart playlists (3 kept as snapshot), 5 missing
files, 1,324 case/unicode path fixes. Imported to the synced data dir; old
`./data` kept as a rollback backup until machine 2 is confirmed.
Artwork migration still to run against the live library (before relying on it):
1. `python3 scripts/audit_artwork.py --data-dir <data> --xml "<iTunes XML>"`
(read-only) to re-confirm the counts on the freshly-imported library.
@ -70,3 +58,27 @@
## done
- [x] confirm last fm works
- [x] do do a volume slider, this can some space between the visualizer and the timeline.
- [x] I'd like in the preferences to have a colors adjustment for each area setting: background, round-rec of buttons, the (currently white) behind the track/artist/album text, the color of that text itself, and the color of the gray of the stripes in the tracklisting. Each of those should have a slider from black to white that appears immediately so the user can tweak it to their liking.
- [x] visualizer should have more discrete mode that's a light gray on light gray
- [x] the ability to not just copy/paste a song but also to ctrl-x cut a song from one space and ctrl-v paste it somewhere else. AND when pasting a song(s) it should paste above the currently selected track. That's where it pastes to, not to the end of the playlist.
- [x] if the user is dragging a track around and they drag above the top of the track list, the track list should scroll up. Same if they hover the track below the track list at the bottom it scrolls down. The track list should scroll in the direction the user is hovering the track. This helps the user move a track to a place in a playlist that isn't currently seen.
- [x] **Move data dir** to `/run/media/trav/tummult/music/lintunes/` — done
2026-07-01 (fresh import written there, config saved with `--save-config`).
Track paths are now stored **relative to the data dir** (`lintunes/paths.py`,
applied at the `json_storage` boundary) so the Syncthing-shared library is
portable to the second machine regardless of its mount point. See tasks-done.md
Round 15.
- [x] Run the real import on trav's library — done 2026-07-01: 21,382 tracks,
462 playlists + 21 folders, 18 smart playlists (3 kept as snapshot), 5 missing
files, 1,324 case/unicode path fixes. Imported to the synced data dir; old
`./data` kept as a rollback backup until machine 2 is confirmed.

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()

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]):

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:

View File

@ -105,8 +105,9 @@ def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None)
from lintunes.gui.main_window import MainWindow
from lintunes.gui.track_table import ClickToJumpScrollStyle
from lintunes.mpris import setup_mpris
from lintunes.sync_watcher import LibrarySyncWatcher
resolve_conflicts(data_dir)
startup_conflicts = resolve_conflicts(data_dir)
library = load_library(data_dir)
app = QApplication([sys.argv[0]] + (qt_args or []))
@ -130,7 +131,13 @@ def run_gui(data_dir: Path, files: list[Path], qt_args: list[str] | None = None)
mpris = setup_mpris(window.player, window) # noqa: F841 — keep alive
app.aboutToQuit.connect(manager.flush)
# Watch the data dir so another machine's synced-in changes appear live.
watcher = LibrarySyncWatcher(data_dir, window) # noqa: F841 — kept alive by parent
watcher.changed.connect(manager.check_for_external_changes)
window.show()
if startup_conflicts:
window.show_conflict_summary(startup_conflicts)
if files:
window.import_files(files)
sys.exit(app.exec())

View File

@ -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)

49
lintunes/sync_watcher.py Normal file
View File

@ -0,0 +1,49 @@
"""Watch the data dir for changes another machine synced in (via Syncthing).
Emits a single debounced ``changed`` signal; the LibraryManager decides whether
the change was external and reloads. Kept deliberately dumb (no library
knowledge) so the manager owns all reconciliation.
"""
from pathlib import Path
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
from PyQt6.QtCore import QFileSystemWatcher
DEBOUNCE_MS = 600 # Syncthing writes in bursts; coalesce them
class LibrarySyncWatcher(QObject):
changed = pyqtSignal()
def __init__(self, data_dir, parent=None):
super().__init__(parent)
self._data_dir = Path(data_dir)
self._watcher = QFileSystemWatcher(self)
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(DEBOUNCE_MS)
self._timer.timeout.connect(self.changed)
self._watcher.fileChanged.connect(self._on_event)
self._watcher.directoryChanged.connect(self._on_event)
self._arm()
def _arm(self):
"""(Re-)watch the data files and their directories. An atomic-rename
replace (Syncthing and our own writes both use one) drops the watch on
the old inode, so we re-add paths after every event."""
wanted = [self._data_dir,
self._data_dir / "playlists",
self._data_dir / "library.json",
self._data_dir / "library_metadata.json"]
playlists_dir = self._data_dir / "playlists"
if playlists_dir.exists():
wanted.extend(playlists_dir.glob("*.json"))
already = set(self._watcher.files()) | set(self._watcher.directories())
to_add = [str(p) for p in wanted if p.exists() and str(p) not in already]
if to_add:
self._watcher.addPaths(to_add)
def _on_event(self, _path):
self._arm()
self._timer.start()

View File

@ -1,5 +1,33 @@
## Done
### Round 16 (2026-07-02) — Live multi-machine sync + conflict alerts
Both machines now reflect each other's changes within a couple seconds, with
genuine conflicts auto-merged (backed up first) and surfaced in an alert. See
`tests/test_round16.py` (9 tests) + the known limitations below.
- [x] **Live external-change reload** — new `lintunes/sync_watcher.py`
(`QFileSystemWatcher`, 600 ms debounce, re-arms after atomic-rename replaces).
`LibraryManager.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. **Self-write suppression:** `flush()` records
each file's `(mtime, size)` in `_own_sigs`; the watcher ignores changes matching
our own writes so a save never triggers a reload.
- [x] **Auto-merge + backups + summaries**`storage/conflict_resolver.py` now
backs up *both* sides into `<data>/.resolved/<timestamp>/{original,incoming}/`
before merging, returns `list[ConflictSummary]`, and adds `restore_backup()`.
Conflicts are resolved at startup **and live** (via the watcher).
- [x] **Alert UI**`gui/conflict_dialog.py` `ConflictSummaryDialog` (scrollable
summary, Open backup folder, Restore pre-merge backup), shown modeless from a
status-bar notice so a merge never interrupts playback. Wired in `main_window`
(`_on_library_reloaded` preserves scroll+selection; `_on_conflict_resolved`).
**Known limitations (v2):** a *simultaneous* conflicting playlist edit unions
membership, so a deletion made on one side while the other edits the same file can
be resurrected (clean, non-simultaneous deletes reload fine) — proper fix is
per-playlist tombstones. No per-change accept/refuse yet (whole-file restore only).
### Round 15 (2026-07-01) — Go live: portable multi-machine paths + final import
Prepared LinTunes to become the canonical library, synced to a second machine via

161
tests/test_round16.py Normal file
View File

@ -0,0 +1,161 @@
"""Round 16: live multi-machine sync — conflict merge summaries + backups, and
the LibraryManager reloading/reconciling external (Syncthing) changes without
losing local unsaved edits.
"""
import json
from lintunes.storage import json_storage, conflict_resolver
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track, Playlist, PlaylistType
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _save(tmp_path, tracks=(), playlists=()):
lib = Library()
for t in tracks:
lib.tracks[t.track_id] = t
for p in playlists:
lib.playlists[p.persistent_id] = p
json_storage.save_library(lib, tmp_path)
return lib
def _write_library_json(tmp_path, tracks_dict):
(tmp_path / "library.json").write_text(
json.dumps(tracks_dict, indent=2), encoding="utf-8")
def _read_library_json(tmp_path):
return json.loads((tmp_path / "library.json").read_text())
def _conflict_name(original: str) -> str:
# Syncthing inserts before the extension: library.json -> library.sync-...json
stem, _, ext = original.rpartition(".")
return f"{stem}.sync-conflict-20240101-120000-ABCDEFG.{ext}"
# --------------------------------------------------------------------------- #
# conflict_resolver: merges, summaries, backups, restore
# --------------------------------------------------------------------------- #
class TestConflictResolver:
def test_library_playcount_max_and_summary(self, tmp_path):
_save(tmp_path, [Track(track_id=1, name="A", play_count=5)])
# Other machine's copy has a higher play count.
conflict = {"1": {"track_id": 1, "name": "A", "play_count": 9}}
(tmp_path / _conflict_name("library.json")).write_text(json.dumps(conflict))
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert _read_library_json(tmp_path)["1"]["play_count"] == 9
assert len(summaries) == 1
assert summaries[0].kind == "library"
assert any("9" in line for line in summaries[0].lines)
def test_backs_up_both_sides_and_removes_conflict(self, tmp_path):
_save(tmp_path, [Track(track_id=1, name="A", play_count=5)])
conflict_path = tmp_path / _conflict_name("library.json")
conflict_path.write_text(json.dumps({"1": {"track_id": 1, "play_count": 9}}))
summaries = conflict_resolver.resolve_conflicts(tmp_path)
backup_dir = tmp_path / ".resolved"
assert (backup_dir).exists()
# both the pre-merge original and the incoming copy were kept
assert list(backup_dir.rglob("original/library.json"))
assert list(backup_dir.rglob("incoming/library.json"))
assert not conflict_path.exists() # conflict file cleared from the data dir
assert summaries[0].backup_dir
def test_playlist_union_keeps_all_tracks(self, tmp_path):
pl = Playlist(name="Road", persistent_id="AAAA1111",
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2])
_save(tmp_path, playlists=[pl])
pfile = tmp_path / "playlists" / "AAAA1111.json"
base = json.loads(pfile.read_text())
other = dict(base)
other["track_ids"] = [2, 3] # other machine added 3
(tmp_path / "playlists" / _conflict_name("AAAA1111.json")).write_text(
json.dumps(other))
conflict_resolver.resolve_conflicts(tmp_path)
merged = set(json.loads(pfile.read_text())["track_ids"])
assert merged == {1, 2, 3} # nothing dropped
def test_restore_backup_reverts(self, tmp_path):
_save(tmp_path, [Track(track_id=1, name="A", play_count=5)])
(tmp_path / _conflict_name("library.json")).write_text(
json.dumps({"1": {"track_id": 1, "play_count": 9}}))
summaries = conflict_resolver.resolve_conflicts(tmp_path)
assert _read_library_json(tmp_path)["1"]["play_count"] == 9
conflict_resolver.restore_backup(summaries[0].backup_dir, tmp_path)
assert _read_library_json(tmp_path)["1"]["play_count"] == 5 # pre-merge
# --------------------------------------------------------------------------- #
# LibraryManager: self-write suppression + reconciling reload
# --------------------------------------------------------------------------- #
class TestManagerSync:
def _mgr(self, tmp_path, tracks=(), playlists=()):
lib = _save(tmp_path, tracks, playlists)
return LibraryManager(lib, tmp_path)
def test_own_write_not_flagged_external(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [Track(track_id=1, name="A", rating=0)])
assert mgr._has_external_changes() is False
mgr.update_track_fields(1, {"rating": 80})
mgr.flush()
assert mgr._has_external_changes() is False # our own save
def test_external_write_flagged(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [Track(track_id=1, name="A")])
data = _read_library_json(tmp_path)
data["1"]["name"] = "A much longer name (changes size + mtime)"
_write_library_json(tmp_path, data)
assert mgr._has_external_changes() is True
def test_reload_adopts_external_and_keeps_local_edit(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [
Track(track_id=1, name="One"), Track(track_id=2, name="Two")])
# Local unsaved edit (bumps date_modified to now → should win for t1).
mgr.update_track_fields(1, {"rating": 100})
# Other machine edited t2 with a clearly-newer timestamp.
data = _read_library_json(tmp_path)
data["2"]["rating"] = 80
data["2"]["date_modified"] = "2999-01-01T00:00:00"
_write_library_json(tmp_path, data)
mgr.reload_from_disk()
assert mgr.library.tracks[1].rating == 100 # local edit preserved
assert mgr.library.tracks[2].rating == 80 # external change adopted
def test_check_for_external_merges_conflict_and_emits(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [Track(track_id=1, name="A", play_count=2)])
seen = []
mgr.conflict_resolved.connect(lambda s: seen.append(s))
(tmp_path / _conflict_name("library.json")).write_text(
json.dumps({"1": {"track_id": 1, "play_count": 7}}))
mgr.check_for_external_changes()
assert mgr.library.tracks[1].play_count == 7 # merged into memory
assert len(seen) == 1 and seen[0][0].kind == "library"
def test_reload_picks_up_new_playlist(self, qapp, tmp_path):
mgr = self._mgr(tmp_path, [Track(track_id=1, name="A")])
# Other machine created a playlist file.
newpl = Playlist(name="Fresh", persistent_id="BBBB2222",
playlist_type=PlaylistType.REGULAR, track_ids=[1])
json_storage.save_playlist(newpl, tmp_path)
mgr.check_for_external_changes()
assert "BBBB2222" in mgr.library.playlists
assert mgr.library.playlists["BBBB2222"].name == "Fresh"