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
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user