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 // -I 1 limits a playlist URL to its first entry, which is all the parsing
19 // below reads anyway.
20 args := []string{"-J", "--no-warnings", "-I", "1"}
21 args, cleanup := s.appendCookies(args)
22 defer cleanup()
23 args = append(args, url)
24
25 cmd := exec.Command(s.cfg.YTDLPPath, args...)
26 var stderr bytes.Buffer
27 cmd.Stderr = &stderr
28 output, err := cmd.Output()
29 if err != nil {
30 return nil, fmt.Errorf("yt-dlp -J failed: %w\n%s", err, stderr.String())
31 }
32
33 return parseFormatJSON(output)
34}
35
36// ytFormat mirrors the subset of yt-dlp's per-format JSON (-J) we surface.
37// Numeric fields are pointers so an absent value (null/omitted) is distinct
38// from a real zero.
39type ytFormat struct {
40 FormatID string `json:"format_id"`
41 Ext string `json:"ext"`
42 Resolution string `json:"resolution"`
43 Width *int `json:"width"`
44 Height *int `json:"height"`
45 FPS *float64 `json:"fps"`
46 VCodec string `json:"vcodec"`
47 ACodec string `json:"acodec"`
48 AudioChannels *int `json:"audio_channels"`
49 Filesize *int64 `json:"filesize"`
50 FilesizeApprox *int64 `json:"filesize_approx"`
51 FormatNote string `json:"format_note"`
52}
53
54// parseFormatJSON reads yt-dlp's single-JSON dump (-J) and returns the available
55// formats. For a single video the formats live at the top level; for a playlist
56// URL the dump is restricted to one entry (-I 1), so we fall back to that
57// entry's formats and the picker still shows something useful.
58func parseFormatJSON(data []byte) ([]*models.FormatInfo, error) {
59 var top struct {
60 Formats []ytFormat `json:"formats"`
61 Entries []struct {
62 Formats []ytFormat `json:"formats"`
63 } `json:"entries"`
64 }
65 if err := json.Unmarshal(data, &top); err != nil {
66 return nil, fmt.Errorf("parse yt-dlp JSON: %w", err)
67 }
68
69 raw := top.Formats
70 if len(raw) == 0 && len(top.Entries) > 0 {
71 raw = top.Entries[0].Formats
72 }
73
74 formats := make([]*models.FormatInfo, 0, len(raw))
75 for _, f := range raw {
76 formats = append(formats, f.toFormatInfo())
77 }
78 return formats, nil
79}
80
81func (f ytFormat) toFormatInfo() *models.FormatInfo {
82 fi := &models.FormatInfo{
83 ID: f.FormatID,
84 Ext: f.Ext,
85 Note: f.FormatNote,
86 }
87
88 switch {
89 case f.Resolution != "":
90 fi.Resolution = f.Resolution
91 case f.Width != nil && f.Height != nil && *f.Width > 0 && *f.Height > 0:
92 fi.Resolution = fmt.Sprintf("%dx%d", *f.Width, *f.Height)
93 }
94
95 if f.FPS != nil && *f.FPS > 0 {
96 fi.FPS = strconv.FormatFloat(*f.FPS, 'f', -1, 64)
97 }
98 if f.AudioChannels != nil && *f.AudioChannels > 0 {
99 fi.Channels = strconv.Itoa(*f.AudioChannels)
100 }
101
102 // Prefer the video codec; fall back to the audio codec for audio-only formats.
103 if f.VCodec != "" && f.VCodec != "none" {
104 fi.Codec = f.VCodec
105 } else if f.ACodec != "" && f.ACodec != "none" {
106 fi.Codec = f.ACodec
107 }
108
109 if f.Filesize != nil && *f.Filesize > 0 {
110 fi.FileSize = util.FormatBytes(*f.Filesize)
111 } else if f.FilesizeApprox != nil && *f.FilesizeApprox > 0 {
112 fi.FileSize = "~" + util.FormatBytes(*f.FilesizeApprox)
113 }
114
115 return fi
116}
117