1package vchan23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "hash/fnv"9 "io"10 "log"11 "os"12 "path/filepath"13 "slices"14 "strconv"15 "strings"16 "sync"17 "time"1819 "airwaves/internal/web"20 "airwaves/internal/youtube"21)2223// YouTube is a channel of uploads from YouTube channels, set up by a24// youtube.json in its folder, and run like TV: a schedule two days ahead25// that holds across restarts, new uploads airing as first runs ahead of26// reruns, and a guard against repeats. Nothing is downloaded: the channel27// keeps only metadata (a catalog and the schedule, hidden files in its28// folder), and streams the video on air from YouTube while someone29// watches.30type YouTube struct {31 Num string32 Title string33 Dir string34 FFmpeg string35 YtDlp *youtube.YtDlp // yt-dlp from PATH when nil36 // Loudness is the level the videos' sound is evened out to, in LUFS;37 // 0 leaves it as it is.38 Loudness float643940 mu sync.Mutex41 loaded bool42 cfg ytConfig43 err error // the config's44 cfgMod time.Time // youtube.json's modification time when read45 cat ytCatalog46 plan ytPlayout47 pool *ytPool // nil after the catalog changes48 poolAt time.Time // when pool was built49 videos map[string]*ytVideo // the catalog's, by ID; built with pool, or seq50 names map[string]string // channel names by ID51 seq []*ytVideo // a playlist's that can play, in order; nil after the catalog changes52 lens []time.Duration // their lengths53 relist bool // a playlist is due a listing, its config edited54 catDirty bool55 catSaved time.Time56 busy bool // a refresh is running57 tried map[string]time.Time // failed listings, by URL58 feedDown map[string]string // failing feeds by URL: how they're stood in for59 resolved map[string]ytResolved // found, by video ID (see ytahead.go)60 ahead map[string]*ytAhead // being found ahead61 missed map[string]time.Time // failed to be found ahead, by video ID62 touched time.Time // when the channel was last tended63 warming bool // finding what's on ahead (see warm)64 closed bool // no longer in the library65 logged string // the last save error logged66 now func() time.Time67 record recordFile68}6970const (71 ytCatalogFile = ".catalog.json"72 ytPlayoutFile = ".playout.json"73 ytListEvery = 24 * time.Hour // a full listing of each channel74 ytFeedEvery = 15 * time.Minute // a look at its feed for new uploads75 ytRetryList = time.Hour76 // Lookups of single videos' details (new uploads' lengths, and what's77 // coming up's descriptions) are rationed per refresh.78 ytLookups = 479 ytDescribeAhead = 8 * time.Hour80 ytResolveTime = 45 * time.Second81)8283// ytLookupGap spaces out lookups of single videos.84var ytLookupGap = 3 * time.Second8586// Number implements Channel.87func (y *YouTube) Number() string { return y.Num }8889// Name implements Channel.90func (y *YouTube) Name() string { return y.Title }9192// Details implements Channel, from channel.json in the folder.93func (y *YouTube) Details() Details { return folderDetails(&y.record, y.Dir, y.Num, y.Title) }9495// Empty reports whether nothing is on: until the channels are first96// listed, or when none has videos that fit. A channel with dead air stays97// in the lineup through it once it has aired, and a playlist with videos98// to play stays in it while off the air.99func (y *YouTube) Empty() bool {100 y.mu.Lock()101 defer y.mu.Unlock()102 y.tend()103 if y.isPlaylist() {104 seq, _ := y.sequence()105 return len(seq) == 0106 }107 _, _, ok := y.plan.at(y.clock())108 return !ok && !(y.cfg.DeadAir && len(y.plan.Airings) > 0)109}110111// Programs implements Channel: the schedule, one entry per video, and112// with dead air, "Off air" where nothing is planned. A playlist's is as113// playlistPrograms has it.114func (y *YouTube) Programs(from, to time.Time) []Program {115 y.mu.Lock()116 defer y.mu.Unlock()117 y.tend()118 if y.isPlaylist() {119 return y.playlistPrograms(from, to)120 }121 var out []Program122 offAir := func(start, end time.Time) {123 if y.cfg.DeadAir && end.After(start) && end.After(from) && start.Before(to) && len(out) < maxPrograms {124 out = append(out, Program{Start: start.Local(), End: end.Local(), Title: "Off air",125 Description: "Everything has aired lately. Back with the next new upload."})126 }127 }128 var gap time.Time // where the last airing ended129 for _, a := range y.plan.Airings {130 if !gap.IsZero() && a.Start.After(gap) {131 offAir(gap, a.Start)132 }133 gap = a.End134 if !a.End.After(from) || !a.Start.Before(to) {135 continue136 }137 if len(out) == maxPrograms {138 break139 }140 p := Program{Start: a.Start.Local(), End: a.End.Local(), Title: a.Title, Subtitle: y.names[a.Channel],141 Image: youtube.Thumbnail(a.ID), New: a.First}142 if v := y.videos[a.ID]; v != nil {143 p.Description = v.Description144 p.Subtitle, p.Date = ytDated(p.Subtitle, v, y.clock())145 p.AudioLang, p.Captions = v.AudioLang, v.Captions146 }147 out = append(out, p)148 }149 if !gap.IsZero() {150 offAir(gap, to)151 }152 return out153}154155// ytDated adds a video's upload date to its channel's name, for the156// guide's subtitle: "Northernlion, Mar 4, 2014" for a known date. A date157// from a listing's "3 years ago" is only that good: "Northernlion, 2014",158// or within the last year "Northernlion, March 2026". date is the same as159// XMLTV writes it.160func ytDated(name string, v *ytVideo, now time.Time) (subtitle, date string) {161 if v.Published.IsZero() {162 return name, ""163 }164 t := v.Published.UTC() // as YouTube counted back165 var shown string166 switch {167 case !v.Approx:168 t = t.Local()169 shown, date = t.Format("Jan 2, 2006"), t.Format("20060102")170 case now.Sub(t) >= 365*24*time.Hour:171 shown, date = t.Format("2006"), t.Format("2006")172 default:173 shown, date = t.Format("January 2006"), t.Format("200601")174 }175 if name == "" {176 return shown, date177 }178 return name + ", " + shown, date179}180181// Stream implements Channel: the video on air from where it is now, then182// the schedule on.183func (y *YouTube) Stream(ctx context.Context, w io.Writer) error {184 if y.Empty() {185 return fmt.Errorf("channel %s: nothing scheduled yet", y.Num)186 }187 return playCues(ctx, w, y.FFmpeg, y.label(), y.cue())188}189190// start loads the channel and begins its first listing, alongside the191// other channels rather than when first asked.192func (y *YouTube) start() {193 go func() {194 y.mu.Lock()195 defer y.mu.Unlock()196 y.tend()197 }()198}199200func (y *YouTube) label() string { return "channel " + y.Num }201202func (y *YouTube) clock() time.Time {203 if y.now != nil {204 return y.now()205 }206 return time.Now()207}208209func (y *YouTube) tool() *youtube.YtDlp {210 if y.YtDlp != nil {211 return y.YtDlp212 }213 return defaultYtDlp214}215216var defaultYtDlp = &youtube.YtDlp{}217218// tend loads the channel's files on first use, keeps the schedule two days219// ahead, and starts a refresh of the catalog when one is due. y.mu is220// held.221func (y *YouTube) tend() {222 if !y.loaded {223 y.load()224 } else if info, err := os.Stat(filepath.Join(y.Dir, youtubeConfig)); err == nil && !info.ModTime().Equal(y.cfgMod) {225 // Edited: the new settings plan the schedule again from now, and a226 // playlist is listed again, for videos added to it.227 y.readConfig(y.cat)228 y.relist = y.isPlaylist()229 switch {230 case y.relist:231 log.Printf("%s: settings changed; listing the playlist again", y.label())232 case y.err == nil:233 log.Printf("%s: settings changed; planning again from now", y.label())234 }235 }236 if y.err != nil {237 return238 }239 now := y.clock()240 y.replan(now)241 if !y.busy && y.due(now) {242 y.busy = true243 go y.refresh()244 }245 y.touched = time.Now()246 if ytWarm && !y.warming && !y.closed {247 y.warming = true248 go y.warm()249 }250}251252func (y *YouTube) load() {253 y.loaded = true254 y.tried, y.resolved, y.ahead, y.missed = map[string]time.Time{}, map[string]ytResolved{}, map[string]*ytAhead{}, map[string]time.Time{}255 y.feedDown = map[string]string{}256 cat, err := readYtCatalog(filepath.Join(y.Dir, ytCatalogFile))257 if err != nil && !errors.Is(err, os.ErrNotExist) {258 log.Printf("%s: %s: %v; listing the channels again", y.label(), ytCatalogFile, err)259 }260 y.catSaved = time.Now()261 if y.plan, err = readYtPlayout(filepath.Join(y.Dir, ytPlayoutFile)); err != nil && !errors.Is(err, os.ErrNotExist) {262 log.Printf("%s: %s: %v; starting a new schedule", y.label(), ytPlayoutFile, err)263 }264 y.readConfig(cat)265}266267// readConfig reads youtube.json and takes the sources it names from cat,268// in order, keeping what's known of each: its channels, or its playlist.269// y.mu is held.270func (y *YouTube) readConfig(cat ytCatalog) {271 path := filepath.Join(y.Dir, youtubeConfig)272 if info, err := os.Stat(path); err == nil {273 y.cfgMod = info.ModTime()274 }275 y.pool, y.seq = nil, nil276 y.cfg, y.err = readYtConfig(path)277 if y.err != nil {278 log.Printf("%s: %v", y.label(), y.err)279 return280 }281 var sources []*ytSource282 if y.cfg.Playlist != "" {283 url, _ := youtube.Playlist(y.cfg.Playlist) // as readYtConfig checked284 sources = append(sources, cat.source(url))285 }286 for _, c := range y.cfg.Channels {287 url := youtube.VideosURL(c)288 if !slices.ContainsFunc(sources, func(s *ytSource) bool { return s.URL == url }) {289 sources = append(sources, cat.source(url))290 }291 }292 y.cat.Sources = sources293}294295// ready reports whether every channel has been listed, or tried, so the296// first schedule isn't made from only some of them.297func (y *YouTube) ready() bool {298 for _, s := range y.cat.Sources {299 if s.Listed.IsZero() && y.tried[s.URL].IsZero() {300 return false301 }302 }303 return true304}305306// changed marks the catalog for saving and the pool, or a playlist's307// sequence, for rebuilding.308func (y *YouTube) changed() {309 y.catDirty, y.pool, y.seq = true, nil, nil310}311312// replan keeps the schedule up to date and saves what changed. A313// playlist has nothing to plan: its order is its schedule. y.mu is held.314func (y *YouTube) replan(now time.Time) {315 if !y.ready() {316 return317 }318 if y.isPlaylist() {319 if y.catDirty && time.Since(y.catSaved) > 10*time.Minute {320 y.saveCatalog()321 }322 return323 }324 pool := y.currentPool()325 // A first run that has started is just a video now.326 for _, a := range y.plan.Airings {327 if v := y.videos[a.ID]; a.First && v != nil && v.New && !a.Start.After(now) {328 v.New = false329 y.changed()330 }331 }332 if y.pool == nil {333 pool = y.currentPool()334 }335 if y.plan.update(pool, now) {336 y.save(ytPlayoutFile, y.plan)337 }338 if y.catDirty && time.Since(y.catSaved) > 10*time.Minute {339 y.saveCatalog()340 }341}342343func (y *YouTube) saveCatalog() {344 y.catDirty, y.catSaved = false, time.Now()345 y.save(ytCatalogFile, y.cat)346}347348// save writes one of the channel's files, whole or not at all.349func (y *YouTube) save(name string, v any) {350 raw, err := json.Marshal(v)351 if err == nil {352 tmp := filepath.Join(y.Dir, name+".tmp")353 if err = os.WriteFile(tmp, raw, 0o644); err == nil {354 err = os.Rename(tmp, filepath.Join(y.Dir, name))355 }356 }357 if err != nil && err.Error() != y.logged {358 log.Printf("%s: saving %s: %v", y.label(), name, err)359 }360 if err != nil {361 y.logged = err.Error()362 }363}364365// currentPool returns what the planner picks from, rebuilt after the366// catalog changes, and hourly with a maximum age, so videos age out.367func (y *YouTube) currentPool() *ytPool {368 now := y.clock()369 if y.pool != nil && (y.cfg.MaxAgeDays <= 0 || now.Sub(y.poolAt) < time.Hour) {370 return y.pool371 }372 h := fnv.New64a()373 h.Write([]byte(filepath.Base(y.Dir)))374 p := &ytPool{Repeat: y.cfg.repeat(), Lock: time.Duration(y.cfg.LockHours * float64(time.Hour)),375 Mix: ytRerunMixes[y.cfg.RerunMix], DeadAir: y.cfg.DeadAir, Seed: h.Sum64(), ids: map[string]bool{}}376 y.videos, y.names = map[string]*ytVideo{}, map[string]string{}377 maxAge := y.cfg.maxAge()378 var ids []string379 for _, s := range y.cat.Sources {380 if s.ID == "" {381 continue382 }383 ids = append(ids, s.ID)384 y.names[s.ID] = s.Name385 var playable []*ytVideo386 for _, v := range s.Videos {387 y.videos[v.ID] = v388 if y.cfg.playable(v) {389 playable = append(playable, v)390 p.ids[v.ID] = true // may stay on the schedule once too old to pick391 }392 }393 ps := ytPoolSource{ID: s.ID}394 for i, age := range ytAges(playable, now) {395 if maxAge > 0 && age > maxAge {396 continue397 }398 v := playable[i]399 ps.Videos = append(ps.Videos, v)400 if age <= ytHalfYear {401 ps.HalfYear = append(ps.HalfYear, v)402 }403 if age <= ytTwoYears {404 ps.TwoYears = append(ps.TwoYears, v)405 }406 if v.New {407 p.Firsts = append(p.Firsts, ytPick{v, s.ID})408 }409 }410 p.Sources = append(p.Sources, ps)411 }412 slices.SortFunc(p.Firsts, newestFirst)413 p.Basis = fmt.Sprintf("%s|%g|%g|%g|%s", strings.Join(ids, ","), y.cfg.RepeatDays, y.cfg.MinMinutes, y.cfg.MaxMinutes, y.cfg.RerunMix)414 if y.cfg.MaxAgeDays > 0 {415 // Only then, so schedules planned before there was a maximum age416 // aren't planned again for it.417 p.Basis += fmt.Sprintf("|%gd", y.cfg.MaxAgeDays)418 }419 if y.cfg.DeadAir {420 p.Basis += "|dead"421 }422 y.pool, y.poolAt = p, now423 return p424}425426// index makes y.videos, the catalog's videos by ID, current. y.mu is427// held.428func (y *YouTube) index() {429 if y.isPlaylist() {430 y.sequence()431 } else {432 y.currentPool()433 }434}435436// fits counts a source's videos that fit the channel's settings now, the437// maximum age included. y.mu is held.438func (y *YouTube) fits(s *ytSource) int {439 for _, ps := range y.currentPool().Sources {440 if s.ID != "" && ps.ID == s.ID {441 return len(ps.Videos)442 }443 }444 return 0445}446447func (y *YouTube) source(url string) *ytSource {448 for _, s := range y.cat.Sources {449 if s.URL == url {450 return s451 }452 }453 return nil454}455456// due reports whether a listing or a feed check is due. y.mu is held.457func (y *YouTube) due(now time.Time) bool {458 for _, s := range y.cat.Sources {459 if y.listDue(s, now) || s.ID != "" && now.Sub(s.Checked) >= ytFeedEvery {460 return true461 }462 }463 return false464}465466func (y *YouTube) listDue(s *ytSource, now time.Time) bool {467 // Catalogs listed before listings had dates are listed again now.468 return y.relist || (now.Sub(s.Listed) >= ytListEvery || !s.Dated) && now.Sub(y.tried[s.URL]) >= ytRetryList469}470471// refresh brings the catalog up to date: full listings when due (daily),472// the feeds for new uploads (every quarter hour), and a few lookups of473// single videos' details. Then the schedule takes in what changed. A474// playlist has no feed: every quarter hour it has only the lookups.475func (y *YouTube) refresh() {476 defer func() {477 y.mu.Lock()478 y.busy = false479 y.mu.Unlock()480 }()481 ctx, cancel := context.WithTimeout(context.Background(), time.Hour)482 defer cancel()483 tool := y.tool()484485 type job struct {486 url, id string487 list, feed bool488 }489 y.mu.Lock()490 playlist := y.isPlaylist()491 var jobs []job492 for _, s := range y.cat.Sources {493 now := y.clock()494 jobs = append(jobs, job{s.URL, s.ID, y.listDue(s, now), !playlist && now.Sub(s.Checked) >= ytFeedEvery})495 if playlist {496 s.Checked = now497 }498 }499 y.relist = false500 y.mu.Unlock()501502 for _, j := range jobs {503 if j.list && playlist {504 y.listPlaylist(ctx, j.url)505 } else if j.list {506 tool.Jobs.Lock()507 raw, err := tool.Run(ctx, slices.Concat(youtube.ListingArgs, []string{j.url})...)508 tool.Jobs.Unlock()509 var l ytListing510 if err == nil {511 l, err = parseYtListing(raw)512 }513 y.mu.Lock()514 if err != nil {515 y.tried[j.url] = y.clock()516 log.Printf("%s: listing %s: %v", y.label(), j.url, err)517 } else if s := y.source(j.url); s != nil { // unless no longer configured518 added := s.mergeListing(l, y.clock())519 j.id, j.feed = l.ID, true520 y.changed()521 log.Printf("%s: %s has %d videos (%d new)", y.label(), l.Name, len(l.Videos), added)522 }523 y.mu.Unlock()524 }525 if j.feed && j.id != "" {526 y.checkFeed(ctx, j.url, j.id, j.list)527 }528 }529530 y.mu.Lock()531 y.replan(y.clock()) // so a first schedule's videos are looked up too532 ids := y.lookupsDue(y.clock())533 y.mu.Unlock()534 for n, id := range ids {535 if n > 0 {536 select {537 case <-ctx.Done():538 return539 case <-time.After(ytLookupGap):540 }541 }542 tool.Jobs.Lock()543 raw, err := tool.Run(ctx, "-J", "--skip-download", "--no-playlist", "--ignore-no-formats-error", youtube.WatchURL(id))544 tool.Jobs.Unlock()545 var info youtube.Info546 if err == nil {547 info, err = youtube.ParseInfo(raw)548 }549 y.mu.Lock()550 y.learn(id, info, err)551 y.mu.Unlock()552 }553554 y.mu.Lock()555 y.replan(y.clock())556 if y.catDirty {557 y.saveCatalog()558 }559 y.mu.Unlock()560}561562// checkFeed looks at a source's feed for new uploads. While the feed563// fails, the newest uploads on its tab stand in, unless it was just564// listed in full. Only changes between the two are logged.565func (y *YouTube) checkFeed(ctx context.Context, url, id string, listed bool) {566 tool := y.tool()567 raw, err := web.Get(ctx, tool.Client(), youtube.FeedURL(id))568 var entries []youtube.FeedEntry569 if err == nil {570 entries, err = youtube.ParseFeed(raw)571 }572 var newest ytListing573 ferr := err574 if err != nil && !listed {575 tool.Jobs.Lock()576 raw, ferr = tool.Run(ctx, slices.Concat(youtube.ListingArgs, []string{"--playlist-end", strconv.Itoa(ytNewAtTop), url})...)577 tool.Jobs.Unlock()578 if ferr == nil {579 newest, ferr = parseYtListing(raw)580 }581 }582 y.mu.Lock()583 defer y.mu.Unlock()584 s := y.source(url)585 if s == nil {586 return // no longer configured587 }588 now := y.clock()589 s.Checked = now590 var added int591 var changed bool592 was := y.feedDown[url]593 switch {594 case err == nil:595 if was != "" {596 log.Printf("%s: feed of %s works again", y.label(), s.Name)597 }598 delete(y.feedDown, url)599 added, changed = s.mergeFeed(entries, now)600 case listed:601 if was == "" {602 y.feedDown[url] = "listed"603 log.Printf("%s: feed of %s: %v", y.label(), s.Name, err)604 }605 case ferr == nil:606 // Said once, not every quarter hour.607 if was != "yt-dlp" {608 log.Printf("%s: feed of %s: %v; reading its newest uploads with yt-dlp until it's back", y.label(), s.Name, err)609 }610 y.feedDown[url] = "yt-dlp"611 added, changed = s.mergeNewest(newest, now)612 default:613 if was != "nothing" {614 log.Printf("%s: feed of %s: %v; and its newest uploads: %v", y.label(), s.Name, err, ferr)615 }616 y.feedDown[url] = "nothing"617 }618 if changed {619 y.changed()620 }621 if added > 0 {622 log.Printf("%s: %d new from %s", y.label(), added, s.Name)623 }624}625626// lookupsDue picks the videos to look up next: new uploads without a627// length, premieres to check again, then what airs soon without a628// description. y.mu is held.629func (y *YouTube) lookupsDue(now time.Time) []string {630 if y.isPlaylist() {631 return y.playlistLookups(now)632 }633 var ids []string634 add := func(id string) {635 if len(ids) < ytLookups && !slices.Contains(ids, id) {636 ids = append(ids, id)637 }638 }639 for _, s := range y.cat.Sources {640 for _, v := range s.Videos {641 if v.Found.IsZero() || now.Sub(v.Found) > 14*24*time.Hour {642 continue643 }644 switch {645 case v.Skip == "" && v.Seconds == 0 && now.Sub(v.Looked) >= 10*time.Minute,646 v.Skip == "upcoming" && now.Sub(v.Looked) >= time.Hour:647 add(v.ID)648 }649 }650 }651 y.currentPool()652 for _, a := range y.plan.Airings {653 if a.Start.After(now.Add(ytDescribeAhead)) {654 break655 }656 if v := y.videos[a.ID]; v != nil && a.End.After(now) && v.Looked.IsZero() {657 add(a.ID)658 }659 }660 return ids661}662663// learn takes in a lookup of one video. y.mu is held.664func (y *YouTube) learn(id string, info youtube.Info, err error) {665 y.index()666 v := y.videos[id]667 if v == nil {668 return669 }670 now := y.clock()671 switch {672 case err == nil:673 y.learnVideo(v, info)674 case youtube.Gone(err):675 v.Skip, v.New, v.Looked = "gone", false, now676 log.Printf("%s: %s can't play: %v", y.label(), id, err)677 default:678 v.Looked = now679 log.Printf("%s: looking up %s: %v", y.label(), id, err)680 }681 y.changed()682}683684// cue plays the schedule: the airing on now, from where it is. When a685// video ends early, because it was shorter than listed or its stream686// failed, it is opened again where it should be, twice at most in a row,687// and otherwise a slate fills its time. A playlist off the air shows a688// slate saying when it's back, a minute at a time.689func (y *YouTube) cue() cueFunc {690 var last ytAiring691 var played time.Time // when last was last opened692 tries := 0693 return func(now time.Time, _ bool) (item, int64, error) {694 y.mu.Lock()695 y.tend()696 on, next, wait, ok := y.airingAt(now)697 y.mu.Unlock()698 if !ok {699 return item{Slate: true, Title: standBy, Frames: 10 * loopFPS}, 0, nil700 }701 if wait != "" {702 left := on.Start.Sub(now)703 if left <= 2*time.Minute {704 // The video is found ahead, as open does for the next.705 ctx, cancel := context.WithDeadline(context.Background(), on.Start.Add(time.Minute))706 go func() {707 defer cancel()708 y.prefetch(ctx, on)709 }()710 }711 return item{Slate: true, Title: wait, Frames: max(framesIn(min(left, time.Minute)), 1)}, 0, nil712 }713 if on.ID == last.ID && on.Start.Equal(last.Start) {714 left := on.End.Sub(now)715 if now.Sub(played) > time.Minute {716 tries = 0 // it played a good while: a new failure717 }718 switch {719 case left <= time.Second && next.ID != "":720 // Done, to a frame or so.721 on, tries = next, 0722 case left < 15*time.Second || tries >= 2:723 return item{Slate: true, Frames: max(framesIn(left), 1)}, 0, nil724 default:725 tries++726 log.Printf("%s: reopening %s %s in", y.label(), on.ID, now.Sub(on.Start).Round(time.Second))727 }728 } else {729 tries = 0730 }731 // A video opened again is found again: its addresses may be why732 // it stopped.733 last, played = on, now734 return y.item(on, tries > 0), max(framesIn(now.Sub(on.Start)), 0), nil735 }736}737738// airingAt is what airs at t: the airing on and the one after it, or for739// a playlist off the air, the next airing and the slate's words until740// then (see playlistAt). y.mu is held.741func (y *YouTube) airingAt(t time.Time) (on, next ytAiring, wait string, ok bool) {742 if y.isPlaylist() {743 return y.playlistAt(t)744 }745 on, next, ok = y.plan.at(t)746 return on, next, "", ok747}748749// after is the airing after a, to find ahead. y.mu is held.750func (y *YouTube) after(a ytAiring) ytAiring {751 if y.isPlaylist() {752 return y.playlistAfter(a)753 }754 _, next, _ := y.plan.at(a.Start)755 return next756}757758// item is an airing to play. fresh finds its video's addresses anew759// rather than using any found before.760func (y *YouTube) item(a ytAiring, fresh bool) item {761 return item{762 Path: a.ID, Frames: framesIn(a.End.Sub(a.Start)), Audio: true, Title: a.Title,763 Open: func(ctx context.Context, offset time.Duration) (opened, error) {764 return y.open(ctx, a, offset, fresh)765 },766 Level: y.level(a.ID),767 }768}769770// open finds where to stream an airing's video from, and its English771// captions, and starts finding the next one's shortly before it's due.772func (y *YouTube) open(ctx context.Context, a ytAiring, offset time.Duration, fresh bool) (opened, error) {773 began := time.Now()774 r, err := y.resolve(ctx, a.ID, fresh)775 info := r.info776 y.mu.Lock()777 if err != nil {778 if v := y.videos[a.ID]; v != nil && youtube.Gone(err) {779 v.Skip, v.New, v.Looked = "gone", false, y.clock()780 y.changed() // and so off the unlocked schedule781 }782 y.mu.Unlock()783 return opened{}, err784 }785 if v := y.videos[a.ID]; v != nil && v.Looked.IsZero() {786 y.learnVideo(v, info)787 y.changed()788 }789 next := y.after(a)790 y.mu.Unlock()791 if next.ID != "" {792 go y.prefetch(ctx, next)793 }794 in := info.Inputs()795 o := opened{AudioLang: info.AudioLang(), Captions: r.caps}796 o.Args, o.Audio = youtube.InputArgs(in, offset, youtube.HTTPChunks(y.FFmpeg))797 log.Printf("%s: on air %s %s in (format %s, %dp, found in %s)", y.label(), a.ID, offset.Round(time.Second),798 info.FormatID, in[0].Height, time.Since(began).Round(100*time.Millisecond))799 return o, nil800}