preset.go
⎇
Raw
1package service
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7 "vidarchive/internal/models"
8 "vidarchive/internal/repository"
9)
10
11type PresetService struct {
12 repo *repository.PresetRepository
13}
14
15func NewPresetService(repo *repository.PresetRepository) *PresetService {
16 return &PresetService{repo: repo}
17}
18
19func (s *PresetService) Create(p *models.Preset) error {
20 return s.repo.Save(p)
21}
22
23func (s *PresetService) GetByID(id int64) (*models.Preset, error) {
24 return s.repo.GetByID(id)
25}
26
27func (s *PresetService) GetAll() ([]*models.Preset, error) {
28 return s.repo.GetAll()
29}
30
31func (s *PresetService) GetDefault() (*models.Preset, error) {
32 return s.repo.GetDefault()
33}
34
35func (s *PresetService) Update(p *models.Preset) error {
36 return s.repo.Save(p)
37}
38
39func (s *PresetService) Delete(id int64) error {
40 return s.repo.Delete(id)
41}
42
43func (s *PresetService) BuildArgs(p *models.Preset, formatOverride, customFlags string) []string {
44 var args []string
45
46 // Format selection
47 if formatOverride != "" {
48 args = append(args, "-f", formatOverride)
49 } else if p.FormatMode == "custom" && p.CustomFormat != "" {
50 args = append(args, "-f", p.CustomFormat)
51 } else if p.FormatMode == "preset" && p.Format != "" {
52 if p.Quality != "" {
53 args = append(args, "-f", applyHeightCap(p.Format, p.Quality))
54 } else {
55 args = append(args, "-f", p.Format)
56 }
57 }
58 // If FormatMode is "default" or empty, don't pass -f (let yt-dlp choose)
59
60 // Audio extraction
61 if p.ExtractAudio {
62 args = append(args, "--extract-audio")
63 if p.AudioFormat != "" {
64 args = append(args, "--audio-format", p.AudioFormat)
65 }
66 }
67
68 // Subtitles
69 if p.EmbedSubs {
70 args = append(args, "--embed-subs")
71 if p.SubLangs != "" {
72 args = append(args, "--sub-langs", p.SubLangs)
73 }
74 }
75
76 // Thumbnail
77 if p.EmbedThumbnail {
78 args = append(args, "--embed-thumbnail")
79 }
80
81 // Metadata
82 if p.EmbedMetadata {
83 args = append(args, "--embed-metadata")
84 }
85
86 // Info JSON and comments. Comments are stored in the info JSON sidecar, so
87 // enabling comment collection implicitly enables sidecar writing.
88 if p.WriteInfoJSON || p.WriteComments {
89 args = append(args, "--write-info-json")
90 }
91 if p.WriteComments {
92 args = append(args, "--write-comments")
93 }
94 var commentArgs []string
95 commentSort := strings.TrimSpace(p.CommentSort)
96 if commentSort != "" {
97 commentArgs = append(commentArgs, "comment_sort="+commentSort)
98 }
99 if p.MaxComments > 0 {
100 commentArgs = append(commentArgs, "max_comments="+strconv.Itoa(p.MaxComments))
101 }
102 // yt-dlp's syntax is IE_KEY:ARG1=VAL1,VAL2;ARG2=VAL: arguments are separated
103 // by ";", only the values of one argument by ",". Passing --extractor-args
104 // twice for the same key makes the second replace the first, so all youtube
105 // arguments must go into a single flag.
106 extra := strings.TrimSpace(p.CommentExtractorArgs)
107 if rest, ok := cutYoutubePrefix(extra); ok {
108 if rest != "" {
109 commentArgs = append(commentArgs, rest)
110 }
111 extra = ""
112 }
113 if len(commentArgs) > 0 {
114 args = append(args, "--extractor-args", "youtube:"+strings.Join(commentArgs, ";"))
115 }
116 if extra != "" {
117 args = append(args, "--extractor-args", extra)
118 }
119
120 // Custom flags
121 args = append(args, splitCustomFlags(p.CustomFlags)...)
122 args = append(args, splitCustomFlags(customFlags)...)
123
124 return args
125}
126
127// cutYoutubePrefix strips a leading "youtube:" (any case) from extractor args.
128// The bool reports whether the value targeted the youtube extractor at all.
129func cutYoutubePrefix(s string) (string, bool) {
130 if len(s) < len("youtube:") || !strings.EqualFold(s[:len("youtube:")], "youtube:") {
131 return s, false
132 }
133 return strings.TrimSpace(s[len("youtube:"):]), true
134}
135
136// splitCustomFlags splits a custom-flags string into arguments. BuildArgs cannot
137// report an error, so malformed quoting falls back to whitespace splitting. The
138// download path already rejected such input in checkReservedFlags, so only the
139// EffectiveFlags display can reach the fallback.
140func splitCustomFlags(s string) []string {
141 if strings.TrimSpace(s) == "" {
142 return nil
143 }
144 fields, err := splitFlags(s)
145 if err != nil {
146 return strings.Fields(s)
147 }
148 return fields
149}
150
151// applyHeightCap adds a max-height filter to a format selector. For a combined
152// "video+audio" selector it binds the filter to the video stream only (e.g.
153// "bestvideo[height<=720]+bestaudio"), since applying it to the whole expression
154// would wrongly constrain the audio selector. It always appends an unfiltered
155// fallback so a missing capped variant still resolves.
156func applyHeightCap(format, maxHeight string) string {
157 parts := strings.Split(format, "+")
158 parts[0] = fmt.Sprintf("%s[height<=%s]", parts[0], maxHeight)
159 return strings.Join(parts, "+") + "/" + format
160}
161
162func (s *PresetService) EffectiveFlags(p *models.Preset, formatOverride, customFlags string) string {
163 args := s.BuildArgs(p, formatOverride, customFlags)
164 return strings.Join(args, " ")
165}
166