package credits import ( "sort" "strings" "time" ) // Candidate generation, and all of it pure. // // The rule this file exists to enforce is the one in the brief that matters most: a viewer // watching Blue Bloods is *not* a reason to scan 293 episodes of Blue Bloods. It is a reason // to scan the three they are about to reach. Everything here is arithmetic on where somebody // has got to and how fast they are moving, and the output is a handful of episodes. // Config is the tuning. Defaults come from DefaultConfig; an operator changes them through // the environment rather than by editing constants, because the right look-ahead depends on // how a particular household watches television. type Config struct { // PrefetchEpisodes is the look-ahead for an ordinary viewer — the "credits.prefetchEpisodes // = 3" of the brief. Velocity moves the actual depth either side of it. PrefetchEpisodes int // MaxPrefetchEpisodes is the ceiling nothing may exceed, however fast somebody watches. // The objective is useful precomputation, not speculative scanning, and this is the line // between the two. MaxPrefetchEpisodes int // The decay thresholds. Demand is evidence with a shelf life: three nights of Blue Bloods // followed by a week of Slow Horses must stop producing Blue Bloods candidates on its own, // without anything having to notice that the household changed its mind. StrongWindow time.Duration UsefulWindow time.Duration WeakWindow time.Duration // QueueLimit bounds the queue. Past it, low-priority speculation is discarded rather than // queued — a backlog of candidates for episodes nobody reached is worse than no backlog. QueueLimit int // RetryCooldown keeps an unsuccessful speculative scan from returning on every queue // refresh. Live playback remains immediate; this applies only to predicted work. RetryCooldown time.Duration } func DefaultConfig() Config { return Config{ PrefetchEpisodes: 3, MaxPrefetchEpisodes: 5, StrongWindow: 24 * time.Hour, UsefulWindow: 3 * 24 * time.Hour, WeakWindow: 7 * 24 * time.Hour, QueueLimit: 20, RetryCooldown: 24 * time.Hour, } } // NormaliseConfig makes both environment and admin-supplied tuning safe. It is exported // because the gateway restores persisted settings before constructing the service. func NormaliseConfig(cfg Config) Config { defaults := DefaultConfig() if cfg.PrefetchEpisodes <= 0 { cfg.PrefetchEpisodes = defaults.PrefetchEpisodes } if cfg.PrefetchEpisodes > 10 { cfg.PrefetchEpisodes = 10 } if cfg.MaxPrefetchEpisodes < cfg.PrefetchEpisodes { cfg.MaxPrefetchEpisodes = cfg.PrefetchEpisodes } if cfg.MaxPrefetchEpisodes > 20 { cfg.MaxPrefetchEpisodes = 20 } if cfg.QueueLimit <= 0 { cfg.QueueLimit = defaults.QueueLimit } if cfg.QueueLimit > 100 { cfg.QueueLimit = 100 } if cfg.StrongWindow <= 0 { cfg.StrongWindow = defaults.StrongWindow } if cfg.UsefulWindow <= 0 { cfg.UsefulWindow = defaults.UsefulWindow } if cfg.WeakWindow <= 0 { cfg.WeakWindow = defaults.WeakWindow } if cfg.RetryCooldown < 0 { cfg.RetryCooldown = 0 } if cfg.RetryCooldown > 30*24*time.Hour { cfg.RetryCooldown = 30 * 24 * time.Hour } return cfg } // Watch is one episode one viewer played, reduced to the four things candidate generation // needs. It is what a Tracearr session becomes on the way in, and keeping it this narrow is // what lets every rule below be tested with a literal. type Watch struct { UserKey string SeriesID string Season int Episode int // WatchedAt is when the session ended, or started where it never ended. Recency is // measured from it and so is velocity. WatchedAt time.Time // Completed distinguishes "finished this episode, will start the next" from "is // part-way through it". The two produce different look-ahead windows, and getting it // wrong costs a scan of an episode somebody is already watching. Completed bool } // EpisodeRef is a position in a series resolved to something scannable. type EpisodeRef struct { ItemID string Season int Episode int } // EpisodeIndex resolves "the episodes after this one" — across a season boundary, since a // season finale is exactly when somebody is most likely to keep going. Backed by the imported // library, which already holds every episode with its numbering. type EpisodeIndex interface { Following(seriesID string, season, episode, count int) []EpisodeRef } // SeriesActivity is one viewer's relationship with one series right now: where they have got // to, when they were last there, and how fast they are moving. type SeriesActivity struct { UserKey string SeriesID string Season int Episode int // InProgress means the furthest episode was not finished, so it is itself a candidate // rather than something to look past. InProgress bool LastViewed time.Time // EpisodesPerDay over the recent run. See viewingVelocity. EpisodesPerDay float64 } // Activities groups raw watches into one activity per viewer and series. // // "Where they have got to" is the *furthest* episode watched recently, not the most recent // session: somebody who dips back to rewatch an earlier episode has not un-watched the ones // after it, and predicting from the rewatch would queue episodes they finished a fortnight // ago. Recency still comes from the latest session, because that is what decay measures. func Activities(watches []Watch, now time.Time, cfg Config) []SeriesActivity { type key struct{ user, series string } grouped := map[key][]Watch{} for _, watch := range watches { if strings.TrimSpace(watch.SeriesID) == "" || watch.Season < 0 || watch.Episode <= 0 { continue } // Anything past the discard threshold is not evidence about tonight. Dropping it // here rather than at the end keeps it out of the velocity calculation too, where // a six-month-old session would otherwise drag an active binge down to a crawl. if now.Sub(watch.WatchedAt) > cfg.WeakWindow { continue } group := key{user: watch.UserKey, series: watch.SeriesID} grouped[group] = append(grouped[group], watch) } out := make([]SeriesActivity, 0, len(grouped)) for group, items := range grouped { activity := SeriesActivity{UserKey: group.user, SeriesID: group.series} for _, watch := range items { if watch.WatchedAt.After(activity.LastViewed) { activity.LastViewed = watch.WatchedAt } if watch.Season > activity.Season || (watch.Season == activity.Season && watch.Episode > activity.Episode) { activity.Season, activity.Episode = watch.Season, watch.Episode activity.InProgress = !watch.Completed } } activity.EpisodesPerDay = viewingVelocity(items, now) out = append(out, activity) } // Sorted so a queue built from identical input is identical, which is what makes the // scheduler's behaviour reproducible in a test and its log readable in production. sort.Slice(out, func(a, b int) bool { if !out[a].LastViewed.Equal(out[b].LastViewed) { return out[a].LastViewed.After(out[b].LastViewed) } if out[a].SeriesID != out[b].SeriesID { return out[a].SeriesID < out[b].SeriesID } return out[a].UserKey < out[b].UserKey }) return out } // viewingVelocity is episodes per day over the run somebody is actually on. // // Measured across the span from the first watch in the window to now, rather than to the // last watch: a viewer who watched four episodes on Monday and nothing since is not still // watching four a day, and rating them as a binge would queue five episodes for somebody // who has moved on. The span floors at a day, so one evening's four episodes reads as four // a day rather than as an infinite rate. func viewingVelocity(watches []Watch, now time.Time) float64 { if len(watches) == 0 { return 0 } // Distinct episodes: a session resumed three times is one episode watched, and counting // the resumes would read an unreliable connection as enthusiasm. type slot struct{ season, episode int } seen := map[slot]bool{} earliest := now for _, watch := range watches { seen[slot{watch.Season, watch.Episode}] = true if watch.WatchedAt.Before(earliest) { earliest = watch.WatchedAt } } days := now.Sub(earliest).Hours() / 24 if days < 1 { days = 1 } return float64(len(seen)) / days } // prefetchDepth turns a rate into a number of episodes to prepare. // // Deliberately a step function rather than anything cleverer. The brief's own guidance is // slow → 1, normal → 2-3, binge → 4-5, and prediction beyond that is not worth the risk of // being confidently wrong about somebody's evening: every episode of depth is a real scan. func prefetchDepth(episodesPerDay float64, cfg Config) int { base := cfg.PrefetchEpisodes if base <= 0 { base = 3 } ceiling := cfg.MaxPrefetchEpisodes if ceiling <= 0 { ceiling = 5 } depth := base switch { case episodesPerDay < 0.5: // One episode every few days. The next one is all that is worth preparing, and // there will be plenty of time to prepare the one after it. depth = 1 case episodesPerDay < 1.5: depth = base - 1 case episodesPerDay < 3: depth = base case episodesPerDay < 5: depth = base + 1 default: depth = base + 2 } if depth < 1 { depth = 1 } if depth > ceiling { depth = ceiling } return depth } // decayWeight is how much a piece of demand still counts for. // // A multiplier rather than a filter because the queue is ordered, not gated: a three-day-old // binge is still worth preparing when the machine is idle, it simply must not outrank // somebody who watched an episode an hour ago. func decayWeight(lastViewed, now time.Time, cfg Config) float64 { age := now.Sub(lastViewed) switch { case age < 0: // A clock disagreement, not a prediction about the future. return 1 case age <= cfg.StrongWindow: return 1 case age <= cfg.UsefulWindow: return 0.75 case age <= cfg.WeakWindow: return 0.4 default: return 0 } } // priorityForOffset is the look-ahead hierarchy: how much less an episode is worth for each // step further from where somebody actually is. func priorityForOffset(offset int) (int, string) { switch offset { case 0, 1: return PriorityNext, ReasonNext case 2: return PriorityAhead2, ReasonBinge case 3: return PriorityAhead3, ReasonBinge default: return PrioritySpeculative, ReasonBinge } } // BuildCandidates is the whole predictor: activities in, a small deduplicated queue out. // // Three things happen here and they are easy to conflate. Each viewer produces a *window* of // episodes ahead of where they are, sized by how fast they watch. Those windows are then // merged across the household, so an episode two people are approaching is one candidate // rather than two. And the merged candidate is boosted for the agreement, because one scan // now serves both of them. func BuildCandidates( activities []SeriesActivity, index EpisodeIndex, now time.Time, cfg Config, ) []Candidate { if index == nil { return nil } merged := map[string]*Candidate{} users := map[string]map[string]bool{} for _, activity := range activities { weight := decayWeight(activity.LastViewed, now, cfg) if weight <= 0 { continue } depth := prefetchDepth(activity.EpisodesPerDay, cfg) // An unfinished episode is itself the first candidate; a finished one means the // window starts at the episode after it. Asking the index for one extra and dropping // the head is wrong here — the unfinished episode has an item id of its own that the // index would not return as "following". refs := index.Following(activity.SeriesID, activity.Season, activity.Episode, depth) window := make([]EpisodeRef, 0, depth+1) if activity.InProgress { window = append(window, EpisodeRef{ ItemID: currentItemID(index, activity), Season: activity.Season, Episode: activity.Episode, }) } window = append(window, refs...) for offset, ref := range window { if strings.TrimSpace(ref.ItemID) == "" { continue } if offset >= depth+boolToInt(activity.InProgress) { break } base, reason := priorityForOffset(offset) priority := int(float64(base) * weight) existing, found := merged[ref.ItemID] if !found { existing = &Candidate{ ItemID: ref.ItemID, SeriesID: activity.SeriesID, Season: ref.Season, Episode: ref.Episode, Priority: priority, Reason: reason, LastViewed: activity.LastViewed, } merged[ref.ItemID] = existing users[ref.ItemID] = map[string]bool{} } // The strongest case for an episode wins; a second, weaker viewer never lowers // a candidate somebody else is about to reach. if priority > existing.Priority { existing.Priority, existing.Reason = priority, reason } if activity.LastViewed.After(existing.LastViewed) { existing.LastViewed = activity.LastViewed } users[ref.ItemID][activity.UserKey] = true } } out := make([]Candidate, 0, len(merged)) for itemID, candidate := range merged { candidate.UserCount = len(users[itemID]) if candidate.UserCount > 1 { // Agreement is the cheapest signal there is: the same single scan now serves // more than one person, so it is worth doing sooner. candidate.Priority += MultiUserBonus * (candidate.UserCount - 1) candidate.Reason = ReasonMultiUser } // Nothing predicted may reach live playback's priority. Live playback is a viewer // waiting; everything here is a guess about one. if candidate.Priority > MaxPredictedPriority { candidate.Priority = MaxPredictedPriority } out = append(out, *candidate) } sortCandidates(out) if cfg.QueueLimit > 0 && len(out) > cfg.QueueLimit { out = out[:cfg.QueueLimit] } return out } // currentItemID resolves the episode a viewer is part-way through. The index is asked for // the episode at that exact position by requesting the one before it and taking the head, // which keeps EpisodeIndex to a single method. func currentItemID(index EpisodeIndex, activity SeriesActivity) string { refs := index.Following(activity.SeriesID, activity.Season, activity.Episode-1, 1) for _, ref := range refs { if ref.Season == activity.Season && ref.Episode == activity.Episode { return ref.ItemID } } return "" } // sortCandidates puts the queue in the order the worker should take it: priority first, then // the most recent demand, then item id so the order is total and a test can assert on it. func sortCandidates(candidates []Candidate) { sort.Slice(candidates, func(a, b int) bool { if candidates[a].Priority != candidates[b].Priority { return candidates[a].Priority > candidates[b].Priority } if !candidates[a].LastViewed.Equal(candidates[b].LastViewed) { return candidates[a].LastViewed.After(candidates[b].LastViewed) } return candidates[a].ItemID < candidates[b].ItemID }) } func boolToInt(value bool) int { if value { return 1 } return 0 }