package service import ( "fmt" "log/slog" "os" "path/filepath" "time" "github.com/robfig/cron/v3" "vidarchive/internal/config" "vidarchive/internal/models" "vidarchive/internal/repository" ) // SubscriptionService adds schedule resolution and archive-file ownership on top // of subscription storage. The repository is embedded rather than wrapped, and // Delete below deliberately shadows the repository's. type SubscriptionService struct { *repository.SubscriptionRepository cfg *config.Config } func NewSubscriptionService(repo *repository.SubscriptionRepository, cfg *config.Config) *SubscriptionService { return &SubscriptionService{SubscriptionRepository: repo, cfg: cfg} } // cronForKind maps a schedule kind to a 5-field cron expression. The preset // kinds run at 03:00 local, away from the top-of-hour rush. func cronForKind(kind, custom string) (string, error) { switch kind { case "hourly": return "0 * * * *", nil case "daily": return "0 3 * * *", nil case "weekly": return "0 3 * * 0", nil case "monthly": return "0 3 1 * *", nil case "cron": if custom == "" { return "", fmt.Errorf("custom schedule requires a cron expression") } return custom, nil default: return "", fmt.Errorf("unknown schedule kind %q", kind) } } func (s *SubscriptionService) CronExprFor(kind, custom string) (string, error) { expr, err := cronForKind(kind, custom) if err != nil { return "", err } if _, err := cron.ParseStandard(expr); err != nil { return "", fmt.Errorf("invalid cron expression %q: %w", expr, err) } return expr, nil } // ComputeNextRun returns the next scheduled run time strictly after `from`. func (s *SubscriptionService) ComputeNextRun(sub *models.Subscription, from time.Time) (time.Time, error) { expr, err := s.CronExprFor(sub.ScheduleKind, sub.CronExpr) if err != nil { return time.Time{}, err } sched, err := cron.ParseStandard(expr) if err != nil { return time.Time{}, err } return sched.Next(from), nil } // ArchivePath is the yt-dlp download-archive file "skip" refresh mode reads. func (s *SubscriptionService) ArchivePath(id int64) string { return filepath.Join(s.cfg.DataDir, "archives", fmt.Sprintf("sub-%d.txt", id)) } // Delete takes the archive file with it: left behind, a later subscription // reusing the id would silently skip entries it never downloaded. The library // directory stays: the archived media is the point of the tool. func (s *SubscriptionService) Delete(id int64) error { if err := s.SubscriptionRepository.Delete(id); err != nil { return err } if err := os.Remove(s.ArchivePath(id)); err != nil && !os.IsNotExist(err) { slog.Warn("failed to remove archive file", "subscription_id", id, "err", err) } return nil }