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