Files
memby/server/internal/credits/candidates.go
T

388 lines
13 KiB
Go
Raw Normal View History

2026-08-15 09:23:26 +12:00
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
}
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,
}
}
// 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
}