Files
memby/server/internal/recommend/magic.go
T

397 lines
15 KiB
Go
Raw Normal View History

2026-08-16 12:13:51 +12:00
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
2026-08-17 13:13:10 +12:00
// 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
2026-08-16 12:13:51 +12:00
// 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
2026-08-17 13:13:10 +12:00
// 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.
2026-08-16 12:13:51 +12:00
Now time.Time
}
2026-08-17 13:13:10 +12:00
// 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"`
}
2026-08-16 12:13:51 +12:00
// MagicSelection is one drawn title with its evidence.
2026-08-17 13:13:10 +12:00
//
// 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.
2026-08-16 12:13:51 +12:00
type MagicSelection struct {
2026-08-17 13:13:10 +12:00
ItemID string
Title string
2026-08-16 12:13:51 +12:00
// 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
}
2026-08-17 13:13:10 +12:00
// 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(
2026-08-16 12:13:51 +12:00
ctx context.Context,
cred emby.Credentials,
2026-08-17 13:13:10 +12:00
now time.Time,
) []MagicCandidate {
if now.IsZero() {
now = e.now()
2026-08-16 12:13:51 +12:00
}
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
2026-08-17 13:13:10 +12:00
// What is left is an unweighted draw over the catalogue, which is still "put
// something on".
2026-08-16 12:13:51 +12:00
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 {
2026-08-17 13:13:10 +12:00
return nil
2026-08-16 12:13:51 +12:00
}
2026-08-17 13:13:10 +12:00
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,
})
2026-08-16 12:13:51 +12:00
}
2026-08-17 13:13:10 +12:00
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)
2026-08-16 12:13:51 +12:00
}
// 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.
2026-08-17 13:13:10 +12:00
func ChooseMagic(candidates []MagicCandidate, opts MagicOptions) (MagicSelection, bool) {
2026-08-16 12:13:51 +12:00
excluded := map[string]bool{}
for _, id := range opts.ExcludeIDs {
if id = strings.TrimSpace(id); id != "" {
excluded[id] = true
}
}
2026-08-17 13:13:10 +12:00
pool := make([]MagicCandidate, 0, len(candidates))
2026-08-16 12:13:51 +12:00
seen := map[string]bool{}
for _, candidate := range candidates {
2026-08-17 13:13:10 +12:00
if candidate.ItemID == "" || excluded[candidate.ItemID] || seen[candidate.ItemID] {
2026-08-16 12:13:51 +12:00
continue
}
2026-08-17 13:13:10 +12:00
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)
2026-08-16 12:13:51 +12:00
}
if len(pool) == 0 {
return MagicSelection{}, false
}
2026-08-17 13:13:10 +12:00
sortMagicPool(pool)
2026-08-16 12:13:51 +12:00
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 {
2026-08-17 13:13:10 +12:00
return magicSelection(entry, len(pool)), true
2026-08-16 12:13:51 +12:00
}
}
2026-08-17 13:13:10 +12:00
return magicSelection(pool[len(pool)-1], len(pool)), true
2026-08-16 12:13:51 +12:00
}
2026-08-17 13:13:10 +12:00
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) {
2026-08-16 12:13:51 +12:00
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")
}
2026-08-17 13:13:10 +12:00
if addedDays, ok := daysSince(item.DateCreated, now); ok && addedDays <= magicRecentlyAddedDays {
2026-08-16 12:13:51 +12:00
score += magicRecentlyAddedBonus
signals = append(signals, "recently_added")
}
return score, signals
}
2026-08-17 13:13:10 +12:00
// 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"
}
}
2026-08-16 12:13:51 +12:00
// 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
}