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