feat: build Stride local app with Garmin imports and friends-only sharing
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"""Download completed activities, preserving originals and decoded FIT data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import fitdecode
|
||||
from garminconnect import Garmin
|
||||
|
||||
MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
"""An actionable error with no provider response or credentials attached."""
|
||||
|
||||
|
||||
def private_directory(path: Path) -> None:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if os.name != "nt":
|
||||
path.chmod(0o700)
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: bytes) -> None:
|
||||
private_directory(path.parent)
|
||||
fd, name = tempfile.mkstemp(prefix=".pending-", dir=path.parent)
|
||||
temporary = Path(name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def json_default(value):
|
||||
if isinstance(value, datetime):
|
||||
# FIT timestamps are UTC; fitdecode returns naive datetime objects.
|
||||
return value.replace(tzinfo=UTC).isoformat() if value.tzinfo is None else value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, bytes):
|
||||
return value.hex()
|
||||
raise TypeError(f"Unsupported JSON type: {type(value).__name__}")
|
||||
|
||||
|
||||
def write_json(path: Path, value) -> None:
|
||||
atomic_write(path, json.dumps(value, indent=2, default=json_default).encode("utf-8"))
|
||||
|
||||
|
||||
def fit_payloads(payload: bytes) -> list[bytes]:
|
||||
"""Read bounded FIT members without extracting archive paths to disk."""
|
||||
if not payload or len(payload) > MAX_BYTES:
|
||||
raise SyncError("Download was empty or exceeded the 64 MiB limit.")
|
||||
if len(payload) >= 12 and payload[8:12] == b".FIT":
|
||||
return [payload]
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
members = [m for m in archive.infolist() if m.filename.lower().endswith(".fit")]
|
||||
if not members:
|
||||
raise SyncError(
|
||||
"Original download contains no FIT file (possibly a manual activity)."
|
||||
)
|
||||
if len(members) > 16 or sum(m.file_size for m in members) > MAX_BYTES:
|
||||
raise SyncError("FIT archive exceeds the extraction limit.")
|
||||
result = []
|
||||
for member in members:
|
||||
with archive.open(member) as stream:
|
||||
content = stream.read(MAX_BYTES + 1)
|
||||
if len(content) > MAX_BYTES:
|
||||
raise SyncError("FIT archive exceeds the extraction limit.")
|
||||
result.append(content)
|
||||
return result
|
||||
except (zipfile.BadZipFile, RuntimeError, NotImplementedError):
|
||||
raise SyncError("Original download is not a readable FIT or ZIP file.") from None
|
||||
|
||||
|
||||
def decode_fit(content: bytes) -> dict:
|
||||
counts = Counter()
|
||||
messages = {"session": [], "lap": [], "record": []}
|
||||
units = {}
|
||||
try:
|
||||
with fitdecode.FitReader(io.BytesIO(content), check_crc=fitdecode.CrcCheck.RAISE) as reader:
|
||||
for frame in reader:
|
||||
if not isinstance(frame, fitdecode.FitDataMessage):
|
||||
continue
|
||||
counts[frame.name] += 1
|
||||
if frame.name in messages:
|
||||
values = {}
|
||||
for field in frame.fields:
|
||||
values[field.name] = field.value
|
||||
if field.units:
|
||||
units[f"{frame.name}.{field.name}"] = field.units
|
||||
messages[frame.name].append(values)
|
||||
except (fitdecode.FitError, ValueError, TypeError, KeyError, OverflowError):
|
||||
raise SyncError("FIT decoding or CRC validation failed.") from None
|
||||
if not counts:
|
||||
raise SyncError("FIT file contains no data messages.")
|
||||
return {"message_counts": dict(counts), "units": units, **messages}
|
||||
|
||||
|
||||
def account_key(api: Garmin, domain: str) -> str:
|
||||
# Loaded by Garmin.login from the provider, never taken from an uploaded label.
|
||||
profile_id = getattr(api, "profile_id", None)
|
||||
identity = f"id:{profile_id}" if isinstance(profile_id, int) else api.display_name
|
||||
if not isinstance(identity, str) or not identity:
|
||||
raise SyncError("Garmin did not return an account identity.")
|
||||
return hashlib.sha256(f"{domain}:{identity}".encode()).hexdigest()[:24]
|
||||
|
||||
|
||||
def complete(directory: Path) -> bool:
|
||||
try:
|
||||
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
|
||||
files = manifest["files"]
|
||||
if manifest.get("version") != 1 or not files:
|
||||
return False
|
||||
for name, digest in files.items():
|
||||
if Path(name).name != name or "/" in name or "\\" in name:
|
||||
return False
|
||||
if hashlib.sha256((directory / name).read_bytes()).hexdigest() != digest:
|
||||
return False
|
||||
return True
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def save_activity(api: Garmin, metadata: dict, root: Path) -> tuple[str, int]:
|
||||
raw_id = metadata.get("activityId")
|
||||
if isinstance(raw_id, bool) or not str(raw_id).isascii() or not str(raw_id).isdigit():
|
||||
raise SyncError("Garmin returned an invalid activity ID.")
|
||||
activity_id = str(int(raw_id))
|
||||
if int(activity_id) <= 0:
|
||||
raise SyncError("Garmin returned an invalid activity ID.")
|
||||
directory = root / activity_id
|
||||
if complete(directory):
|
||||
return "skipped", 0
|
||||
payload = api.download_activity(activity_id, dl_fmt=Garmin.ActivityDownloadFormat.ORIGINAL)
|
||||
fits = fit_payloads(payload)
|
||||
decoded = [decode_fit(content) for content in fits]
|
||||
# Parse first; write a manifest last so incomplete imports are retried.
|
||||
originals_name = "original.fit" if payload[8:12] == b".FIT" else "original.zip"
|
||||
files = {originals_name: payload}
|
||||
files["activity.json"] = json.dumps(metadata, indent=2, default=json_default).encode()
|
||||
for index, (content, parsed) in enumerate(zip(fits, decoded, strict=True), start=1):
|
||||
files[f"activity-{index}.fit"] = content
|
||||
files[f"activity-{index}.json"] = json.dumps(
|
||||
parsed, indent=2, default=json_default
|
||||
).encode()
|
||||
for name, content in files.items():
|
||||
atomic_write(directory / name, content)
|
||||
records = sum(item["message_counts"].get("record", 0) for item in decoded)
|
||||
write_json(
|
||||
directory / "manifest.json",
|
||||
{
|
||||
"version": 1,
|
||||
"activity_id": activity_id,
|
||||
"imported_at": datetime.now(UTC),
|
||||
"fit_files": len(fits),
|
||||
"record_count": records,
|
||||
"files": {name: hashlib.sha256(content).hexdigest() for name, content in files.items()},
|
||||
},
|
||||
)
|
||||
return "downloaded", records
|
||||
|
||||
|
||||
def sync_activities(api: Garmin, root: Path, limit: int, activity_type: str | None) -> dict:
|
||||
if not 1 <= limit <= 1000:
|
||||
raise SyncError("Limit must be between 1 and 1000.")
|
||||
result = {"found": 0, "downloaded": 0, "skipped": 0, "failed": 0, "records": 0, "errors": []}
|
||||
offset = 0
|
||||
while offset < limit:
|
||||
page_size = min(50, limit - offset)
|
||||
page = api.get_activities(start=offset, limit=page_size, activitytype=activity_type)
|
||||
if not isinstance(page, list):
|
||||
raise SyncError("Garmin returned an unexpected activity list.")
|
||||
if not page:
|
||||
break
|
||||
for metadata in page[:page_size]:
|
||||
result["found"] += 1
|
||||
try:
|
||||
if not isinstance(metadata, dict):
|
||||
raise SyncError("Garmin returned invalid activity metadata.")
|
||||
state, records = save_activity(api, metadata, root)
|
||||
result[state] += 1
|
||||
result["records"] += records
|
||||
except SyncError as error:
|
||||
result["failed"] += 1
|
||||
result["errors"].append(str(error))
|
||||
offset += len(page)
|
||||
if len(page) < page_size:
|
||||
break
|
||||
return result
|
||||
Reference in New Issue
Block a user