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.middleware.cors import CORSMiddleware
|
||||
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
|
||||
|
||||
@@ -783,3 +783,46 @@ async def strava_reset(request: Request) -> JSONResponse:
|
||||
token["last_sync_at"] = last_ts
|
||||
save_token(dd, token)
|
||||
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.
|
||||
server: {
|
||||
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': {
|
||||
target: serveTarget,
|
||||
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 = () => {
|
||||
zipStatus.textContent = 'Upload failed — check your connection.';
|
||||
zipStatus.style.color = '#f87171';
|
||||
|
||||
@@ -27,5 +27,6 @@ def test_edit_app_has_routes():
|
||||
from bincio.edit.server import app
|
||||
paths = {r.path for r in app.routes}
|
||||
assert "/api/upload" in paths
|
||||
assert "/api/upload/strava-zip" in paths
|
||||
assert "/api/activity/{activity_id}" in paths
|
||||
assert "/api/strava/sync" in paths
|
||||
|
||||
Reference in New Issue
Block a user