"""Headless multi-user Garmin sync — designed to run as a systemd timer. For each user directory that contains garmin_creds.json, tries to refresh the cached garth OAuth2 session (fast, no full login), falls back to a full email/password re-login only when the session has expired, then fetches and ingests new activities via garmin_sync_iter. After all users are synced, optionally POSTs to a server endpoint to trigger an Astro rebuild + rsync. """ from __future__ import annotations import json import logging import urllib.error import urllib.request from datetime import datetime, timezone from pathlib import Path import click _STATUS_FILE = "_garmin_sync_status.json" log = logging.getLogger("bincio.sync_garmin") def _write_status( user_dir: Path, status: str, imported: int, errors: int, error_message: str | None = None, ) -> None: payload: dict = { "status": status, "imported": imported, "errors": errors, "last_run": datetime.now(timezone.utc).isoformat(), } if error_message is not None: payload["error_message"] = error_message try: (user_dir / _STATUS_FILE).write_text( json.dumps(payload, indent=2), encoding="utf-8" ) except Exception: pass def _post_rebuild(url: str, secret: str | None) -> None: headers: dict[str, str] = {"Content-Type": "application/json"} if secret: headers["X-Sync-Secret"] = secret req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=10) as resp: log.info("rebuild triggered: HTTP %d", resp.status) except urllib.error.HTTPError as exc: log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100]) except Exception as exc: log.error("rebuild trigger failed: %s", exc) def sync_user(data_dir: Path, user_dir: Path) -> tuple[int, int]: """Sync one user's Garmin activities. Returns (imported_count, error_count). Skips silently if no credentials. """ from bincio.extract.garmin_api import GarminError, has_credentials, get_client from bincio.extract.garmin_sync import run_garmin_sync handle = user_dir.name if not has_credentials(user_dir): log.debug("sync[%s]: no garmin_creds.json — skipped", handle) _write_status(user_dir, "no_credentials", 0, 0) return 0, 0 # Explicit auth step so we can distinguish auth failures from API failures. try: get_client(data_dir, user_dir) except GarminError as exc: log.error("sync[%s]: auth failed: %s", handle, exc) _write_status(user_dir, "auth_error", 0, 1, str(exc)) return 0, 1 try: result = run_garmin_sync(data_dir, user_dir) except RuntimeError as exc: log.error("sync[%s]: sync failed: %s", handle, exc) _write_status(user_dir, "api_error", 0, 1, str(exc)) return 0, 1 imported = result.get("imported", 0) error_count = result.get("error_count", 0) log.info("sync[%s]: done — %d imported, %d errors", handle, imported, error_count) _write_status(user_dir, "ok", imported, error_count) return imported, error_count def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]: """Sync all users that have garmin_creds.json. Returns {handle: (imported, errors)}.""" results: dict[str, tuple[int, int]] = {} cred_files = sorted(root_data_dir.glob("*/garmin_creds.json")) if not cred_files: log.info("sync_all: no users with garmin_creds.json found in %s", root_data_dir) return results log.info("sync_all: %d user(s) with Garmin credentials", len(cred_files)) for cf in cred_files: user_dir = cf.parent handle = user_dir.name try: results[handle] = sync_user(root_data_dir, user_dir) except Exception as exc: log.exception("sync_all[%s]: unexpected error: %s", handle, exc) results[handle] = (0, -1) return results @click.command("sync-garmin") @click.option("--data-dir", "data_dir_str", required=True, help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).") @click.option("--user", "only_user", default=None, help="Sync only this handle instead of all users.") @click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL", help="POST here after a successful sync to trigger a site rebuild.") @click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Value sent as X-Sync-Secret header to the rebuild endpoint.") def sync_garmin_cmd( data_dir_str: str, only_user: str | None, rebuild_url: str | None, rebuild_secret: str | None, ) -> None: """Headless Garmin sync for all users (designed for systemd timer). Discovers every user directory that has garmin_creds.json, tries to resume the cached garth session (no full re-login if the token is still valid), fetches new activities, and optionally triggers a site rebuild. """ logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) root = Path(data_dir_str).expanduser().resolve() if not root.is_dir(): raise click.ClickException(f"Data dir not found: {root}") if only_user: user_dir = root / only_user if not user_dir.is_dir(): raise click.ClickException(f"User dir not found: {user_dir}") new_count, err_count = sync_user(root, user_dir) click.echo(f"{only_user}: {new_count} imported, {err_count} errors") total_new = new_count else: results = sync_all(root) total_new = sum(n for n, _ in results.values()) total_err = sum(e for _, e in results.values()) click.echo( f"Sync complete: {len(results)} users, " f"{total_new} new activities, {total_err} errors" ) if total_new > 0 and rebuild_url: _post_rebuild(rebuild_url, rebuild_secret)