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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user