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 they came from, empty for a plain video URL: entries can
16// differ, so the caller has to say which one this is.
17func (s *DownloadService) ListFormats(ctx context.Context, url string) ([]*models.FormatInfo, string, error) {
18 // -J rather than scraping the human "-F" table, whose columns shift between
19 // yt-dlp versions. stderr is captured separately so warnings can't corrupt the
20 // JSON on stdout. -I 1 is all the parsing below reads anyway.
21 args := []string{"-J", "--no-warnings", "-I", "1"}
22 args, cleanup := s.appendCookies(args)
23 defer cleanup()
24 args = append(args, url)
25
26 cmd := util.KillableCommand(ctx, s.cfg.YTDLPPath, args...)
27 var stderr bytes.Buffer
28 cmd.Stderr = &stderr
29 output, err := cmd.Output()
30 if err != nil {
31 return nil, "", fmt.Errorf("yt-dlp -J failed: %w\n%s", err, stderr.String())
32 }
33
34 return parseFormatJSON(output)
35}
36
37// ytFormat mirrors the subset of yt-dlp's per-format JSON VidArchive surfaces.
38// Numeric fields are pointers so an absent value stays distinct 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 takes the top-level formats for a single video. A playlist
55// dump carries them under its one entry (-I 1) instead, and that entry's title
56// becomes the second result.
57func parseFormatJSON(data []byte) ([]*models.FormatInfo, string, error) {
58 var top struct {
59 Formats []ytFormat `json:"formats"`
60 Entries []struct {
61 Title string `json:"title"`
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 entry := ""
71 if len(raw) == 0 && len(top.Entries) > 0 {
72 raw = top.Entries[0].Formats
73 if entry = top.Entries[0].Title; entry == "" {
74 entry = "the first entry"
75 }
76 }
77
78 formats := make([]*models.FormatInfo, 0, len(raw))
79 for _, f := range raw {
80 formats = append(formats, f.toFormatInfo())
81 }
82 return formats, entry, nil
83}
84
85func (f ytFormat) toFormatInfo() *models.FormatInfo {
86 fi := &models.FormatInfo{
87 ID: f.FormatID,
88 Ext: f.Ext,
89 Note: f.FormatNote,
90 }
91
92 switch {
93 case f.Resolution != "":
94 fi.Resolution = f.Resolution
95 case f.Width != nil && f.Height != nil && *f.Width > 0 && *f.Height > 0:
96 fi.Resolution = fmt.Sprintf("%dx%d", *f.Width, *f.Height)
97 }
98
99 if f.FPS != nil && *f.FPS > 0 {
100 fi.FPS = strconv.FormatFloat(*f.FPS, 'f', -1, 64)
101 }
102 if f.AudioChannels != nil && *f.AudioChannels > 0 {
103 fi.Channels = strconv.Itoa(*f.AudioChannels)
104 }
105
106 if f.VCodec != "" && f.VCodec != "none" {
107 fi.Codec = f.VCodec
108 } else if f.ACodec != "" && f.ACodec != "none" {
109 fi.Codec = f.ACodec
110 }
111
112 if f.Filesize != nil && *f.Filesize > 0 {
113 fi.FileSize = util.FormatBytes(*f.Filesize)
114 } else if f.FilesizeApprox != nil && *f.FilesizeApprox > 0 {
115 fi.FileSize = "~" + util.FormatBytes(*f.FilesizeApprox)
116 }
117
118 return fi
119}
120