fix strava import?
This commit is contained in:
+47
-19
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user