sync strava data from web ui

This commit is contained in:
Davide Scaini
2026-04-06 12:38:41 +02:00
parent ad2710e759
commit 17f36889f3
5 changed files with 605 additions and 34 deletions
+116 -1
View File
@@ -5,16 +5,19 @@ from __future__ import annotations
import json
import re
import shutil
import time
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
# Populated by the CLI before uvicorn starts
data_dir: Path | None = None
site_url: str = "http://localhost:4321"
strava_client_id: str = ""
strava_client_secret: str = ""
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
@@ -618,3 +621,115 @@ async def delete_image(activity_id: str, filename: str) -> JSONResponse:
if not any(target.parent.iterdir()):
shutil.rmtree(target.parent)
return JSONResponse({"ok": True})
# ── Strava sync ───────────────────────────────────────────────────────────────
@app.get("/api/strava/status")
async def strava_status() -> JSONResponse:
"""Return whether Strava is configured and whether a token is stored."""
dd = _get_data_dir()
from bincio.extract.strava_api import load_token
token = load_token(dd)
return JSONResponse({
"configured": bool(strava_client_id),
"connected": token is not None,
"last_sync": token.get("last_sync_at") if token else None,
})
@app.get("/api/strava/auth-url")
async def strava_auth_url(request: Request) -> JSONResponse:
"""Return the Strava OAuth URL the browser should open."""
if not strava_client_id:
raise HTTPException(400, "Strava client ID not configured. Pass --strava-client-id to bincio edit.")
redirect_uri = str(request.url_for("strava_callback"))
from bincio.extract.strava_api import auth_url
return JSONResponse({"url": auth_url(strava_client_id, redirect_uri)})
@app.get("/api/strava/callback", name="strava_callback")
async def strava_callback(code: str = "", error: str = "") -> RedirectResponse:
"""Strava OAuth callback — exchange code for token then redirect to the site."""
if error or not code:
return RedirectResponse(f"{site_url}?strava=error")
if not strava_client_id or not strava_client_secret:
return RedirectResponse(f"{site_url}?strava=error")
dd = _get_data_dir()
from bincio.extract.strava_api import StravaError, exchange_code, save_token
try:
token = exchange_code(strava_client_id, strava_client_secret, code)
except StravaError:
return RedirectResponse(f"{site_url}?strava=error")
# Stamp last_sync_at at connect time so the first sync only fetches new activities
token.setdefault("last_sync_at", int(time.time()))
save_token(dd, token)
return RedirectResponse(f"{site_url}?strava=connected")
@app.post("/api/strava/sync")
async def strava_sync() -> JSONResponse:
"""Fetch new Strava activities since last sync and add them to the data store."""
if not strava_client_id or not strava_client_secret:
raise HTTPException(400, "Strava not configured. Pass --strava-client-id and --strava-client-secret to bincio edit.")
dd = _get_data_dir()
from bincio.extract.strava_api import (
StravaError, ensure_fresh, fetch_activities, fetch_streams,
save_token, strava_to_parsed,
)
try:
token = ensure_fresh(dd, strava_client_id, strava_client_secret)
except StravaError as e:
raise HTTPException(502, str(e))
after: int | None = token.get("last_sync_at")
try:
activities = fetch_activities(token["access_token"], after=after)
except StravaError as e:
raise HTTPException(502, str(e))
from bincio.extract.metrics import compute
from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index
from bincio.extract.strava_api import strava_meta_to_partial
from bincio.render.merge import merge_all
# Load existing index once
index_path = dd / "index.json"
if index_path.exists():
index_data = json.loads(index_path.read_text(encoding="utf-8"))
else:
index_data = {"owner": {"handle": "unknown"}, "activities": []}
owner = index_data.get("owner", {})
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
imported = 0
skipped = 0
errors: list[str] = []
for meta in activities:
try:
# Compute ID from meta alone (no API call) to skip already-known activities
activity_id = make_activity_id(strava_meta_to_partial(meta))
if (dd / "activities" / f"{activity_id}.json").exists():
skipped += 1
continue
# Only fetch streams for genuinely new activities
streams = fetch_streams(token["access_token"], meta["id"])
parsed = strava_to_parsed(meta, streams)
metrics = compute(parsed)
write_activity(parsed, metrics, dd, privacy="public", rdp_epsilon=0.0001)
summaries[activity_id] = build_summary(parsed, metrics, activity_id, "public")
imported += 1
except Exception as exc:
errors.append(f"{meta.get('id')}: {type(exc).__name__}")
if imported:
write_index(list(summaries.values()), dd, owner)
merge_all(dd)
token["last_sync_at"] = int(time.time())
save_token(dd, token)
return JSONResponse({"ok": True, "imported": imported, "skipped": skipped, "errors": errors[:5]})