Files
lintunes/lintunes/sync_identity.py
T
travandClaude Opus 5 c8de124543 v0.13.0: the merge report says who, what, and where
The merge window kept reporting "Order kept from this machine (most recently
edited)" for playlists edited on the other machine. Three defects, confirmed
against the real snapshots in .resolved/:

* "this machine" was inferred from which copy held the plain filename. That is
  Syncthing's call, not a statement about authorship — it sets the local copy
  aside as readily as a remote one. In the 9:34 PM `* a fresh master` merge the
  copy labelled "the other machine" was this machine's own 3:15 PM merge output,
  so the label was exactly backwards. The 7-char device ID in the conflict
  filename — the only real evidence — was matched by a bare \w+ and deleted with
  the file. New sync_identity.py decodes it against Syncthing's config.xml and
  works out which device is us from cert.pem.

* The decision leaned local. date_modified was only consulted when *both* copies
  had one, and an iTunes playlist never reordered here has none — so the honest
  comparison was skipped exactly when one machine had edited and the other
  hadn't. A stamped copy now beats an unstamped one; mtime is the fallback only
  when neither side has ever been edited. And every merge used to rewrite the
  file it kept whether or not anything changed, freshening its mtime while the
  conflict file kept its origin's: a ratchet. No-op merges write nothing, and a
  merge whose result is a union neither copy had stamps date_modified, so the
  other machine adopts it instead of trading the same 19 tracks back and forth.

* Nothing was actionable. Re-inserted tracks are now named with their position
  ("Pola — Abeille -> position 24, after ..."), six in the window and all of
  them in what-changed.txt at the top of the backup snapshot, alongside the real
  conflict filename and its device. Tracks only this copy has are reported too
  rather than resurrected in silence.

Also fixed while in here: a rename or folder move made elsewhere was discarded
by every merge (only track_ids and settings were adopted); _reconcile_playlist
asserted the local edit was newer and never checked, so a reorder synced in from
the other machine was undone and flushed back to disk, and the branch reaching
it was gated on a dirty flag that a column drag sets; and _merge_metadata
decided the music folder from whichever copy an mtime coin flip had kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
2026-08-27 15:16:49 -04:00

112 lines
3.6 KiB
Python

"""Who wrote the copy Syncthing set aside.
A conflict file is named ``<base>.sync-conflict-<date>-<time>-<DEVICE>.<ext>``,
where DEVICE is the first seven characters of the Syncthing device ID that last
wrote the copy being renamed. The merge report used to call one copy "this
machine" and the other "the other machine" purely from *which filename* it had
— and that is a guess Syncthing does not honor: it decides which copy keeps the
plain name, and it routinely sets the local one aside instead. The token in the
filename is the only real evidence about authorship, so this module turns it
into a name a person recognizes.
Everything here is best-effort and read-only: no Syncthing, no config, an
unreadable certificate or an unknown token all mean "we don't know", and the
report falls back to describing the two copies by their edit times alone.
"""
import base64
import hashlib
import os
import ssl
from pathlib import Path
# Where Syncthing keeps config.xml + cert.pem. Newer builds use XDG state, older
# ones ~/.config; the last two are for people who never migrated.
_CONFIG_DIRS = (
"$XDG_STATE_HOME/syncthing",
"~/.local/state/syncthing",
"~/.config/syncthing",
"~/.syncthing",
)
_cache: dict | None = None
def _config_dir() -> Path | None:
for raw in _CONFIG_DIRS:
expanded = Path(os.path.expandvars(raw)).expanduser()
if "$" in str(expanded): # unset XDG_STATE_HOME
continue
if (expanded / "config.xml").is_file():
return expanded
return None
def _read_device_names(config_dir: Path) -> dict[str, str]:
"""{7-char ID prefix: device name} for every device in the config."""
import xml.etree.ElementTree as ET
names = {}
try:
root = ET.parse(config_dir / "config.xml").getroot()
except Exception:
return names
for device in root.iter("device"):
device_id = (device.get("id") or "").strip()
name = (device.get("name") or "").strip()
if device_id and name:
names[device_id[:7].upper()] = name
return names
def _read_self_id(config_dir: Path) -> str | None:
"""This machine's own device ID prefix.
config.xml lists every device including ourselves and doesn't say which one
we are, so derive it the way Syncthing does: a device ID is the base32 of
the SHA-256 of its certificate in DER form.
"""
try:
der = ssl.PEM_cert_to_DER_cert((config_dir / "cert.pem").read_text())
except Exception:
return None
digest = hashlib.sha256(der).digest()
return base64.b32encode(digest).decode("ascii").rstrip("=")[:7]
def _identity() -> dict:
global _cache
if _cache is None:
config_dir = _config_dir()
if config_dir is None:
_cache = {"names": {}, "self": None}
else:
_cache = {"names": _read_device_names(config_dir),
"self": _read_self_id(config_dir)}
return _cache
def reset_cache():
"""Forget what we read (tests point HOME somewhere else mid-run)."""
global _cache
_cache = None
def device_names() -> dict[str, str]:
return dict(_identity()["names"])
def self_device_id() -> str | None:
return _identity()["self"]
def label_for(token: str | None) -> str | None:
"""A human name for a conflict filename's device token, or None."""
if not token:
return None
token = token.upper()[:7]
identity = _identity()
name = identity["names"].get(token)
if identity["self"] and token == identity["self"]:
return f"{name} (this machine)" if name else "this machine"
return name