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 // 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. Now time.Time } // MagicSelection is one drawn title with its evidence. type MagicSelection struct { Item Item // 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 } // MagicPick gathers the signals and draws. Errors only when the profile cannot be built at // all and the catalogue is empty with it — every lesser failure degrades, on the principle // [Engine.RelatedTo] already applies: a button that sometimes does nothing is worse than one // that occasionally picks less well. func (e *Engine) MagicPick( ctx context.Context, cred emby.Credentials, opts MagicOptions, ) (MagicSelection, bool) { if opts.Now.IsZero() { opts.Now = e.now() } history, favorites, err := e.gatherSignals(ctx, cred) if err != nil { // A profile that cannot be built costs the weighting, not the button. 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 MagicSelection{}, false } selection, ok := ChooseMagic(profile, candidates, opts) if !ok { return MagicSelection{}, false } selection.Reasons = Why(profile, selection.Item, 2) return selection, true } // 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(profile Profile, candidates []Item, opts MagicOptions) (MagicSelection, bool) { excluded := map[string]bool{} for _, id := range opts.ExcludeIDs { if id = strings.TrimSpace(id); id != "" { excluded[id] = true } } type scored struct { item Item score float64 signals []string } pool := make([]scored, 0, len(candidates)) seen := map[string]bool{} for _, candidate := range candidates { if candidate.ID == "" || excluded[candidate.ID] || seen[candidate.ID] { continue } seen[candidate.ID] = true score, signals := magicScore(profile, candidate, opts) pool = append(pool, scored{item: candidate, score: score, signals: signals}) } if len(pool) == 0 { return MagicSelection{}, false } sort.SliceStable(pool, func(i, j int) bool { if pool[i].score != pool[j].score { return pool[i].score > pool[j].score } // Ties break by id so the *pool* is reproducible even though the draw is not. return pool[i].item.ID < pool[j].item.ID }) 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{ Item: entry.item, Signals: entry.signals, Score: entry.score, PoolSize: len(pool), }, true } } last := pool[len(pool)-1] return MagicSelection{ Item: last.item, Signals: last.signals, Score: last.score, PoolSize: len(pool), }, true } // magicScore sums the stated terms 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. func magicScore(profile Profile, item Item, opts MagicOptions) (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, opts.Now); ok && addedDays <= magicRecentlyAddedDays { score += magicRecentlyAddedBonus signals = append(signals, "recently_added") } if opts.AvailableMinutes > 0 { switch runtime := item.RuntimeMinutes(); { case runtime <= 0: // Nothing recorded is not evidence either way, and refusing to draw it would // quietly delete a slice of the library from the feature. case runtime > opts.AvailableMinutes+magicRuntimeSlackMinutes: score -= magicRuntimeOverPenalty signals = append(signals, "too_long") default: score += magicRuntimeFitBonus signals = append(signals, "fits_time") } } return score, signals } // 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 }