package service import ( "bytes" "context" "encoding/json" "fmt" "strconv" "vidarchive/internal/models" "vidarchive/internal/util" ) // ListFormats returns the formats offered for url. The second result names the // playlist entry they came from, empty for a plain video URL: entries can // differ, so the caller has to say which one this is. func (s *DownloadService) ListFormats(ctx context.Context, url string) ([]*models.FormatInfo, string, error) { // -J rather than scraping the human "-F" table, whose columns shift between // yt-dlp versions. stderr is captured separately so warnings can't corrupt the // JSON on stdout. -I 1 is all the parsing below reads anyway. args := []string{"-J", "--no-warnings", "-I", "1"} args, cleanup := s.appendCookies(args) defer cleanup() args = append(args, url) cmd := util.KillableCommand(ctx, s.cfg.YTDLPPath, args...) var stderr bytes.Buffer cmd.Stderr = &stderr output, err := cmd.Output() if err != nil { return nil, "", fmt.Errorf("yt-dlp -J failed: %w\n%s", err, stderr.String()) } return parseFormatJSON(output) } // ytFormat mirrors the subset of yt-dlp's per-format JSON VidArchive surfaces. // Numeric fields are pointers so an absent value stays distinct from a real zero. type ytFormat struct { FormatID string `json:"format_id"` Ext string `json:"ext"` Resolution string `json:"resolution"` Width *int `json:"width"` Height *int `json:"height"` FPS *float64 `json:"fps"` VCodec string `json:"vcodec"` ACodec string `json:"acodec"` AudioChannels *int `json:"audio_channels"` Filesize *int64 `json:"filesize"` FilesizeApprox *int64 `json:"filesize_approx"` FormatNote string `json:"format_note"` } // parseFormatJSON takes the top-level formats for a single video. A playlist // dump carries them under its one entry (-I 1) instead, and that entry's title // becomes the second result. func parseFormatJSON(data []byte) ([]*models.FormatInfo, string, error) { var top struct { Formats []ytFormat `json:"formats"` Entries []struct { Title string `json:"title"` Formats []ytFormat `json:"formats"` } `json:"entries"` } if err := json.Unmarshal(data, &top); err != nil { return nil, "", fmt.Errorf("parse yt-dlp JSON: %w", err) } raw := top.Formats entry := "" if len(raw) == 0 && len(top.Entries) > 0 { raw = top.Entries[0].Formats if entry = top.Entries[0].Title; entry == "" { entry = "the first entry" } } formats := make([]*models.FormatInfo, 0, len(raw)) for _, f := range raw { formats = append(formats, f.toFormatInfo()) } return formats, entry, nil } func (f ytFormat) toFormatInfo() *models.FormatInfo { fi := &models.FormatInfo{ ID: f.FormatID, Ext: f.Ext, Note: f.FormatNote, } switch { case f.Resolution != "": fi.Resolution = f.Resolution case f.Width != nil && f.Height != nil && *f.Width > 0 && *f.Height > 0: fi.Resolution = fmt.Sprintf("%dx%d", *f.Width, *f.Height) } if f.FPS != nil && *f.FPS > 0 { fi.FPS = strconv.FormatFloat(*f.FPS, 'f', -1, 64) } if f.AudioChannels != nil && *f.AudioChannels > 0 { fi.Channels = strconv.Itoa(*f.AudioChannels) } if f.VCodec != "" && f.VCodec != "none" { fi.Codec = f.VCodec } else if f.ACodec != "" && f.ACodec != "none" { fi.Codec = f.ACodec } if f.Filesize != nil && *f.Filesize > 0 { fi.FileSize = util.FormatBytes(*f.Filesize) } else if f.FilesizeApprox != nil && *f.FilesizeApprox > 0 { fi.FileSize = "~" + util.FormatBytes(*f.FilesizeApprox) } return fi }