Initial commit

Signed-off-by: Thomas Klaehn <thomas.klaehn@perinet.io>
This commit is contained in:
Thomas Klaehn
2026-02-10 11:47:49 +01:00
commit f496eebe2a
17 changed files with 1835 additions and 0 deletions

49
.dockerignore Normal file
View File

@@ -0,0 +1,49 @@
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Data directories
data/
/data
# Build artifacts
*.exe
*.exe~
*.dll
*.so
*.dylib
zwift-monitor
# Test files
*_test.go
testdata/
# IDE
.idea
.vscode
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Documentation
README.md
*.md
# Config files (will be mounted)
config.yaml
.env
# Logs
*.log
# State files
*.json

49
.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# Binaries
zwift-monitor
*.exe
*.exe~
*.dll
*.so
*.dylib
# Data directories
data/
/data
# Config files with credentials
config.yaml
.env
# State files
*.json
!go.mod
!go.sum
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Dependency directories
vendor/
# Go workspace file
go.work
# IDE
.idea
.vscode
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Token cache
.zwift-token.json

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o zwift-monitor .
# Final stage
FROM alpine:latest
# Install runtime dependencies
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/zwift-monitor .
# Create data directory
RUN mkdir -p /data/activities
# Run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
RUN chown -R appuser:appgroup /app /data
USER appuser
# Set default environment variables
ENV ZWIFT_OUTPUT_DIR=/data/activities \
ZWIFT_STATE_FILE=/data/zwift-state.json \
ZWIFT_TOKEN_CACHE=/data/.zwift-token.json \
ZWIFT_POLL_INTERVAL=5m \
ZWIFT_LOG_LEVEL=info \
ZWIFT_RATE_LIMIT=5 \
ZWIFT_MAX_RETRIES=3
# Expose volume for data
VOLUME ["/data"]
# Run the service
CMD ["/app/zwift-monitor"]

337
README.md Normal file
View File

@@ -0,0 +1,337 @@
# Zwift Activity Monitor
Automatic monitoring service that continuously watches for new Zwift activities and downloads their FIT files.
## Features
-**Continuous Monitoring**: Automatically checks for new activities every 5 minutes (configurable)
-**Automatic Downloads**: Downloads FIT files for new activities as soon as they're available
-**Docker Support**: Easy deployment with Docker and Docker Compose
-**Persistent State**: Tracks downloaded activities to prevent duplicates
-**Rate Limiting**: Respects API rate limits with built-in throttling
-**Graceful Shutdown**: Handles SIGTERM/SIGINT for clean container restarts
-**Structured Logging**: JSON logging with configurable levels
-**Retry Logic**: Automatic retry with exponential backoff for transient failures
## Quick Start
### Using Docker Compose (Recommended)
1. **Clone the repository**
```bash
git clone <your-repo-url>
cd zwift
```
2. **Create environment file**
```bash
cat > .env <<EOF
ZWIFT_USERNAME=your@email.com
ZWIFT_PASSWORD=yourpassword
TZ=Europe/Berlin
EOF
```
3. **Create data directory**
```bash
mkdir -p data/activities
```
4. **Start the service**
```bash
docker-compose up -d
```
5. **View logs**
```bash
docker-compose logs -f
```
6. **Check downloaded files**
```bash
ls -lh data/activities/
```
### Using Docker
```bash
docker build -t zwift-monitor .
docker run -d \
--name zwift-monitor \
--restart unless-stopped \
-e ZWIFT_USERNAME=your@email.com \
-e ZWIFT_PASSWORD=yourpassword \
-e TZ=Europe/Berlin \
-v $(pwd)/data:/data \
zwift-monitor
```
### Local Development
```bash
# Install dependencies
go mod download
# Set credentials
export ZWIFT_USERNAME=your@email.com
export ZWIFT_PASSWORD=yourpassword
export ZWIFT_OUTPUT_DIR=./data/activities
export ZWIFT_STATE_FILE=./data/state.json
# Run the service
go run .
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ZWIFT_USERNAME` | *required* | Your Zwift email address |
| `ZWIFT_PASSWORD` | *required* | Your Zwift password |
| `ZWIFT_OUTPUT_DIR` | `/data/activities` | Directory for FIT files |
| `ZWIFT_STATE_FILE` | `/data/zwift-state.json` | State persistence file |
| `ZWIFT_TOKEN_CACHE` | `/data/.zwift-token.json` | OAuth token cache |
| `ZWIFT_POLL_INTERVAL` | `5m` | How often to check for new activities |
| `ZWIFT_RATE_LIMIT` | `5` | Maximum requests per second |
| `ZWIFT_MAX_RETRIES` | `3` | Number of retries for failed requests |
| `ZWIFT_LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) |
| `ZWIFT_LOG_FILE` | `` | Optional log file path |
| `TZ` | `UTC` | Timezone for timestamps |
### Configuration File (Optional)
You can also use a `config.yaml` file:
```yaml
username: your@email.com
password: yourpassword
output_dir: /data/activities
poll_interval: 5m
rate_limit: 5
max_retries: 3
log_level: info
```
Place the config file:
- Local: `config.yaml` in the project directory
- Docker: Mount as `/app/config.yaml`
**Note**: Environment variables take precedence over config file values.
## File Naming
Downloaded FIT files are named using the format:
```
{activityID}_{date}_{activity-name}.fit
```
Example:
```
12345678_2025-02-10_Morning-Ride.fit
```
## Logging
The service uses structured logging with the following levels:
- **DEBUG**: Detailed API calls and responses
- **INFO**: Normal operation, downloads, statistics
- **WARN**: Retries, non-fatal errors
- **ERROR**: Failed downloads, authentication issues
### Example Log Output
```
2025-02-10T10:00:00Z INFO Starting Zwift Activity Monitor poll_interval=5m0s output_dir=/data/activities
2025-02-10T10:00:01Z INFO Loaded state total_downloaded=42 last_check=2025-02-10T09:55:00Z
2025-02-10T10:00:02Z INFO Running initial check...
2025-02-10T10:00:03Z INFO Checking for new activities...
2025-02-10T10:00:05Z INFO Found new activities count=1
2025-02-10T10:00:06Z INFO Downloading activity activity_id=12345678 name="Morning Ride"
2025-02-10T10:00:08Z INFO Successfully downloaded activity activity_id=12345678 filepath=/data/activities/12345678_2025-02-10_Morning-Ride.fit
2025-02-10T10:00:08Z INFO Download complete downloaded=1 total_new=1
```
## State Management
The service maintains a JSON state file (`zwift-state.json`) that tracks:
- All downloaded activity IDs
- Activity metadata (name, date, file path)
- Last check timestamp
- Total downloads
### Example State File
```json
{
"downloaded_activities": {
"12345678": {
"id": 12345678,
"name": "Morning Ride",
"date": "2025-02-10T08:30:00Z",
"file_path": "/data/activities/12345678_2025-02-10_Morning-Ride.fit",
"downloaded_at": "2025-02-10T10:00:08Z",
"sport_type": "cycling"
}
},
"last_check": "2025-02-10T10:00:08Z",
"total_downloaded": 1
}
```
This state file ensures:
- No duplicate downloads after restarts
- Resume capability after crashes
- Historical tracking of all downloads
## Troubleshooting
### Authentication Fails
```
ERROR Failed to create service error="failed to create Zwift client: ..."
```
**Solution**: Verify your Zwift credentials are correct.
### No New Activities Detected
```
INFO No new activities found
```
**Possible causes**:
- No new activities since last check
- Activities already downloaded
- Activity privacy settings
**Check**: View your state file to see what's already downloaded:
```bash
cat data/zwift-state.json | jq .
```
### Rate Limiting Issues
```
WARN Rate limited by Zwift API
```
**Solution**: Increase the poll interval:
```bash
ZWIFT_POLL_INTERVAL=10m # Check every 10 minutes instead of 5
```
### Disk Space Issues
```
ERROR Failed to save FIT file error="no space left on device"
```
**Solution**: Free up disk space or mount a larger volume.
## Docker Commands
```bash
# View logs
docker-compose logs -f
# Restart service
docker-compose restart
# Stop service
docker-compose down
# Rebuild after code changes
docker-compose up -d --build
# View container stats
docker stats zwift-monitor
# Access container shell
docker-compose exec zwift-monitor sh
# Check state file
docker-compose exec zwift-monitor cat /data/zwift-state.json
```
## Building from Source
```bash
# Build binary
go build -o zwift-monitor .
# Run tests (when available)
go test ./...
# Build Docker image
docker build -t zwift-monitor .
# Cross-compile for different platforms
GOOS=linux GOARCH=amd64 go build -o zwift-monitor-linux-amd64
GOOS=darwin GOARCH=arm64 go build -o zwift-monitor-darwin-arm64
```
## Architecture
```
zwift/
├── main.go # Entry point with signal handling
├── internal/
│ ├── service/ # Main service loop
│ │ └── service.go # Ticker-based monitoring
│ ├── monitor/ # Activity monitoring logic
│ │ └── monitor.go # Check and download orchestration
│ ├── client/ # Zwift API client
│ │ ├── client.go # API wrapper with rate limiting
│ │ └── auth.go # OAuth token management
│ ├── state/ # State persistence
│ │ └── state.go # Thread-safe JSON state
│ ├── storage/ # File system operations
│ │ └── storage.go # Atomic FIT file writes
│ └── config/ # Configuration management
│ └── config.go # Env vars + YAML loading
└── Dockerfile # Multi-stage Docker build
```
## Security Considerations
- **Credentials**: Never commit credentials to git. Use environment variables or Docker secrets.
- **Token Cache**: Stored with 0600 permissions (owner read/write only).
- **State File**: Contains activity IDs but no sensitive data.
- **Container**: Runs as non-root user for security.
## API Usage
This service uses the unofficial Zwift API via the [gravl](https://github.com/bzimmer/gravl) library. The API:
- Is not officially supported by Zwift
- May change without notice
- Requires regular Zwift credentials (no special API key needed)
- Respects rate limiting (5 requests/second by default)
## License
[Your License Here]
## Contributing
Contributions welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## Support
For issues or questions:
- Create an issue in the repository
- Check existing issues for similar problems
- Include logs and configuration (redact credentials!)
## Acknowledgments
- [gravl](https://github.com/bzimmer/gravl) - Go library for Zwift API
- [Zwift](https://zwift.com) - Virtual cycling platform

22
config.example.yaml Normal file
View File

@@ -0,0 +1,22 @@
# Zwift Activity Monitor Configuration
# Copy this file to config.yaml and fill in your credentials
# Authentication (prefer environment variables for security)
username: "" # Or set ZWIFT_USERNAME
password: "" # Or set ZWIFT_PASSWORD
# Storage paths
output_dir: /data/activities # Directory for FIT files
state_file: /data/zwift-state.json # State persistence file
token_cache: /data/.zwift-token.json # OAuth token cache
# Monitoring configuration
poll_interval: 5m # How often to check for new activities (e.g., 5m, 10m, 1h)
# API configuration
rate_limit: 5 # Maximum requests per second to Zwift API
max_retries: 3 # Number of retries for failed API calls
# Logging configuration
log_level: info # Logging level: debug, info, warn, error
log_file: "" # Optional: path to log file (empty = stdout only)

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
version: '3.8'
services:
zwift-monitor:
build: .
container_name: zwift-monitor
restart: unless-stopped
user: root
environment:
# Required: Zwift credentials (use .env file or set directly)
- ZWIFT_USERNAME=${ZWIFT_USERNAME}
- ZWIFT_PASSWORD=${ZWIFT_PASSWORD}
# Optional: Monitoring configuration
- ZWIFT_POLL_INTERVAL=${ZWIFT_POLL_INTERVAL:-5m}
- ZWIFT_LOG_LEVEL=${ZWIFT_LOG_LEVEL:-info}
# Optional: API configuration
- ZWIFT_RATE_LIMIT=${ZWIFT_RATE_LIMIT:-5}
- ZWIFT_MAX_RETRIES=${ZWIFT_MAX_RETRIES:-3}
# Optional: Timezone (adjust to your timezone)
- TZ=${TZ:-Europe/Berlin}
volumes:
# Persistent storage for FIT files and state
- ./data:/data
# Optional: Mount custom config file
# - ./config.yaml:/app/config.yaml:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "test", "-f", "/data/zwift-state.json"]
interval: 1m
timeout: 10s
retries: 3
start_period: 30s

43
go.mod Normal file
View File

@@ -0,0 +1,43 @@
module zwift-activity-loader
go 1.25.6
require (
github.com/bzimmer/activity v0.10.0 // indirect
github.com/bzimmer/gravl v0.10.0 // indirect
github.com/bzimmer/httpwares v0.1.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/hashicorp/go-immutable-radix v1.0.0 // indirect
github.com/hashicorp/go-metrics v0.5.3 // indirect
github.com/hashicorp/golang-lru v0.5.0 // indirect
github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twpayne/go-geom v1.5.4 // indirect
github.com/twpayne/go-gpx v1.3.1 // indirect
github.com/twpayne/go-polyline v1.1.1 // indirect
github.com/urfave/cli/v2 v2.27.2 // indirect
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/time v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

175
go.sum Normal file
View File

@@ -0,0 +1,175 @@
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bzimmer/activity v0.10.0 h1:cAsAG3cEmQk9FT2+cjswDvwM2FbRAETaWYZLshv7d/s=
github.com/bzimmer/activity v0.10.0/go.mod h1:cZDT5DzGUNh7xghR71LSQp89TpLW1KF2bYIJ2rfdLog=
github.com/bzimmer/gravl v0.10.0 h1:YTFtpBN32PKmMrXKGDUz97mxxZSFZOn7snSuGuORtbo=
github.com/bzimmer/gravl v0.10.0/go.mod h1:BbqxnJJ83QQ8f8yhwWTUpTM52BxhgEQzeKl0rvojuF8=
github.com/bzimmer/httpwares v0.1.3 h1:Haw1fGBRW51iv7O2NIkIZyuUt3XLZZG+ePd6NEwbD5Q=
github.com/bzimmer/httpwares v0.1.3/go.mod h1:8pi184rxXR7Pbn7cNL8uMPeqYA8+DbQSl4oOQE5q4Vk=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-metrics v0.5.3 h1:M5uADWMOGCTUNU1YuC4hfknOeHNaX54LDm4oYSucoNE=
github.com/hashicorp/go-metrics v0.5.3/go.mod h1:KEjodfebIOuBYSAe/bHTm+HChmKSxAOXPBieMLYozDE=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6 h1:muzoir7BEy+lDPqdROr57IjJBP7OydzCg0VDhZtdG+w=
github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6/go.mod h1:8QbxAolnDKw/JhUJMU80MRjHjEs0tLwkjZAPrTn+xLA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twpayne/go-geom v1.5.4 h1:b8fiZd0SsEmQEeUdz2atT6KggF1KHiaZIi3DGi5p+sI=
github.com/twpayne/go-geom v1.5.4/go.mod h1:Hw8RszQ2/d9Y/KfOm9CvUJo78BOoIA5g0e4P7JCVKvo=
github.com/twpayne/go-gpx v1.3.1 h1:V7fjRvQa4Hl5kEgft7JGOQYhOtPEBFbsd/5Eao4LgBM=
github.com/twpayne/go-gpx v1.3.1/go.mod h1:cOFdNmqGjdjb3POPoecMEUko160iS9AuJvknftGW7jI=
github.com/twpayne/go-polyline v1.1.1 h1:/tSF1BR7rN4HWj4XKqvRUNrCiYVMCvywxTFVofvDV0w=
github.com/twpayne/go-polyline v1.1.1/go.mod h1:ybd9IWWivW/rlXPXuuckeKUyF3yrIim+iqA7kSl4NFY=
github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

90
internal/client/auth.go Normal file
View File

@@ -0,0 +1,90 @@
package client
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"golang.org/x/oauth2"
)
// TokenCache handles token persistence
type TokenCache struct {
filePath string
}
// NewTokenCache creates a new token cache
func NewTokenCache(filePath string) *TokenCache {
return &TokenCache{filePath: filePath}
}
// Load loads a token from cache
func (tc *TokenCache) Load() (*oauth2.Token, error) {
// Check if file exists
if _, err := os.Stat(tc.filePath); os.IsNotExist(err) {
return nil, nil // No cached token
}
// Read file
data, err := os.ReadFile(tc.filePath)
if err != nil {
return nil, fmt.Errorf("failed to read token cache: %w", err)
}
// Unmarshal token
var token oauth2.Token
if err := json.Unmarshal(data, &token); err != nil {
return nil, fmt.Errorf("failed to unmarshal token: %w", err)
}
// Check if token is expired
if token.Expiry.Before(time.Now()) {
return nil, nil // Token expired
}
return &token, nil
}
// Save saves a token to cache
func (tc *TokenCache) Save(token *oauth2.Token) error {
// Create directory if it doesn't exist
dir := filepath.Dir(tc.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create token cache directory: %w", err)
}
// Marshal token
data, err := json.MarshalIndent(token, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal token: %w", err)
}
// Write to file with restricted permissions
if err := os.WriteFile(tc.filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write token cache: %w", err)
}
return nil
}
// Authenticate authenticates with Zwift using username and password
func Authenticate(ctx context.Context, username, password string, tokenCache *TokenCache) (*oauth2.Token, error) {
// Try to load cached token first
if tokenCache != nil {
token, err := tokenCache.Load()
if err == nil && token != nil && token.Valid() {
return token, nil
}
}
// Authenticate with username/password
// Note: The gravl library handles the actual OAuth2 flow
// We'll use the credentials directly with the client
// For now, we'll return nil as the gravl client handles authentication
// This function serves as a placeholder for explicit token management
return nil, fmt.Errorf("authentication handled by gravl client")
}

125
internal/client/client.go Normal file
View File

@@ -0,0 +1,125 @@
package client
import (
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/bzimmer/activity"
"github.com/bzimmer/activity/zwift"
"golang.org/x/time/rate"
)
// Client wraps the Zwift API client with rate limiting and retry logic
type Client struct {
client *zwift.Client
rateLimiter *rate.Limiter
maxRetries int
athleteID int64 // Cached athlete ID
}
// NewClient creates a new Zwift client
func NewClient(username, password string, rateLimit, maxRetries int) (*Client, error) {
// Create HTTP client
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
// Create rate limiter (requests per second)
limiter := rate.NewLimiter(rate.Limit(rateLimit), rateLimit*2)
// Create Zwift client with credentials
zwiftClient, err := zwift.NewClient(
zwift.WithHTTPClient(httpClient),
zwift.WithRateLimiter(limiter),
zwift.WithTokenRefresh(username, password),
)
if err != nil {
return nil, fmt.Errorf("failed to create Zwift client: %w", err)
}
return &Client{
client: zwiftClient,
rateLimiter: limiter,
maxRetries: maxRetries,
}, nil
}
// GetProfile gets the authenticated user's profile and caches the athlete ID
func (c *Client) GetProfile(ctx context.Context) (int64, error) {
// Return cached athlete ID if available
if c.athleteID != 0 {
return c.athleteID, nil
}
// Get profile from Zwift (using "me" as the profile ID)
profile, err := c.client.Profile.Profile(ctx, zwift.Me)
if err != nil {
return 0, fmt.Errorf("failed to get profile: %w", err)
}
// Cache athlete ID
c.athleteID = profile.ID
return profile.ID, nil
}
// ListRecentActivities lists recent activities for the athlete
func (c *Client) ListRecentActivities(ctx context.Context, athleteID int64, limit int) ([]*zwift.Activity, error) {
// Create pagination spec
spec := activity.Pagination{
Total: limit,
}
// List activities from Zwift
activities, err := c.client.Activity.Activities(ctx, athleteID, spec)
if err != nil {
return nil, fmt.Errorf("failed to list activities: %w", err)
}
return activities, nil
}
// DownloadFIT downloads the FIT file for an activity
func (c *Client) DownloadFIT(ctx context.Context, athleteID, activityID int64) ([]byte, string, error) {
// Retry logic
var lastErr error
for attempt := 0; attempt < c.maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff
backoff := time.Duration(1<<uint(attempt)) * time.Second
select {
case <-ctx.Done():
return nil, "", ctx.Err()
case <-time.After(backoff):
}
}
// Export FIT file
export, err := c.client.Activity.Export(ctx, activityID)
if err != nil {
lastErr = err
continue
}
// Read data from reader
data, err := io.ReadAll(export.Reader)
if err != nil {
lastErr = err
continue
}
// Return data and filename
return data, export.Filename, nil
}
return nil, "", fmt.Errorf("failed to download FIT file after %d attempts: %w", c.maxRetries, lastErr)
}
// Close closes the client
func (c *Client) Close() error {
// Nothing to close for now
return nil
}

123
internal/config/config.go Normal file
View File

@@ -0,0 +1,123 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/viper"
)
// Config holds all configuration for the Zwift monitor service
type Config struct {
// Authentication
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
// Storage
OutputDir string `mapstructure:"output_dir"`
StateFile string `mapstructure:"state_file"`
TokenCachePath string `mapstructure:"token_cache"`
// Monitoring
PollInterval time.Duration `mapstructure:"poll_interval"`
// API Configuration
RateLimit int `mapstructure:"rate_limit"`
MaxRetries int `mapstructure:"max_retries"`
// Logging
LogLevel string `mapstructure:"log_level"`
LogFile string `mapstructure:"log_file"`
}
// Load loads configuration from environment variables and optional config file
func Load() (*Config, error) {
v := viper.New()
// Set defaults
v.SetDefault("output_dir", "/data/activities")
v.SetDefault("state_file", "/data/zwift-state.json")
v.SetDefault("token_cache", "/data/.zwift-token.json")
v.SetDefault("poll_interval", "5m")
v.SetDefault("rate_limit", 5)
v.SetDefault("max_retries", 3)
v.SetDefault("log_level", "info")
v.SetDefault("log_file", "")
// Bind environment variables
v.SetEnvPrefix("ZWIFT")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// Try to load config file if it exists
configPaths := []string{
"config.yaml",
"/app/config.yaml",
filepath.Join(os.Getenv("HOME"), ".zwift-monitor.yaml"),
}
for _, path := range configPaths {
if _, err := os.Stat(path); err == nil {
v.SetConfigFile(path)
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file %s: %w", path, err)
}
break
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Validate required fields
if err := cfg.Validate(); err != nil {
return nil, err
}
// Expand paths (handle ~)
cfg.OutputDir = expandPath(cfg.OutputDir)
cfg.StateFile = expandPath(cfg.StateFile)
cfg.TokenCachePath = expandPath(cfg.TokenCachePath)
if cfg.LogFile != "" {
cfg.LogFile = expandPath(cfg.LogFile)
}
return &cfg, nil
}
// Validate checks if required configuration is present
func (c *Config) Validate() error {
if c.Username == "" {
return fmt.Errorf("username is required (set ZWIFT_USERNAME environment variable)")
}
if c.Password == "" {
return fmt.Errorf("password is required (set ZWIFT_PASSWORD environment variable)")
}
if c.PollInterval < time.Minute {
return fmt.Errorf("poll_interval must be at least 1 minute")
}
if c.RateLimit < 1 {
return fmt.Errorf("rate_limit must be at least 1")
}
if c.MaxRetries < 1 {
return fmt.Errorf("max_retries must be at least 1")
}
return nil
}
// expandPath expands ~ to home directory
func expandPath(path string) string {
if strings.HasPrefix(path, "~") {
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[1:])
}
return path
}

149
internal/monitor/monitor.go Normal file
View File

@@ -0,0 +1,149 @@
package monitor
import (
"context"
"fmt"
"time"
"zwift-activity-loader/internal/client"
"zwift-activity-loader/internal/state"
"zwift-activity-loader/internal/storage"
"github.com/bzimmer/activity/zwift"
"go.uber.org/zap"
)
// Monitor handles activity monitoring and downloading
type Monitor struct {
client *client.Client
state *state.State
storage *storage.Storage
logger *zap.Logger
athleteID int64 // Cached athlete ID
}
// New creates a new Monitor instance
func New(client *client.Client, state *state.State, storage *storage.Storage, logger *zap.Logger) *Monitor {
return &Monitor{
client: client,
state: state,
storage: storage,
logger: logger,
}
}
// CheckAndDownload checks for new activities and downloads them
func (m *Monitor) CheckAndDownload(ctx context.Context) error {
m.logger.Info("Checking for new activities...")
// Get athlete ID (cached after first call)
if m.athleteID == 0 {
athleteID, err := m.client.GetProfile(ctx)
if err != nil {
return fmt.Errorf("failed to get profile: %w", err)
}
m.athleteID = athleteID
m.logger.Debug("Got athlete profile", zap.Int64("athlete_id", athleteID))
}
// List recent activities (last 100)
activities, err := m.client.ListRecentActivities(ctx, m.athleteID, 100)
if err != nil {
return fmt.Errorf("failed to list activities: %w", err)
}
m.logger.Debug("Listed recent activities", zap.Int("count", len(activities)))
// Filter new activities
newActivities := m.filterNewActivities(activities)
if len(newActivities) == 0 {
m.logger.Info("No new activities found")
if err := m.state.UpdateLastCheck(); err != nil {
m.logger.Warn("Failed to update last check time", zap.Error(err))
}
return nil
}
m.logger.Info("Found new activities", zap.Int("count", len(newActivities)))
// Download new activities
downloaded := 0
for _, act := range newActivities {
if err := m.downloadActivity(ctx, act); err != nil {
m.logger.Error("Failed to download activity",
zap.Int64("activity_id", act.ID),
zap.String("name", act.Name),
zap.Error(err))
continue
}
downloaded++
}
m.logger.Info("Download complete",
zap.Int("downloaded", downloaded),
zap.Int("total_new", len(newActivities)))
return nil
}
// filterNewActivities filters activities that haven't been downloaded yet
func (m *Monitor) filterNewActivities(activities []*zwift.Activity) []*zwift.Activity {
var newActivities []*zwift.Activity
for _, act := range activities {
if !m.state.IsDownloaded(act.ID) {
newActivities = append(newActivities, act)
}
}
return newActivities
}
// downloadActivity downloads a single activity
func (m *Monitor) downloadActivity(ctx context.Context, act *zwift.Activity) error {
m.logger.Info("Downloading activity",
zap.Int64("activity_id", act.ID),
zap.String("name", act.Name),
zap.Time("date", act.StartDate.Time))
// Download FIT file
data, filename, err := m.client.DownloadFIT(ctx, m.athleteID, act.ID)
if err != nil {
return fmt.Errorf("failed to download FIT file: %w", err)
}
m.logger.Debug("Downloaded FIT file",
zap.Int64("activity_id", act.ID),
zap.String("filename", filename),
zap.Int("size_bytes", len(data)))
// Save to storage
filepath, err := m.storage.Save(act.ID, act.Name, act.StartDate.Time, data)
if err != nil {
return fmt.Errorf("failed to save FIT file: %w", err)
}
// Mark as downloaded in state
info := state.ActivityInfo{
ID: act.ID,
Name: act.Name,
Date: act.StartDate.Time,
FilePath: filepath,
DownloadedAt: time.Now(),
SportType: act.Sport,
}
if err := m.state.MarkDownloaded(info); err != nil {
m.logger.Error("Failed to mark activity as downloaded",
zap.Int64("activity_id", act.ID),
zap.Error(err))
// Don't return error here - file is saved, just state update failed
}
m.logger.Info("Successfully downloaded activity",
zap.Int64("activity_id", act.ID),
zap.String("name", act.Name),
zap.String("filepath", filepath))
return nil
}

160
internal/service/service.go Normal file
View File

@@ -0,0 +1,160 @@
package service
import (
"context"
"fmt"
"time"
"zwift-activity-loader/internal/client"
"zwift-activity-loader/internal/config"
"zwift-activity-loader/internal/monitor"
"zwift-activity-loader/internal/state"
"zwift-activity-loader/internal/storage"
"go.uber.org/zap"
)
// Service represents the main monitoring service
type Service struct {
config *config.Config
client *client.Client
monitor *monitor.Monitor
state *state.State
storage *storage.Storage
logger *zap.Logger
ticker *time.Ticker
ctx context.Context
cancel context.CancelFunc
}
// New creates a new Service instance
func New(cfg *config.Config, logger *zap.Logger) (*Service, error) {
// Create client
zwiftClient, err := client.NewClient(cfg.Username, cfg.Password, cfg.RateLimit, cfg.MaxRetries)
if err != nil {
return nil, fmt.Errorf("failed to create Zwift client: %w", err)
}
// Create storage
stor, err := storage.New(cfg.OutputDir)
if err != nil {
return nil, fmt.Errorf("failed to create storage: %w", err)
}
// Create state
st := state.New(cfg.StateFile)
if err := st.Load(); err != nil {
return nil, fmt.Errorf("failed to load state: %w", err)
}
// Create monitor
mon := monitor.New(zwiftClient, st, stor, logger)
// Create context
ctx, cancel := context.WithCancel(context.Background())
return &Service{
config: cfg,
client: zwiftClient,
monitor: mon,
state: st,
storage: stor,
logger: logger,
ticker: time.NewTicker(cfg.PollInterval),
ctx: ctx,
cancel: cancel,
}, nil
}
// Run starts the service loop
func (s *Service) Run() error {
s.logger.Info("Starting Zwift Activity Monitor",
zap.Duration("poll_interval", s.config.PollInterval),
zap.String("output_dir", s.config.OutputDir))
// Get initial stats
totalDownloaded, lastCheck := s.state.GetStats()
s.logger.Info("Loaded state",
zap.Int("total_downloaded", totalDownloaded),
zap.Time("last_check", lastCheck))
// Run initial check immediately
s.logger.Info("Running initial check...")
if err := s.runCheck(); err != nil {
s.logger.Error("Initial check failed", zap.Error(err))
}
// Start ticker loop
for {
select {
case <-s.ctx.Done():
s.logger.Info("Service context cancelled, shutting down")
return nil
case <-s.ticker.C:
if err := s.runCheck(); err != nil {
s.logger.Error("Check failed", zap.Error(err))
}
}
}
}
// runCheck runs a single check for new activities
func (s *Service) runCheck() error {
// Recover from panics
defer func() {
if r := recover(); r != nil {
s.logger.Error("Panic recovered in runCheck",
zap.Any("panic", r),
zap.Stack("stack"))
}
}()
// Create context with timeout
ctx, cancel := context.WithTimeout(s.ctx, 5*time.Minute)
defer cancel()
// Run check and download
return s.monitor.CheckAndDownload(ctx)
}
// Shutdown gracefully shuts down the service
func (s *Service) Shutdown() error {
s.logger.Info("Shutting down gracefully...")
// Stop ticker
s.ticker.Stop()
// Cancel context (with timeout for current operation)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Wait for current operation to complete
done := make(chan struct{})
go func() {
s.cancel()
close(done)
}()
select {
case <-done:
s.logger.Info("Service stopped")
case <-shutdownCtx.Done():
s.logger.Warn("Shutdown timeout, forcing exit")
}
// Save final state
if err := s.state.Save(); err != nil {
s.logger.Error("Failed to save state on shutdown", zap.Error(err))
return err
}
// Close client
if err := s.client.Close(); err != nil {
s.logger.Error("Failed to close client", zap.Error(err))
return err
}
s.logger.Info("Shutdown complete")
return nil
}

166
internal/state/state.go Normal file
View File

@@ -0,0 +1,166 @@
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// ActivityInfo holds information about a downloaded activity
type ActivityInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Date time.Time `json:"date"`
FilePath string `json:"file_path"`
DownloadedAt time.Time `json:"downloaded_at"`
SportType string `json:"sport_type,omitempty"`
}
// State manages the persistent state of downloaded activities
type State struct {
DownloadedActivities map[int64]ActivityInfo `json:"downloaded_activities"`
LastCheck time.Time `json:"last_check"`
TotalDownloaded int `json:"total_downloaded"`
filePath string
mu sync.RWMutex
}
// New creates a new State instance
func New(filePath string) *State {
return &State{
DownloadedActivities: make(map[int64]ActivityInfo),
LastCheck: time.Time{},
TotalDownloaded: 0,
filePath: filePath,
}
}
// Load loads state from JSON file
func (s *State) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
// Check if file exists
if _, err := os.Stat(s.filePath); os.IsNotExist(err) {
// File doesn't exist, start with empty state
return nil
}
// Read file
data, err := os.ReadFile(s.filePath)
if err != nil {
return fmt.Errorf("failed to read state file: %w", err)
}
// Unmarshal JSON
if err := json.Unmarshal(data, s); err != nil {
// Try to backup corrupted file
backupPath := s.filePath + ".backup"
_ = os.WriteFile(backupPath, data, 0600)
return fmt.Errorf("failed to unmarshal state file (backed up to %s): %w", backupPath, err)
}
return nil
}
// Save saves state to JSON file
func (s *State) Save() error {
s.mu.RLock()
defer s.mu.RUnlock()
// Create directory if it doesn't exist
dir := filepath.Dir(s.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create state directory: %w", err)
}
// Marshal to JSON
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
// Write to temporary file first (atomic write)
tmpFile := s.filePath + ".tmp"
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
return fmt.Errorf("failed to write temporary state file: %w", err)
}
// Rename temporary file to actual file (atomic operation)
if err := os.Rename(tmpFile, s.filePath); err != nil {
return fmt.Errorf("failed to rename temporary state file: %w", err)
}
return nil
}
// IsDownloaded checks if an activity has already been downloaded
func (s *State) IsDownloaded(activityID int64) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, exists := s.DownloadedActivities[activityID]
return exists
}
// MarkDownloaded marks an activity as downloaded
func (s *State) MarkDownloaded(info ActivityInfo) error {
s.mu.Lock()
defer s.mu.Unlock()
s.DownloadedActivities[info.ID] = info
s.TotalDownloaded++
s.LastCheck = time.Now()
// Save state after marking downloaded
return s.save()
}
// UpdateLastCheck updates the last check timestamp
func (s *State) UpdateLastCheck() error {
s.mu.Lock()
defer s.mu.Unlock()
s.LastCheck = time.Now()
return s.save()
}
// GetStats returns statistics about downloaded activities
func (s *State) GetStats() (totalDownloaded int, lastCheck time.Time) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.TotalDownloaded, s.LastCheck
}
// save is an internal method that saves without locking (assumes lock is held)
func (s *State) save() error {
// Create directory if it doesn't exist
dir := filepath.Dir(s.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create state directory: %w", err)
}
// Marshal to JSON
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
// Write to temporary file first (atomic write)
tmpFile := s.filePath + ".tmp"
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
return fmt.Errorf("failed to write temporary state file: %w", err)
}
// Rename temporary file to actual file (atomic operation)
if err := os.Rename(tmpFile, s.filePath); err != nil {
return fmt.Errorf("failed to rename temporary state file: %w", err)
}
return nil
}

117
internal/storage/storage.go Normal file
View File

@@ -0,0 +1,117 @@
package storage
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// Storage handles local file system operations for FIT files
type Storage struct {
outputDir string
}
// New creates a new Storage instance
func New(outputDir string) (*Storage, error) {
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
return &Storage{
outputDir: outputDir,
}, nil
}
// Save saves a FIT file to the output directory
func (s *Storage) Save(activityID int64, activityName string, date time.Time, data []byte) (string, error) {
// Generate filename
filename := s.generateFilename(activityID, activityName, date)
filepath := filepath.Join(s.outputDir, filename)
// Check if file already exists
if _, err := os.Stat(filepath); err == nil {
return filepath, nil // File already exists, no need to save
}
// Write to temporary file first (atomic write)
tmpFile := filepath + ".tmp"
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
return "", fmt.Errorf("failed to write temporary FIT file: %w", err)
}
// Verify file integrity (non-zero size)
if len(data) == 0 {
_ = os.Remove(tmpFile)
return "", fmt.Errorf("FIT file is empty")
}
// Rename temporary file to actual file (atomic operation)
if err := os.Rename(tmpFile, filepath); err != nil {
_ = os.Remove(tmpFile)
return "", fmt.Errorf("failed to rename temporary FIT file: %w", err)
}
return filepath, nil
}
// Exists checks if a FIT file already exists for an activity
func (s *Storage) Exists(activityID int64) bool {
// List files in output directory
files, err := os.ReadDir(s.outputDir)
if err != nil {
return false
}
// Check if any file starts with the activity ID
prefix := fmt.Sprintf("%d_", activityID)
for _, file := range files {
if strings.HasPrefix(file.Name(), prefix) {
return true
}
}
return false
}
// generateFilename generates a filename for a FIT file
// Format: {activityID}_{date}_{name}.fit
func (s *Storage) generateFilename(activityID int64, activityName string, date time.Time) string {
// Format date as YYYY-MM-DD
dateStr := date.Format("2006-01-02")
// Sanitize activity name (remove special characters)
name := sanitizeFilename(activityName)
if name == "" {
name = "activity"
}
// Limit name length
if len(name) > 50 {
name = name[:50]
}
return fmt.Sprintf("%d_%s_%s.fit", activityID, dateStr, name)
}
// sanitizeFilename removes special characters from filename
func sanitizeFilename(name string) string {
// Replace spaces with dashes
name = strings.ReplaceAll(name, " ", "-")
// Remove special characters (keep alphanumeric, dash, underscore)
re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
name = re.ReplaceAllString(name, "")
// Remove multiple consecutive dashes
re = regexp.MustCompile(`-+`)
name = re.ReplaceAllString(name, "-")
// Trim dashes from start and end
name = strings.Trim(name, "-")
return name
}

106
main.go Normal file
View File

@@ -0,0 +1,106 @@
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"zwift-activity-loader/internal/config"
"zwift-activity-loader/internal/service"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
os.Exit(1)
}
// Set up logging
logger, err := setupLogger(cfg.LogLevel, cfg.LogFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to setup logger: %v\n", err)
os.Exit(1)
}
defer logger.Sync()
// Create service
svc, err := service.New(cfg, logger)
if err != nil {
logger.Fatal("Failed to create service", zap.Error(err))
}
// Set up signal handling for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Start service in goroutine
errChan := make(chan error, 1)
go func() {
errChan <- svc.Run()
}()
// Wait for shutdown signal or error
select {
case sig := <-sigChan:
logger.Info("Received signal", zap.String("signal", sig.String()))
if err := svc.Shutdown(); err != nil {
logger.Error("Error during shutdown", zap.Error(err))
os.Exit(1)
}
case err := <-errChan:
if err != nil {
logger.Error("Service error", zap.Error(err))
os.Exit(1)
}
}
logger.Info("Service exited successfully")
}
// setupLogger creates a zap logger based on configuration
func setupLogger(logLevel, logFile string) (*zap.Logger, error) {
// Parse log level
var level zapcore.Level
if err := level.UnmarshalText([]byte(logLevel)); err != nil {
level = zapcore.InfoLevel
}
// Create encoder config
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "time"
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
// Create core
var core zapcore.Core
if logFile != "" {
// Log to both file and stdout
file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
fileEncoder := zapcore.NewJSONEncoder(encoderConfig)
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
core = zapcore.NewTee(
zapcore.NewCore(fileEncoder, zapcore.AddSync(file), level),
zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level),
)
} else {
// Log to stdout only
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
core = zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level)
}
// Create logger
logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
return logger, nil
}

33
run-local.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Script to run Zwift monitor locally with proper configuration
# Check if credentials are set
if [ -z "$ZWIFT_USERNAME" ] || [ -z "$ZWIFT_PASSWORD" ]; then
echo "Error: ZWIFT_USERNAME and ZWIFT_PASSWORD must be set"
echo ""
echo "Usage:"
echo " export ZWIFT_USERNAME=your@email.com"
echo " export ZWIFT_PASSWORD=yourpassword"
echo " ./run-local.sh"
exit 1
fi
# Set local paths (relative to current directory)
export ZWIFT_OUTPUT_DIR="$(pwd)/data/activities"
export ZWIFT_STATE_FILE="$(pwd)/data/zwift-state.json"
export ZWIFT_TOKEN_CACHE="$(pwd)/data/.zwift-token.json"
export ZWIFT_LOG_LEVEL="${ZWIFT_LOG_LEVEL:-info}"
export ZWIFT_POLL_INTERVAL="${ZWIFT_POLL_INTERVAL:-5m}"
# Create data directory if it doesn't exist
mkdir -p "$ZWIFT_OUTPUT_DIR"
echo "Starting Zwift Activity Monitor..."
echo "Output directory: $ZWIFT_OUTPUT_DIR"
echo "State file: $ZWIFT_STATE_FILE"
echo "Log level: $ZWIFT_LOG_LEVEL"
echo "Poll interval: $ZWIFT_POLL_INTERVAL"
echo ""
# Run the monitor
./zwift-monitor