1package vchan23// YouTube playlists: a YouTube channel whose youtube.json names a playlist4// rather than channels plays the playlist's videos in its order, looping,5// as a folder channel plays its files: around the clock from the epoch,6// or on the schedule in its channel.json (see schedule.go). There's no7// planner, first runs or repeat guard: the airings follow from the order8// and the videos' lengths, so the catalog is all that's kept, a single9// source holding the playlist's videos in order. It's listed again daily,10// and when youtube.json is edited, so videos added to the playlist join.1112import (13 "cmp"14 "context"15 "log"16 "slices"17 "strings"18 "time"1920 "airwaves/internal/youtube"21)2223// parseYtPlaylist reads a playlist's listing, from yt-dlp --flat-playlist24// -J with youtube.PlaylistArgs (see youtube.ParsePlaylist), undated as a25// playlist channel has it.26func parseYtPlaylist(raw []byte) (ytListing, error) {27 l, err := youtube.ParsePlaylist(raw)28 return listingOf(l, false), err29}3031// mergePlaylist takes in a listing of the playlist: its videos in its32// order, keeping what's known about each. Videos taken out of it go, and33// one found unplayable stays so.34func (s *ytSource) mergePlaylist(l ytListing, now time.Time) (added int) {35 old := map[string]*ytVideo{}36 for _, v := range s.Videos {37 old[v.ID] = v38 }39 first := s.Listed.IsZero()40 out := make([]*ytVideo, 0, len(l.Videos))41 for _, v := range l.Videos {42 o := old[v.ID]43 if o == nil {44 if !first {45 v.Found = now46 added++47 }48 out = append(out, v)49 continue50 }51 o.Title, o.Channel = v.Title, cmp.Or(v.Channel, o.Channel)52 if v.Seconds > 0 {53 o.Seconds = v.Seconds54 }55 if o.Skip != "gone" {56 o.Skip = v.Skip57 }58 out = append(out, o)59 }60 s.ID, s.Name, s.Owner, s.OwnerID = l.ID, l.Name, l.Owner, l.OwnerID61 s.Listed, s.Dated, s.Videos = now, true, out62 return added63}6465// isPlaylist reports whether the channel plays a playlist. y.mu is held.66func (y *YouTube) isPlaylist() bool { return y.err == nil && y.cfg.Playlist != "" }6768// sequence is the playlist's videos that can play, in its order, and69// their lengths: what the schedule airs. It's made again, with y.videos,70// when the catalog changes. y.mu is held.71func (y *YouTube) sequence() ([]*ytVideo, []time.Duration) {72 if y.seq == nil {73 y.seq, y.lens, y.videos = []*ytVideo{}, nil, map[string]*ytVideo{}74 for _, s := range y.cat.Sources {75 for _, v := range s.Videos {76 y.videos[v.ID] = v77 if v.Skip == "" && v.Seconds > 0 {78 y.seq = append(y.seq, v)79 y.lens = append(y.lens, time.Duration(v.Seconds)*time.Second)80 }81 }82 }83 }84 return y.seq, y.lens85}8687// airTimes is when the playlist airs: on the schedule in channel.json, or88// without one, around the clock from the epoch as folder channels loop.89// y.mu is held.90func (y *YouTube) airTimes() (s *sched, scheduled bool) {91 if s := folderSchedule(&y.record, y.Dir); s != nil {92 return s, true93 }94 return &sched{start: scheduleEpoch}, false95}9697// Sequence implements Sequenced: the playlist's videos that can play, in98// its order. Uploads play in no order, so for them ok is false.99func (y *YouTube) Sequence() (items []SequenceItem, ok bool) {100 y.mu.Lock()101 defer y.mu.Unlock()102 y.tend()103 if !y.isPlaylist() {104 return nil, false105 }106 seq, lens := y.sequence()107 items = make([]SequenceItem, len(seq))108 for i, v := range seq {109 items[i] = SequenceItem{Title: v.Title, Subtitle: y.channelOf(v), Length: lens[i]}110 }111 return items, true112}113114// channelOf names the channel of a playlist's video: its own, or failing115// that the playlist's. y.mu is held.116func (y *YouTube) channelOf(v *ytVideo) string {117 if v.Channel != "" || len(y.cat.Sources) == 0 {118 return v.Channel119 }120 return y.cat.Sources[0].Owner121}122123// playlistPrograms is the playlist's guide: an entry per video, and on a124// schedule, "Off air" between its blocks, saying when it's back and with125// what. y.mu is held.126func (y *YouTube) playlistPrograms(from, to time.Time) []Program {127 seq, lens := y.sequence()128 if len(seq) == 0 {129 return nil130 }131 s, scheduled := y.airTimes()132 var slots []slot133 if scheduled {134 slots = s.guide(lens, from, to, maxPrograms)135 } else {136 slots = s.slots(lens, from, to, maxPrograms)137 }138 now := y.clock()139 out := make([]Program, 0, len(slots))140 for k, sl := range slots {141 if sl.I >= 0 {142 v := seq[sl.I]143 p := Program{Start: sl.Start.Local(), End: sl.End.Local(), Title: v.Title, Image: youtube.Thumbnail(v.ID),144 Description: v.Description, AudioLang: v.AudioLang, Captions: v.Captions}145 p.Subtitle, p.Date = ytDated(y.channelOf(v), v, now)146 out = append(out, p)147 continue148 }149 // A gap ends with the next entry, or past the window, the next airing.150 next, ok := slot{}, false151 if k+1 < len(slots) {152 next, ok = slots[k+1], true153 } else {154 next, _, ok = s.at(lens, sl.End)155 }156 p := offAirProgram(sl, "", time.Time{}, false)157 if ok {158 p = offAirProgram(sl, seq[next.I].Title, next.Start, isPremiere(s, lens, next))159 }160 p.Start, p.End = p.Start.Local(), p.End.Local()161 out = append(out, p)162 }163 return out164}165166// playlistAt is what the playlist airs at t: the airing on, and the one167// after it when that follows straight on; or while it's off the air, the168// next airing and what the slate says until then, as the guide does169// ("Back at 8:00 PM with Episode 3"). ok is false when nothing will air.170// y.mu is held.171func (y *YouTube) playlistAt(t time.Time) (on, next ytAiring, wait string, ok bool) {172 seq, lens := y.sequence()173 if len(seq) == 0 {174 return on, next, "", false175 }176 s, _ := y.airTimes()177 sl, onAir, ok := s.at(lens, t)178 if !ok {179 return on, next, "", false180 }181 on = playlistAiring(seq[sl.I], sl)182 if !onAir {183 note := offAirProgram(slot{-1, t, sl.Start}, on.Title, sl.Start.Local(), isPremiere(s, lens, sl))184 return on, next, strings.TrimSuffix(note.Description, "."), true185 }186 if after, _, ok := s.at(lens, sl.End); ok && after.Start.Equal(sl.End) {187 next = playlistAiring(seq[after.I], after)188 }189 return on, next, "", true190}191192// playlistAfter is the airing after a on the playlist, after a gap or193// not. y.mu is held.194func (y *YouTube) playlistAfter(a ytAiring) ytAiring {195 seq, lens := y.sequence()196 if len(seq) == 0 {197 return ytAiring{}198 }199 s, _ := y.airTimes()200 if sl, _, ok := s.at(lens, a.End); ok {201 return playlistAiring(seq[sl.I], sl)202 }203 return ytAiring{}204}205206func playlistAiring(v *ytVideo, sl slot) ytAiring {207 return ytAiring{ID: v.ID, Title: v.Title, Start: sl.Start, End: sl.End}208}209210// listPlaylist lists the playlist at url again. y.mu isn't held.211func (y *YouTube) listPlaylist(ctx context.Context, url string) {212 tool := y.tool()213 tool.Jobs.Lock()214 raw, err := tool.Run(ctx, slices.Concat(youtube.PlaylistArgs, []string{url})...)215 tool.Jobs.Unlock()216 var l ytListing217 if err == nil {218 l, err = parseYtPlaylist(raw)219 }220 y.mu.Lock()221 defer y.mu.Unlock()222 if err != nil {223 y.tried[url] = y.clock()224 log.Printf("%s: listing %s: %v", y.label(), url, err)225 return226 }227 if s := y.source(url); s != nil { // unless no longer configured228 added := s.mergePlaylist(l, y.clock())229 y.changed()230 log.Printf("%s: playlist %q has %d videos (%d new)", y.label(), l.Name, len(l.Videos), added)231 }232}233234// playlistLookups picks the playlist's videos to look up next: those235// without a length, premieres to check again, then what airs soon236// without a description. y.mu is held.237func (y *YouTube) playlistLookups(now time.Time) []string {238 var ids []string239 add := func(id string) {240 if len(ids) < ytLookups && !slices.Contains(ids, id) {241 ids = append(ids, id)242 }243 }244 for _, s := range y.cat.Sources {245 for _, v := range s.Videos {246 switch {247 case v.Skip == "" && v.Seconds == 0 && now.Sub(v.Looked) >= 10*time.Minute,248 v.Skip == "upcoming" && now.Sub(v.Looked) >= time.Hour:249 add(v.ID)250 }251 }252 }253 seq, lens := y.sequence()254 if len(seq) == 0 {255 return ids256 }257 s, _ := y.airTimes()258 for _, sl := range s.slots(lens, now, now.Add(ytDescribeAhead), 2*ytLookups) {259 if v := seq[sl.I]; v.Looked.IsZero() {260 add(v.ID)261 }262 }263 return ids264}265266// learnVideo takes in a video's looked-up details. A playlist's Shorts267// and recorded streams play like any other video. y.mu is held.268func (y *YouTube) learnVideo(v *ytVideo, i youtube.Info) {269 v.learn(i, y.clock())270 if y.isPlaylist() && (v.Skip == "short" || v.Skip == "live" && i.LiveStatus == "was_live") {271 v.Skip = ""272 }273}