Round 43 made the merge report honest, and in doing so made its one real
limitation impossible to miss: every merge was a union, so a song removed from a
playlist on one machine was handed straight back by the other on the next sync,
and a track deleted from the library came back with it. A deletion you cannot
make stick is not a deletion.
The union was there for a real reason — two copies with no common ancestor
cannot tell "A added this" from "B removed it" — so the missing evidence is
written down instead of inferred. New lintunes/tombstones.py: a playlist keeps
track_events {track id: [when, add|remove]}, the library keeps deleted_tracks
{track id: when} in library_metadata.json, and a merge applies the newest event
per track across both copies. A removal beats a copy that merely still had the
song; a deliberate re-add afterwards beats the removal; a track nobody touched
still merges as a union, which stays the safe behavior where there is no
evidence either way.
Deliberately not "the newer copy wins wholesale": that one-liner silently drops
a song the other machine added while you were removing one, which
test_an_unrelated_addition_is_not_lost pins.
Events are recorded in the funnels that already exist — _set_track_ids diffs
before/after so a reorder records nothing, _remove_tracks stamps the library,
_restore_tracks clears it so Ctrl+Z takes the tombstone back — and pruned after
30 days at the save boundary.
Three edges worth naming:
* Track ids are never reused. The next id came from max(library.tracks), so
deleting the highest-numbered track freed its id, and the next import would be
dropped on sight by the dead id's own tombstone on every machine.
* library_metadata.json is merged before library.json, since it carries the
record the library merge is filtered against and rglob order is not a plan.
* A merge applying a deletion never touches a music file — it drops the library
entry only, and test_a_merge_never_touches_a_music_file fails the run if
send_to_trash is so much as called. Applied removals grade WARNING and name
the song.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
385 lines
17 KiB
Python
385 lines
17 KiB
Python
"""Round 43: the merge report says who, what, and where.
|
|
|
|
The window kept reporting "Order kept from this machine (most recently edited)"
|
|
for playlists that had been edited on the *other* machine, and offered a backup
|
|
folder holding two directories with one hex-named JSON each.
|
|
|
|
Three defects behind that:
|
|
|
|
* "this machine" was inferred from which copy held the plain filename. Syncthing
|
|
decides that, and it sets the local copy aside as readily as a remote one — so
|
|
the label was a coin flip presented as a fact. The device ID in the conflict
|
|
filename, the one piece of real evidence, was matched by a bare ``\\w+`` and
|
|
deleted with the file.
|
|
* The decision itself leaned local: ``date_modified`` was only consulted when
|
|
*both* copies had one (an iTunes playlist never reordered here has none), and
|
|
every merge rewrote the file it kept, so its mtime — the fallback — got
|
|
fresher each time while the conflict file kept its origin's.
|
|
* Nothing was actionable: ``merge_track_order`` knows where every re-inserted
|
|
track landed and threw it away, no song was ever named, and a track the other
|
|
copy had *deleted* came back silently.
|
|
"""
|
|
import json
|
|
import os
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from lintunes import sync_identity
|
|
from lintunes.models import Library, Playlist, PlaylistType
|
|
from lintunes.storage import json_storage, conflict_resolver
|
|
from lintunes.storage.conflict_resolver import CONFLICT_PATTERN, CHANGE, INFO
|
|
|
|
|
|
def _conflict_name(original: str, device: str = "ABCDEFG") -> str:
|
|
stem, _, ext = original.rpartition(".")
|
|
return f"{stem}.sync-conflict-20240101-120000-{device}.{ext}"
|
|
|
|
|
|
def _library(tmp_path, playlists=(), tracks=()):
|
|
lib = Library()
|
|
for p in playlists:
|
|
lib.playlists[p.persistent_id] = p
|
|
for track in tracks:
|
|
lib.tracks[track.track_id] = track
|
|
json_storage.save_library(lib, tmp_path)
|
|
return lib
|
|
|
|
|
|
def _playlist(ids, stamp=None, name="a nissa ideas"):
|
|
pl = Playlist(persistent_id="AAAA1111", name=name,
|
|
playlist_type=PlaylistType.REGULAR, track_ids=list(ids))
|
|
pl.date_modified = stamp
|
|
return pl
|
|
|
|
|
|
def _pair(tmp_path, ours, theirs, our_stamp=None, their_stamp=None,
|
|
device="ABCDEFG", their_name=None, tracks=()):
|
|
"""Write our playlist plus a conflict file holding theirs."""
|
|
_library(tmp_path, [_playlist(ours, our_stamp)], tracks)
|
|
pfile = tmp_path / "playlists" / "AAAA1111.json"
|
|
other = json.loads(pfile.read_text())
|
|
other["track_ids"] = list(theirs)
|
|
if their_stamp:
|
|
other["date_modified"] = their_stamp
|
|
elif "date_modified" in other:
|
|
del other["date_modified"]
|
|
if their_name:
|
|
other["name"] = their_name
|
|
cfile = tmp_path / "playlists" / _conflict_name("AAAA1111.json", device)
|
|
cfile.write_text(json.dumps(other))
|
|
return pfile, cfile
|
|
|
|
|
|
class TestWhichCopyWon:
|
|
"""The half-stamped case: one machine edited, the other never has."""
|
|
|
|
def test_the_stamped_copy_wins_over_an_unstamped_one(self, tmp_path):
|
|
"""A stamp only exists once LinTunes recorded an edit, so "stamped vs
|
|
never edited" is evidence — and it used to fall through to mtime, which
|
|
is exactly the `sci vibes` case where their copy had no stamp at all."""
|
|
pfile, cfile = _pair(tmp_path, ours=[1, 2, 3], theirs=[3, 2, 1],
|
|
our_stamp=None, their_stamp="2026-08-20T18:00:00")
|
|
os.utime(cfile, (time.time() - 10_000, time.time() - 10_000)) # older file
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
|
|
assert "set aside" in summaries[0].lines[0]
|
|
|
|
def test_and_the_other_way_round(self, tmp_path):
|
|
pfile, cfile = _pair(tmp_path, ours=[3, 2, 1], theirs=[1, 2, 3],
|
|
our_stamp="2026-08-20T18:00:00", their_stamp=None)
|
|
os.utime(cfile, (time.time() + 10_000, time.time() + 10_000)) # newer file
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
|
|
assert "already here" in summaries[0].lines[0]
|
|
|
|
def test_neither_stamped_still_falls_back_to_mtime(self, tmp_path):
|
|
pfile, cfile = _pair(tmp_path, ours=[1, 2, 3], theirs=[3, 2, 1])
|
|
os.utime(cfile, (time.time() + 10_000, time.time() + 10_000))
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert json.loads(pfile.read_text())["track_ids"] == [3, 2, 1]
|
|
assert any("file timestamp" in line for line in summaries[0].lines)
|
|
|
|
|
|
class TestTheMtimeRatchet:
|
|
"""Every merge used to rewrite the file it kept, so the copy in place got a
|
|
fresher mtime each round while the conflict file kept its origin's — the
|
|
fallback got more biased with every merge."""
|
|
|
|
def test_a_merge_that_changes_nothing_does_not_rewrite_the_file(self, tmp_path):
|
|
pfile, _ = _pair(tmp_path, ours=[1, 2, 3], theirs=[1, 2, 3])
|
|
before = pfile.stat().st_mtime_ns
|
|
time.sleep(0.01)
|
|
|
|
conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert pfile.stat().st_mtime_ns == before
|
|
|
|
def test_a_union_neither_copy_had_is_stamped_as_new(self, tmp_path):
|
|
"""Content neither side had is newer than both. Saying so is what stops
|
|
the two machines trading the same tracks back and forth."""
|
|
pfile, _ = _pair(tmp_path, ours=[1, 2], theirs=[3],
|
|
our_stamp="2026-08-20T18:00:00",
|
|
their_stamp="2026-08-19T09:00:00")
|
|
|
|
conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
merged = json.loads(pfile.read_text())
|
|
assert set(merged["track_ids"]) == {1, 2, 3}
|
|
assert merged["date_modified"] > "2026-08-20T18:00:00"
|
|
|
|
def test_a_losing_copys_stamp_never_walks_ours_backwards(self, tmp_path):
|
|
"""Their list can be a superset of ours while being the older edit. The
|
|
merged file is still content we did not have — taking the loser's stamp
|
|
would make this file claim to be older than it is and lose the next
|
|
comparison for no reason."""
|
|
pfile, _ = _pair(tmp_path, ours=[1], theirs=[1, 2],
|
|
our_stamp="2026-08-20T18:00:00",
|
|
their_stamp="2026-08-19T09:00:00")
|
|
|
|
conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
merged = json.loads(pfile.read_text())
|
|
assert merged["track_ids"] == [1, 2]
|
|
assert merged["date_modified"] > "2026-08-20T18:00:00"
|
|
|
|
def test_but_taking_their_list_wholesale_keeps_their_stamp(self, tmp_path):
|
|
pfile, _ = _pair(tmp_path, ours=[1, 2], theirs=[1, 2, 3],
|
|
our_stamp="2026-08-19T09:00:00",
|
|
their_stamp="2026-08-20T18:00:00")
|
|
|
|
conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert json.loads(pfile.read_text())["date_modified"] == "2026-08-20T18:00:00"
|
|
|
|
|
|
class TestWhoWroteIt:
|
|
def test_the_pattern_captures_the_device_token(self):
|
|
match = CONFLICT_PATTERN.match(
|
|
"AAAA1111.sync-conflict-20240101-120000-CMFNCIX.json")
|
|
assert match.group(1) == "AAAA1111"
|
|
assert match.group(2) == "20240101-120000"
|
|
assert match.group(3) == "CMFNCIX"
|
|
assert match.group(4) == ".json"
|
|
|
|
def test_the_original_is_still_found_with_the_extension_in_group_four(
|
|
self, tmp_path):
|
|
_pair(tmp_path, ours=[1], theirs=[2])
|
|
found = conflict_resolver._find_conflict_files(tmp_path)
|
|
assert len(found) == 1
|
|
assert found[0].original_path.name == "AAAA1111.json"
|
|
assert found[0].device == "ABCDEFG"
|
|
|
|
def test_a_known_device_is_named_in_the_report(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(sync_identity, "_cache",
|
|
{"names": {"CMFNCIX": "trave14"}, "self": "EA4TRYN"})
|
|
_pair(tmp_path, ours=[1, 2], theirs=[2, 1], device="CMFNCIX",
|
|
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert summaries[0].device_label == "trave14"
|
|
assert any("trave14" in line for line in summaries[0].lines)
|
|
|
|
def test_our_own_device_is_named_as_this_machine(self, monkeypatch):
|
|
monkeypatch.setattr(sync_identity, "_cache",
|
|
{"names": {"EA4TRYN": "console"}, "self": "EA4TRYN"})
|
|
assert sync_identity.label_for("EA4TRYN") == "console (this machine)"
|
|
|
|
def test_no_syncthing_config_means_no_claim_about_machines(
|
|
self, tmp_path, monkeypatch):
|
|
"""A machine without Syncthing installed, or with its config somewhere
|
|
we don't look, must still merge — just without naming anyone."""
|
|
monkeypatch.setenv("HOME", str(tmp_path / "elsewhere"))
|
|
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "elsewhere" / "state"))
|
|
sync_identity.reset_cache()
|
|
try:
|
|
assert sync_identity.device_names() == {}
|
|
assert sync_identity.self_device_id() is None
|
|
assert sync_identity.label_for("CMFNCIX") is None
|
|
_pair(tmp_path, ours=[1, 2], theirs=[2, 1],
|
|
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
assert summaries[0].device_label == ""
|
|
assert "written on" not in summaries[0].lines[0]
|
|
finally:
|
|
sync_identity.reset_cache()
|
|
|
|
|
|
class TestNamingTheSongs:
|
|
def _tracks(self):
|
|
from lintunes.models import Track
|
|
return [Track(track_id=1, name="Kid A", artist="Radiohead"),
|
|
Track(track_id=2, name="Abeille", artist="Pola"),
|
|
Track(track_id=3, name="morning.", artist="jinsang")]
|
|
|
|
def test_re_inserted_tracks_are_named_with_their_position(self, tmp_path):
|
|
_pair(tmp_path, ours=[1, 3], theirs=[1, 2, 3], tracks=self._tracks(),
|
|
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
body = "\n".join(summaries[0].lines)
|
|
assert "Pola — Abeille" in body
|
|
assert "position 2" in body
|
|
assert "Radiohead — Kid A" in body # what it landed after
|
|
|
|
def test_tracks_the_other_copy_dropped_are_reported_not_hidden(self, tmp_path):
|
|
"""A union resurrects a track deleted on the other machine. That used to
|
|
happen in silence, so the deletion bounced back every sync with no clue
|
|
why."""
|
|
_pair(tmp_path, ours=[1, 2], theirs=[1], tracks=self._tracks(),
|
|
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
body = "\n".join(summaries[0].lines)
|
|
assert "the other copy did not have" in body
|
|
assert "Pola — Abeille" in body
|
|
# Round 44: a removal now carries across on its own, so the note no
|
|
# longer tells you to delete it here by hand.
|
|
assert "Most likely you added it here" in body
|
|
|
|
def test_the_window_caps_the_list_and_the_backup_holds_all_of_it(self, tmp_path):
|
|
from lintunes.models import Track
|
|
tracks = [Track(track_id=i, name=f"Song {i}", artist="A") for i in range(1, 21)]
|
|
_pair(tmp_path, ours=[1], theirs=list(range(1, 21)), tracks=tracks,
|
|
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
named = [line for line in summaries[0].lines if line.startswith(" • ")]
|
|
assert len(named) == conflict_resolver.TRACKS_TO_NAME
|
|
assert any("…and 13 more" in line for line in summaries[0].lines)
|
|
assert len([line for line in summaries[0].detail
|
|
if line.startswith(" • ")]) == 19
|
|
|
|
def test_a_rename_on_the_winning_copy_is_adopted_and_reported(self, tmp_path):
|
|
"""Only track_ids and settings were ever taken from the winner, so a
|
|
rename or a folder move made elsewhere was discarded by every merge."""
|
|
pfile, _ = _pair(tmp_path, ours=[1, 2], theirs=[2, 1],
|
|
their_name="a nissa two",
|
|
our_stamp="2026-08-19T09:00:00",
|
|
their_stamp="2026-08-20T18:00:00")
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
assert json.loads(pfile.read_text())["name"] == "a nissa two"
|
|
assert summaries[0].file == "a nissa two"
|
|
assert any("Renamed from" in line for line in summaries[0].lines)
|
|
|
|
|
|
class TestTheBackupReport:
|
|
def test_what_changed_is_written_beside_the_snapshots(self, tmp_path):
|
|
from lintunes.models import Track
|
|
_pair(tmp_path, ours=[1], theirs=[1, 2], device="CMFNCIX",
|
|
tracks=[Track(track_id=1, name="Kid A", artist="Radiohead"),
|
|
Track(track_id=2, name="Abeille", artist="Pola")],
|
|
our_stamp="2026-08-20T18:00:00", their_stamp="2026-08-19T09:00:00")
|
|
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
|
|
report = (tmp_path / ".resolved" / summaries[0].backup_dir.rsplit("/", 1)[-1]
|
|
/ "what-changed.txt").read_text()
|
|
assert "a nissa ideas" in report
|
|
assert "Pola — Abeille" in report
|
|
# The conflict filename is the only record of the device, and the file
|
|
# itself is deleted by the merge.
|
|
assert "sync-conflict-20240101-120000-CMFNCIX.json" in report
|
|
|
|
def test_restore_still_only_touches_the_original_snapshot(self, tmp_path):
|
|
pfile, _ = _pair(tmp_path, ours=[1, 2], theirs=[3],
|
|
our_stamp="2026-08-20T18:00:00",
|
|
their_stamp="2026-08-19T09:00:00")
|
|
summaries = conflict_resolver.resolve_conflicts(tmp_path)
|
|
assert set(json.loads(pfile.read_text())["track_ids"]) == {1, 2, 3}
|
|
|
|
from pathlib import Path
|
|
conflict_resolver.restore_backup(Path(summaries[0].backup_dir), tmp_path)
|
|
|
|
assert json.loads(pfile.read_text())["track_ids"] == [1, 2]
|
|
|
|
|
|
class TestTheLiveReloadPath:
|
|
"""Same symptom with no conflict file: Syncthing delivers a clean update
|
|
while we hold the playlist dirty."""
|
|
|
|
def _manager(self, tmp_path, qapp, ids, stamp):
|
|
from lintunes.library_manager import LibraryManager
|
|
lib = _library(tmp_path, [_playlist(ids, stamp)])
|
|
return LibraryManager(lib, tmp_path)
|
|
|
|
def _write_disk_copy(self, tmp_path, ids, stamp, name=None):
|
|
pfile = tmp_path / "playlists" / "AAAA1111.json"
|
|
data = json.loads(pfile.read_text())
|
|
data["track_ids"] = list(ids)
|
|
data["date_modified"] = stamp
|
|
if name:
|
|
data["name"] = name
|
|
pfile.write_text(json.dumps(data))
|
|
|
|
def test_a_newer_disk_reorder_is_not_reverted(self, tmp_path, qapp):
|
|
manager = self._manager(tmp_path, qapp, [1, 2, 3], "2026-08-19T09:00:00")
|
|
manager.library.playlists["AAAA1111"].track_ids = [1, 2, 3]
|
|
manager._dirty_playlist_content.add("AAAA1111")
|
|
self._write_disk_copy(tmp_path, [3, 2, 1], "2026-08-20T18:00:00")
|
|
|
|
manager.reload_from_disk()
|
|
|
|
assert manager.library.playlists["AAAA1111"].track_ids == [3, 2, 1]
|
|
|
|
def test_our_newer_edit_still_wins(self, tmp_path, qapp):
|
|
manager = self._manager(tmp_path, qapp, [1, 2, 3], "2026-08-19T09:00:00")
|
|
pl = manager.library.playlists["AAAA1111"]
|
|
pl.track_ids = [3, 1, 2]
|
|
pl.date_modified = "2026-08-21T10:00:00"
|
|
manager._dirty_playlist_content.add("AAAA1111")
|
|
self._write_disk_copy(tmp_path, [1, 2, 3], "2026-08-20T18:00:00")
|
|
|
|
manager.reload_from_disk()
|
|
|
|
assert manager.library.playlists["AAAA1111"].track_ids == [3, 1, 2]
|
|
|
|
def test_a_column_drag_does_not_make_us_the_edited_copy(self, tmp_path, qapp):
|
|
"""`_dirty_playlists` is set by a sort click too, and that used to route
|
|
the reload through the keep-local branch — reverting the other machine's
|
|
rename and reorder, then flushing the revert back to disk."""
|
|
manager = self._manager(tmp_path, qapp, [1, 2, 3], "2026-08-19T09:00:00")
|
|
manager.mark_playlist_settings_dirty("AAAA1111")
|
|
self._write_disk_copy(tmp_path, [3, 2, 1], "2026-08-20T18:00:00",
|
|
name="renamed elsewhere")
|
|
|
|
manager.reload_from_disk()
|
|
|
|
playlist = manager.library.playlists["AAAA1111"]
|
|
assert playlist.track_ids == [3, 2, 1]
|
|
assert playlist.name == "renamed elsewhere"
|
|
|
|
def test_a_rename_now_records_an_edit(self, tmp_path, qapp):
|
|
"""Without a stamp a rename is invisible to every merge."""
|
|
manager = self._manager(tmp_path, qapp, [1], None)
|
|
manager.rename_playlist("AAAA1111", "a nissa three")
|
|
assert manager.library.playlists["AAAA1111"].date_modified
|
|
assert "AAAA1111" in manager._dirty_playlist_content
|
|
|
|
def test_a_superset_on_disk_does_not_age_our_stamp(self, tmp_path, qapp):
|
|
"""Same hazard as the conflict path: the disk copy can hold every track
|
|
we have and still be the older edit."""
|
|
manager = self._manager(tmp_path, qapp, [1], "2026-08-21T10:00:00")
|
|
pl = manager.library.playlists["AAAA1111"]
|
|
pl.track_ids = [1]
|
|
manager._dirty_playlist_content.add("AAAA1111")
|
|
self._write_disk_copy(tmp_path, [1, 2], "2026-08-20T18:00:00")
|
|
|
|
manager.reload_from_disk()
|
|
|
|
pl = manager.library.playlists["AAAA1111"]
|
|
assert pl.track_ids == [1, 2]
|
|
assert pl.date_modified > "2026-08-21T10:00:00"
|