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 }