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`). hex `persistent_id` (folder containment via `parent_persistent_id`).
- **`lintunes/storage/json_storage.py`** — the library is **multiple files** in - **`lintunes/storage/json_storage.py`** — the library is **multiple files** in
the data dir: `library.json` (all tracks), `library_metadata.json`, and one the data dir: `library.json` (all tracks), `library_metadata.json`, one
`playlists/<persistent_id>.json` per playlist. Writes are atomic (`*.json.tmp` `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 → rename). **`storage/conflict_resolver.py`** merges Syncthing
`*.sync-conflict-*` files on startup: play counts take the max, edited fields `*.sync-conflict-*` files on startup: play counts take the max, edited fields
take the newest, playlist membership takes the union. 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 - **`lintunes/library_manager.py`** — `LibraryManager(QObject)` owns the
`Library`, is the single funnel for all mutations, and persists them `Library`, is the single funnel for all mutations, and persists them
**debounced** (3 s) with per-area dirty tracking (a play-count bump rewrites **debounced** (3 s) with per-area dirty tracking (a play-count bump rewrites
+9 -9
View File
@@ -14,10 +14,13 @@ When a round closes, move its finished items to `tasks-done.md`.
Folder + web-mix export, audio.js/jQuery dropped for a dependency-free Folder + web-mix export, audio.js/jQuery dropped for a dependency-free
player, lossless-only conversion. Round 36 below is still open. player, lossless-only conversion. Round 36 below is still open.
## Round 36 — the merge rework (planned, Round 35 covered the speed half) ## Round 38 (2026-08-20) — merge windows + play journals: done, see tasks-done.md
## Round 36 — the merge rework (the playlist half is still open)
The three symptoms in Round 35 shared a root cause; that round fixed the The three symptoms in Round 35 shared a root cause; that round fixed the
performance half. This is the correctness half. performance half. Round 38 took the play-count and dialog items; the two
playlist items below are what's left.
- [ ] **Playlist merges lose position.** `_merge_playlist` is a 2-way union with - [ ] **Playlist merges lose position.** `_merge_playlist` is a 2-way union with
no common ancestor: it takes one side's order wholesale and *appends* the no common ancestor: it takes one side's order wholesale and *appends* the
@@ -31,13 +34,10 @@ performance half. This is the correctness half.
So "most recently edited" often means "most recently resized". Give So "most recently edited" often means "most recently resized". Give
`Playlist` a `date_modified` bumped only in `_set_track_ids`, and merge on `Playlist` a `date_modified` bumped only in `_set_track_ids`, and merge on
that. that.
- [ ] **`max()` play counts discard concurrent plays.** Base 100, one machine - [x] **`max()` play counts discard concurrent plays.** Done as described —
plays 5 (105), the other plays 3 (103) → merge keeps 105 and those 3 are per-machine `plays/<machine-id>.json` journals, machine id in the config
gone. Per-machine `plays/<machine-id>.json` journal (machine id kept dir. Three plays on the real library now write 83 bytes instead of 15 MB,
outside the synced dir): only its owner ever writes it, so play data can and `library.json` isn't touched at all.
never conflict, effective count = base + sum of journals, and `library.json`
stops being rewritten every 3 s during playback — which is what generates
the conflicts in the first place.
- [x] **Quiet the merge dialog.** One dialog per session: each merge is folded - [x] **Quiet the merge dialog.** One dialog per session: each merge is folded
in as a timestamped entry (newest first) instead of opening another in as a timestamped entry (newest first) instead of opening another
window. Fifteen had stacked up. Kept the window for every merge rather window. Fifteen had stacked up. Kept the window for every merge rather
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.8.0" __version__ = "0.9.0"
+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.models import Library, Playlist, PlaylistType
from lintunes.perf import timed from lintunes.perf import timed
from lintunes.storage import json_storage from lintunes.storage import json_storage
from lintunes.storage.play_journal import PlayJournal
from lintunes.undo import Command, UndoStack from lintunes.undo import Command, UndoStack
@@ -53,6 +54,13 @@ class LibraryManager(QObject):
self._dirty_playlists: set[str] = set() self._dirty_playlists: set[str] = set()
self._deleted_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 = QTimer(self)
self._save_timer.setSingleShot(True) self._save_timer.setSingleShot(True)
self._save_timer.setInterval(SAVE_DEBOUNCE_MS) self._save_timer.setInterval(SAVE_DEBOUNCE_MS)
@@ -680,9 +688,14 @@ class LibraryManager(QObject):
track = self.library.tracks.get(track_id) track = self.library.tracks.get(track_id)
if not track: if not track:
return return
now = _utc_now_iso()
track.play_count += 1 track.play_count += 1
track.play_date_utc = _utc_now_iso() track.play_date_utc = now
self._dirty_tracks = True # 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._schedule_save()
self.track_updated.emit(track_id) self.track_updated.emit(track_id)
self._touch_smart({"play_count", "play_date_utc"}) self._touch_smart({"play_count", "play_date_utc"})
@@ -691,9 +704,11 @@ class LibraryManager(QObject):
track = self.library.tracks.get(track_id) track = self.library.tracks.get(track_id)
if not track: if not track:
return return
now = _utc_now_iso()
track.skip_count += 1 track.skip_count += 1
track.skip_date = _utc_now_iso() track.skip_date = now
self._dirty_tracks = True self.play_journal.record_skip(track_id, now)
self._dirty_journal = True
self._schedule_save() self._schedule_save()
self.track_updated.emit(track_id) self.track_updated.emit(track_id)
self._touch_smart({"skip_count", "skip_date"}) self._touch_smart({"skip_count", "skip_date"})
@@ -801,10 +816,16 @@ class LibraryManager(QObject):
def flush(self): def flush(self):
self._save_timer.stop() 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: if self._dirty_tracks:
with timed("flush library.json (%d tracks)", with timed("flush library.json (%d tracks)",
len(self.library.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._dirty_tracks = False
self._record_sig(self.data_dir / "library.json") self._record_sig(self.data_dir / "library.json")
for pid in list(self._dirty_playlists): for pid in list(self._dirty_playlists):
@@ -831,6 +852,9 @@ class LibraryManager(QObject):
playlists_dir = self.data_dir / "playlists" playlists_dir = self.data_dir / "playlists"
if playlists_dir.exists(): if playlists_dir.exists():
files.extend(sorted(playlists_dir.glob("*.json"))) 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 return files
@staticmethod @staticmethod
@@ -877,6 +901,11 @@ class LibraryManager(QObject):
or self._dirty_metadata) or self._dirty_metadata)
dirty_pids = set(self._dirty_playlists) dirty_pids = set(self._dirty_playlists)
disk = json_storage.load_library(self.data_dir) 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(): for tid, disk_track in disk.tracks.items():
mem_track = self.library.tracks.get(tid) mem_track = self.library.tracks.get(tid)
+33
View File
@@ -66,6 +66,8 @@ def resolve_conflicts(data_dir: Path) -> list[ConflictSummary]:
summary = _merge_metadata(original_path, conflict_path) summary = _merge_metadata(original_path, conflict_path)
elif original_path.parent.name == "playlists": elif original_path.parent.name == "playlists":
summary = _merge_playlist(original_path, conflict_path) summary = _merge_playlist(original_path, conflict_path)
elif original_path.parent.name == "plays":
summary = _merge_play_journal(original_path, conflict_path)
else: else:
summary = _keep_newer(original_path, conflict_path) summary = _keep_newer(original_path, conflict_path)
except Exception as e: # never let a bad file abort startup/live reload except Exception as e: # never let a bad file abort startup/live reload
@@ -264,6 +266,37 @@ def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary
return ConflictSummary(name, "playlist", lines) return ConflictSummary(name, "playlist", lines)
def _merge_play_journal(original_path: Path, conflict_path: Path) -> ConflictSummary:
"""Insurance only. A journal is owned by one machine, so it should never
conflict — but if a filesystem or clock oddity produces one, falling through
to _keep_newer would throw away a machine's entire play history. Take the
higher total per track instead."""
original = read_json(original_path)
incoming = read_json(conflict_path)
merged = dict(original)
changed = 0
for tid, entry in incoming.items():
mine = merged.get(tid)
if mine is None:
merged[tid] = entry
changed += 1
continue
combined = dict(mine)
for count in ("plays", "skips"):
combined[count] = max(mine.get(count, 0), entry.get(count, 0))
for stamp in ("last_played", "last_skipped"):
a, b = mine.get(stamp), entry.get(stamp)
combined[stamp] = a if b is None else (b if a is None else max(a, b))
if combined != mine:
changed += 1
merged[tid] = combined
write_json(original_path, merged)
return ConflictSummary(
f"plays/{original_path.name}", "plays",
[f"{changed} track(s) of play history reconciled (highest total kept)."]
if changed else ["No differences needed reconciling."])
def _merge_metadata(original_path: Path, conflict_path: Path) -> ConflictSummary: def _merge_metadata(original_path: Path, conflict_path: Path) -> ConflictSummary:
summary = _keep_newer(original_path, conflict_path) summary = _keep_newer(original_path, conflict_path)
summary.file, summary.kind = "library_metadata.json", "metadata" summary.file, summary.kind = "library_metadata.json", "metadata"
+17 -1
View File
@@ -22,7 +22,7 @@ def save_library(library: Library, data_dir: Path):
playlist_file.unlink() playlist_file.unlink()
def save_tracks(library: Library, data_dir: Path): def save_tracks(library: Library, data_dir: Path, journal=None):
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
tracks_dict = {} tracks_dict = {}
for tid, track in library.tracks.items(): for tid, track in library.tracks.items():
@@ -31,10 +31,26 @@ def save_tracks(library: Library, data_dir: Path):
# Store paths relative to the data dir so the library is portable # Store paths relative to the data dir so the library is portable
# across machines that sync the folder to different mount points. # across machines that sync the folder to different mount points.
data["location"] = to_relative(data["location"], data_dir) data["location"] = to_relative(data["location"], data_dir)
if journal is not None:
# A Track carries the *effective* play count (base plus every
# machine's journal). library.json must get the base back — writing
# the effective value here would fold this machine's plays into the
# base and count them twice on the next load. See play_journal.
_write_base_play_fields(data, journal.base_fields(tid))
tracks_dict[str(tid)] = data tracks_dict[str(tid)] = data
write_json(data_dir / "library.json", tracks_dict) write_json(data_dir / "library.json", tracks_dict)
def _write_base_play_fields(data: dict, base: dict):
"""Put the base play fields back into a to_dict() result, keeping to_dict's
rule that a default-valued field is omitted rather than stored."""
for field, value in base.items():
if value in (0, None, ""):
data.pop(field, None)
else:
data[field] = value
def save_metadata(library: Library, data_dir: Path): def save_metadata(library: Library, data_dir: Path):
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
metadata = { metadata = {
+191
View File
@@ -0,0 +1,191 @@
"""Per-machine play journals — the reason play counts can no longer conflict.
``library.json`` used to carry the live play count, so finishing a track
rewrote all 15 MB of it. Both machines did that, so Syncthing saw two edits to
one big file between syncs and produced a conflict file roughly once per song.
The merge then took ``max()`` of the two counts, silently discarding whichever
side had played less.
Now ``library.json`` holds only a **base** count, and each machine additionally
owns exactly one file ``<data_dir>/plays/<machine-id>.json`` recording *its own*
per-track totals. Effective count = base + the sum of every journal.
Three properties fall out of that:
* **No conflict is possible.** A journal file is only ever written by the
machine that owns it, so two machines never touch the same file.
* **No compaction step.** The journal stores running totals, not an append log,
so it is bounded by "tracks this machine has played" and never needs folding
back into the base — which is what would risk double-counting.
* **Version skew is safe.** A machine still on older code keeps bumping its own
base in ``library.json``; we never write base, so the sum stays correct. When
it updates, its already-counted plays stay frozen in base and it starts its
own journal.
The machine id lives in the config dir, deliberately *outside* the synced data
dir — a synced id would make two machines share one journal.
"""
import uuid
from pathlib import Path
from lintunes.config import config_path
from lintunes.storage.json_storage import read_json, write_json
JOURNAL_DIR = "plays"
# The four Track fields a journal contributes to. Everything else about a track
# still lives in library.json.
PLAY_FIELDS = ("play_count", "skip_count", "play_date_utc", "skip_date")
def machine_id() -> str:
"""This machine's stable id, created on first use. Kept next to config.json
(not in the data dir) so Syncthing never copies it to the other machine."""
path = config_path().parent / "machine_id"
try:
text = path.read_text(encoding="utf-8").strip()
if text:
return text
except OSError:
pass
new_id = uuid.uuid4().hex[:12]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(new_id + "\n", encoding="utf-8")
return new_id
def _newer(a, b):
"""The later of two ISO-8601 stamps, either of which may be None."""
if a is None:
return b
if b is None:
return a
return a if a >= b else b
class PlayJournal:
"""The base counts from library.json plus every machine's journal.
``load()`` snapshots the base off the freshly loaded Tracks and then folds
the journals in, so the rest of the app keeps reading one plain
``track.play_count``. Only the save path needs to know the difference —
see ``base_fields``.
"""
def __init__(self, machine=None):
self.machine = machine or machine_id()
self._base = {} # track_id -> {field: value} as stored in library.json
self._own = {} # track_id -> {"plays", "skips", "last_played", "last_skipped"}
self._others = {} # machine id -> same shape as _own
self.dirty = False
# ---- loading ----
def load(self, data_dir: Path, library):
"""Read every journal in the data dir and fold them into ``library``.
``library`` must be one whose tracks still hold the **base** counts —
the freshly loaded library at startup, or the disk copy during a reload.
Passing an already-folded library would promote the effective count to
base and double-count it.
"""
self._base = {
tid: {f: getattr(track, f) for f in PLAY_FIELDS}
for tid, track in library.tracks.items()
}
# Our own journal is only ever written by us, so unsaved bumps in memory
# are always more current than the file — never re-read over them.
keep_own = self._own if self.dirty else None
self._own = {}
self._others = {}
journal_dir = Path(data_dir) / JOURNAL_DIR
if journal_dir.is_dir():
for path in sorted(journal_dir.glob("*.json")):
if path.name.endswith(".tmp"):
continue
try:
entries = read_json(path)
except (ValueError, OSError):
continue # a half-synced journal must never abort startup
entries = {int(k): v for k, v in entries.items()
if isinstance(v, dict)}
if path.stem == self.machine:
self._own = entries
else:
self._others[path.stem] = entries
if keep_own is not None:
self._own = keep_own
else:
self.dirty = False
self.apply(library)
def apply(self, library):
"""Set every track's play fields to base + the sum of all journals."""
for tid, track in library.tracks.items():
added = sum(e[tid].get("plays", 0)
for e in self._all_journals() if tid in e)
skipped_n = sum(e[tid].get("skips", 0)
for e in self._all_journals() if tid in e)
base = self._base.get(tid)
if base is None:
# A track added after the snapshot. Its current count already
# includes anything recorded into the journal since, so back
# that out rather than baking it into the base.
base = {f: getattr(track, f) for f in PLAY_FIELDS}
base["play_count"] = (base["play_count"] or 0) - added
base["skip_count"] = (base["skip_count"] or 0) - skipped_n
self._base[tid] = base
plays = (base["play_count"] or 0) + added
skips = (base["skip_count"] or 0) + skipped_n
played = base["play_date_utc"]
skipped = base["skip_date"]
for entries in self._all_journals():
e = entries.get(tid)
if not e:
continue
played = _newer(played, e.get("last_played"))
skipped = _newer(skipped, e.get("last_skipped"))
track.play_count = plays
track.skip_count = skips
track.play_date_utc = played
track.skip_date = skipped
def _all_journals(self):
yield self._own
yield from self._others.values()
# ---- recording ----
def record_play(self, track_id: int, when: str):
entry = self._own.setdefault(track_id, {})
entry["plays"] = entry.get("plays", 0) + 1
entry["last_played"] = when
self.dirty = True
def record_skip(self, track_id: int, when: str):
entry = self._own.setdefault(track_id, {})
entry["skips"] = entry.get("skips", 0) + 1
entry["last_skipped"] = when
self.dirty = True
# ---- saving ----
def base_fields(self, track_id: int) -> dict:
"""What library.json should hold for this track. Writing the *effective*
count here instead would double-count it on the next load — this is the
one place in the change where that can go wrong."""
base = self._base.get(track_id)
if base is None: # never seen at load time — nothing to preserve
return {"play_count": 0, "skip_count": 0,
"play_date_utc": None, "skip_date": None}
return dict(base)
def save(self, data_dir: Path):
"""Write this machine's journal only. Nothing else ever writes this file,
so it cannot conflict."""
journal_dir = Path(data_dir) / JOURNAL_DIR
journal_dir.mkdir(parents=True, exist_ok=True)
write_json(journal_dir / f"{self.machine}.json",
{str(k): v for k, v in sorted(self._own.items())})
self.dirty = False
+4
View File
@@ -34,11 +34,15 @@ class LibrarySyncWatcher(QObject):
the old inode, so we re-add paths after every event.""" the old inode, so we re-add paths after every event."""
wanted = [self._data_dir, wanted = [self._data_dir,
self._data_dir / "playlists", self._data_dir / "playlists",
self._data_dir / "plays",
self._data_dir / "library.json", self._data_dir / "library.json",
self._data_dir / "library_metadata.json"] self._data_dir / "library_metadata.json"]
playlists_dir = self._data_dir / "playlists" playlists_dir = self._data_dir / "playlists"
if playlists_dir.exists(): if playlists_dir.exists():
wanted.extend(playlists_dir.glob("*.json")) wanted.extend(playlists_dir.glob("*.json"))
plays_dir = self._data_dir / "plays"
if plays_dir.exists():
wanted.extend(plays_dir.glob("*.json"))
already = set(self._watcher.files()) | set(self._watcher.directories()) already = set(self._watcher.files()) | set(self._watcher.directories())
to_add = [str(p) for p in wanted if p.exists() and str(p) not in already] to_add = [str(p) for p in wanted if p.exists() and str(p) not in already]
if to_add: if to_add:
+40
View File
@@ -1,5 +1,45 @@
## Done ## Done
### Round 38 (2026-08-20) — One merge window, and play counts that can't conflict (v0.9.0)
Fifteen "Synced changes merged" windows were stacked on the desktop. Two
independent bugs, and the `.resolved/` backups said which mattered: every one of
the last nine `library.json` merges was ~98% play counts (43-47 of ~45 changed
tracks) plus exactly one real edit — the same single rating each time. The same
~45 tracks disagreed in *every* merge and the count only crept down (48 → 44 over
a day), so `max()` had been quietly discarding plays for at least that long.
- [x] **One window, not one per merge.** `show_conflict_summary` built a fresh
`ConflictSummaryDialog` each time and only reassigned the attribute — the
old dialog stayed a live child of the window. Since
`check_for_external_changes` runs `resolve_conflicts` on every Syncthing
watch tick, that was one window per merge for the life of the process. The
dialog is now a session log: `add_event()` folds each merge in as its own
timestamped entry (newest first), the restore button names the merge it
would undo, and `finished` clears MainWindow's reference so a close lets
the next merge open a fresh one.
- [x] **Per-machine play journals.** `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 all journals. Only the owner
writes its journal, so play data can't conflict; 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, so the version-skew window is safe too. On the real 21k
library: three plays wrote **83 bytes** and left `library.json` untouched,
where before each one rewrote 15 MB.
- [x] **The two hazards, pinned by tests.** `save_tracks` must write the base and
`PlayJournal.load` must be handed a base-valued library — the second is why
`reload_from_disk` folds the journals onto the `disk` copy *before*
reconciling, or the max() against the effective in-memory count would win
by exactly this machine's journal.
- [x] Journal conflicts merge by highest total rather than newest-wins. They
shouldn't be possible; if a filesystem oddity makes one, falling through to
`_keep_newer` would discard a machine's whole history.
Deliberately not done: lossless merges still open the window rather than
demoting to a status-bar line. With journals landing, a merge stops being
routine — the ones left are real edits, and worth seeing.
### Round 37 (2026-08-19) — Export a playlist: a folder, or a whole website (v0.8.0) ### Round 37 (2026-08-19) — Export a playlist: a folder, or a whole website (v0.8.0)
`File → Export Playlist…`, and the same item on a playlist's right-click menu. `File → Export Playlist…`, and the same item on a playlist's right-click menu.
+210
View File
@@ -99,3 +99,213 @@ class TestWindowReuse:
host.show_conflict_summary([_summary()]) host.show_conflict_summary([_summary()])
assert host._conflict_dialog is not None assert host._conflict_dialog is not None
assert host._conflict_dialog is not first 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