commit c54fa58c359e5b82a5cbace787eea10dc71332e0 Author: Thomas Klaehn Date: Tue Feb 10 11:47:49 2026 +0100 Initial commit Signed-off-by: Thomas Klaehn diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..917276d --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7758c60 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77a1f19 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5768cea --- /dev/null +++ b/README.md @@ -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 + cd zwift + ``` + +2. **Create environment file** + ```bash + cat > .env < 0 { + // Exponential backoff + backoff := time.Duration(1< 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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..adf3cd3 --- /dev/null +++ b/main.go @@ -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 +} diff --git a/run-local.sh b/run-local.sh new file mode 100644 index 0000000..8706e9f --- /dev/null +++ b/run-local.sh @@ -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