formats.go
⎇
Raw
1package service
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os/exec"
8 "strconv"
9
10 "vidarchive/internal/models"
11 "vidarchive/internal/util"
12)
13
14func (s *DownloadService) ListFormats(url string) ([]*models.FormatInfo, error) {
15 // Use machine-readable JSON (-J) rather than scraping the human "-F" table,
16 // whose columns/separators shift between yt-dlp versions. stderr is captured
17 // separately so warnings can't corrupt the JSON on stdout.
18 cmd := exec.Command(s.cfg.YTDLPPath, "-J", "--no-warnings", url)
19 var stderr bytes.Buffer
20 cmd.Stderr = &stderr
21 output, err := cmd.Output()
22 if err != nil {
23 return nil, fmt.Errorf("yt-dlp -J failed: %w\n%s", err, stderr.String())
24 }
25
26 return parseFormatJSON(output)
27}
28
29// ytFormat mirrors the subset of yt-dlp's per-format JSON (-J) we surface.
30// Numeric fields are pointers so an absent value (null/omitted) is distinct
31// from a real zero.
32type ytFormat struct {
33 FormatID string `json:"format_id"`
34 Ext string `json:"ext"`
35 Resolution string `json:"resolution"`
36 Width *int `json:"width"`
37 Height *int `json:"height"`
38 FPS *float64 `json:"fps"`
39 VCodec string `json:"vcodec"`
40 ACodec string `json:"acodec"`
41 AudioChannels *int `json:"audio_channels"`
42 Filesize *int64 `json:"filesize"`
43 FilesizeApprox *int64 `json:"filesize_approx"`
44 FormatNote string `json:"format_note"`
45}
46
47// parseFormatJSON reads yt-dlp's single-JSON dump (-J) and returns the available
48// formats. For a single video the formats live at the top level; for a playlist
49// URL we fall back to the first entry's formats so the picker still shows
50// something useful.
51func parseFormatJSON(data []byte) ([]*models.FormatInfo, error) {
52 var top struct {
53 Formats []ytFormat `json:"formats"`
54 Entries []struct {
55 Formats []ytFormat `json:"formats"`
56 } `json:"entries"`
57 }
58 if err := json.Unmarshal(data, &top); err != nil {
59 return nil, fmt.Errorf("parse yt-dlp JSON: %w", err)
60 }
61
62 raw := top.Formats
63 if len(raw) == 0 && len(top.Entries) > 0 {
64 raw = top.Entries[0].Formats
65 }
66
67 formats := make([]*models.FormatInfo, 0, len(raw))
68 for _, f := range raw {
69 formats = append(formats, f.toFormatInfo())
70 }
71 return formats, nil
72}
73
74func (f ytFormat) toFormatInfo() *models.FormatInfo {
75 fi := &models.FormatInfo{
76 ID: f.FormatID,
77 Ext: f.Ext,
78 Note: f.FormatNote,
79 }
80
81 switch {
82 case f.Resolution != "":
83 fi.Resolution = f.Resolution
84 case f.Width != nil && f.Height != nil && *f.Width > 0 && *f.Height > 0:
85 fi.Resolution = fmt.Sprintf("%dx%d", *f.Width, *f.Height)
86 }
87
88 if f.FPS != nil && *f.FPS > 0 {
89 fi.FPS = strconv.FormatFloat(*f.FPS, 'f', -1, 64)
90 }
91 if f.AudioChannels != nil && *f.AudioChannels > 0 {
92 fi.Channels = strconv.Itoa(*f.AudioChannels)
93 }
94
95 // Prefer the video codec; fall back to the audio codec for audio-only formats.
96 if f.VCodec != "" && f.VCodec != "none" {
97 fi.Codec = f.VCodec
98 } else if f.ACodec != "" && f.ACodec != "none" {
99 fi.Codec = f.ACodec
100 }
101
102 if f.Filesize != nil && *f.Filesize > 0 {
103 fi.FileSize = util.FormatBytes(*f.Filesize)
104 } else if f.FilesizeApprox != nil && *f.FilesizeApprox > 0 {
105 fi.FileSize = "~" + util.FormatBytes(*f.FilesizeApprox)
106 }
107
108 return fi
109}
110