This commit is contained in:
2025-11-17 06:26:37 -08:00
parent afba5973d2
commit 4f9221f5d4
6 changed files with 504 additions and 57 deletions

View File

@@ -26,6 +26,15 @@ A comprehensive Python application for analyzing Garmin workout data from FIT, T
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### Database Setup (New Feature)
The application now uses SQLite with Alembic for database migrations to track downloaded activities. To initialize the database:
```bash
# Run database migrations
alembic upgrade head
```
### Optional Dependencies ### Optional Dependencies
For PDF report generation: For PDF report generation:
@@ -59,11 +68,26 @@ Download all cycling activities from Garmin Connect:
python main.py download --all --limit 100 --output-dir data/garmin_downloads python main.py download --all --limit 100 --output-dir data/garmin_downloads
``` ```
Download only missing activities (not already in database or filesystem):
```bash
python main.py download --missing --output-dir data/garmin_downloads
```
Dry-run to see what would be downloaded without actually downloading:
```bash
python main.py download --missing --dry-run --output-dir data/garmin_downloads
```
Re-analyze previously downloaded workouts: Re-analyze previously downloaded workouts:
```bash ```bash
python main.py reanalyze --input-dir data/garmin_downloads --output-dir reports/reanalysis --charts --report python main.py reanalyze --input-dir data/garmin_downloads --output-dir reports/reanalysis --charts --report
``` ```
Force re-download of specific activity (bypasses database tracking):
```bash
python main.py download --workout-id 123456789 --force
```
Show current configuration: Show current configuration:
```bash ```bash
python main.py config --show python main.py config --show
@@ -122,6 +146,9 @@ options:
Report format Report format
--charts Generate charts --charts Generate charts
--report Generate comprehensive report --report Generate comprehensive report
--force Force download even if activity already exists in database
--missing Download only activities not already in database or filesystem
--dry-run Show what would be downloaded without actually downloading
``` ```
### Configuration: ### Configuration:
@@ -171,6 +198,40 @@ Note on app passwords:
Parity and unaffected behavior: Parity and unaffected behavior:
- Authentication and download parity is maintained. Original ZIP downloads and FIT extraction workflows are unchanged in [clients/garmin_client.py](clients/garmin_client.py). - Authentication and download parity is maintained. Original ZIP downloads and FIT extraction workflows are unchanged in [clients/garmin_client.py](clients/garmin_client.py).
- Alternate format downloads (FIT, TCX, GPX) are unaffected by this credentials change. - Alternate format downloads (FIT, TCX, GPX) are unaffected by this credentials change.
## Database Tracking
The application now tracks downloaded activities in a SQLite database (`garmin_analyser.db`) to avoid redundant downloads and provide download history.
### Database Schema
The database tracks:
- Activity ID and metadata
- Download status and timestamps
- File checksums and sizes
- Error information for failed downloads
### Database Location
By default, the database is stored at:
- `garmin_analyser.db` in the project root directory
### Migration Commands
```bash
# Initialize database schema
alembic upgrade head
# Create new migration (for developers)
alembic revision --autogenerate -m "description"
# Check migration status
alembic current
# Downgrade database
alembic downgrade -1
```
## Configuration ## Configuration
### Basic Configuration ### Basic Configuration
@@ -306,6 +367,15 @@ python main.py --garmin-connect --report --charts --summary
# Download specific period # Download specific period
python main.py --garmin-connect --report --output-dir reports/january/ python main.py --garmin-connect --report --output-dir reports/january/
# Download only missing activities (smart sync)
python main.py download --missing --output-dir data/garmin_downloads
# Preview what would be downloaded (dry-run)
python main.py download --missing --dry-run --output-dir data/garmin_downloads
# Force re-download of all activities (bypass database)
python main.py download --all --force --output-dir data/garmin_downloads
``` ```
## Output Structure ## Output Structure
@@ -324,6 +394,9 @@ output/
│ └── summary_report_20240115_143022.html │ └── summary_report_20240115_143022.html
└── logs/ └── logs/
└── garmin_analyser.log └── garmin_analyser.log
garmin_analyser.db # SQLite database for download tracking
alembic/ # Database migration scripts
``` ```
## Analysis Features ## Analysis Features
@@ -442,6 +515,10 @@ def generate_custom_chart(self, workout: WorkoutData, analysis: dict) -> str:
- For large datasets, use batch processing - For large datasets, use batch processing
- Consider using `--summary` flag for multiple files - Consider using `--summary` flag for multiple files
**Database Issues**
- If database becomes corrupted, delete `garmin_analyser.db` and run `alembic upgrade head`
- Check database integrity: `sqlite3 garmin_analyser.db "PRAGMA integrity_check;"`
### Debug Mode ### Debug Mode
Enable verbose logging for troubleshooting: Enable verbose logging for troubleshooting:

View File

@@ -6,21 +6,104 @@ import zipfile
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
import logging import logging
import hashlib
from datetime import datetime
import time
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
try: try:
from garminconnect import Garmin from garminconnect import Garmin
except ImportError: except ImportError:
raise ImportError("garminconnect package required. Install with: pip install garminconnect") raise ImportError("garminconnect package required. Install with: pip install garminconnect")
from config.settings import get_garmin_credentials, DATA_DIR from config.settings import get_garmin_credentials, DATA_DIR, DATABASE_URL
from db.models import ActivityDownload
from db.session import SessionLocal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def calculate_sha256(file_path: Path) -> str:
"""Calculate the SHA256 checksum of a file."""
hasher = hashlib.sha256()
with open(file_path, 'rb') as f:
while True:
chunk = f.read(8192) # Read in 8KB chunks
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
def upsert_activity_download(
activity_id: int,
source: str,
file_path: Path,
file_format: str,
status: str,
http_status: Optional[int] = None,
etag: Optional[str] = None,
last_modified: Optional[datetime] = None,
size_bytes: Optional[int] = None,
checksum_sha256: Optional[str] = None,
error_message: Optional[str] = None,
db_session: Optional[Session] = None,
):
"""Upsert an activity download record in the database."""
if db_session is not None:
db = db_session
close_session = False
else:
db = SessionLocal()
close_session = True
try:
record = db.query(ActivityDownload).filter_by(activity_id=activity_id).first()
if record:
record.source = source
record.file_path = str(file_path)
record.file_format = file_format
record.status = status
record.http_status = http_status
record.etag = etag
record.last_modified = last_modified
record.size_bytes = size_bytes
record.checksum_sha256 = checksum_sha256
record.updated_at = datetime.utcnow()
record.error_message = error_message
else:
record = ActivityDownload(
activity_id=activity_id,
source=source,
file_path=str(file_path),
file_format=file_format,
status=status,
http_status=http_status,
etag=etag,
last_modified=last_modified,
size_bytes=size_bytes,
checksum_sha256=checksum_sha256,
downloaded_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
error_message=error_message,
)
db.add(record)
db.commit()
db.refresh(record)
finally:
if close_session:
db.close()
return record
class GarminClient: class GarminClient:
"""Client for interacting with Garmin Connect API.""" """Client for interacting with Garmin Connect API."""
def __init__(self, email: Optional[str] = None, password: Optional[str] = None): def __init__(self, email: Optional[str] = None, password: Optional[str] = None, db_session: Optional[Session] = None):
"""Initialize Garmin client. """Initialize Garmin client.
Args: Args:
@@ -33,6 +116,8 @@ class GarminClient:
else: else:
self.email, self.password = get_garmin_credentials() self.email, self.password = get_garmin_credentials()
self.db_session = db_session if db_session else SessionLocal()
self.client = None self.client = None
self._authenticated = False self._authenticated = False
@@ -117,12 +202,15 @@ class GarminClient:
logger.error(f"Failed to get activity {activity_id}: {e}") logger.error(f"Failed to get activity {activity_id}: {e}")
return None return None
def download_activity_file(self, activity_id: str, file_format: str = "fit") -> Optional[Path]: def download_activity_file(
self, activity_id: str, file_format: str = "fit", force_download: bool = False
) -> Optional[Path]:
"""Download activity file in specified format. """Download activity file in specified format.
Args: Args:
activity_id: Garmin activity ID activity_id: Garmin activity ID
file_format: File format to download (fit, tcx, gpx, csv, original) file_format: File format to download (fit, tcx, gpx, csv, original)
force_download: If True, bypasses database checks and forces a re-download.
Returns: Returns:
Path to downloaded file or None if download failed Path to downloaded file or None if download failed
@@ -155,7 +243,9 @@ class GarminClient:
# FIT is not a direct dl_fmt in some client versions; use ORIGINAL to obtain ZIP and extract .fit # FIT is not a direct dl_fmt in some client versions; use ORIGINAL to obtain ZIP and extract .fit
if fmt_upper in {"FIT", "ORIGINAL"} or file_format.lower() == "fit": if fmt_upper in {"FIT", "ORIGINAL"} or file_format.lower() == "fit":
fit_path = self.download_activity_original(activity_id) fit_path = self.download_activity_original(
activity_id, force_download=force_download
)
return fit_path return fit_path
logger.error(f"Unsupported download format '{file_format}'. Valid: GPX, TCX, ORIGINAL, CSV") logger.error(f"Unsupported download format '{file_format}'. Valid: GPX, TCX, ORIGINAL, CSV")
@@ -165,11 +255,13 @@ class GarminClient:
logger.error(f"Failed to download activity {activity_id}: {e}") logger.error(f"Failed to download activity {activity_id}: {e}")
return None return None
def download_activity_original(self, activity_id: str) -> Optional[Path]: def download_activity_original(self, activity_id: str, force_download: bool = False, db_session: Optional[Session] = None) -> Optional[Path]:
"""Download original activity file (usually FIT format). """Download original activity file (usually FIT format).
Args: Args:
activity_id: Garmin activity ID activity_id: Garmin activity ID
force_download: If True, bypasses database checks and forces a re-download.
db_session: Optional SQLAlchemy session to use for database operations.
Returns: Returns:
Path to downloaded file or None if download failed Path to downloaded file or None if download failed
@@ -178,6 +270,33 @@ class GarminClient:
if not self.authenticate(): if not self.authenticate():
return None return None
db = db_session if db_session else self.db_session
if not db:
db = SessionLocal()
close_session = True
else:
close_session = False
try:
# Check database for existing record unless force_download is True
if not force_download:
record = db.query(ActivityDownload).filter_by(activity_id=int(activity_id)).first()
if record and record.status == "success" and Path(record.file_path).exists():
current_checksum = calculate_sha256(Path(record.file_path))
if current_checksum == record.checksum_sha256:
logger.info(f"Activity {activity_id} already downloaded and verified; skipping.")
return Path(record.file_path)
else:
logger.warning(f"Checksum mismatch for activity {activity_id}. Re-downloading.")
finally:
if close_session:
db.close()
download_status = "failed"
error_message = None
http_status = None
downloaded_path = None
try: try:
# Create data directory if it doesn't exist # Create data directory if it doesn't exist
DATA_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True)
@@ -249,7 +368,7 @@ class GarminClient:
logger.debug(f"{method_name}(activity_id, '{fmt}') succeeded, got data type: {type(file_data).__name__}") logger.debug(f"{method_name}(activity_id, '{fmt}') succeeded, got data type: {type(file_data).__name__}")
break break
except Exception as e: except Exception as e:
logger.debug(f"{method_name}(activity_id, '{fmt}') failed: {e} (type={type(e).__name__})") logger.debug(f"Attempting {method_name}(activity_id, '{fmt}') failed: {e} (type={type(e).__name__})")
file_data = None file_data = None
if file_data is not None: if file_data is not None:
break break
@@ -265,7 +384,7 @@ class GarminClient:
logger.debug(f"{method_name}(activity_id) succeeded, got data type: {type(file_data).__name__}") logger.debug(f"{method_name}(activity_id) succeeded, got data type: {type(file_data).__name__}")
break break
except Exception as e: except Exception as e:
logger.debug(f"{method_name}(activity_id) failed: {e} (type={type(e).__name__})") logger.debug(f"Attempting {method_name}(activity_id) failed: {e} (type={type(e).__name__})")
file_data = None file_data = None
if file_data is None: if file_data is None:
@@ -298,17 +417,30 @@ class GarminClient:
content_type = getattr(resp, "headers", {}).get("Content-Type", "") content_type = getattr(resp, "headers", {}).get("Content-Type", "")
logger.debug(f"HTTP fallback succeeded: status={status}, content-type='{content_type}', bytes={len(content)}") logger.debug(f"HTTP fallback succeeded: status={status}, content-type='{content_type}', bytes={len(content)}")
file_data = content file_data = content
http_status = status
break break
else: else:
logger.debug(f"HTTP fallback GET {url} returned status={status} or empty content") logger.debug(f"HTTP fallback GET {url} returned status={status} or empty content")
http_status = status
except Exception as e: except Exception as e:
logger.debug(f"HTTP fallback GET {url} failed: {e} (type={type(e).__name__})") logger.debug(f"HTTP fallback GET {url} failed: {e} (type={type(e).__name__})")
error_message = str(e)
if file_data is None: if file_data is None:
logger.error( logger.error(
f"Failed to obtain original/FIT data for activity {activity_id}. " f"Failed to obtain original/FIT data for activity {activity_id}. "
f"Attempts: {attempts}" f"Attempts: {attempts}"
) )
upsert_activity_download(
activity_id=int(activity_id),
source="garmin-connect",
file_path=DATA_DIR / f"activity_{activity_id}.fit", # Placeholder path
file_format="fit", # Assuming fit as target format
status="failed",
http_status=http_status,
error_message=error_message or f"All download attempts failed: {attempts}",
db_session=db
)
return None return None
# Normalize to raw bytes if response-like object returned # Normalize to raw bytes if response-like object returned
@@ -326,6 +458,16 @@ class GarminClient:
if not isinstance(file_data, (bytes, bytearray)): if not isinstance(file_data, (bytes, bytearray)):
logger.error(f"Downloaded data for activity {activity_id} is not bytes (type={type(file_data).__name__}); aborting") logger.error(f"Downloaded data for activity {activity_id} is not bytes (type={type(file_data).__name__}); aborting")
logger.debug(f"Data content: {repr(file_data)[:200]}") logger.debug(f"Data content: {repr(file_data)[:200]}")
upsert_activity_download(
activity_id=int(activity_id),
source="garmin-connect",
file_path=DATA_DIR / f"activity_{activity_id}.fit", # Placeholder path
file_format="fit", # Assuming fit as target format
status="failed",
http_status=http_status,
error_message=f"Downloaded data is not bytes: {type(file_data).__name__}",
db_session=db
)
return None return None
# Save to temporary file first # Save to temporary file first
@@ -334,6 +476,9 @@ class GarminClient:
tmp_path = Path(tmp_file.name) tmp_path = Path(tmp_file.name)
# Determine if the response is a ZIP archive (original) or a direct FIT file # Determine if the response is a ZIP archive (original) or a direct FIT file
file_format_detected = "fit" # Default to fit
extracted_path = DATA_DIR / f"activity_{activity_id}.fit" # Default path
if zipfile.is_zipfile(tmp_path): if zipfile.is_zipfile(tmp_path):
# Extract zip file # Extract zip file
with zipfile.ZipFile(tmp_path, 'r') as zip_ref: with zipfile.ZipFile(tmp_path, 'r') as zip_ref:
@@ -343,7 +488,6 @@ class GarminClient:
if fit_files: if fit_files:
# Extract the first FIT file # Extract the first FIT file
fit_filename = fit_files[0] fit_filename = fit_files[0]
extracted_path = DATA_DIR / f"activity_{activity_id}.fit"
with zip_ref.open(fit_filename) as source, open(extracted_path, 'wb') as target: with zip_ref.open(fit_filename) as source, open(extracted_path, 'wb') as target:
target.write(source.read()) target.write(source.read())
@@ -352,27 +496,60 @@ class GarminClient:
tmp_path.unlink() tmp_path.unlink()
logger.info(f"Downloaded original activity file: {extracted_path}") logger.info(f"Downloaded original activity file: {extracted_path}")
return extracted_path downloaded_path = extracted_path
download_status = "success"
else: else:
logger.warning("No FIT file found in downloaded archive") logger.warning("No FIT file found in downloaded archive")
tmp_path.unlink() tmp_path.unlink()
return None error_message = "No FIT file found in downloaded archive"
else: else:
# Treat data as direct FIT bytes # Treat data as direct FIT bytes
extracted_path = DATA_DIR / f"activity_{activity_id}.fit"
try: try:
tmp_path.rename(extracted_path) tmp_path.rename(extracted_path)
downloaded_path = extracted_path
download_status = "success" # Consider copy as success if file is there
except Exception as move_err: except Exception as move_err:
logger.debug(f"Rename temp FIT to destination failed ({move_err}); falling back to copy") logger.debug(f"Rename temp FIT to destination failed ({move_err}); falling back to copy")
with open(extracted_path, 'wb') as target, open(tmp_path, 'rb') as source: with open(extracted_path, 'wb') as target, open(tmp_path, 'rb') as source:
target.write(source.read()) target.write(source.read())
tmp_path.unlink() tmp_path.unlink()
downloaded_path = extracted_path
download_status = "success" # Consider copy as success if file is there
logger.info(f"Downloaded original activity file: {extracted_path}") logger.info(f"Downloaded original activity file: {extracted_path}")
return extracted_path
except Exception as e: except Exception as e:
logger.error(f"Failed to download original activity {activity_id}: {e} (type={type(e).__name__})") logger.error(f"Failed to download original activity {activity_id}: {e} (type={type(e).__name__})")
return None error_message = str(e)
finally:
if downloaded_path:
file_size = os.path.getsize(downloaded_path)
file_checksum = calculate_sha256(downloaded_path)
upsert_activity_download(
activity_id=int(activity_id),
source="garmin-connect",
file_path=downloaded_path,
file_format=file_format_detected,
status=download_status,
http_status=http_status,
size_bytes=file_size,
checksum_sha256=file_checksum,
error_message=error_message,
db_session=db
)
else:
upsert_activity_download(
activity_id=int(activity_id),
source="garmin-connect",
file_path=DATA_DIR / f"activity_{activity_id}.fit", # Placeholder path
file_format="fit", # Assuming fit as target format
status="failed",
http_status=http_status,
error_message=error_message or "Unknown error during download",
db_session=db
)
if close_session:
db.close()
return downloaded_path
def get_activity_summary(self, activity_id: str) -> Optional[Dict[str, Any]]: def get_activity_summary(self, activity_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed activity summary. """Get detailed activity summary.
@@ -403,6 +580,44 @@ class GarminClient:
logger.error(f"Failed to get activity summary for {activity_id}: {e}") logger.error(f"Failed to get activity summary for {activity_id}: {e}")
return None return None
def get_all_activities(self, limit: int = 1000) -> List[Dict[str, Any]]:
"""Get all activities from Garmin Connect.
Args:
limit: Maximum number of activities to retrieve
Returns:
List of activity dictionaries
"""
if not self.is_authenticated():
if not self.authenticate():
return []
try:
activities = []
offset = 0
batch_size = 100
while offset < limit:
batch = self.client.get_activities(offset, min(batch_size, limit - offset))
if not batch:
break
activities.extend(batch)
offset += len(batch)
# Stop if we got fewer activities than requested
if len(batch) < batch_size:
break
logger.info(f"Found {len(activities)} activities")
return activities
except Exception as e:
logger.error(f"Failed to get activities: {e}")
return []
def get_all_cycling_workouts(self, limit: int = 1000) -> List[Dict[str, Any]]: def get_all_cycling_workouts(self, limit: int = 1000) -> List[Dict[str, Any]]:
"""Get all cycling activities from Garmin Connect. """Get all cycling activities from Garmin Connect.
@@ -481,15 +696,18 @@ class GarminClient:
downloaded_path.rename(file_path) downloaded_path.rename(file_path)
return True return True
return False return False
def download_all_workouts(self, limit: int = 50, output_dir: Path = DATA_DIR) -> List[Dict[str, Path]]: def download_all_workouts(
"""Download up to 'limit' cycling workouts and save FIT files to output_dir. self, limit: int = 50, output_dir: Path = DATA_DIR, force_download: bool = False
) -> List[Dict[str, Path]]:
"""Download up to 'limit' activities and save FIT files to output_dir.
Uses get_all_cycling_workouts() to list activities, then downloads each original Uses get_all_activities() to list activities, then downloads each original
activity archive and extracts the FIT file via download_activity_original(). activity archive and extracts the FIT file via download_activity_original().
Args: Args:
limit: Maximum number of cycling activities to download limit: Maximum number of activities to download
output_dir: Directory to save downloaded FIT files output_dir: Directory to save downloaded FIT files
force_download: If True, bypasses database checks and forces a re-download.
Returns: Returns:
List of dicts with 'file_path' pointing to downloaded FIT paths List of dicts with 'file_path' pointing to downloaded FIT paths
@@ -501,9 +719,9 @@ class GarminClient:
try: try:
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
activities = self.get_all_cycling_workouts(limit=limit) activities = self.get_all_activities(limit=limit) # Changed from get_all_cycling_workouts
total = min(limit, len(activities)) total = min(limit, len(activities))
logger.info(f"Preparing to download up to {total} cycling activities into {output_dir}") logger.info(f"Preparing to download up to {total} activities into {output_dir}") # Changed from cycling activities
results: List[Dict[str, Path]] = [] results: List[Dict[str, Path]] = []
for idx, activity in enumerate(activities[:limit], start=1): for idx, activity in enumerate(activities[:limit], start=1):
@@ -516,25 +734,48 @@ class GarminClient:
logger.warning("Skipping activity with missing ID key (activityId/activity_id/id)") logger.warning("Skipping activity with missing ID key (activityId/activity_id/id)")
continue continue
logger.debug(f"Downloading activity ID {activity_id} ({idx}/{total})") dest_path = output_dir / f"activity_{activity_id}.fit"
src_path = self.download_activity_original(str(activity_id)) data_dir_path = DATA_DIR / f"activity_{activity_id}.fit"
if src_path and src_path.exists():
dest_path = output_dir / src_path.name
try:
if src_path.resolve() != dest_path.resolve():
if dest_path.exists():
# Overwrite existing destination to keep most recent download
dest_path.unlink()
src_path.rename(dest_path)
else:
# Already in the desired location
pass
except Exception as move_err:
logger.error(f"Failed to move {src_path} to {dest_path}: {move_err}")
dest_path = src_path # fall back to original location
if dest_path.exists():
logger.info(f"Activity {activity_id} already exists in {output_dir}; skipping download.")
results.append({"file_path": dest_path})
continue
elif data_dir_path.exists():
logger.info(f"Activity {activity_id} found in {DATA_DIR}; moving to {output_dir} and skipping download.")
try:
data_dir_path.rename(dest_path)
results.append({"file_path": dest_path})
continue
except Exception as move_err:
logger.error(f"Failed to move {data_dir_path} to {dest_path}: {move_err}")
# Fall through to download if move fails
logger.debug(f"Downloading activity ID {activity_id} ({idx}/{total})")
# Add rate limiting
import time
time.sleep(1.0)
src_path = self.download_activity_original(
str(activity_id), force_download=force_download, db_session=self.db_session
)
if src_path and src_path.exists():
# Check if the downloaded file is already the desired destination
if src_path.resolve() == dest_path.resolve():
logger.info(f"Saved activity {activity_id} to {dest_path}") logger.info(f"Saved activity {activity_id} to {dest_path}")
results.append({"file_path": dest_path}) results.append({"file_path": dest_path})
else:
try:
# If not, move it to the desired location
if dest_path.exists():
dest_path.unlink() # Overwrite existing destination to keep most recent download
src_path.rename(dest_path)
logger.info(f"Saved activity {activity_id} to {dest_path}")
results.append({"file_path": dest_path})
except Exception as move_err:
logger.error(f"Failed to move {src_path} to {dest_path}: {move_err}")
results.append({"file_path": src_path}) # Fall back to original location
else: else:
logger.warning(f"Download returned no file for activity {activity_id}") logger.warning(f"Download returned no file for activity {activity_id}")
@@ -545,7 +786,9 @@ class GarminClient:
logger.error(f"Failed during batch download: {e}") logger.error(f"Failed during batch download: {e}")
return [] return []
def download_latest_workout(self, output_dir: Path = DATA_DIR) -> Optional[Path]: def download_latest_workout(
self, output_dir: Path = DATA_DIR, force_download: bool = False
) -> Optional[Path]:
"""Download the latest cycling workout and save FIT file to output_dir. """Download the latest cycling workout and save FIT file to output_dir.
Uses get_latest_activity('cycling') to find the most recent cycling activity, Uses get_latest_activity('cycling') to find the most recent cycling activity,
@@ -553,6 +796,7 @@ class GarminClient:
Args: Args:
output_dir: Directory to save the downloaded FIT file output_dir: Directory to save the downloaded FIT file
force_download: If True, bypasses database checks and forces a re-download.
Returns: Returns:
Path to the downloaded FIT file or None if download failed Path to the downloaded FIT file or None if download failed
@@ -578,7 +822,9 @@ class GarminClient:
return None return None
logger.info(f"Downloading latest cycling activity ID {activity_id}") logger.info(f"Downloading latest cycling activity ID {activity_id}")
src_path = self.download_activity_original(str(activity_id)) src_path = self.download_activity_original(
str(activity_id), force_download=force_download, db_session=self.db_session
)
if src_path and src_path.exists(): if src_path and src_path.exists():
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
dest_path = output_dir / src_path.name dest_path = output_dir / src_path.name

View File

@@ -17,6 +17,10 @@ BASE_DIR = Path(__file__).parent.parent
DATA_DIR = BASE_DIR / "data" DATA_DIR = BASE_DIR / "data"
REPORTS_DIR = BASE_DIR / "reports" REPORTS_DIR = BASE_DIR / "reports"
# Database settings
DB_PATH = BASE_DIR / "garmin_analyser.db"
DATABASE_URL = f"sqlite:///{DB_PATH}"
# Create directories if they don't exist # Create directories if they don't exist
DATA_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True)
REPORTS_DIR.mkdir(exist_ok=True) REPORTS_DIR.mkdir(exist_ok=True)

103
main.py
View File

@@ -133,17 +133,27 @@ def parse_args() -> argparse.Namespace:
# Download command # Download command
download_parser = subparsers.add_parser('download', help='Download activities from Garmin Connect') download_parser = subparsers.add_parser('download', help='Download activities from Garmin Connect')
download_parser.add_argument( download_parser.add_argument(
'--all', action='store_true', help='Download all cycling activities' '--all', action='store_true', help='Download all activities'
)
download_parser.add_argument(
'--missing', action='store_true', help='Download only missing activities (not already downloaded)'
) )
download_parser.add_argument( download_parser.add_argument(
'--workout-id', type=int, help='Download specific workout by ID' '--workout-id', type=int, help='Download specific workout by ID'
) )
download_parser.add_argument( download_parser.add_argument(
'--limit', type=int, default=50, help='Maximum number of activities to download (with --all)' '--limit', type=int, default=50, help='Maximum number of activities to download (with --all or --missing)'
) )
download_parser.add_argument( download_parser.add_argument(
'--output-dir', type=str, default='data', help='Directory to save downloaded files' '--output-dir', type=str, default='data', help='Directory to save downloaded files'
) )
download_parser.add_argument(
'--force', action='store_true', help='Force re-download even if activity already tracked'
)
download_parser.add_argument(
'--dry-run', action='store_true', help='Show what would be downloaded without actually downloading'
)
# TODO: Add argument for --format {fit, tcx, gpx, csv, original} here in the future
# Reanalyze command # Reanalyze command
reanalyze_parser = subparsers.add_parser('reanalyze', help='Re-analyze all downloaded activities') reanalyze_parser = subparsers.add_parser('reanalyze', help='Re-analyze all downloaded activities')
@@ -280,15 +290,38 @@ class GarminAnalyser:
download_output_dir = Path(getattr(args, 'output_dir', 'data')) download_output_dir = Path(getattr(args, 'output_dir', 'data'))
download_output_dir.mkdir(parents=True, exist_ok=True) download_output_dir.mkdir(parents=True, exist_ok=True)
logging.debug(f"download_workouts: all={getattr(args, 'all', False)}, workout_id={getattr(args, 'workout_id', None)}, limit={getattr(args, 'limit', 50)}, output_dir={download_output_dir}") logging.debug(f"download_workouts: all={getattr(args, 'all', False)}, missing={getattr(args, 'missing', False)}, workout_id={getattr(args, 'workout_id', None)}, limit={getattr(args, 'limit', 50)}, output_dir={download_output_dir}, dry_run={getattr(args, 'dry_run', False)}")
downloaded_activities = [] downloaded_activities = []
if getattr(args, 'all', False):
logging.info(f"Downloading up to {getattr(args, 'limit', 50)} cycling activities...") if getattr(args, 'missing', False):
downloaded_activities = client.download_all_workouts(limit=getattr(args, 'limit', 50), output_dir=download_output_dir) logging.info(f"Finding and downloading missing activities...")
elif getattr(args, 'workout_id', None): # Get all activities from Garmin Connect
logging.info(f"Downloading workout {args.workout_id}...") all_activities = client.get_all_activities(limit=getattr(args, "limit", 50))
activity_path = client.download_activity_original(str(args.workout_id))
# Get already downloaded activities
downloaded_ids = client.get_downloaded_activity_ids(download_output_dir)
# Find missing activities (those not in downloaded_ids)
missing_activities = [activity for activity in all_activities
if str(activity['activityId']) not in downloaded_ids]
if getattr(args, 'dry_run', False):
logging.info(f"DRY RUN: Would download {len(missing_activities)} missing activities:")
for activity in missing_activities:
activity_id = activity['activityId']
activity_name = activity.get('activityName', 'Unknown')
activity_date = activity.get('startTimeLocal', 'Unknown date')
logging.info(f" ID: {activity_id}, Name: {activity_name}, Date: {activity_date}")
return []
logging.info(f"Downloading {len(missing_activities)} missing activities...")
for activity in missing_activities:
activity_id = activity['activityId']
try:
activity_path = client.download_activity_original(
str(activity_id), force_download=getattr(args, "force", False)
)
if activity_path: if activity_path:
dest_path = download_output_dir / activity_path.name dest_path = download_output_dir / activity_path.name
try: try:
@@ -297,12 +330,58 @@ class GarminAnalyser:
dest_path.unlink() dest_path.unlink()
activity_path.rename(dest_path) activity_path.rename(dest_path)
except Exception as move_err: except Exception as move_err:
logging.error(f"Failed to move {activity_path} to {dest_path}: {move_err}") logging.error(
f"Failed to move {activity_path} to {dest_path}: {move_err}"
)
dest_path = activity_path dest_path = activity_path
downloaded_activities.append({'file_path': dest_path}) downloaded_activities.append({"file_path": dest_path})
logging.info(f"Downloaded activity {activity_id} to {dest_path}")
except Exception as e:
logging.error(f"Error downloading activity {activity_id}: {e}")
elif getattr(args, 'all', False):
if getattr(args, 'dry_run', False):
logging.info(f"DRY RUN: Would download up to {getattr(args, 'limit', 50)} activities")
return []
logging.info(f"Downloading up to {getattr(args, 'limit', 50)} activities...")
downloaded_activities = client.download_all_workouts(
limit=getattr(args, "limit", 50),
output_dir=download_output_dir,
force_download=getattr(args, "force", False),
)
elif getattr(args, "workout_id", None):
if getattr(args, 'dry_run', False):
logging.info(f"DRY RUN: Would download workout {args.workout_id}")
return []
logging.info(f"Downloading workout {args.workout_id}...")
activity_path = client.download_activity_original(
str(args.workout_id), force_download=getattr(args, "force", False)
)
if activity_path:
dest_path = download_output_dir / activity_path.name
try:
if activity_path.resolve() != dest_path.resolve():
if dest_path.exists():
dest_path.unlink()
activity_path.rename(dest_path)
except Exception as move_err:
logging.error(
f"Failed to move {activity_path} to {dest_path}: {move_err}"
)
dest_path = activity_path
downloaded_activities.append({"file_path": dest_path})
else: else:
if getattr(args, 'dry_run', False):
logging.info("DRY RUN: Would download latest cycling activity")
return []
logging.info("Downloading latest cycling activity...") logging.info("Downloading latest cycling activity...")
activity_path = client.download_latest_workout(output_dir=download_output_dir) activity_path = client.download_latest_workout(
output_dir=download_output_dir,
force_download=getattr(args, "force", False),
)
if activity_path: if activity_path:
downloaded_activities.append({'file_path': activity_path}) downloaded_activities.append({'file_path': activity_path})

View File

@@ -1,13 +1,53 @@
alembic==1.8.1
annotated-types==0.7.0
Brotli==1.1.0
certifi==2025.10.5
cffi==2.0.0
charset-normalizer==3.4.3
contourpy==1.3.3
cssselect2==0.8.0
cycler==0.12.1
fitparse==1.2.0 fitparse==1.2.0
fonttools==4.60.1
garminconnect==0.2.30 garminconnect==0.2.30
garth==0.5.17
greenlet==3.2.4
idna==3.10
Jinja2==3.1.6 Jinja2==3.1.6
kiwisolver==1.4.9
Mako==1.3.10
Markdown==3.9 Markdown==3.9
MarkupSafe==3.0.3
matplotlib==3.10.6 matplotlib==3.10.6
narwhals==2.7.0
numpy==2.3.3 numpy==2.3.3
oauthlib==3.3.1
packaging==25.0
pandas==2.3.2 pandas==2.3.2
pillow==11.3.0
plotly==6.3.0 plotly==6.3.0
pycparser==2.23
pydantic==2.11.10
pydantic_core==2.33.2
pydyf==0.11.0
pyparsing==3.2.5
pyphen==0.17.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1 python-dotenv==1.1.1
python_magic==0.4.27 python-magic==0.4.27
pytz==2025.2
requests==2.32.5
requests-oauthlib==2.0.0
seaborn==0.13.2 seaborn==0.13.2
setuptools==80.9.0 setuptools==80.9.0
six==1.17.0
SQLAlchemy==1.4.52
tinycss2==1.4.0
tinyhtml5==2.0.0
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2025.2
urllib3==2.5.0
weasyprint==66.0 weasyprint==66.0
webencodings==0.5.1
zopfli==0.2.3.post1

View File

@@ -53,5 +53,6 @@ setup(
include_package_data=True, include_package_data=True,
package_data={ package_data={
"garmin_analyser": ["config/*.yaml", "visualizers/templates/*.html", "visualizers/templates/*.md"], "garmin_analyser": ["config/*.yaml", "visualizers/templates/*.html", "visualizers/templates/*.md"],
"alembic": ["alembic.ini", "alembic/env.py", "alembic/script.py.mako", "alembic/versions/*.py"],
}, },
) )