0.2.73
This commit is contained in:
@@ -30,6 +30,14 @@ const (
|
||||
// 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
|
||||
@@ -63,13 +71,47 @@ type MagicOptions struct {
|
||||
// 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 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 {
|
||||
Item Item
|
||||
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
|
||||
@@ -82,38 +124,70 @@ type MagicSelection struct {
|
||||
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(
|
||||
// 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,
|
||||
opts MagicOptions,
|
||||
) (MagicSelection, bool) {
|
||||
if opts.Now.IsZero() {
|
||||
opts.Now = e.now()
|
||||
now time.Time,
|
||||
) []MagicCandidate {
|
||||
if now.IsZero() {
|
||||
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".
|
||||
// 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
|
||||
return nil
|
||||
}
|
||||
|
||||
selection, ok := ChooseMagic(profile, candidates, opts)
|
||||
if !ok {
|
||||
return MagicSelection{}, false
|
||||
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,
|
||||
})
|
||||
}
|
||||
selection.Reasons = Why(profile, selection.Item, 2)
|
||||
return selection, true
|
||||
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
|
||||
@@ -175,7 +249,7 @@ func onlyMovies(items []Item) []Item {
|
||||
// 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) {
|
||||
func ChooseMagic(candidates []MagicCandidate, opts MagicOptions) (MagicSelection, bool) {
|
||||
excluded := map[string]bool{}
|
||||
for _, id := range opts.ExcludeIDs {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
@@ -183,32 +257,28 @@ func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSe
|
||||
}
|
||||
}
|
||||
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
signals []string
|
||||
}
|
||||
pool := make([]scored, 0, len(candidates))
|
||||
pool := make([]MagicCandidate, 0, len(candidates))
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.ID == "" || excluded[candidate.ID] || seen[candidate.ID] {
|
||||
if candidate.ItemID == "" || excluded[candidate.ItemID] || seen[candidate.ItemID] {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
score, signals := magicScore(profile, candidate, opts)
|
||||
pool = append(pool, scored{item: candidate, score: score, signals: signals})
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
sortMagicPool(pool)
|
||||
if len(pool) > MagicPoolLimit {
|
||||
pool = pool[:MagicPoolLimit]
|
||||
}
|
||||
@@ -226,27 +296,42 @@ func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSe
|
||||
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
|
||||
return magicSelection(entry, len(pool)), true
|
||||
}
|
||||
}
|
||||
last := pool[len(pool)-1]
|
||||
return MagicSelection{
|
||||
Item: last.item,
|
||||
Signals: last.signals,
|
||||
Score: last.score,
|
||||
PoolSize: len(pool),
|
||||
}, true
|
||||
return magicSelection(pool[len(pool)-1], 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) {
|
||||
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 {
|
||||
@@ -266,28 +351,33 @@ func magicScore(profile Profile, item Item, opts MagicOptions) (float64, []strin
|
||||
signals = append(signals, "favourite")
|
||||
}
|
||||
|
||||
if addedDays, ok := daysSince(item.DateCreated, opts.Now); ok && addedDays <= magicRecentlyAddedDays {
|
||||
if addedDays, ok := daysSince(item.DateCreated, 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user