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: every one of its methods 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 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) } } // A "default" or empty FormatMode passes no -f at all: yt-dlp chooses. if p.ExtractAudio { args = append(args, "--extract-audio") if p.AudioFormat != "" { args = append(args, "--audio-format", p.AudioFormat) } } if p.EmbedSubs { args = append(args, "--embed-subs") if p.SubLangs != "" { args = append(args, "--sub-langs", p.SubLangs) } } if p.EmbedThumbnail { args = append(args, "--embed-thumbnail") } if p.EmbedMetadata { args = append(args, "--embed-metadata") } // Comments live in the info JSON sidecar, so collecting them implies writing it. 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. A second --extractor-args // for the same key replaces the first, so every youtube argument has to go // into one 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) } args = append(args, splitCustomFlags(p.CustomFlags)...) args = append(args, splitCustomFlags(customFlags)...) return args } // cutYoutubePrefix strips a leading "youtube:", any case. 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 falls back to whitespace splitting on malformed quoting, // because BuildArgs cannot report an error. Only the EffectiveFlags display // reaches that fallback; the download path rejects such input beforehand. 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 binds the filter to the video stream only, since a combined // "video+audio" selector would otherwise constrain the audio too. The unfiltered // fallback keeps a missing capped variant resolvable. 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, " ") }