0.2.68
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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
|
||||
}
|
||||
@@ -262,21 +262,40 @@ func (p Profile) TopGenres(n int) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Score rates a candidate against the profile. A negative score means "exclude".
|
||||
func (p Profile) Score(candidate Item) float64 {
|
||||
// HasSeen reports whether this viewer has already watched or begun the candidate, by any
|
||||
// of the four things that can say so: the item itself, the series it belongs to, another
|
||||
// copy of the same title, and Emby's own user data on the payload.
|
||||
//
|
||||
// It is the veto half of [Score], separated because one caller needs the two halves apart:
|
||||
// the Magic pick treats "already seen" as a heavy penalty rather than an exclusion, since a
|
||||
// library whose owner has watched most of it must still be able to answer "put something
|
||||
// good on".
|
||||
func (p Profile) HasSeen(candidate Item) bool {
|
||||
if p.Seen[candidate.ID] {
|
||||
return -1
|
||||
return true
|
||||
}
|
||||
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
|
||||
return -1
|
||||
return true
|
||||
}
|
||||
if p.SeenTitles[candidate.SeenKey()] {
|
||||
return -1
|
||||
}
|
||||
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
|
||||
return -1
|
||||
return true
|
||||
}
|
||||
return candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0
|
||||
}
|
||||
|
||||
// Score rates a candidate against the profile. A negative score means "exclude".
|
||||
func (p Profile) Score(candidate Item) float64 {
|
||||
if p.HasSeen(candidate) {
|
||||
return -1
|
||||
}
|
||||
return p.Affinity(candidate)
|
||||
}
|
||||
|
||||
// Affinity is what [Score] measures once the candidate has passed the seen veto: genre and
|
||||
// studio weight, a mild quality nudge and a small bonus for a recent production. Never
|
||||
// negative for a plausible candidate, which is what makes it usable as one term of a larger
|
||||
// sum rather than only as a verdict.
|
||||
func (p Profile) Affinity(candidate Item) float64 {
|
||||
var genreScore float64
|
||||
for _, genre := range candidate.Genres {
|
||||
genreScore += p.GenreWeights[strings.TrimSpace(genre)]
|
||||
|
||||
Reference in New Issue
Block a user