1package vchan23import (4 "context"5 "errors"6 "log"7 "time"89 "airwaves/internal/phase"10 "airwaves/internal/youtube"11)1213// Finding where to stream a video from takes yt-dlp seconds, too long to14// wait for when tuning in. So each YouTube channel keeps the addresses of15// the video on now and the next found ahead of time (see warm), and finds16// them again before they expire. That's one yt-dlp run per video as it17// comes up, and one more for a video longer than its addresses last.1819// ytResolved is a video's details with its addresses, found ahead.20type ytResolved struct {21 info youtube.Info22 // caps are its English captions, fetched as soon as it's found, so23 // tuning in doesn't wait for them either; nil when it has none.24 caps *captions25 height int // the most picture asked for26 until time.Time // when its addresses may stop working27}2829type ytAhead struct {30 started bool // asking yt-dlp, rather than waiting to31 done chan struct{} // closed at the end32}3334const (35 // ytWarmIdle is how long a channel nobody lists, guides or watches36 // keeps finding its videos ahead.37 ytWarmIdle = 6 * time.Hour38 // ytWarmEvery is how often it looks at what to find.39 ytWarmEvery = 30 * time.Second40 // ytMinLeft is how long addresses must still work to be used, or41 // kept: they're found again sooner.42 ytMinLeft = 20 * time.Minute43 // ytMissedRetry spaces out tries to find a video ahead that failed.44 ytMissedRetry = 10 * time.Minute45)4647// ytWarm turns finding videos ahead on; tests turn it off.48var ytWarm = true4950// warm keeps the addresses of the videos on now and next found, so51// tuning in never waits for yt-dlp. It runs while the channel is tended52// (listed, guided or watched) and stops ytWarmIdle after it last was, or53// once the channel leaves the library.54func (y *YouTube) warm() {55 for {56 y.mu.Lock()57 if y.closed || time.Since(y.touched) > ytWarmIdle {58 y.warming = false59 y.mu.Unlock()60 return61 }62 ids := y.warmDue(time.Now())63 y.mu.Unlock()64 for _, id := range ids {65 y.lookAhead(id)66 }67 time.Sleep(ytWarmEvery)68 }69}7071// warmDue lists the videos on and next whose addresses need finding, and72// forgets addresses that expired. y.mu is held.73func (y *YouTube) warmDue(now time.Time) []string {74 for id, r := range y.resolved {75 if now.After(r.until) {76 delete(y.resolved, id)77 }78 }79 if !y.ready() || y.err != nil {80 return nil81 }82 on, next, _, ok := y.airingAt(y.clock())83 if !ok {84 return nil85 }86 if next.ID == "" {87 next = y.after(on)88 }89 var ids []string90 for _, a := range []ytAiring{on, next} {91 if a.ID == "" || y.ahead[a.ID] != nil || now.Sub(y.missed[a.ID]) < ytMissedRetry {92 continue93 }94 if v := y.videos[a.ID]; v != nil && v.Skip == "gone" {95 continue96 }97 if r, ok := y.resolved[a.ID]; ok && r.height == y.cfg.MaxHeight && r.until.Sub(now) >= ytMinLeft {98 continue99 }100 if len(ids) == 0 || ids[0] != a.ID {101 ids = append(ids, a.ID)102 }103 }104 return ids105}106107// lookAhead finds a video's addresses now, one video at a time across108// channels, so a tune for it waits for this rather than asking yt-dlp too.109func (y *YouTube) lookAhead(id string) {110 y.mu.Lock()111 if y.ahead[id] != nil {112 y.mu.Unlock()113 return114 }115 f := &ytAhead{done: make(chan struct{})}116 y.ahead[id] = f117 height := y.cfg.MaxHeight118 y.mu.Unlock()119 tool := y.tool()120 tool.Ahead.Lock()121 y.mu.Lock()122 f.started = true123 y.mu.Unlock()124 ctx, cancel := context.WithTimeout(context.Background(), ytResolveTime)125 info, err := y.fetch(ctx, id, height)126 cancel()127 tool.Ahead.Unlock()128 y.mu.Lock()129 delete(y.ahead, id)130 switch {131 case err == nil:132 delete(y.missed, id)133 y.keep(id, info, height)134 case youtube.Gone(err):135 if v := y.videos[id]; v != nil {136 v.Skip, v.New, v.Looked = "gone", false, y.clock()137 y.changed()138 }139 log.Printf("%s: %s can't play: %v", y.label(), id, err)140 default:141 y.missed[id] = time.Now()142 log.Printf("%s: finding %s ahead: %v", y.label(), id, err)143 }144 y.mu.Unlock()145 close(f.done)146}147148// keep remembers a video's addresses until they expire, and starts149// fetching its captions. y.mu is held.150func (y *YouTube) keep(id string, info youtube.Info, height int) ytResolved {151 r := ytResolved{info: info, height: height, until: youtube.AddressesUntil(info, time.Now())}152 if old, ok := y.resolved[id]; ok && old.caps != nil && !old.caps.failed() {153 r.caps = old.caps // the same captions, fetched already or under way154 } else if sub, ok := info.Captions(); ok {155 client := y.tool().Client()156 r.caps = newCaptions(id, func(ctx context.Context) ([]byte, error) { return youtube.FetchCaptions(ctx, client, sub) })157 r.caps.start()158 }159 y.resolved[id] = r160 return r161}162163// resolve finds where to stream a video from, and its captions: what was164// found ahead of time, waiting for it when that's under way, else yt-dlp165// now. fresh skips what was found before. What's found is kept for the166// next tune.167func (y *YouTube) resolve(ctx context.Context, id string, fresh bool) (ytResolved, error) {168 y.mu.Lock()169 f := y.ahead[id]170 busy := f != nil && f.started171 y.mu.Unlock()172 if busy && !fresh {173 select {174 case <-f.done:175 case <-ctx.Done():176 return ytResolved{}, ctx.Err()177 }178 phase.Step(ctx, "waited for yt-dlp")179 }180 y.mu.Lock()181 r, ok := y.resolved[id]182 height := y.cfg.MaxHeight183 if ok && !fresh && r.height == height && time.Until(r.until) >= ytMinLeft {184 if r.caps != nil && r.caps.failed() {185 r = y.keep(id, r.info, height) // the captions, tried again186 }187 y.mu.Unlock()188 phase.Step(ctx, "found ahead")189 return r, nil190 }191 if fresh {192 delete(y.resolved, id)193 }194 y.mu.Unlock()195 info, err := y.fetch(ctx, id, height)196 phase.Step(ctx, "yt-dlp")197 if err != nil {198 return ytResolved{}, err199 }200 y.mu.Lock()201 r = y.keep(id, info, height)202 y.mu.Unlock()203 return r, nil204}205206// fetch asks yt-dlp where to stream a video from.207func (y *YouTube) fetch(ctx context.Context, id string, height int) (youtube.Info, error) {208 ctx, cancel := context.WithTimeout(ctx, ytResolveTime)209 defer cancel()210 raw, err := y.tool().Run(ctx, "-J", "--no-playlist", "-f", youtube.FormatSpec(height), youtube.WatchURL(id))211 if err != nil {212 return youtube.Info{}, err213 }214 info, err := youtube.ParseInfo(raw)215 if err == nil && len(info.Inputs()) == 0 {216 err = errors.New("yt-dlp found no stream")217 }218 return info, err219}220221// prefetch finds an airing's video a minute before it starts, unless it222// has been already, so the change from one video to the next is quick223// even while the channel isn't finding videos ahead.224func (y *YouTube) prefetch(ctx context.Context, a ytAiring) {225 select {226 case <-ctx.Done():227 return228 case <-time.After(time.Until(a.Start.Add(-time.Minute))):229 }230 y.mu.Lock()231 r, ok := y.resolved[a.ID]232 found := ok && r.height == y.cfg.MaxHeight && time.Until(r.until) >= ytMinLeft233 y.mu.Unlock()234 if !found {235 y.lookAhead(a.ID)236 }237}238239// close stops the channel finding videos ahead, once it's left the240// library.241func (y *YouTube) close() {242 y.mu.Lock()243 y.closed = true244 y.mu.Unlock()245}