package service import ( "bytes" "encoding/json" "fmt" "os/exec" "strconv" "vidarchive/internal/models" "vidarchive/internal/util" ) func (s *DownloadService) ListFormats(url string) ([]*models.FormatInfo, error) { // Use machine-readable JSON (-J) rather than scraping the human "-F" table, // whose columns/separators shift between yt-dlp versions. stderr is captured // separately so warnings can't corrupt the JSON on stdout. // -I 1 limits a playlist URL to its first entry, which 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 := exec.Command(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 (-J) we surface. // Numeric fields are pointers so an absent value (null/omitted) is 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 reads yt-dlp's single-JSON dump (-J) and returns the available // formats. For a single video the formats live at the top level; for a playlist // URL the dump is restricted to one entry (-I 1), so we fall back to that // entry's formats and the picker still shows something useful. func parseFormatJSON(data []byte) ([]*models.FormatInfo, error) { var top struct { Formats []ytFormat `json:"formats"` Entries []struct { 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 if len(raw) == 0 && len(top.Entries) > 0 { raw = top.Entries[0].Formats } formats := make([]*models.FormatInfo, 0, len(raw)) for _, f := range raw { formats = append(formats, f.toFormatInfo()) } return formats, 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) } // Prefer the video codec; fall back to the audio codec for audio-only formats. 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 }