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