157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
package credits
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Tracearr as a candidate source, kept deliberately apart from the detector.
|
|
//
|
|
// Everything about *why* an episode is worth scanning lives on this side of the boundary,
|
|
// and nothing about it crosses. The detector is handed a file and a window; it never learns
|
|
// that Paul watched four episodes of Blue Bloods this week, and it must not, or it would
|
|
// become possible to tune the detector to agree with the predictor rather than with the
|
|
// media.
|
|
|
|
// Database is the narrow slice of the store this package reads. Narrow so the whole candidate
|
|
// pipeline can be exercised against a map in a test, and so it is obvious at a glance that
|
|
// nothing here writes anything but a marker.
|
|
type Database interface {
|
|
RecentWatches(ctx context.Context, since time.Time, limit int) ([]Watch, error)
|
|
SeriesEpisodes(ctx context.Context, seriesIDs []string) ([]SeriesEpisode, error)
|
|
}
|
|
|
|
// SeriesEpisode is an episode with its series and numbering, which is what an index is built from.
|
|
type SeriesEpisode struct {
|
|
ItemID string
|
|
SeriesID string
|
|
Season int
|
|
Episode int
|
|
}
|
|
|
|
// watchLimit bounds the demand query. A household producing more than this many episode
|
|
// sessions inside the decay window is one whose oldest sessions cannot possibly still be
|
|
// predictive, so the newest-first ordering makes the cap harmless.
|
|
const watchLimit = 500
|
|
|
|
// TracearrSource builds candidates from what the household has actually been watching.
|
|
type TracearrSource struct {
|
|
DB Database
|
|
Cfg Config
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func (s *TracearrSource) Configure(cfg Config) {
|
|
s.mu.Lock()
|
|
s.Cfg = NormaliseConfig(cfg)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *TracearrSource) config() Config {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return NormaliseConfig(s.Cfg)
|
|
}
|
|
|
|
// Candidates is the whole predictive pipeline: read recent demand, group it into per-viewer
|
|
// activities, load the numbering for the few series involved, and project a small window
|
|
// ahead of each viewer.
|
|
//
|
|
// Two database reads for the entire household, whatever it is watching. Nothing here is
|
|
// per-candidate and nothing is per-episode.
|
|
func (s *TracearrSource) Candidates(ctx context.Context) ([]Candidate, error) {
|
|
if s == nil || s.DB == nil {
|
|
return nil, nil
|
|
}
|
|
cfg := s.config()
|
|
now := time.Now().UTC()
|
|
|
|
// The decay window is the query's window too. Anything older cannot survive
|
|
// decayWeight, so fetching it would be reading rows in order to discard them.
|
|
watches, err := s.DB.RecentWatches(ctx, now.Add(-cfg.WeakWindow), watchLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
activities := Activities(watches, now, cfg)
|
|
if len(activities) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
seriesIDs := make([]string, 0, len(activities))
|
|
seen := map[string]bool{}
|
|
for _, activity := range activities {
|
|
if !seen[activity.SeriesID] {
|
|
seen[activity.SeriesID] = true
|
|
seriesIDs = append(seriesIDs, activity.SeriesID)
|
|
}
|
|
}
|
|
episodes, err := s.DB.SeriesEpisodes(ctx, seriesIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return BuildCandidates(activities, NewEpisodeIndex(episodes), now, cfg), nil
|
|
}
|
|
|
|
// episodeIndex is an in-memory ordering of each series' episodes.
|
|
//
|
|
// Built once per refresh cycle from one query, so stepping forward — including across a
|
|
// season boundary, which is exactly where somebody is most likely to keep watching — is
|
|
// arithmetic rather than a lookup per candidate.
|
|
type episodeIndex struct {
|
|
bySeries map[string][]SeriesEpisode
|
|
}
|
|
|
|
// NewEpisodeIndex orders the episodes of each series by season and number.
|
|
func NewEpisodeIndex(episodes []SeriesEpisode) EpisodeIndex {
|
|
index := &episodeIndex{bySeries: map[string][]SeriesEpisode{}}
|
|
for _, episode := range episodes {
|
|
if episode.ItemID == "" || episode.SeriesID == "" || episode.Episode <= 0 {
|
|
continue
|
|
}
|
|
index.bySeries[episode.SeriesID] = append(index.bySeries[episode.SeriesID], episode)
|
|
}
|
|
for _, list := range index.bySeries {
|
|
sort.Slice(list, func(a, b int) bool {
|
|
if list[a].Season != list[b].Season {
|
|
return list[a].Season < list[b].Season
|
|
}
|
|
return list[a].Episode < list[b].Episode
|
|
})
|
|
}
|
|
return index
|
|
}
|
|
|
|
// Following returns the next count episodes after a position.
|
|
//
|
|
// Specials are skipped. Season 0 is a real season and its episodes are real files, but
|
|
// nobody finishing S06E07 goes on to a behind-the-scenes featurette, and queueing one would
|
|
// spend a scan on an episode that will not be watched.
|
|
func (i *episodeIndex) Following(seriesID string, season, episode, count int) []EpisodeRef {
|
|
if count <= 0 {
|
|
return nil
|
|
}
|
|
list := i.bySeries[seriesID]
|
|
out := make([]EpisodeRef, 0, count)
|
|
for _, candidate := range list {
|
|
if candidate.Season <= 0 {
|
|
continue
|
|
}
|
|
after := candidate.Season > season ||
|
|
(candidate.Season == season && candidate.Episode > episode)
|
|
if !after {
|
|
continue
|
|
}
|
|
out = append(out, EpisodeRef{
|
|
ItemID: candidate.ItemID,
|
|
Season: candidate.Season,
|
|
Episode: candidate.Episode,
|
|
})
|
|
if len(out) == count {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|