upload zip archive from strava
This commit is contained in:
+44
-1
@@ -11,7 +11,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||||
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 HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
|
||||||
|
|
||||||
from bincio.edit.ops import SPORTS, STAT_PANELS, VALID_ACTIVITY_ID
|
from bincio.edit.ops import SPORTS, STAT_PANELS, VALID_ACTIVITY_ID
|
||||||
|
|
||||||
@@ -783,3 +783,46 @@ async def strava_reset(request: Request) -> JSONResponse:
|
|||||||
token["last_sync_at"] = last_ts
|
token["last_sync_at"] = last_ts
|
||||||
save_token(dd, token)
|
save_token(dd, token)
|
||||||
return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts})
|
return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/upload/strava-zip")
|
||||||
|
async def upload_strava_zip(file: UploadFile = File(...)) -> StreamingResponse:
|
||||||
|
"""Accept a Strava bulk export ZIP and stream SSE progress while processing.
|
||||||
|
|
||||||
|
The ZIP is written to a temp file, processed activity-by-activity, then deleted.
|
||||||
|
Originals are never kept — the UI informs the user of this upfront.
|
||||||
|
"""
|
||||||
|
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||||||
|
raise HTTPException(400, "Please upload a .zip file")
|
||||||
|
|
||||||
|
dd = _get_data_dir()
|
||||||
|
import tempfile
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=dd)
|
||||||
|
zip_path = Path(tmp.name)
|
||||||
|
try:
|
||||||
|
while chunk := await file.read(1024 * 1024): # 1 MB chunks
|
||||||
|
tmp.write(chunk)
|
||||||
|
finally:
|
||||||
|
tmp.close()
|
||||||
|
|
||||||
|
from bincio.extract.strava_zip import strava_zip_iter
|
||||||
|
from bincio.render.merge import merge_all
|
||||||
|
|
||||||
|
def event_stream():
|
||||||
|
any_imported = False
|
||||||
|
try:
|
||||||
|
for event in strava_zip_iter(zip_path, dd):
|
||||||
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
if event.get("type") == "progress" and event.get("status") == "imported":
|
||||||
|
any_imported = True
|
||||||
|
if event.get("type") == "done" and any_imported:
|
||||||
|
merge_all(dd)
|
||||||
|
except Exception as exc:
|
||||||
|
zip_path.unlink(missing_ok=True)
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,6 +25,27 @@ export default defineConfig({
|
|||||||
// In production nginx handles this — same pattern, no code change needed.
|
// In production nginx handles this — same pattern, no code change needed.
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// SSE response to a POST — Vite's default proxy buffers the full body before
|
||||||
|
// forwarding, which breaks streaming and can cause EPIPE on long uploads.
|
||||||
|
// selfHandleResponse + manual pipe sends chunks as they arrive.
|
||||||
|
'/api/upload/strava-zip': {
|
||||||
|
target: serveTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
selfHandleResponse: true,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||||
|
res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
|
||||||
|
proxyRes.pipe(res, { end: true });
|
||||||
|
});
|
||||||
|
proxy.on('error', (err, _req, res) => {
|
||||||
|
if (err.code === 'EPIPE' || err.code === 'ECONNRESET') return;
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502);
|
||||||
|
res.end('proxy error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: serveTarget,
|
target: serveTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
@@ -732,6 +732,17 @@ try {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
// Fires when the request completes. If we already got a 'done' or 'error'
|
||||||
|
// SSE event via onprogress the status is already set. If not (e.g. a non-SSE
|
||||||
|
// error response), surface the failure.
|
||||||
|
if (xhr.status !== 200) {
|
||||||
|
zipStatus.textContent = `Upload failed (${xhr.status}).`;
|
||||||
|
zipStatus.style.color = '#f87171';
|
||||||
|
zipInput.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
zipStatus.textContent = 'Upload failed — check your connection.';
|
zipStatus.textContent = 'Upload failed — check your connection.';
|
||||||
zipStatus.style.color = '#f87171';
|
zipStatus.style.color = '#f87171';
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ def test_edit_app_has_routes():
|
|||||||
from bincio.edit.server import app
|
from bincio.edit.server import app
|
||||||
paths = {r.path for r in app.routes}
|
paths = {r.path for r in app.routes}
|
||||||
assert "/api/upload" in paths
|
assert "/api/upload" in paths
|
||||||
|
assert "/api/upload/strava-zip" in paths
|
||||||
assert "/api/activity/{activity_id}" in paths
|
assert "/api/activity/{activity_id}" in paths
|
||||||
assert "/api/strava/sync" in paths
|
assert "/api/strava/sync" in paths
|
||||||
|
|||||||
Reference in New Issue
Block a user