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 / vchan / ytplayout.go
ytplayout.go 12.6K
1package vchan23import (4 "cmp"5 "encoding/json"6 "math"7 "math/rand/v2"8 "os"9 "regexp"10 "slices"11 "strings"12 "time"13)1415// ytAiring is one video on a YouTube channel's schedule.16type ytAiring struct {17 ID string `json:"id"`18 Channel string `json:"channel"` // the YouTube channel's ID19 Title string `json:"title"`20 Start time.Time `json:"start"`21 End time.Time `json:"end"`22 First bool `json:"first,omitempty"` // a new upload's first run23}2425// ytPlayout is a YouTube channel's schedule, kept in the channel's folder26// so the guide holds across restarts: planned two days ahead and only27// ever added to, except that new uploads may move airings beyond the lock28// window (a couple of hours) to go first, and that changing the channel's29// settings plans it again from now. Past airings are the history the30// repeat guard reads.31type ytPlayout struct {32 // Basis sums up the settings the schedule was planned with.33 Basis string `json:"basis,omitempty"`34 Airings []ytAiring `json:"airings"` // back to back, by start35}3637const (38 ytPlanAhead = 48 * time.Hour39 // ytKeepPast is the history kept even without a repeat guard, for the40 // guide's look back.41 ytKeepPast = 2 * 24 * time.Hour42 // ytResume is how long after the schedule runs out it carries on43 // from its end rather than from now.44 ytResume = 5 * time.Minute45 // Rerun mixes count ages up to these as recent, and as fairly recent.46 ytHalfYear = 182 * 24 * time.Hour47 ytTwoYears = 730 * 24 * time.Hour48)4950// ytPool is what the planner picks from.51type ytPool struct {52 Sources []ytPoolSource // in config order53 // Firsts are new uploads due a first run, newest first.54 Firsts []ytPick55 Repeat time.Duration56 // Lock is how far ahead new uploads leave the schedule as it is.57 Lock time.Duration58 // Mix is the shares of reruns picked from the last six months, the59 // last two years and any time, as in ytRerunMixes.60 Mix [3]float6461 // DeadAir leaves gaps rather than repeat a video within Repeat.62 DeadAir bool63 Seed uint6464 Basis string65 // ids are the videos that may stay on the schedule: every playable66 // one, including those too old to be picked now.67 ids map[string]bool68}6970type ytPoolSource struct {71 ID string // the YouTube channel's ID72 Videos []*ytVideo // newest first73 // HalfYear and TwoYears are the videos uploaded in the last six74 // months and the last two years (see ytAges).75 HalfYear, TwoYears []*ytVideo76}7778// ytByAge picks out a channel's videos (newest first) uploaded in the last79// six months and in the last two years, by their ytAges.80func ytByAge(videos []*ytVideo, now time.Time) (halfYear, twoYears []*ytVideo) {81 for i, age := range ytAges(videos, now) {82 if age <= ytHalfYear {83 halfYear = append(halfYear, videos[i])84 }85 if age <= ytTwoYears {86 twoYears = append(twoYears, videos[i])87 }88 }89 return halfYear, twoYears90}9192// ytAges are how long ago a channel's videos (newest first) were93// uploaded. A video without a date takes the date of its nearest dated94// neighbour in the list, the newer of two as near. In a list without95// dates, rank stands in: the newest 3% count as uploaded now, the next 9%96// as just over six months ago, and the rest as long ago.97func ytAges(videos []*ytVideo, now time.Time) []time.Duration {98 var dated []int99 for i, v := range videos {100 if !v.Published.IsZero() {101 dated = append(dated, i)102 }103 }104 ages := make([]time.Duration, len(videos))105 for i := range videos {106 age := time.Duration(math.MaxInt64)107 switch {108 case len(dated) > 0:109 j, _ := slices.BinarySearch(dated, i)110 near := -1111 if j < len(dated) {112 near = dated[j]113 }114 if j > 0 && (near < 0 || i-dated[j-1] <= near-i) {115 near = dated[j-1]116 }117 age = now.Sub(videos[near].Published)118 case i < len(videos)*3/100:119 age = 0120 case i < len(videos)*12/100:121 age = ytHalfYear + 1122 }123 ages[i] = age124 }125 return ages126}127128type ytPick struct {129 Video *ytVideo130 Channel string131}132133func (p *ytPool) has(id string) bool {134 if p.ids == nil {135 p.ids = map[string]bool{}136 for _, s := range p.Sources {137 for _, v := range s.Videos {138 p.ids[v.ID] = true139 }140 }141 }142 return p.ids[id]143}144145func readYtPlayout(path string) (ytPlayout, error) {146 var p ytPlayout147 raw, err := os.ReadFile(path)148 if err != nil {149 return p, err150 }151 err = json.Unmarshal(raw, &p)152 return p, err153}154155// end is when the next airing starts: after the last, or now when the156// schedule ran out a while ago.157func (p *ytPlayout) end(now time.Time) time.Time {158 if n := len(p.Airings); n > 0 && p.Airings[n-1].End.After(now.Add(-ytResume)) {159 return p.Airings[n-1].End160 }161 return now.UTC().Truncate(time.Minute)162}163164// at returns the airing on at t and the one after it.165func (p *ytPlayout) at(t time.Time) (on, next ytAiring, ok bool) {166 i, _ := slices.BinarySearchFunc(p.Airings, t, func(a ytAiring, t time.Time) int {167 if !a.End.After(t) {168 return -1169 }170 if a.Start.After(t) {171 return 1172 }173 return 0174 })175 if i >= len(p.Airings) || p.Airings[i].Start.After(t) {176 return ytAiring{}, ytAiring{}, false177 }178 if i+1 < len(p.Airings) {179 next = p.Airings[i+1]180 }181 return p.Airings[i], next, true182}183184// update brings the schedule up to date at now and reports whether it185// changed. History older than the repeat guard needs is dropped. When the186// pool's basis (the channel's settings) changed, everything from now is187// planned again, what's on included. Otherwise the part beyond the lock188// is replanned when first runs are due, which go first, newest first,189// with the reruns planned there after them, or when it holds videos that190// can no longer play. Then reruns are added until the schedule reaches191// two days ahead.192func (p *ytPlayout) update(pool *ytPool, now time.Time) bool {193 changed := false194 i := slices.IndexFunc(p.Airings, func(a ytAiring) bool {195 return a.End.After(now.Add(-max(pool.Repeat, ytKeepPast)))196 })197 if i < 0 {198 i = len(p.Airings)199 }200 if i > 0 {201 p.Airings, changed = slices.Clone(p.Airings[i:]), true202 }203204 fresh := p.Basis != pool.Basis205 var head, tail []ytAiring206 if fresh {207 // What's on is cut short, and stays with what aired.208 cut := now.UTC().Truncate(time.Second)209 k := slices.IndexFunc(p.Airings, func(a ytAiring) bool { return a.End.After(cut) })210 if k < 0 {211 k = len(p.Airings)212 }213 head = slices.Clone(p.Airings[:k])214 if k < len(p.Airings) && p.Airings[k].Start.Before(cut) {215 a := p.Airings[k]216 a.End = cut217 head = append(head, a)218 }219 } else {220 lock := now.Add(pool.Lock)221 k := slices.IndexFunc(p.Airings, func(a ytAiring) bool { return !a.Start.Before(lock) })222 if k < 0 {223 k = len(p.Airings)224 }225 head, tail = p.Airings[:k], slices.Clone(p.Airings[k:])226 }227 locked := map[string]bool{}228 for _, a := range head {229 locked[a.ID] = true230 }231 var firsts []ytPick232 for _, f := range pool.Firsts {233 if !locked[f.Video.ID] {234 firsts = append(firsts, f)235 }236 }237 replan := fresh || slices.ContainsFunc(tail, func(a ytAiring) bool { return !pool.has(a.ID) })238 for i, f := range firsts {239 // Due first runs lead the unlocked part, newest first.240 if i >= len(tail) || tail[i].ID != f.Video.ID || !tail[i].First {241 replan = true242 break243 }244 }245 if replan {246 k := len(head)247 p.Airings, p.Basis = slices.Clone(head), pool.Basis248 for _, f := range firsts {249 p.add(f.Video, f.Channel, true, now)250 }251 for _, a := range tail {252 if !a.First && pool.has(a.ID) {253 p.add(&ytVideo{ID: a.ID, Title: a.Title, Seconds: int(a.End.Sub(a.Start) / time.Second)}, a.Channel, false, now)254 }255 }256 changed = changed || fresh || !sameAirings(p.Airings[k:], tail)257 }258259 if p.end(now).Before(now.Add(ytPlanAhead - time.Hour)) {260 g := newYtGuard(p.Airings, pool)261 for p.end(now).Before(now.Add(ytPlanAhead)) {262 f, ok := g.pick(p.end(now))263 if !ok || f.Video.Seconds <= 0 {264 break265 }266 g.note(p.add(f.Video, f.Channel, false, now))267 changed = true268 }269 }270 return changed271}272273func (p *ytPlayout) add(v *ytVideo, channel string, first bool, now time.Time) ytAiring {274 start := p.end(now)275 a := ytAiring{ID: v.ID, Channel: channel, Title: v.Title, Start: start,276 End: start.Add(time.Duration(v.Seconds) * time.Second), First: first}277 p.Airings = append(p.Airings, a)278 return a279}280281func sameAirings(a, b []ytAiring) bool {282 return slices.EqualFunc(a, b, func(x, y ytAiring) bool {283 return x.ID == y.ID && x.Start.Equal(y.Start) && x.End.Equal(y.End) && x.First == y.First284 })285}286287// ytGuard picks reruns: the channels take turns, and each picks a video at288// random that hasn't aired within the repeat window, or failing that the289// one that aired longest ago (with dead air, a channel without a rested290// video sits out instead), preferring one of a different series than291// the last few airings. The pool's mix says how often to pick from the292// last six months, the last two years, or any time; when nothing there293// is rested, the pick looks further back.294type ytGuard struct {295 pool *ytPool296 aired map[string]time.Time // last start by video297 turn map[string]time.Time // last start by channel298 recent []string // series of the latest airings299 sources []ytPoolSource300}301302func newYtGuard(history []ytAiring, pool *ytPool) *ytGuard {303 g := &ytGuard{pool: pool, aired: map[string]time.Time{}, turn: map[string]time.Time{}}304 for _, s := range pool.Sources {305 if len(s.Videos) > 0 {306 g.sources = append(g.sources, s)307 }308 }309 for _, a := range history {310 g.note(a)311 }312 return g313}314315func (g *ytGuard) note(a ytAiring) {316 g.aired[a.ID], g.turn[a.Channel] = a.Start, a.Start317 g.recent = append(g.recent, series(a.Title))318 if len(g.recent) > 3 {319 g.recent = g.recent[1:]320 }321}322323func (g *ytGuard) pick(at time.Time) (ytPick, bool) {324 rested := func(v *ytVideo) bool {325 t, ok := g.aired[v.ID]326 return !ok || at.Sub(t) >= g.pool.Repeat327 }328 // The channel that aired least lately goes next; config order breaks329 // ties. With dead air, only channels with a rested video take turns,330 // and with none, nothing airs.331 var src *ytPoolSource332 for i := range g.sources {333 s := &g.sources[i]334 if g.pool.DeadAir && !slices.ContainsFunc(s.Videos, rested) {335 continue336 }337 if src == nil || g.turn[s.ID].Before(g.turn[src.ID]) {338 src = s339 }340 }341 if src == nil {342 return ytPick{}, false343 }344 rng := rand.New(rand.NewPCG(g.pool.Seed, uint64(at.Unix())))345 different := func(v *ytVideo) bool {346 s := series(v.Title)347 return s == "" || !slices.Contains(g.recent, s)348 }349 ages := [][]*ytVideo{src.HalfYear, src.TwoYears, src.Videos}350 from, r := 0, rng.Float64()351 for from < 2 && r >= g.pool.Mix[from] {352 r -= g.pool.Mix[from]353 from++354 }355 for _, vs := range ages[from:] {356 if v := pickRested(vs, rng, rested, different); v != nil {357 return ytPick{v, src.ID}, true358 }359 }360 oldest := slices.MinFunc(src.Videos, func(a, b *ytVideo) int { return g.aired[a.ID].Compare(g.aired[b.ID]) })361 return ytPick{oldest, src.ID}, true362}363364// pickRested picks a rested video from vs at random, of a different series365// if it can, or nil.366func pickRested(vs []*ytVideo, rng *rand.Rand, rested, different func(*ytVideo) bool) *ytVideo {367 if len(vs) == 0 {368 return nil369 }370 for range 40 {371 if v := vs[rng.IntN(len(vs))]; rested(v) && different(v) {372 return v373 }374 }375 var ok []*ytVideo376 for _, v := range vs {377 if rested(v) {378 ok = append(ok, v)379 }380 }381 if len(ok) == 0 {382 return nil383 }384 return ok[rng.IntN(len(ok))]385}386387var (388 // seriesNoise is what changes between a series' titles: "[Episode 4]",389 // "(2/2)", "(Episode 15)", "#23".390 seriesNoise = regexp.MustCompile(`(?i)\s*(\[[^\]]*\]|\((\d+/\d+|(episode|ep\.?|part)\s*\d+)\)|#\d+)`)391 episodePart = regexp.MustCompile(`(?i)^(episode|ep\.?|part)\s*\d+$`)392 genericPart = regexp.MustCompile(`(?i)^(season\s*\d+|let'?s play|northernlion plays)$`)393)394395// series guesses a video's series or game from its title, so reruns of396// one don't run back to back: "(Slay the Spire 2)" ending a title, what397// follows " | ", the part before "Episode 12" in "Game - Let's Play -398// Episode 12", or the last part of "Topic - Bits and Banter". Titles399// without such parts are their own series.400func series(title string) string {401 t := strings.TrimSpace(seriesNoise.ReplaceAllString(title, ""))402 if strings.HasSuffix(t, ")") {403 if i := strings.LastIndex(t, "("); i > 0 {404 return strings.ToLower(strings.TrimSpace(t[i+1 : len(t)-1]))405 }406 }407 if i := strings.LastIndex(t, " | "); i >= 0 {408 t = t[i+3:]409 }410 parts := strings.Split(t, " - ")411 i := len(parts) - 1412 if e := slices.IndexFunc(parts, func(s string) bool { return episodePart.MatchString(strings.TrimSpace(s)) }); e >= 0 {413 i = e - 1414 }415 for i > 0 && genericPart.MatchString(strings.TrimSpace(parts[i])) {416 i--417 }418 return strings.ToLower(strings.TrimSpace(parts[max(i, 0)]))419}420421// newestFirst orders first runs.422func newestFirst(a, b ytPick) int {423 return cmp.Or(424 b.Video.Published.Compare(a.Video.Published),425 b.Video.Found.Compare(a.Video.Found),426 strings.Compare(a.Video.ID, b.Video.ID))427}