clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / youtube / video.go
video.go 9.4K
1package youtube23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "io"9 "net/http"10 "net/url"11 "os/exec"12 "regexp"13 "slices"14 "sort"15 "strconv"16 "strings"17 "sync"18 "time"19)2021// Info is a video's details from yt-dlp -J, and with a format chosen,22// where to stream it from.23type Info struct {24 ID string `json:"id"`25 Title string `json:"title"`26 Duration float64 `json:"duration"`27 Timestamp float64 `json:"timestamp"`28 Description string `json:"description"`29 Language string `json:"language"`30 LiveStatus string `json:"live_status"`31 MediaType string `json:"media_type"`32 FormatID string `json:"format_id"`33 Format34 RequestedFormats []Format `json:"requested_formats"`35 // Subtitles are the uploader's, AutoCaptions YouTube's speech36 // recognition, by language ("en", "en-US", "en-orig").37 Subtitles map[string][]Subtitle `json:"subtitles"`38 AutoCaptions map[string][]Subtitle `json:"automatic_captions"`39 // Thumbnails are its pictures, largest last as yt-dlp lists them.40 Thumbnails []struct {41 URL string `json:"url"`42 Width int `json:"width"`43 Height int `json:"height"`44 } `json:"thumbnails"`45}4647// Format is a stream of a video: its picture, its sound, or both.48type Format struct {49 URL string `json:"url"`50 VCodec string `json:"vcodec"`51 ACodec string `json:"acodec"`52 Height int `json:"height"`53 Language string `json:"language"`54 HTTPHeaders map[string]string `json:"http_headers"`55}5657// Subtitle is a subtitle track in one format.58type Subtitle struct {59 Ext string `json:"ext"`60 URL string `json:"url"`61 Protocol string `json:"protocol"` // "m3u8_native" for a playlist of pieces62}6364// ParseInfo reads yt-dlp -J's details of a video.65func ParseInfo(raw []byte) (Info, error) {66 var i Info67 if err := json.Unmarshal(raw, &i); err != nil {68 return i, err69 }70 if i.ID == "" {71 return i, errors.New("no video in yt-dlp's output")72 }73 return i, nil74}7576// Inputs are the streams to play a resolved video from, picture first.77func (i Info) Inputs() []Format {78 fs := i.RequestedFormats79 if len(fs) == 0 && i.URL != "" {80 fs = []Format{i.Format}81 }82 out := make([]Format, 0, len(fs))83 for _, f := range fs {84 if f.VCodec != "none" && f.VCodec != "" {85 out = append(out, f)86 }87 }88 for _, f := range fs {89 if f.VCodec == "none" || f.VCodec == "" {90 out = append(out, f)91 }92 }93 return out94}9596// AudioLang is the sound's language, "" when YouTube doesn't say.97func (i Info) AudioLang() string {98 for _, f := range i.Inputs() {99 if f.ACodec != "none" && f.ACodec != "" && f.Language != "" {100 return f.Language101 }102 }103 return i.Language104}105106// Captions picks the video's English captions: the uploader's, else107// YouTube's automatic ones, as json3, which times each word of automatic108// captions, else WebVTT or SRT. ok is false when there are none.109func (i Info) Captions() (sub Subtitle, ok bool) {110 pick := func(tracks map[string][]Subtitle, langs ...string) (Subtitle, bool) {111 for _, l := range langs {112 for _, ext := range []string{"json3", "vtt", "srt"} {113 for _, s := range tracks[l] {114 if s.Ext == ext && s.URL != "" {115 return s, true116 }117 }118 }119 }120 return Subtitle{}, false121 }122 var others []string123 for l := range i.Subtitles {124 if strings.HasPrefix(l, "en-") {125 others = append(others, l)126 }127 }128 slices.Sort(others)129 if s, ok := pick(i.Subtitles, append([]string{"en"}, others...)...); ok {130 return s, true131 }132 return pick(i.AutoCaptions, "en-orig", "en")133}134135// FormatSpec picks H.264 picture up to height and AAC sound, English136// sound of a video dubbed in several languages, else the best single file137// up to height.138func FormatSpec(height int) string {139 return fmt.Sprintf("bv*[height<=%[1]d][vcodec^=avc1]+ba[ext=m4a][language^=en]/bv*[height<=%[1]d][vcodec^=avc1]+ba[ext=m4a]/b[height<=%[1]d]", height)140}141142// InputArgs are ffmpeg's inputs for a resolved video, played from offset143// in: picture first, with yt-dlp's headers, reconnecting when a144// connection drops. A stream that fails for good stops ffmpeg (-xerror)145// rather than ending the video early, so it can be reopened. chunked146// fetches in 10 MB ranges as yt-dlp does, which YouTube serves without147// throttling (see HTTPChunks). audio is the sound's stream specifier.148func InputArgs(fs []Format, offset time.Duration, chunked bool) (args []string, audio string) {149 args = []string{"-xerror"}150 for i, f := range fs {151 args = append(args, "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_on_network_error", "1",152 "-reconnect_delay_max", "4", "-rw_timeout", "15000000")153 if chunked {154 args = append(args, "-request_size", "10485760", "-multiple_requests", "1")155 }156 if len(f.HTTPHeaders) > 0 {157 var h strings.Builder158 for _, k := range sortedKeys(f.HTTPHeaders) {159 h.WriteString(k + ": " + f.HTTPHeaders[k] + "\r\n")160 }161 args = append(args, "-headers", h.String())162 }163 if offset > 0 {164 args = append(args, "-ss", strconv.FormatFloat(offset.Seconds(), 'f', 3, 64))165 }166 args = append(args, "-i", f.URL)167 if audio == "" && f.ACodec != "none" && f.ACodec != "" {168 audio = fmt.Sprintf("%d:a:0", i)169 }170 }171 return args, audio172}173174func sortedKeys(m map[string]string) []string {175 keys := make([]string, 0, len(m))176 for k := range m {177 keys = append(keys, k)178 }179 sort.Strings(keys)180 return keys181}182183var chunks sync.Map // ffmpeg path: bool184185// HTTPChunks reports whether ffmpeg can fetch HTTP in ranges186// (-request_size, FFmpeg 8).187func HTTPChunks(ffmpeg string) bool {188 if ok, found := chunks.Load(ffmpeg); found {189 return ok.(bool)190 }191 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)192 defer cancel()193 out, _ := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-h", "protocol=http").Output()194 ok := strings.Contains(string(out), "-request_size")195 chunks.Store(ffmpeg, ok)196 return ok197}198199// AddressesUntil is when a video's addresses, found at, may stop working:200// ten minutes before the earliest "expire" they carry (YouTube's last six201// hours), or an hour after they were found when they carry none.202func AddressesUntil(info Info, at time.Time) time.Time {203 until := at.Add(time.Hour)204 var earliest time.Time205 for _, f := range info.Inputs() {206 u, err := url.Parse(f.URL)207 if err != nil {208 return until209 }210 sec, err := strconv.ParseInt(u.Query().Get("expire"), 10, 64)211 if err != nil || sec <= 0 {212 return until213 }214 if t := time.Unix(sec, 0); earliest.IsZero() || t.Before(earliest) {215 earliest = t216 }217 }218 if earliest.IsZero() {219 return until220 }221 if latest := at.Add(6 * time.Hour); earliest.After(latest) {222 return latest223 }224 return earliest.Add(-10 * time.Minute)225}226227// FetchCaptions fetches a caption track: a json3, WebVTT or SRT file, or a228// playlist of WebVTT pieces, joined.229func FetchCaptions(ctx context.Context, client *http.Client, sub Subtitle) ([]byte, error) {230 raw, err := get(ctx, client, sub.URL)231 if err != nil || !strings.HasPrefix(sub.Protocol, "m3u8") {232 return raw, err233 }234 base, err := url.Parse(sub.URL)235 if err != nil {236 return nil, errors.New("captions: bad address")237 }238 var all []byte239 pieces := 0240 for line := range strings.Lines(string(raw)) {241 line = strings.TrimSpace(line)242 if line == "" || strings.HasPrefix(line, "#") {243 continue244 }245 if pieces++; pieces > 500 {246 break247 }248 u, err := base.Parse(line)249 if err != nil {250 return nil, errors.New("captions: bad address")251 }252 piece, err := get(ctx, client, u.String())253 if err != nil {254 return nil, err255 }256 all = append(append(all, piece...), "\n\n"...)257 }258 return all, nil259}260261// get fetches captions, leaving their address, which is signed, out of262// errors.263func get(ctx context.Context, client *http.Client, address string) ([]byte, error) {264 req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)265 if err != nil {266 return nil, errors.New("captions: bad address")267 }268 resp, err := client.Do(req)269 if uerr := (*url.Error)(nil); errors.As(err, &uerr) {270 return nil, fmt.Errorf("captions: %w", uerr.Err)271 }272 if err != nil {273 return nil, fmt.Errorf("captions: %w", err)274 }275 defer resp.Body.Close()276 if resp.StatusCode != http.StatusOK {277 return nil, fmt.Errorf("captions: HTTP %d", resp.StatusCode)278 }279 return io.ReadAll(io.LimitReader(resp.Body, 16<<20))280}281282var (283 separator = regexp.MustCompile(`(?m)^\s*[=\-_*~#]{5,}\s*$`)284 label = regexp.MustCompile(`^.{0,40}:$`)285)286287// Description makes a short description of a YouTube description: its288// first paragraph that says something, before the links section, without289// addresses or the labels left of them ("Game on Steam:").290func Description(s string) string {291 if i := separator.FindStringIndex(s); i != nil {292 s = s[:i[0]]293 }294 for para := range strings.SplitSeq(strings.ReplaceAll(s, "\r", ""), "\n\n") {295 var keep []string296 for line := range strings.SplitSeq(para, "\n") {297 line = strings.TrimSpace(urls.ReplaceAllString(line, ""))298 if line != "" && !label.MatchString(line) {299 keep = append(keep, strings.TrimSuffix(line, ":"))300 }301 }302 if len(keep) > 0 {303 return firstParagraph(strings.Join(keep, "\n"), 300)304 }305 }306 return ""307}308309// firstParagraph is s's first paragraph on one line, cut at a word to n310// characters at most.311func firstParagraph(s string, n int) string {312 s = strings.TrimSpace(s)313 if i := strings.Index(s, "\n\n"); i >= 0 {314 s = s[:i]315 }316 s = strings.Join(strings.Fields(s), " ")317 if r := []rune(s); len(r) > n {318 cut := string(r[:n])319 if i := strings.LastIndex(cut, " "); i > n/2 {320 cut = cut[:i]321 }322 s = cut + "…"323 }324 return s325}