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 in
19// forwarding methods; 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 standard 5-field cron expression. Preset
30// kinds use canonical expressions; "cron" passes the user's custom expression
31// through. Preset times default to 03:00 local to avoid the top-of-hour rush.
32func cronForKind(kind, custom string) (string, error) {
33 switch kind {
34 case "hourly":
35 return "0 * * * *", nil
36 case "daily":
37 return "0 3 * * *", nil
38 case "weekly":
39 return "0 3 * * 0", nil
40 case "monthly":
41 return "0 3 1 * *", nil
42 case "cron":
43 if custom == "" {
44 return "", fmt.Errorf("custom schedule requires a cron expression")
45 }
46 return custom, nil
47 default:
48 return "", fmt.Errorf("unknown schedule kind %q", kind)
49 }
50}
51
52// CronExprFor resolves and validates the effective cron expression for a kind +
53// custom expression, returning an error for unknown kinds or unparseable cron.
54func (s *SubscriptionService) CronExprFor(kind, custom string) (string, error) {
55 expr, err := cronForKind(kind, custom)
56 if err != nil {
57 return "", err
58 }
59 if _, err := cron.ParseStandard(expr); err != nil {
60 return "", fmt.Errorf("invalid cron expression %q: %w", expr, err)
61 }
62 return expr, nil
63}
64
65// ComputeNextRun returns the next scheduled run time strictly after `from`.
66func (s *SubscriptionService) ComputeNextRun(sub *models.Subscription, from time.Time) (time.Time, error) {
67 expr, err := s.CronExprFor(sub.ScheduleKind, sub.CronExpr)
68 if err != nil {
69 return time.Time{}, err
70 }
71 sched, err := cron.ParseStandard(expr)
72 if err != nil {
73 return time.Time{}, err
74 }
75 return sched.Next(from), nil
76}
77
78// ArchivePath is the per-subscription yt-dlp download-archive file used by
79// "skip" refresh mode to avoid re-downloading already-fetched entries.
80func (s *SubscriptionService) ArchivePath(id int64) string {
81 return filepath.Join(s.cfg.DataDir, "archives", fmt.Sprintf("sub-%d.txt", id))
82}
83
84// Delete removes the subscription and the download-archive file "skip" mode
85// keeps for it. Leaving the archive behind would make a later subscription that
86// reuses the id silently skip entries it never downloaded. The library
87// directory is left alone: the archived media is the point of the tool.
88func (s *SubscriptionService) Delete(id int64) error {
89 if err := s.SubscriptionRepository.Delete(id); err != nil {
90 return err
91 }
92 if err := os.Remove(s.ArchivePath(id)); err != nil && !os.IsNotExist(err) {
93 slog.Warn("failed to remove archive file", "subscription_id", id, "err", err)
94 }
95 return nil
96}
97