package service import ( "fmt" "strconv" "strings" "vidarchive/internal/models" "vidarchive/internal/repository" ) // PresetService adds yt-dlp argument building on top of preset storage. The // repository is embedded rather than wrapped in forwarding methods: every one of // its methods (Save, GetByID, GetAll, GetDefault, Delete) is part of this // service's contract anyway. type PresetService struct { *repository.PresetRepository } func NewPresetService(repo *repository.PresetRepository) *PresetService { return &PresetService{PresetRepository: repo} } func (s *PresetService) BuildArgs(p *models.Preset, formatOverride, customFlags string) []string { var args []string // Format selection if formatOverride != "" { args = append(args, "-f", formatOverride) } else if p.FormatMode == "custom" && p.CustomFormat != "" { args = append(args, "-f", p.CustomFormat) } else if p.FormatMode == "preset" && p.Format != "" { if p.Quality != "" { args = append(args, "-f", applyHeightCap(p.Format, p.Quality)) } else { args = append(args, "-f", p.Format) } } // If FormatMode is "default" or empty, don't pass -f (let yt-dlp choose) // Audio extraction if p.ExtractAudio { args = append(args, "--extract-audio") if p.AudioFormat != "" { args = append(args, "--audio-format", p.AudioFormat) } } // Subtitles if p.EmbedSubs { args = append(args, "--embed-subs") if p.SubLangs != "" { args = append(args, "--sub-langs", p.SubLangs) } } // Thumbnail if p.EmbedThumbnail { args = append(args, "--embed-thumbnail") } // Metadata if p.EmbedMetadata { args = append(args, "--embed-metadata") } // Info JSON and comments. Comments are stored in the info JSON sidecar, so // enabling comment collection implicitly enables sidecar writing. if p.WriteInfoJSON || p.WriteComments { args = append(args, "--write-info-json") } if p.WriteComments { args = append(args, "--write-comments") } var commentArgs []string commentSort := strings.TrimSpace(p.CommentSort) if commentSort != "" { commentArgs = append(commentArgs, "comment_sort="+commentSort) } if p.MaxComments > 0 { commentArgs = append(commentArgs, "max_comments="+strconv.Itoa(p.MaxComments)) } // yt-dlp's syntax is IE_KEY:ARG1=VAL1,VAL2;ARG2=VAL: arguments are separated // by ";", only the values of one argument by ",". Passing --extractor-args // twice for the same key makes the second replace the first, so all youtube // arguments must go into a single flag. extra := strings.TrimSpace(p.CommentExtractorArgs) if rest, ok := cutYoutubePrefix(extra); ok { if rest != "" { commentArgs = append(commentArgs, rest) } extra = "" } if len(commentArgs) > 0 { args = append(args, "--extractor-args", "youtube:"+strings.Join(commentArgs, ";")) } if extra != "" { args = append(args, "--extractor-args", extra) } // Custom flags args = append(args, splitCustomFlags(p.CustomFlags)...) args = append(args, splitCustomFlags(customFlags)...) return args } // cutYoutubePrefix strips a leading "youtube:" (any case) from extractor args. // The bool reports whether the value targeted the youtube extractor at all. func cutYoutubePrefix(s string) (string, bool) { if len(s) < len("youtube:") || !strings.EqualFold(s[:len("youtube:")], "youtube:") { return s, false } return strings.TrimSpace(s[len("youtube:"):]), true } // splitCustomFlags splits a custom-flags string into arguments. BuildArgs cannot // report an error, so malformed quoting falls back to whitespace splitting. The // download path already rejected such input in checkReservedFlags, so only the // EffectiveFlags display can reach the fallback. func splitCustomFlags(s string) []string { if strings.TrimSpace(s) == "" { return nil } fields, err := splitFlags(s) if err != nil { return strings.Fields(s) } return fields } // applyHeightCap adds a max-height filter to a format selector. For a combined // "video+audio" selector it binds the filter to the video stream only (e.g. // "bestvideo[height<=720]+bestaudio"), since applying it to the whole expression // would wrongly constrain the audio selector. It always appends an unfiltered // fallback so a missing capped variant still resolves. func applyHeightCap(format, maxHeight string) string { parts := strings.Split(format, "+") parts[0] = fmt.Sprintf("%s[height<=%s]", parts[0], maxHeight) return strings.Join(parts, "+") + "/" + format } func (s *PresetService) EffectiveFlags(p *models.Preset, formatOverride, customFlags string) string { args := s.BuildArgs(p, formatOverride, customFlags) return strings.Join(args, " ") }