feat: Strava support — bulk-export .gz/.tcx import fix + .tcx parser; full Strava API OAuth live sync (activities via streams) with Profile connect UI; colour gym/no-GPS sports red
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s

This commit is contained in:
2026-06-21 10:18:09 +01:00
parent e8615dd12d
commit 07139120df
12 changed files with 1169 additions and 116 deletions
+41 -5
View File
@@ -74,6 +74,33 @@ def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]:
return extracted
def _gunzip(path: Path) -> Path | None:
"""Decompress a .gz member to a sibling file without the .gz suffix,
enforcing the same uncompressed-size cap. Returns the new path, or None on
failure. The .gz is removed once expanded."""
import gzip
out_path = path.with_suffix("") # strips the trailing .gz
try:
total = 0
with gzip.open(path, "rb") as src, open(out_path, "wb") as out:
while True:
chunk = src.read(_CHUNK)
if not chunk:
break
total += len(chunk)
if total > MAX_EXTRACT_SIZE:
out.close()
out_path.unlink(missing_ok=True)
return None
out.write(chunk)
except (OSError, EOFError):
out_path.unlink(missing_ok=True)
return None
finally:
path.unlink(missing_ok=True)
return out_path
@router.post("/activity")
async def upload_activity(
file: UploadFile = File(...),
@@ -81,10 +108,10 @@ async def upload_activity(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a single .fit or .gpx activity file."""
"""Upload a single .fit, .gpx or .tcx activity file."""
suffix = Path(file.filename).suffix.lower()
if suffix not in {".fit", ".gpx"}:
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
if suffix not in {".fit", ".gpx", ".tcx"}:
raise HTTPException(status_code=400, detail="Only .fit, .gpx and .tcx files are supported")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
dest = save_upload(file, dest_dir)
@@ -184,15 +211,24 @@ async def upload_strava_export(
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
for path in extracted:
# Strava compresses most exported activities as <id>.fit.gz / .gpx.gz /
# .tcx.gz. Decompress those to their underlying file first, otherwise the
# gzipped majority of the archive is silently skipped.
if path.suffix.lower() == ".gz":
inner = _gunzip(path)
if inner is None:
continue
path = inner
suffix = path.suffix.lower()
if suffix in (".fit", ".gpx"):
if suffix in (".fit", ".gpx", ".tcx"):
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
task_ids.append(task.id)
if not task_ids:
raise HTTPException(
status_code=400,
detail="No activity files (.fit or .gpx) found in this Strava archive",
detail="No activity files (.fit, .gpx or .tcx) found in this Strava archive",
)
return {