49
.dockerignore
Normal file
49
.dockerignore
Normal 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
49
.gitignore
vendored
Normal 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
51
Dockerfile
Normal 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
337
README.md
Normal 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
22
config.example.yaml
Normal 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)
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
zwift-monitor:
|
||||
build: .
|
||||
container_name: zwift-monitor
|
||||
restart: unless-stopped
|
||||
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
43
go.mod
Normal 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
175
go.sum
Normal 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
90
internal/client/auth.go
Normal 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
125
internal/client/client.go
Normal 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
123
internal/config/config.go
Normal 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
149
internal/monitor/monitor.go
Normal 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
160
internal/service/service.go
Normal 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
166
internal/state/state.go
Normal 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
117
internal/storage/storage.go
Normal 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
106
main.go
Normal 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
33
run-local.sh
Normal 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
|
||||
Reference in New Issue
Block a user