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

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
}