Skip to content

Instantly share code, notes, and snippets.

@derEremit
Last active August 6, 2026 14:20
Show Gist options
  • Select an option

  • Save derEremit/e31d4eef2da4d57e61c26962f507ae83 to your computer and use it in GitHub Desktop.

Select an option

Save derEremit/e31d4eef2da4d57e61c26962f507ae83 to your computer and use it in GitHub Desktop.
dart-trainer on-device rename bj → bjowol (stats.db + highscores + roster)

On-device player rename: bjbjowol

Central already has history under bjowol. This renames local board data on the Pi.

Gist: https://gist.github.com/derEremit/e31d4eef2da4d57e61c26962f507ae83

Critical

  1. Run as the data owner (opendarts), not plain sudo python3 — root-owned WAL files break the trainer.
  2. Set DT_PLAYER=bjowol in /etc/dart-trainer/config.env if it still says bj, or the next game recreates the short name.
  3. Script backs itself up under backup-merge-… (including sqlite WAL sidecars). Identity .nsec is copied into backup then renamed.
  4. Do not run mid-tournament. Old recaps may still show bj inside games.config / summary blobs (player column is updated).

On the Pi

cd ~
curl -sL -o merge_player_rename.py \
  https://gist.githubusercontent.com/derEremit/e31d4eef2da4d57e61c26962f507ae83/raw/merge_player_rename.py

sudo systemctl stop dart-trainer

sudo -u "$(stat -c %U /var/lib/dart-trainer)" python3 ~/merge_player_rename.py \
  /var/lib/dart-trainer bj bjowol --dry-run

sudo -u "$(stat -c %U /var/lib/dart-trainer)" python3 ~/merge_player_rename.py \
  /var/lib/dart-trainer bj bjowol --yes

grep DT_PLAYER /etc/dart-trainer/config.env
# if bj → edit to bjowol, e.g.:
#   sudo sed -i 's/^DT_PLAYER=.*/DT_PLAYER=bjowol/' /etc/dart-trainer/config.env

sudo systemctl start dart-trainer
# or flip mode via dart-switch if you use it

Verify (no sqlite3 CLI needed)

python3 -c "import sqlite3;print(sqlite3.connect('/var/lib/dart-trainer/stats.db').execute(\\"SELECT player,COUNT(*) FROM games WHERE lower(player) LIKE 'bj%' GROUP BY 1\\").fetchall())"

Expect only ('bjowol', N).

Undo

Restore files from backup-merge-…. For identity: mv identities/bjowol.nsec identities/bj.nsec (also a copy in the backup dir).

#!/usr/bin/env python3
"""Rename/merge a player name on a dart-trainer --out-dir (on-device).
Use when a short name was forced longer (e.g. bj -> bjowol after the 3-char
roster floor). Touches only local durable files under OUT_DIR:
- stats.db games.player (NOT games.config / summary / throw context)
- highscores.json entry.player
- players.json roster name (+ keeps npub/color)
- identities/*.nsec rename key file if present (backed up first)
Does NOT talk to central (central merge is separate). Always writes a timestamped
backup of each file it changes before mutating.
IMPORTANT — run as the data owner, not as root (service User=opendarts):
sudo systemctl stop dart-trainer
sudo -u "$(stat -c %U /var/lib/dart-trainer)" python3 merge_player_rename.py \\
/var/lib/dart-trainer bj bjowol --dry-run
sudo -u "$(stat -c %U /var/lib/dart-trainer)" python3 merge_player_rename.py \\
/var/lib/dart-trainer bj bjowol
# If DT_PLAYER=bj in /etc/dart-trainer/config.env, change it to bjowol
# or the next game recreates the short name via --player default.
sudo systemctl start dart-trainer
Idempotent: re-running when no source rows remain is a no-op success.
Does NOT rewrite tournament journals (tournaments/*.jsonl) — stop any live
tournament first. Does NOT rewrite games.config / summary / thrower context;
old recaps may still display the short name inside those blobs (player column
is updated).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import sqlite3
import sys
import time
def _norm(s: str) -> str:
"""Match trainer.identity.normalize_player (strip + lower)."""
return (s or "").strip().lower()
def _atomic_write(path: str, text: str) -> None:
"""Write via temp + os.replace so a crash cannot leave a truncated JSON file."""
d = os.path.dirname(path) or "."
tmp = os.path.join(d, ".%s.tmp.%d" % (os.path.basename(path), os.getpid()))
try:
with open(tmp, "w") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
try:
os.unlink(tmp)
except OSError:
pass
def _backup(path: str, bak_dir: str) -> str | None:
if not os.path.exists(path):
return None
os.makedirs(bak_dir, exist_ok=True)
dest = os.path.join(bak_dir, os.path.basename(path))
shutil.copy2(path, dest)
for suf in ("-wal", "-shm"):
side = path + suf
if os.path.exists(side):
shutil.copy2(side, dest + suf)
return dest
def _count_games(path: str, name: str) -> int:
if not os.path.exists(path):
return 0
c = sqlite3.connect(path)
try:
row = c.execute(
"SELECT COUNT(*) FROM games WHERE lower(player)=?", (name,)
).fetchone()
return int(row[0]) if row else 0
finally:
c.close()
def _count_highscores(path: str, name: str) -> int:
if not os.path.exists(path):
return 0
try:
data = json.loads(open(path).read())
except (OSError, ValueError):
return 0
n = 0
def walk(obj):
nonlocal n
if isinstance(obj, list):
for e in obj:
walk(e)
elif isinstance(obj, dict):
if "player" in obj and isinstance(obj["player"], str):
if _norm(obj["player"]) == name:
n += 1
for v in obj.values():
walk(v)
walk(data)
return n
def preview(out: str, src: str, dst: str) -> dict:
"""Read-only counts for dry-run / pre-flight print."""
stats = os.path.join(out, "stats.db")
hs = os.path.join(out, "highscores.json")
roster = os.path.join(out, "players.json")
idir = os.path.join(out, "identities")
src_nsec = os.path.join(idir, _identity_filename(src)) if os.path.isdir(idir) else ""
dst_nsec = os.path.join(idir, _identity_filename(dst)) if os.path.isdir(idir) else ""
roster_src = roster_dst = False
if os.path.exists(roster):
try:
rows = json.loads(open(roster).read())
if isinstance(rows, list):
roster_src = any(_norm(r.get("name")) == src for r in rows)
roster_dst = any(_norm(r.get("name")) == dst for r in rows)
except (OSError, ValueError):
pass
return {
"games_src": _count_games(stats, src),
"games_dst": _count_games(stats, dst),
"highscores_src": _count_highscores(hs, src),
"highscores_dst": _count_highscores(hs, dst),
"roster_src": roster_src,
"roster_dst": roster_dst,
"nsec_src": bool(src_nsec and os.path.exists(src_nsec)),
"nsec_dst": bool(dst_nsec and os.path.exists(dst_nsec)),
"uid": os.stat(out).st_uid if os.path.isdir(out) else None,
"euid": os.geteuid(),
}
def print_preview(src: str, dst: str, p: dict) -> None:
print(
" stats.db: %d games move %s → %s (%s already has %d)"
% (p["games_src"], src, dst, dst, p["games_dst"])
)
print(
" highscores: %d entries move %s → %s (%s already has %d)"
% (p["highscores_src"], src, dst, dst, p["highscores_dst"])
)
print(
" roster: src=%s dst=%s"
% (
"yes" if p["roster_src"] else "no",
"yes" if p["roster_dst"] else "no",
)
)
print(
" identities: src.nsec=%s dst.nsec=%s"
% (
"yes" if p["nsec_src"] else "no",
"yes" if p["nsec_dst"] else "no",
)
)
if p.get("euid") == 0:
print(
" WARN: running as root — prefer:\n"
" sudo -u \"$(stat -c %%U %s)\" python3 …"
% "/var/lib/dart-trainer"
)
print(
" NOTE: games.config / summary / thrower context keep the old name "
"(recaps stay self-consistent; player column is updated)."
)
print(
" NOTE: after rename, set DT_PLAYER=%s in /etc/dart-trainer/config.env "
"if it still says %s — else the next game recreates the short name."
% (dst, src)
)
def merge_stats_db(path: str, src: str, dst: str) -> int:
if not os.path.exists(path):
print(" stats.db: missing, skip")
return 0
c = sqlite3.connect(path)
try:
before = c.execute(
"SELECT player, COUNT(*) FROM games WHERE lower(player)=? GROUP BY 1",
(src,),
).fetchall()
print(" stats.db games BEFORE (src):", before)
cur = c.execute(
"UPDATE games SET player=? WHERE lower(player)=?", (dst, src)
)
n = cur.rowcount
c.execute(
"UPDATE games SET player=? WHERE lower(player)=? AND player!=?",
(dst, dst, dst),
)
c.commit()
after = c.execute(
"SELECT player, COUNT(*) FROM games WHERE lower(player) IN (?,?) GROUP BY 1",
(src, dst),
).fetchall()
print(" stats.db games AFTER:", after, "updated_rows=", n)
return n
finally:
c.close()
def merge_highscores(path: str, src: str, dst: str) -> int:
if not os.path.exists(path):
print(" highscores.json: missing, skip")
return 0
try:
data = json.loads(open(path).read())
except ValueError as e:
print(" highscores.json: parse error, aborting this file:", e, file=sys.stderr)
raise
n = 0
def walk(obj):
nonlocal n
if isinstance(obj, list):
for e in obj:
walk(e)
elif isinstance(obj, dict):
if "player" in obj and isinstance(obj["player"], str):
p = obj["player"]
if _norm(p) == src or (_norm(p) == dst and p != dst):
if _norm(p) == src:
n += 1
obj["player"] = dst
for v in obj.values():
walk(v)
walk(data)
_atomic_write(path, json.dumps(data, separators=(",", ":")))
print(" highscores.json renamed entries:", n)
return n
def merge_roster(path: str, src: str, dst: str) -> int:
if not os.path.exists(path):
print(" players.json: missing, skip")
return 0
try:
rows = json.loads(open(path).read())
except ValueError as e:
print(" players.json: parse error, aborting this file:", e, file=sys.stderr)
raise
if not isinstance(rows, list):
print(" players.json: unexpected shape, skip")
return 0
src_row = next((r for r in rows if _norm(r.get("name")) == src), None)
dst_row = next((r for r in rows if _norm(r.get("name")) == dst), None)
n = 0
if src_row and not dst_row:
src_row["name"] = dst
n = 1
print(" players.json: renamed row", src, "->", dst)
elif src_row and dst_row:
if not dst_row.get("npub") and src_row.get("npub"):
dst_row["npub"] = src_row["npub"]
if not dst_row.get("color") and src_row.get("color"):
dst_row["color"] = src_row["color"]
dst_row["last_seen"] = max(
float(dst_row.get("last_seen") or 0),
float(src_row.get("last_seen") or 0),
)
rows = [r for r in rows if _norm(r.get("name")) != src]
n = 1
print(" players.json: merged src into dst, dropped src")
else:
print(" players.json: no src row (ok if already renamed)")
for r in rows:
if _norm(r.get("name")) == dst and r.get("name") != dst:
r["name"] = dst
n += 1
_atomic_write(path, json.dumps(rows))
return n
def _identity_filename(name: str) -> str:
"""Mirror trainer.identity._key_path sanitization (basename only)."""
norm = _norm(name)
safe = re.sub(r"[^a-z0-9_-]", "", norm) or "player"
if safe != norm:
safe += "-" + hashlib.sha256(norm.encode()).hexdigest()[:6]
return safe + ".nsec"
def merge_identity_file(out_dir: str, src: str, dst: str, bak_dir: str) -> int:
idir = os.path.join(out_dir, "identities")
if not os.path.isdir(idir):
print(" identities/: missing, skip")
return 0
sp = os.path.join(idir, _identity_filename(src))
dp = os.path.join(idir, _identity_filename(dst))
if os.path.exists(sp) and not os.path.exists(dp):
# backup before rename (undo is mv back; also copy into bak_dir)
_backup(sp, bak_dir)
os.rename(sp, dp)
print(
" identities: renamed",
os.path.basename(sp),
"->",
os.path.basename(dp),
"(copy in backup; undo: mv the .nsec back)",
)
return 1
if os.path.exists(sp) and os.path.exists(dp):
print(
" identities: BOTH src and dst key files exist — left untouched "
"(resolve manually; do not clobber nsec)"
)
return 0
print(" identities: no src key file")
return 0
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("out_dir", help="trainer --out-dir (e.g. /var/lib/dart-trainer)")
ap.add_argument("src", help="old name (e.g. bj)")
ap.add_argument("dst", help="new name (e.g. bjowol)")
ap.add_argument(
"--dry-run",
action="store_true",
help="print counts only; no backup, no writes",
)
ap.add_argument(
"--yes",
action="store_true",
help="skip interactive confirm when dst already has games (typo guard)",
)
args = ap.parse_args()
src, dst = _norm(args.src), _norm(args.dst)
if not src or not dst or src == dst:
print(
"src and dst must be distinct non-empty normalized names",
file=sys.stderr,
)
return 2
if len(dst) < 3:
print("dst must be >= 3 chars (roster floor)", file=sys.stderr)
return 2
out = os.path.abspath(args.out_dir)
if not os.path.isdir(out):
print("out_dir not a directory:", out, file=sys.stderr)
return 2
print("OUT", out)
print("RENAME", src, "->", dst)
p = preview(out, src, dst)
print_preview(src, dst, p)
if args.dry_run:
print("dry-run: no changes written")
return 0
if p["games_src"] == 0 and p["highscores_src"] == 0 and not p["roster_src"] and not p["nsec_src"]:
print("nothing to do (src already gone)")
return 0
# Typo guard: merging into a non-empty destination needs --yes or a TTY confirm
if p["games_dst"] > 0 and not args.yes:
if sys.stdin.isatty():
ans = input(
"dst %r already has %d games — merge into that player? [y/N] "
% (dst, p["games_dst"])
)
if ans.strip().lower() not in ("y", "yes"):
print("aborted")
return 1
else:
print(
"dst already has games; pass --yes to merge non-interactively",
file=sys.stderr,
)
return 1
bak_dir = os.path.join(
out,
"backup-merge-%s-to-%s-%s"
% (src, dst, time.strftime("%Y%m%d-%H%M%S")),
)
os.makedirs(bak_dir, exist_ok=True)
print("backup dir", bak_dir)
for name in ("stats.db", "highscores.json", "players.json"):
pth = os.path.join(out, name)
if _backup(pth, bak_dir):
print(" backed up", name)
try:
merge_stats_db(os.path.join(out, "stats.db"), src, dst)
merge_highscores(os.path.join(out, "highscores.json"), src, dst)
merge_roster(os.path.join(out, "players.json"), src, dst)
merge_identity_file(out, src, dst, bak_dir)
except Exception as e:
print("FAILED:", e, file=sys.stderr)
print("Restore from", bak_dir, "if needed", file=sys.stderr)
return 1
print("DONE")
print(
" 1) grep DT_PLAYER /etc/dart-trainer/config.env # set to %s if it was %s"
% (dst, src)
)
print(" 2) restart dart-trainer (or flip mode via dart-switch if you use it)")
print(
" 3) verify: python3 -c \"import sqlite3;print(sqlite3.connect("
"'%s/stats.db').execute("
"\\\"SELECT player,COUNT(*) FROM games WHERE lower(player) LIKE 'bj%%' "
"GROUP BY 1\\\").fetchall())\""
% out
)
print(
" Undo nsec: mv identities/%s identities/%s (also under backup dir)"
% (_identity_filename(dst), _identity_filename(src))
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment