fix strava import?

This commit is contained in:
Davide Scaini
2026-04-10 18:13:32 +02:00
parent e2765ea012
commit cf414a08ad
4 changed files with 122 additions and 37 deletions
+47 -19
View File
@@ -76,24 +76,19 @@ def ingest_parsed(
return activity_id return activity_id
def strava_sync( def strava_sync_iter(
data_dir: Path, data_dir: Path,
client_id: str, client_id: str,
client_secret: str, client_secret: str,
originals_dir: Optional[Path] = None, originals_dir: Optional[Path] = None,
) -> dict[str, Any]: ):
"""Fetch new Strava activities and ingest them into data_dir. """Generator version of strava_sync — yields progress dicts, then a final summary.
Args: Each yielded dict has a ``type`` key:
data_dir: Per-user data directory. - ``"fetching"`` — about to fetch the activity list from Strava
client_id: Strava OAuth client ID. - ``"progress"`` — one activity processed; keys: n, total, name, status ("imported"|"skipped"|"error")
client_secret: Strava OAuth client secret. - ``"done"`` — final summary; keys: imported, skipped, error_count, errors
- ``"error"`` — fatal error before processing started; key: message
Returns:
Dict with keys: ok, imported, skipped, error_count, errors.
Raises:
RuntimeError: If Strava credentials are missing or API calls fail.
""" """
import time import time
@@ -109,28 +104,36 @@ def strava_sync(
from bincio.extract.writer import make_activity_id from bincio.extract.writer import make_activity_id
if not client_id or not client_secret: if not client_id or not client_secret:
raise RuntimeError("Strava not configured (missing client_id or client_secret)") yield {"type": "error", "message": "Strava not configured"}
return
try: try:
token = ensure_fresh(data_dir, client_id, client_secret) token = ensure_fresh(data_dir, client_id, client_secret)
except StravaError as e: except StravaError as e:
raise RuntimeError(str(e)) from e yield {"type": "error", "message": str(e)}
return
yield {"type": "fetching"}
after: Optional[int] = token.get("last_sync_at") after: Optional[int] = token.get("last_sync_at")
try: try:
activities = fetch_activities(token["access_token"], after=after) activities = fetch_activities(token["access_token"], after=after)
except StravaError as e: except StravaError as e:
raise RuntimeError(str(e)) from e yield {"type": "error", "message": str(e)}
return
total = len(activities)
imported = 0 imported = 0
skipped = 0 skipped = 0
errors: list[str] = [] errors: list[str] = []
for meta in activities: for n, meta in enumerate(activities, 1):
name = meta.get("name", "Untitled")
try: try:
activity_id = make_activity_id(strava_meta_to_partial(meta)) activity_id = make_activity_id(strava_meta_to_partial(meta))
if (data_dir / "activities" / f"{activity_id}.json").exists(): if (data_dir / "activities" / f"{activity_id}.json").exists():
skipped += 1 skipped += 1
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "skipped"}
continue continue
streams = fetch_streams(token["access_token"], meta["id"]) streams = fetch_streams(token["access_token"], meta["id"])
if originals_dir is not None: if originals_dir is not None:
@@ -142,16 +145,41 @@ def strava_sync(
parsed = strava_to_parsed(meta, streams) parsed = strava_to_parsed(meta, streams)
ingest_parsed(parsed, data_dir, privacy="public", rdp_epsilon=0.0001) ingest_parsed(parsed, data_dir, privacy="public", rdp_epsilon=0.0001)
imported += 1 imported += 1
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "imported"}
except Exception as exc: except Exception as exc:
errors.append(f"{meta.get('id')}: {type(exc).__name__}") errors.append(f"{meta.get('id')}: {type(exc).__name__}")
yield {"type": "progress", "n": n, "total": total, "name": name, "status": "error"}
token["last_sync_at"] = int(time.time()) token["last_sync_at"] = int(time.time())
save_token(data_dir, token) save_token(data_dir, token)
return { yield {
"ok": True, "type": "done",
"imported": imported, "imported": imported,
"skipped": skipped, "skipped": skipped,
"error_count": len(errors), "error_count": len(errors),
"errors": errors[:5], "errors": errors[:5],
} }
def strava_sync(
data_dir: Path,
client_id: str,
client_secret: str,
originals_dir: Optional[Path] = None,
) -> dict[str, Any]:
"""Fetch new Strava activities and ingest them into data_dir.
Returns:
Dict with keys: ok, imported, skipped, error_count, errors.
Raises:
RuntimeError: If Strava credentials are missing or API calls fail.
"""
result: dict[str, Any] = {}
for event in strava_sync_iter(data_dir, client_id, client_secret, originals_dir):
if event["type"] == "error":
raise RuntimeError(event["message"])
if event["type"] == "done":
result = event
return {"ok": True, **{k: v for k, v in result.items() if k != "type"}}
+32 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -712,6 +712,37 @@ async def strava_callback(
return RedirectResponse(f"{site_origin}/?strava=connected") return RedirectResponse(f"{site_origin}/?strava=connected")
@app.get("/api/strava/sync/stream")
async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse:
"""SSE endpoint — streams per-activity progress then a final summary event."""
user = _require_user(bincio_session)
if not strava_client_id or not strava_client_secret:
raise HTTPException(400, "Strava not configured on this server")
dd = _get_data_dir() / user.handle
store_orig_setting = get_setting(_get_db(), "store_originals")
store_orig = store_orig_setting == "true"
originals_dir = (dd / "originals" / "strava") if store_orig else None
if originals_dir:
originals_dir.mkdir(parents=True, exist_ok=True)
from bincio.extract.ingest import strava_sync_iter
def event_stream():
try:
for event in strava_sync_iter(dd, strava_client_id, strava_client_secret, originals_dir):
yield f"data: {json.dumps(event)}\n\n"
if event["type"] == "done":
_trigger_rebuild(user.handle)
except Exception as exc:
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.post("/api/strava/sync") @app.post("/api/strava/sync")
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = _require_user(bincio_session) user = _require_user(bincio_session)
+33 -8
View File
@@ -561,26 +561,51 @@ try {
} }
}); });
stravaSyncBtn.addEventListener('click', async () => { stravaSyncBtn.addEventListener('click', () => {
stravaSyncBtn.disabled = true; stravaSyncBtn.disabled = true;
stravaSyncBtn.textContent = 'Syncing…'; stravaSyncBtn.textContent = 'Syncing…';
stravaStatus.textContent = ''; stravaStatus.textContent = '';
try { stravaStatus.style.color = '';
const r = await fetch(`${editUrl}/api/strava/sync`, { method: 'POST' });
if (!r.ok) throw new Error(await r.text()); const es = new EventSource(`${editUrl}/api/strava/sync/stream`, { withCredentials: true });
const d = await r.json(); let imported = 0;
es.onmessage = (e) => {
const d = JSON.parse(e.data);
if (d.type === 'fetching') {
stravaStatus.textContent = 'Fetching activity list from Strava…';
} else if (d.type === 'progress') {
const pct = Math.round((d.n / d.total) * 100);
const icon = d.status === 'imported' ? '↓' : d.status === 'error' ? '✗' : '·';
stravaStatus.textContent = `${icon} ${d.n}/${d.total} (${pct}%) — ${d.name}`;
if (d.status === 'imported') imported++;
} else if (d.type === 'done') {
es.close();
stravaLastSync.textContent = new Date().toLocaleString(); stravaLastSync.textContent = new Date().toLocaleString();
const errNote = d.error_count ? `, ${d.error_count} errors` : ''; const errNote = d.error_count ? `, ${d.error_count} errors` : '';
stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`; stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`;
stravaStatus.style.color = '#4ade80'; stravaStatus.style.color = '#4ade80';
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
if (d.imported > 0) setTimeout(() => window.location.reload(), 1500); if (d.imported > 0) setTimeout(() => window.location.reload(), 1500);
} catch (e) { } else if (d.type === 'error') {
stravaStatus.textContent = 'Error: ' + e.message; es.close();
stravaStatus.textContent = 'Error: ' + d.message;
stravaStatus.style.color = '#f87171'; stravaStatus.style.color = '#f87171';
} finally {
stravaSyncBtn.disabled = false; stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now'; stravaSyncBtn.textContent = 'Sync now';
} }
};
es.onerror = () => {
es.close();
if (stravaSyncBtn.disabled) {
stravaStatus.textContent = 'Connection lost. Check logs.';
stravaStatus.style.color = '#f87171';
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
}
};
}); });
async function stravaReset(mode) { async function stravaReset(mode) {
+1
View File
@@ -18,6 +18,7 @@ def test_serve_app_has_routes():
assert "/api/strava/auth-url" in paths assert "/api/strava/auth-url" in paths
assert "/api/strava/callback" in paths assert "/api/strava/callback" in paths
assert "/api/strava/sync" in paths assert "/api/strava/sync" in paths
assert "/api/strava/sync/stream" in paths
assert "/api/register" in paths assert "/api/register" in paths