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