From 9575f0400f85a91bdc55b1dbc161263970e62abb Mon Sep 17 00:00:00 2001 From: trav Date: Thu, 2 Jul 2026 00:55:45 -0400 Subject: [PATCH] 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//{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 --- TASKS.md | 56 +++--- lintunes/gui/conflict_dialog.py | 79 +++++++++ lintunes/gui/main_window.py | 30 ++++ lintunes/library_manager.py | 132 ++++++++++++++ lintunes/main.py | 9 +- lintunes/storage/conflict_resolver.py | 246 ++++++++++++++++++-------- lintunes/sync_watcher.py | 49 +++++ tasks-done.md | 28 +++ tests/test_round16.py | 161 +++++++++++++++++ 9 files changed, 691 insertions(+), 99 deletions(-) create mode 100644 lintunes/gui/conflict_dialog.py create mode 100644 lintunes/sync_watcher.py create mode 100644 tests/test_round16.py diff --git a/TASKS.md b/TASKS.md index d2db14d..14f3e08 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 --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. diff --git a/lintunes/gui/conflict_dialog.py b/lintunes/gui/conflict_dialog.py new file mode 100644 index 0000000..dc96630 --- /dev/null +++ b/lintunes/gui/conflict_dialog.py @@ -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() diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 5eb1631..029969e 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -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]): diff --git a/lintunes/library_manager.py b/lintunes/library_manager.py index b1b8d32..61d003a 100644 --- a/lintunes/library_manager.py +++ b/lintunes/library_manager.py @@ -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: diff --git a/lintunes/main.py b/lintunes/main.py index 1ad52cb..4a84a88 100644 --- a/lintunes/main.py +++ b/lintunes/main.py @@ -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()) diff --git a/lintunes/storage/conflict_resolver.py b/lintunes/storage/conflict_resolver.py index e46478f..31a8fce 100644 --- a/lintunes/storage/conflict_resolver.py +++ b/lintunes/storage/conflict_resolver.py @@ -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 = "" # /.resolved/ + + +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 ``/.resolved//`` 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) diff --git a/lintunes/sync_watcher.py b/lintunes/sync_watcher.py new file mode 100644 index 0000000..559de92 --- /dev/null +++ b/lintunes/sync_watcher.py @@ -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() diff --git a/tasks-done.md b/tasks-done.md index 0f7d293..78264ff 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -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 `/.resolved//{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 diff --git a/tests/test_round16.py b/tests/test_round16.py new file mode 100644 index 0000000..90ce034 --- /dev/null +++ b/tests/test_round16.py @@ -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"