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

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