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
+210
View File
@@ -99,3 +99,213 @@ class TestWindowReuse:
host.show_conflict_summary([_summary()])
assert host._conflict_dialog is not None
assert host._conflict_dialog is not first
# --------------------------------------------------------------------------
# Per-machine play journals
# --------------------------------------------------------------------------
from lintunes.models import Library, Track # noqa: E402
from lintunes.storage import json_storage # noqa: E402
from lintunes.storage.play_journal import PlayJournal # noqa: E402
@pytest.fixture
def data_dir(tmp_path, monkeypatch):
"""A data dir with a two-track library, and a machine id kept out of the
real ~/.config."""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
d = tmp_path / "data"
library = Library(tracks={
1: Track(track_id=1, name="Thunder Peel", play_count=102),
2: Track(track_id=2, name="Black Blood", play_count=42),
})
json_storage.save_library(library, d)
return d
def _base_on_disk(data_dir, tid=1, field="play_count"):
return json_storage.read_json(data_dir / "library.json")[str(tid)].get(field, 0)
def _write_journal(data_dir, machine, entries):
(data_dir / "plays").mkdir(parents=True, exist_ok=True)
json_storage.write_json(data_dir / "plays" / f"{machine}.json", entries)
class TestPlayJournal:
def test_machine_id_is_stable_and_outside_the_data_dir(self, data_dir, tmp_path):
from lintunes.storage.play_journal import machine_id
first = machine_id()
assert first and machine_id() == first
# A synced id would make both machines share one journal.
assert not (data_dir / "machine_id").exists()
assert (tmp_path / "config" / "lintunes" / "machine_id").exists()
def test_effective_count_is_base_plus_every_journal(self, data_dir):
_write_journal(data_dir, "machine-a", {"1": {"plays": 5, "last_played": "2026-08-20T10:00:00"}})
_write_journal(data_dir, "machine-b", {"1": {"plays": 3, "last_played": "2026-08-20T12:00:00"}})
library = json_storage.load_library(data_dir)
PlayJournal(machine="machine-a").load(data_dir, library)
# This is the case max() used to throw away: 102 + 5 + 3, not 107.
assert library.tracks[1].play_count == 110
assert library.tracks[1].play_date_utc == "2026-08-20T12:00:00"
assert library.tracks[2].play_count == 42 # untouched
def test_a_play_moves_the_journal_and_leaves_library_json_alone(self, data_dir):
library = json_storage.load_library(data_dir)
journal = PlayJournal(machine="machine-a")
journal.load(data_dir, library)
before = (data_dir / "library.json").read_bytes()
journal.record_play(1, "2026-08-20T18:42:00")
journal.save(data_dir)
assert (data_dir / "library.json").read_bytes() == before
assert json_storage.read_json(data_dir / "plays" / "machine-a.json") == {
"1": {"plays": 1, "last_played": "2026-08-20T18:42:00"}}
def test_save_tracks_writes_the_base_not_the_effective_count(self, data_dir):
"""The one sharp edge: writing the folded total back into library.json
would fold this machine's plays into the base and count them twice."""
_write_journal(data_dir, "machine-a", {"1": {"plays": 5}})
library = json_storage.load_library(data_dir)
journal = PlayJournal(machine="machine-a")
journal.load(data_dir, library)
assert library.tracks[1].play_count == 107
json_storage.save_tracks(library, data_dir, journal)
assert _base_on_disk(data_dir) == 102
# And a second round trip must not drift either.
again = json_storage.load_library(data_dir)
PlayJournal(machine="machine-a").load(data_dir, again)
assert again.tracks[1].play_count == 107
def test_zero_base_is_omitted_from_library_json(self, data_dir):
"""to_dict() omits default-valued fields; the base rewrite must too, or
21k tracks each grow a redundant "play_count": 0."""
library = json_storage.load_library(data_dir)
library.tracks[3] = Track(track_id=3, name="New Song")
journal = PlayJournal(machine="machine-a")
journal.load(data_dir, library)
journal.record_play(3, "2026-08-20T18:42:00")
json_storage.save_tracks(library, data_dir, journal)
assert "play_count" not in json_storage.read_json(
data_dir / "library.json")["3"]
def test_other_machine_on_old_code_does_not_double_count(self, data_dir):
"""While the other machine still bumps its base in library.json, our
journal stays additive on top — no double count, no loss."""
_write_journal(data_dir, "machine-a", {"1": {"plays": 5}})
library = json_storage.load_library(data_dir)
PlayJournal(machine="machine-a").load(data_dir, library)
assert library.tracks[1].play_count == 107
# The other machine, still on old code, syncs in a base bumped by 3.
raw = json_storage.read_json(data_dir / "library.json")
raw["1"]["play_count"] = 105
json_storage.write_json(data_dir / "library.json", raw)
library = json_storage.load_library(data_dir)
PlayJournal(machine="machine-a").load(data_dir, library)
assert library.tracks[1].play_count == 110
def test_unsaved_plays_survive_a_journal_reread(self, data_dir):
"""A reload re-reads every journal; ours is only ever written by us, so
in-memory bumps must not be read over."""
library = json_storage.load_library(data_dir)
journal = PlayJournal(machine="machine-a")
journal.load(data_dir, library)
journal.record_play(1, "2026-08-20T18:42:00")
fresh = json_storage.load_library(data_dir)
journal.load(data_dir, fresh) # what reload_from_disk does
assert fresh.tracks[1].play_count == 103
assert journal.dirty
class TestManagerRoundTrip:
"""End to end through LibraryManager, which is where a base/effective mix-up
would actually cost trav play counts."""
@pytest.fixture
def manager(self, data_dir, qapp):
from lintunes.library_manager import LibraryManager
return LibraryManager(json_storage.load_library(data_dir), data_dir)
def test_playing_does_not_rewrite_library_json(self, manager, data_dir):
before = (data_dir / "library.json").read_bytes()
manager.record_play(1)
manager.flush()
assert (data_dir / "library.json").read_bytes() == before
assert manager.library.tracks[1].play_count == 103
def test_a_play_survives_a_restart_exactly_once(self, manager, data_dir):
manager.record_play(1)
manager.flush()
from lintunes.library_manager import LibraryManager
restarted = LibraryManager(json_storage.load_library(data_dir), data_dir)
assert restarted.library.tracks[1].play_count == 103
assert _base_on_disk(data_dir) == 102
def test_an_edit_after_a_play_still_writes_the_base(self, manager, data_dir):
"""The dangerous sequence: a play inflates the in-memory count, then an
unrelated edit flushes the whole library.json."""
manager.record_play(1)
manager.add_track(Track(track_id=9, name="Sev Beni Beni"))
manager.flush()
assert _base_on_disk(data_dir) == 102
assert manager.library.tracks[1].play_count == 103
from lintunes.library_manager import LibraryManager
restarted = LibraryManager(json_storage.load_library(data_dir), data_dir)
assert restarted.library.tracks[1].play_count == 103
assert 9 in restarted.library.tracks
def test_reload_does_not_inflate_the_count(self, manager, data_dir):
"""reload_from_disk reconciles in-memory (effective) against disk (base);
folding the journals onto the disk copy first is what keeps max() honest."""
manager.record_play(1)
manager.flush()
manager.reload_from_disk()
assert manager.library.tracks[1].play_count == 103
manager.reload_from_disk()
manager.flush()
assert manager.library.tracks[1].play_count == 103
assert _base_on_disk(data_dir) == 102
def test_the_other_machines_plays_arrive_on_reload(self, manager, data_dir):
_write_journal(data_dir, "machine-other", {"1": {"plays": 7}})
manager.reload_from_disk()
assert manager.library.tracks[1].play_count == 109
class TestJournalConflictInsurance:
"""A journal has one writer, so it should never conflict. If one ever does,
falling through to newest-wins would discard a machine's whole history."""
def test_conflicting_journals_merge_by_highest_total(self, data_dir):
from lintunes.storage import conflict_resolver
_write_journal(data_dir, "machine-a", {"1": {"plays": 9, "last_played": "2026-08-20T10:00:00"},
"2": {"plays": 4}})
json_storage.write_json(
data_dir / "plays" / "machine-a.sync-conflict-20260820-184200-ABCDEFG.json",
{"1": {"plays": 3, "last_played": "2026-08-20T18:00:00"},
"3": {"plays": 6}})
summaries = conflict_resolver.resolve_conflicts(data_dir)
assert [s.kind for s in summaries] == ["plays"]
merged = json_storage.read_json(data_dir / "plays" / "machine-a.json")
assert merged["1"]["plays"] == 9 # not 3
assert merged["1"]["last_played"] == "2026-08-20T18:00:00"
assert merged["2"]["plays"] == 4 # kept
assert merged["3"]["plays"] == 6 # gained