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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
This commit is contained in:
2026-08-20 20:42:07 -04:00
co-authored by Claude Opus 5
parent 5df0af0bae
commit 2d961c0c9e
10 changed files with 556 additions and 18 deletions
+17 -2
View File
@@ -52,12 +52,27 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
hex `persistent_id` (folder containment via `parent_persistent_id`).
- **`lintunes/storage/json_storage.py`** — the library is **multiple files** in
the data dir: `library.json` (all tracks), `library_metadata.json`, and one
`playlists/<persistent_id>.json` per playlist. Writes are atomic (`*.json.tmp`
the data dir: `library.json` (all tracks), `library_metadata.json`, one
`playlists/<persistent_id>.json` per playlist, and one
`plays/<machine-id>.json` per machine. Writes are atomic (`*.json.tmp`
→ rename). **`storage/conflict_resolver.py`** merges Syncthing
`*.sync-conflict-*` files on startup: play counts take the max, edited fields
take the newest, playlist membership takes the union.
- **`lintunes/storage/play_journal.py`** — why play counts can't conflict. Since
Round 38 `library.json` holds only a **base** count and each machine owns
`plays/<machine-id>.json` with *its own* per-track totals; effective count =
base + the sum of every journal. Only the owner ever writes its journal, so
two machines never touch the same file — and finishing a track no longer
rewrites 15 MB, which is what handed Syncthing a conflict once per song.
Totals, not an append log, so there is no compaction step to double-count in.
`PlayJournal.load()` must be handed a library whose tracks still carry **base**
counts (the fresh load at startup, the `disk` copy inside `reload_from_disk`) —
folding an already-folded library promotes the effective count to base.
Mirror-image rule in `json_storage.save_tracks()`: it writes
`journal.base_fields(tid)`, never the `Track`'s effective count. Those two
places are the whole hazard; `tests/test_round38.py` pins both.
- **`lintunes/library_manager.py`** — `LibraryManager(QObject)` owns the
`Library`, is the single funnel for all mutations, and persists them
**debounced** (3 s) with per-area dirty tracking (a play-count bump rewrites