package recommend import ( "context" "math" "net/url" "sort" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/emby" ) // Magic answers "I don't care exactly what — put something good on". // // It is deliberately not the same thing as the top of a recommendation row. A row is read, // compared and chosen from, so its order is the whole product and being stable matters; this // is pressed instead of choosing, so being *the same answer twice* is the one failure it // cannot have. What it does share with the rows is the evidence: the same profile, the same // candidate pool, the same explanation layer. The difference is only what happens after the // scoring, which is a weighted draw rather than a sort. // // The scoring is a sum of stated terms rather than an opaque model, for the same reason the // rest of this package is: a Magic pick that lands badly is something the household will // want explained, and [MagicSelection.Signals] is the explanation. const ( // MagicPoolLimit is how many titles are in the hat. Large enough that a household // pressing Magic every evening for a fortnight does not exhaust it, small enough that // nothing genuinely unsuitable can be drawn. MagicPoolLimit = 40 // MagicPoolReserve is how many scored titles [Engine.MagicPool] keeps, which is // deliberately several times the hat. The pool is built once and drawn from many // times, and every press narrows it further — the film playing now and the last few // this button offered come out, and a viewer who said how long they had re-ranks // what is left. Reserving only the hat's own size would leave a household with // nothing to draw after a handful of presses. MagicPoolReserve = MagicPoolLimit * 3 // magicUnwatchedBonus is the largest single term, because "something I have not seen" // is most of what somebody means by the button. magicUnwatchedBonus = 1.4 // magicSeenPenalty applies to a title this viewer has already watched. A penalty and // not an exclusion: a rewatch is a legitimate answer, and a library whose owner has // seen most of it must still have something to offer. magicSeenPenalty = 1.1 // magicFavouriteBonus is for a title the viewer marked themselves. Below the unwatched // bonus deliberately — a favourite is by definition something already watched. magicFavouriteBonus = 0.7 // magicRecentlyAddedBonus surfaces what the household has just acquired. magicRecentlyAddedBonus = 0.5 magicRecentlyAddedDays = 30 // magicRuntimeFitBonus and its penalty are how "there is an hour before bed" gets a // different answer from "it is Saturday afternoon". Only applied when the television // said how long it had. magicRuntimeFitBonus = 0.5 magicRuntimeOverPenalty = 1.2 magicRuntimeSlackMinutes = 10 ) // MagicOptions are the request-scoped constraints on one press. type MagicOptions struct { // ExcludeIDs is what must not come back: the film playing right now, and whatever the // last few presses produced. Repetition protection lives with the caller because it is // the caller that knows what it already offered. ExcludeIDs []string // AvailableMinutes is zero for no limit. AvailableMinutes int // Roll is the randomness, in [0,1). Injected rather than read from a global so the // draw is deterministic under test — and so that the *only* non-deterministic thing // about this feature sits in one named parameter. Roll float64 // Now is injectable for the same reason. It reaches the pool rather than the draw — // the only thing it decides is what counts as recently added. Now time.Time } // MagicCandidate is one title already weighed, reduced to what a draw needs and nothing // more. // // It exists because the two halves of this feature have completely different costs. Working // out what the viewer likes is two full reads of their Emby history plus a catalogue query; // drawing from the result is arithmetic over a few dozen numbers. Separating them is what // lets the expensive half be done once and kept, while every press still gets its own // genuinely unpredictable answer — the property the button cannot lose. It is JSON-tagged // because being cached is the whole point of the separation. type MagicCandidate struct { ItemID string `json:"itemId"` // Title is carried so a draw can be logged by name without re-reading the item. Title string `json:"title"` // Score is everything the profile had to say, which is fixed for as long as the pool // is. The request-scoped terms are applied at the draw. Score float64 `json:"score"` // RuntimeMinutes is kept rather than folded into the score because "there is an hour // before bed" is a property of the press, not of the title. RuntimeMinutes int `json:"runtimeMinutes,omitempty"` // Signals is why this title was eligible, in machine-readable slugs. Not shown. Signals []string `json:"signals,omitempty"` // Reasons is viewer-facing wording from the same explanation layer a detail page // uses. It is computed here rather than at the draw because it needs the profile, // which is exactly what the pool exists to avoid rebuilding. Reasons []string `json:"reasons,omitempty"` } // MagicSelection is one drawn title with its evidence. // // It carries the item's id and name rather than the item itself: a draw may be made from a // pool built hours ago, and the caller re-reads the record it is about to hand a television // regardless — which is one lookup, against a title somebody is about to watch for two // hours. type MagicSelection struct { ItemID string Title string // Reasons is viewer-facing wording from the same explanation layer a detail page uses. Reasons []string // Signals is why this title was *eligible*, in machine-readable slugs, so the choice // can be reported and the weighting improved later. Not shown to anybody. Signals []string // Score is what it scored, and PoolSize how many it was drawn from. Both are recorded // rather than displayed: a pool of one is a household that has run out of library, and // that is a different problem from a bad weighting. Score float64 PoolSize int } // MagicPool does the expensive half: the taste profile, the candidate query and the // weighing. Nothing about it is request-scoped, which is what makes it safe to keep. // // It never errors. A profile that cannot be built costs the weighting and not the button, // on the principle [Engine.RelatedTo] already applies — an empty pool is the one failure, // and it means a household with no films rather than a server having trouble. func (e *Engine) MagicPool( ctx context.Context, cred emby.Credentials, now time.Time, ) []MagicCandidate { if now.IsZero() { now = e.now() } history, favorites, err := e.gatherSignals(ctx, cred) if err != nil { // What is left is an unweighted draw over the catalogue, which is still "put // something on". e.log.Warn("magic signals unavailable; drawing without taste", "error", err) } profile := BuildProfile(history, favorites) candidates := e.magicCandidates(ctx, cred, profile) if len(candidates) == 0 { return nil } pool := make([]MagicCandidate, 0, len(candidates)) byID := make(map[string]Item, len(candidates)) for _, candidate := range candidates { if candidate.ID == "" || byID[candidate.ID].ID != "" { continue } byID[candidate.ID] = candidate score, signals := magicScore(profile, candidate, now) pool = append(pool, MagicCandidate{ ItemID: candidate.ID, Title: candidate.Name, Score: score, RuntimeMinutes: candidate.RuntimeMinutes(), Signals: signals, }) } sortMagicPool(pool) if len(pool) > MagicPoolReserve { pool = pool[:MagicPoolReserve] } // Worded only for what survived the reserve: the explanation layer runs per title, and // wording several hundred nobody will ever be offered is work thrown away. for i := range pool { pool[i].Reasons = Why(profile, byID[pool[i].ItemID], 2) } return pool } // MagicPick builds a pool and draws from it in one go — the whole feature for a caller with // nowhere to keep the pool, and what the tests exercise. func (e *Engine) MagicPick( ctx context.Context, cred emby.Credentials, opts MagicOptions, ) (MagicSelection, bool) { return ChooseMagic(e.MagicPool(ctx, cred, opts.Now), opts) } // magicCandidates prefers the imported catalogue, which costs Postgres one read rather than // costing Emby a library scan per press, and falls back to Emby where there is no library. // Both paths ask for films only: the button plays something immediately, and a series is a // question about which episode. func (e *Engine) magicCandidates( ctx context.Context, cred emby.Credentials, profile Profile, ) []Item { // A cold profile has no genres to ask the catalogue for, and LibraryCandidates answers // nothing when asked for none — so that case goes to Emby, which can list a library // without being told what the viewer likes. if genres := profile.TopGenres(12); e.Library != nil && len(genres) > 0 { if raws, libErr := e.Library.LibraryCandidates(ctx, genres, MagicPoolLimit*8); libErr == nil { if movies := onlyMovies(Decode(raws)); len(movies) > 0 { return movies } } else { e.log.Warn("magic library candidates failed; falling back to emby", "error", libErr) } } // Not filtered to unplayed: a rewatch is a legitimate Magic answer and the scoring is // what decides between them. Sorted by rating so a truncated read keeps the better // half of the library rather than the alphabetical front of it. result, err := e.source.Items(ctx, cred, url.Values{ "IncludeItemTypes": {"Movie"}, "Recursive": {"true"}, "SortBy": {"CommunityRating"}, "SortOrder": {"Descending"}, "Limit": {strconv.Itoa(MagicPoolLimit * 8)}, "Fields": {candidateFields}, "ImageTypeLimit": {"1"}, "EnableImages": {"true"}, "EnableImageTypes": {rowImageTypes}, "EnableUserData": {"true"}, }) if err != nil { e.log.Warn("magic emby candidates failed", "error", err) return nil } return onlyMovies(Decode(result.Items)) } func onlyMovies(items []Item) []Item { out := make([]Item, 0, len(items)) for _, item := range items { if strings.EqualFold(item.Type, "Movie") && item.ID != "" { out = append(out, item) } } return out } // ChooseMagic is the draw, and it is pure so the weighting can be argued with in a test // rather than on a television. // // Ranking then taking the top is what a row does and is exactly wrong here: the same // household would get the same film every night, which is the one outcome the button cannot // have. Ranking then *drawing from the ranking* keeps merit deciding which titles are in the // hat and how many tickets each holds, while leaving the answer genuinely unpredictable. func ChooseMagic(candidates []MagicCandidate, opts MagicOptions) (MagicSelection, bool) { excluded := map[string]bool{} for _, id := range opts.ExcludeIDs { if id = strings.TrimSpace(id); id != "" { excluded[id] = true } } pool := make([]MagicCandidate, 0, len(candidates)) seen := map[string]bool{} for _, candidate := range candidates { if candidate.ItemID == "" || excluded[candidate.ItemID] || seen[candidate.ItemID] { continue } seen[candidate.ItemID] = true // Only the terms that belong to this press: everything the profile had to say is // already in the score the pool was built with. if adjustment, signal := magicRuntimeAdjustment( candidate.RuntimeMinutes, opts.AvailableMinutes, ); signal != "" { candidate.Score += adjustment candidate.Signals = append(append([]string(nil), candidate.Signals...), signal) } pool = append(pool, candidate) } if len(pool) == 0 { return MagicSelection{}, false } sortMagicPool(pool) if len(pool) > MagicPoolLimit { pool = pool[:MagicPoolLimit] } // Tickets decay linearly with rank rather than by score, so a library whose scores // happen to be bunched together still favours the front of the pool, and one with a // runaway leader still gives the rest a real chance. total := float64(len(pool)*(len(pool)+1)) / 2 roll := opts.Roll if roll < 0 || roll >= 1 || math.IsNaN(roll) { roll = 0 } target := roll * total var cumulative float64 for index, entry := range pool { cumulative += float64(len(pool) - index) if target < cumulative { return magicSelection(entry, len(pool)), true } } return magicSelection(pool[len(pool)-1], len(pool)), true } func magicSelection(entry MagicCandidate, poolSize int) MagicSelection { return MagicSelection{ ItemID: entry.ItemID, Title: entry.Title, Reasons: entry.Reasons, Signals: entry.Signals, Score: entry.Score, PoolSize: poolSize, } } // sortMagicPool orders by merit, with ties broken by id so the *pool* is reproducible even // though the draw from it is not. It is one function because the pool is ordered twice — as // it is built and again after a press has adjusted it — and two copies of a comparison is // how the two orders come to disagree. func sortMagicPool(pool []MagicCandidate) { sort.SliceStable(pool, func(i, j int) bool { if pool[i].Score != pool[j].Score { return pool[i].Score > pool[j].Score } return pool[i].ItemID < pool[j].ItemID }) } // magicScore sums the terms that belong to the *title*, and reports which of them fired. // The signals are the point of returning two values: a weighting nobody can see the // workings of is a weighting nobody can improve. // // The runtime fit is deliberately not here — see [magicRuntimeAdjustment]. func magicScore(profile Profile, item Item, now time.Time) (float64, []string) { signals := make([]string, 0, 6) score := profile.Affinity(item) if score > 0 { signals = append(signals, "taste") } if profile.HasSeen(item) { score -= magicSeenPenalty signals = append(signals, "seen") } else { score += magicUnwatchedBonus signals = append(signals, "unwatched") } if item.UserData.IsFavorite { score += magicFavouriteBonus signals = append(signals, "favourite") } if addedDays, ok := daysSince(item.DateCreated, now); ok && addedDays <= magicRecentlyAddedDays { score += magicRecentlyAddedBonus signals = append(signals, "recently_added") } return score, signals } // magicRuntimeAdjustment is how "there is an hour before bed" gets a different answer from // "it is Saturday afternoon". It is applied at the draw rather than folded into the pool // because it belongs to the press: the same pool has to be able to answer both questions. // // An empty signal means the term did not apply at all, which covers both "no limit was // given" and "this title has no runtime recorded" — nothing recorded is not evidence // either way, and refusing to draw it would quietly delete a slice of the library from the // feature. func magicRuntimeAdjustment(runtimeMinutes, availableMinutes int) (float64, string) { switch { case availableMinutes <= 0, runtimeMinutes <= 0: return 0, "" case runtimeMinutes > availableMinutes+magicRuntimeSlackMinutes: return -magicRuntimeOverPenalty, "too_long" default: return magicRuntimeFitBonus, "fits_time" } } // daysSince reads Emby's ISO-8601 DateCreated. A field that is absent or unreadable is not // an error: it simply cannot earn the recently-added bonus. func daysSince(value string, now time.Time) (int, bool) { value = strings.TrimSpace(value) if value == "" { return 0, false } created, err := time.Parse(time.RFC3339, value) if err != nil { return 0, false } if created.After(now) { return 0, true } return int(now.Sub(created).Hours() / 24), true }