subscription.go
⎇
Raw
1package service
2
3import (
4 "fmt"
5 "log"
6 "os"
7 "path/filepath"
8 "time"
9
10 "github.com/robfig/cron/v3"
11
12 "vidarchive/internal/config"
13 "vidarchive/internal/models"
14 "vidarchive/internal/repository"
15)
16
17type SubscriptionService struct {
18 repo *repository.SubscriptionRepository
19 cfg *config.Config
20}
21
22func NewSubscriptionService(repo *repository.SubscriptionRepository, cfg *config.Config) *SubscriptionService {
23 return &SubscriptionService{repo: repo, cfg: cfg}
24}
25
26// cronForKind maps a schedule kind to a standard 5-field cron expression. Preset
27// kinds use canonical expressions; "cron" passes the user's custom expression
28// through. Preset times default to 03:00 local to avoid the top-of-hour rush.
29func cronForKind(kind, custom string) (string, error) {
30 switch kind {
31 case "hourly":
32 return "0 * * * *", nil
33 case "daily":
34 return "0 3 * * *", nil
35 case "weekly":
36 return "0 3 * * 0", nil
37 case "monthly":
38 return "0 3 1 * *", nil
39 case "cron":
40 if custom == "" {
41 return "", fmt.Errorf("custom schedule requires a cron expression")
42 }
43 return custom, nil
44 default:
45 return "", fmt.Errorf("unknown schedule kind %q", kind)
46 }
47}
48
49// CronExprFor resolves and validates the effective cron expression for a kind +
50// custom expression, returning an error for unknown kinds or unparseable cron.
51func (s *SubscriptionService) CronExprFor(kind, custom string) (string, error) {
52 expr, err := cronForKind(kind, custom)
53 if err != nil {
54 return "", err
55 }
56 if _, err := cron.ParseStandard(expr); err != nil {
57 return "", fmt.Errorf("invalid cron expression %q: %w", expr, err)
58 }
59 return expr, nil
60}
61
62// ComputeNextRun returns the next scheduled run time strictly after `from`.
63func (s *SubscriptionService) ComputeNextRun(sub *models.Subscription, from time.Time) (time.Time, error) {
64 expr, err := s.CronExprFor(sub.ScheduleKind, sub.CronExpr)
65 if err != nil {
66 return time.Time{}, err
67 }
68 sched, err := cron.ParseStandard(expr)
69 if err != nil {
70 return time.Time{}, err
71 }
72 return sched.Next(from), nil
73}
74
75// ArchivePath is the per-subscription yt-dlp download-archive file used by
76// "skip" refresh mode to avoid re-downloading already-fetched entries.
77func (s *SubscriptionService) ArchivePath(id int64) string {
78 return filepath.Join(s.cfg.DataDir, "archives", fmt.Sprintf("sub-%d.txt", id))
79}
80
81func (s *SubscriptionService) GetAll() ([]*models.Subscription, error) {
82 return s.repo.GetAll()
83}
84
85func (s *SubscriptionService) GetByID(id int64) (*models.Subscription, error) {
86 return s.repo.GetByID(id)
87}
88
89func (s *SubscriptionService) GetDue(now time.Time) ([]*models.Subscription, error) {
90 return s.repo.GetDue(now)
91}
92
93func (s *SubscriptionService) Create(sub *models.Subscription) error {
94 return s.repo.Create(sub)
95}
96
97func (s *SubscriptionService) Update(sub *models.Subscription) error {
98 return s.repo.Update(sub)
99}
100
101func (s *SubscriptionService) SetEnabled(id int64, enabled bool) error {
102 return s.repo.SetEnabled(id, enabled)
103}
104
105func (s *SubscriptionService) MarkRun(id int64, lastRunAt, nextRunAt time.Time, status string) error {
106 return s.repo.MarkRun(id, lastRunAt, nextRunAt, status)
107}
108
109// Delete removes the subscription and the download-archive file "skip" mode
110// keeps for it. Leaving the archive behind would make a later subscription that
111// reuses the id silently skip entries it never downloaded. The library
112// directory is left alone: the archived media is the point of the tool.
113func (s *SubscriptionService) Delete(id int64) error {
114 if err := s.repo.Delete(id); err != nil {
115 return err
116 }
117 if err := os.Remove(s.ArchivePath(id)); err != nil && !os.IsNotExist(err) {
118 log.Printf("subscription %d: failed to remove archive file: %v", id, err)
119 }
120 return nil
121}
122