subscription.go
⎇
Raw
1package service
2
3import (
4 "fmt"
5 "log/slog"
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
17// SubscriptionService adds schedule resolution and archive-file ownership on top
18// of subscription storage. The repository is embedded rather than wrapped, and
19// Delete below deliberately shadows the repository's.
20type SubscriptionService struct {
21 *repository.SubscriptionRepository
22 cfg *config.Config
23}
24
25func NewSubscriptionService(repo *repository.SubscriptionRepository, cfg *config.Config) *SubscriptionService {
26 return &SubscriptionService{SubscriptionRepository: repo, cfg: cfg}
27}
28
29// cronForKind maps a schedule kind to a 5-field cron expression. The preset
30// kinds run at 03:00 local, away from the top-of-hour rush.
31func cronForKind(kind, custom string) (string, error) {
32 switch kind {
33 case "hourly":
34 return "0 * * * *", nil
35 case "daily":
36 return "0 3 * * *", nil
37 case "weekly":
38 return "0 3 * * 0", nil
39 case "monthly":
40 return "0 3 1 * *", nil
41 case "cron":
42 if custom == "" {
43 return "", fmt.Errorf("custom schedule requires a cron expression")
44 }
45 return custom, nil
46 default:
47 return "", fmt.Errorf("unknown schedule kind %q", kind)
48 }
49}
50
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 yt-dlp download-archive file "skip" refresh mode reads.
76func (s *SubscriptionService) ArchivePath(id int64) string {
77 return filepath.Join(s.cfg.DataDir, "archives", fmt.Sprintf("sub-%d.txt", id))
78}
79
80// Delete takes the archive file with it: left behind, a later subscription
81// reusing the id would silently skip entries it never downloaded. The library
82// directory stays: the archived media is the point of the tool.
83func (s *SubscriptionService) Delete(id int64) error {
84 if err := s.SubscriptionRepository.Delete(id); err != nil {
85 return err
86 }
87 if err := os.Remove(s.ArchivePath(id)); err != nil && !os.IsNotExist(err) {
88 slog.Warn("failed to remove archive file", "subscription_id", id, "err", err)
89 }
90 return nil
91}
92