"""Leaderboard backend for Reachy Mini Simon JS. Single endpoint: POST /score. Validates the caller's HF OAuth token, keeps one best score per (username, difficulty), persists the merged table back to the public dataset `apirrone/reachy-mini-simon-scores` as `scores.json`. Reads happen client-side directly against the dataset CDN — no need to round-trip through here for the leaderboard render. """ import json import os import threading from datetime import datetime, timezone import httpx from fastapi import FastAPI, Header, HTTPException from fastapi.middleware.cors import CORSMiddleware from huggingface_hub import HfApi from pydantic import BaseModel, Field # ─── Config ────────────────────────────────────────────────────────── DATASET_REPO = os.environ.get("DATASET_REPO", "apirrone/reachy-mini-simon-scores") SCORES_PATH = "scores.json" HF_TOKEN = os.environ.get("HF_TOKEN") # set as Space secret DATASET_URL = f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/{SCORES_PATH}" DIFFICULTIES = {"1", "2", "3"} MAX_ENTRIES = 100 # keep table bounded # ─── App ───────────────────────────────────────────────────────────── app = FastAPI(title="Reachy Mini Simon — leaderboard") app.add_middleware( CORSMiddleware, allow_origins=["*"], # Spaces serve under their own origin allow_methods=["*"], allow_headers=["*"], ) api = HfApi(token=HF_TOKEN) if HF_TOKEN else None _write_lock = threading.Lock() # serialise commits to the dataset _cache: dict = {} # latest table, freshly loaded on boot # ─── Models ────────────────────────────────────────────────────────── class ScorePayload(BaseModel): difficulty: str score: int display_name: str = "" avatar_url: str = "" class ScoreEntry(BaseModel): u: str = Field(..., description="HF username (slug)") n: str = Field("", description="display name") a: str = Field("", description="avatar URL") s: int = Field(0, description="score (best round reached)") d: str = Field("", description="ISO timestamp") # ─── Helpers ───────────────────────────────────────────────────────── def _load_from_hub() -> dict: try: r = httpx.get(DATASET_URL, timeout=10.0) if r.status_code == 200: return r.json() except Exception as e: print(f"[leaderboard] could not load {DATASET_URL}: {e}") return {} def _commit(data: dict) -> None: if not api: print("[leaderboard] HF_TOKEN missing — skipping commit") return body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") api.upload_file( path_or_fileobj=body, path_in_repo=SCORES_PATH, repo_id=DATASET_REPO, repo_type="dataset", commit_message="update leaderboard", ) async def _whoami(token: str) -> dict: async with httpx.AsyncClient(timeout=10.0) as client: r = await client.get( "https://huggingface.co/api/whoami-v2", headers={"Authorization": f"Bearer {token}"}, ) if r.status_code != 200: raise HTTPException(401, "Invalid HF token") return r.json() # ─── Boot ──────────────────────────────────────────────────────────── @app.on_event("startup") def _boot() -> None: global _cache _cache = _load_from_hub() print(f"[leaderboard] loaded {sum(len(v) for v in _cache.values())} entries") # ─── Routes ────────────────────────────────────────────────────────── @app.get("/") def root(): return {"status": "ok", "dataset": DATASET_URL} @app.post("/score") async def post_score(payload: ScorePayload, authorization: str = Header(default="")): if not authorization.startswith("Bearer "): raise HTTPException(401, "Missing Bearer token") token = authorization[len("Bearer "):].strip() if payload.difficulty not in DIFFICULTIES: raise HTTPException(400, "Invalid difficulty") if not (0 < payload.score < 1000): raise HTTPException(400, "Invalid score") me = await _whoami(token) username = me.get("name") or me.get("preferred_username") or "" if not username: raise HTTPException(401, "Cannot resolve username") with _write_lock: bucket = _cache.setdefault(payload.difficulty, []) mine = next((e for e in bucket if e.get("u") == username), None) if mine and mine["s"] >= payload.score: return {"status": "not_best", "entries": bucket} entry = { "u": username, "n": payload.display_name or username, "a": payload.avatar_url or "", "s": int(payload.score), "d": datetime.now(timezone.utc).isoformat(timespec="seconds"), } if mine: bucket.remove(mine) bucket.append(entry) bucket.sort(key=lambda e: e["s"], reverse=True) del bucket[MAX_ENTRIES:] snapshot = {k: list(v) for k, v in _cache.items()} # Commit outside the lock — network can be slow. try: _commit(snapshot) except Exception as e: print(f"[leaderboard] commit failed: {e}") # The score is still in memory; next successful write will catch up. return {"status": "saved", "entries": _cache[payload.difficulty]}