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
This commit is contained in:
2026-08-20 20:42:07 -04:00
parent 3037415928
commit 3214bb7d96
10 changed files with 556 additions and 18 deletions
+34 -5
View File
@@ -12,6 +12,7 @@ from lintunes.importers.file_importer import organized_destination, unique_path
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.perf import timed
from lintunes.storage import json_storage
from lintunes.storage.play_journal import PlayJournal
from lintunes.undo import Command, UndoStack
@@ -53,6 +54,13 @@ class LibraryManager(QObject):
self._dirty_playlists: set[str] = set()
self._deleted_playlists: set[str] = set()
# Play counts live in per-machine journals, not library.json — see
# storage/play_journal. Folding them in here means the rest of the app
# keeps reading one plain track.play_count.
self.play_journal = PlayJournal()
self.play_journal.load(self.data_dir, self.library)
self._dirty_journal = False
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.setInterval(SAVE_DEBOUNCE_MS)
@@ -680,9 +688,14 @@ class LibraryManager(QObject):
track = self.library.tracks.get(track_id)
if not track:
return
now = _utc_now_iso()
track.play_count += 1
track.play_date_utc = _utc_now_iso()
self._dirty_tracks = True
track.play_date_utc = now
# Deliberately does NOT mark library.json dirty: a play only moves this
# machine's journal, so finishing a track no longer rewrites 15 MB (and
# no longer hands Syncthing a conflict once per song).
self.play_journal.record_play(track_id, now)
self._dirty_journal = True
self._schedule_save()
self.track_updated.emit(track_id)
self._touch_smart({"play_count", "play_date_utc"})
@@ -691,9 +704,11 @@ class LibraryManager(QObject):
track = self.library.tracks.get(track_id)
if not track:
return
now = _utc_now_iso()
track.skip_count += 1
track.skip_date = _utc_now_iso()
self._dirty_tracks = True
track.skip_date = now
self.play_journal.record_skip(track_id, now)
self._dirty_journal = True
self._schedule_save()
self.track_updated.emit(track_id)
self._touch_smart({"skip_count", "skip_date"})
@@ -801,10 +816,16 @@ class LibraryManager(QObject):
def flush(self):
self._save_timer.stop()
if self._dirty_journal:
self.play_journal.save(self.data_dir)
self._dirty_journal = False
self._record_sig(self.data_dir / "plays"
/ f"{self.play_journal.machine}.json")
if self._dirty_tracks:
with timed("flush library.json (%d tracks)",
len(self.library.tracks)):
json_storage.save_tracks(self.library, self.data_dir)
json_storage.save_tracks(self.library, self.data_dir,
self.play_journal)
self._dirty_tracks = False
self._record_sig(self.data_dir / "library.json")
for pid in list(self._dirty_playlists):
@@ -831,6 +852,9 @@ class LibraryManager(QObject):
playlists_dir = self.data_dir / "playlists"
if playlists_dir.exists():
files.extend(sorted(playlists_dir.glob("*.json")))
plays_dir = self.data_dir / "plays"
if plays_dir.exists():
files.extend(sorted(plays_dir.glob("*.json")))
return files
@staticmethod
@@ -877,6 +901,11 @@ class LibraryManager(QObject):
or self._dirty_metadata)
dirty_pids = set(self._dirty_playlists)
disk = json_storage.load_library(self.data_dir)
# Fold the journals onto the disk copy *before* reconciling. The tracks
# in memory already carry effective counts, so without this the merge
# would compare effective against base and the max() would win by an
# amount equal to this machine's journal.
self.play_journal.load(self.data_dir, disk)
for tid, disk_track in disk.tracks.items():
mem_track = self.library.tracks.get(tid)