Files
lintunes/lintunes/sync_watcher.py
T
trav 3214bb7d96 v0.9.0: play counts that can't conflict
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
2026-08-20 20:42:07 -04:00

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