The merge windows kept coming because finishing a track rewrote all 15 MB of library.json. Both machines did that, so Syncthing saw two edits to one big file between syncs and produced a conflict file roughly once per song — and the merge then took max() of the two counts, discarding whichever side had played less. The .resolved/ backups showed the last nine library.json merges were ~98% play counts plus exactly one real edit, with the same ~45 tracks disagreeing every time and the count only creeping down over a day. library.json now holds only a base count. Each machine owns plays/<machine-id>.json with its own per-track totals, and the effective count is base + the sum of every journal. Only the owner writes its journal, so play data can't conflict; the journal stores totals rather than an append log, so there's no compaction step to double-count in; and a machine still on older code keeps bumping its own base, which stays additive with our journal. Two places carry the whole hazard, and both are pinned by tests: save_tracks writes journal.base_fields(), never the Track's effective count, and PlayJournal.load must be given a base-valued library — which is why reload_from_disk folds the journals onto the disk copy before reconciling. On the real 21k library: three plays write 83 bytes and leave library.json untouched. Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
54 lines
2.0 KiB
Python
54 lines
2.0 KiB
Python
"""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 / "plays",
|
|
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"))
|
|
plays_dir = self._data_dir / "plays"
|
|
if plays_dir.exists():
|
|
wanted.extend(plays_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()
|