464 lines
14 KiB
Go
464 lines
14 KiB
Go
// Package recommend turns a user's Emby watch history into home-screen rows.
|
|
//
|
|
// The scoring here is deliberately simple and explainable — genre and studio affinity
|
|
// weighted by recency, penalised for what the user has already seen. It runs against one
|
|
// household's library, where a heavier model would have neither the data to learn from
|
|
// nor a way to show its work when a row looks wrong.
|
|
package recommend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// recencyDecay is applied per position down the history list. At 0.94, the 12th item
|
|
// carries about half the weight of the most recent one, so tastes can shift without the
|
|
// rows lagging weeks behind.
|
|
const recencyDecay = 0.94
|
|
|
|
// favoriteWeight is what an explicit favourite contributes. Deliberately below a fresh
|
|
// play: favouriting is a durable signal, but what someone watched last night is a better
|
|
// predictor of what they want tonight.
|
|
const favoriteWeight = 0.6
|
|
|
|
// Item is the slice of an Emby item this package reasons about. The raw payload rides
|
|
// along so rows can be emitted without re-fetching or re-encoding.
|
|
type Item struct {
|
|
ID string `json:"Id"`
|
|
Name string `json:"Name"`
|
|
Type string `json:"Type"`
|
|
SeriesID string `json:"SeriesId"`
|
|
SeriesName string `json:"SeriesName"`
|
|
ProductionYear int `json:"ProductionYear"`
|
|
Genres []string `json:"Genres"`
|
|
CommunityRating float64 `json:"CommunityRating"`
|
|
RunTimeTicks int64 `json:"RunTimeTicks"`
|
|
OfficialRating string `json:"OfficialRating"`
|
|
CollectionName string `json:"CollectionName"`
|
|
PremiereDate string `json:"PremiereDate"`
|
|
DateCreated string `json:"DateCreated"`
|
|
IndexNumber int `json:"IndexNumber"`
|
|
ParentIndexNumber int `json:"ParentIndexNumber"`
|
|
Container string `json:"Container"`
|
|
MediaStreams []struct {
|
|
Type string `json:"Type"`
|
|
Codec string `json:"Codec"`
|
|
} `json:"MediaStreams"`
|
|
Studios []struct {
|
|
Name string `json:"Name"`
|
|
} `json:"Studios"`
|
|
People []Person `json:"People"`
|
|
UserData struct {
|
|
Played bool `json:"Played"`
|
|
PlayCount int `json:"PlayCount"`
|
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
|
IsFavorite bool `json:"IsFavorite"`
|
|
} `json:"UserData"`
|
|
|
|
Raw json.RawMessage `json:"-"`
|
|
}
|
|
|
|
func (i Item) RuntimeMinutes() int {
|
|
if i.RunTimeTicks <= 0 {
|
|
return 0
|
|
}
|
|
return int(i.RunTimeTicks / 600_000_000)
|
|
}
|
|
|
|
func (i Item) TitleKey() string {
|
|
value := i.Name
|
|
if i.Type == "Episode" && strings.TrimSpace(i.SeriesName) != "" {
|
|
value = i.SeriesName
|
|
}
|
|
var b strings.Builder
|
|
for _, r := range strings.ToLower(value) {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// SeenKey is a title-level fallback for imported catalogue records, whose payloads
|
|
// deliberately contain no per-user UserData. Movies include their year so watching an
|
|
// older film does not hide a remake with the same name; episodes collapse to series.
|
|
func (i Item) SeenKey() string {
|
|
key := i.TitleKey()
|
|
if key != "" && strings.EqualFold(i.Type, "Movie") && i.ProductionYear > 0 {
|
|
return key + "|" + strconv.Itoa(i.ProductionYear)
|
|
}
|
|
return key
|
|
}
|
|
|
|
// Seed is a title recent enough to anchor a "Because you watched …" row.
|
|
type Seed struct {
|
|
ID string
|
|
Name string
|
|
}
|
|
|
|
// Profile is what the engine learned about one user.
|
|
type Profile struct {
|
|
GenreWeights map[string]float64
|
|
StudioWeights map[string]float64
|
|
// PersonWeights is read only by the explanation layer, never by Score. Casting is a
|
|
// good reason to *tell* someone about a title and a poor reason to rank by it: two
|
|
// films sharing an actor are often nothing alike.
|
|
PersonWeights map[string]float64
|
|
// DecadeWeights and ReasonEvidence retain just enough of the source history for the
|
|
// explanation layer to say why a particular title fits. They do not participate in
|
|
// ranking: ordering and wording remain deliberately separate concerns.
|
|
DecadeWeights map[int]float64
|
|
ReasonEvidence []ReasonEvidence
|
|
// Seen holds item ids *and* series ids already watched or in progress, so a
|
|
// recommendation never suggests something the user is already partway through.
|
|
Seen map[string]bool
|
|
SeenTitles map[string]bool
|
|
Seeds []Seed
|
|
}
|
|
|
|
type ReasonEvidence struct {
|
|
Item Item
|
|
Favourite bool
|
|
}
|
|
|
|
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
|
|
|
|
// Decode parses raw Emby items, keeping the original payload attached.
|
|
func Decode(raws []json.RawMessage) []Item {
|
|
items := make([]Item, 0, len(raws))
|
|
for _, raw := range raws {
|
|
var item Item
|
|
if err := json.Unmarshal(raw, &item); err != nil || item.ID == "" {
|
|
continue
|
|
}
|
|
item.Raw = raw
|
|
items = append(items, item)
|
|
}
|
|
return items
|
|
}
|
|
|
|
// BuildProfile weights history by recency and folds in favourites.
|
|
//
|
|
// history must be ordered most-recent-first; favourites are unordered and all carry the
|
|
// same weight.
|
|
func BuildProfile(history, favorites []Item) Profile {
|
|
profile := Profile{
|
|
GenreWeights: map[string]float64{},
|
|
StudioWeights: map[string]float64{},
|
|
PersonWeights: map[string]float64{},
|
|
DecadeWeights: map[int]float64{},
|
|
Seen: map[string]bool{},
|
|
SeenTitles: map[string]bool{},
|
|
}
|
|
|
|
seedSeen := map[string]bool{}
|
|
tasteSeen := map[string]bool{}
|
|
reasonSeen := map[string]bool{}
|
|
for i, item := range history {
|
|
profile.markSeen(item)
|
|
reasonItem := item
|
|
reasonItem.Raw = nil
|
|
reasonID := item.ID
|
|
if item.SeriesID != "" {
|
|
reasonID = item.SeriesID
|
|
}
|
|
if reasonID != "" && !reasonSeen[reasonID] {
|
|
reasonSeen[reasonID] = true
|
|
profile.ReasonEvidence = append(profile.ReasonEvidence, ReasonEvidence{Item: reasonItem})
|
|
}
|
|
|
|
// Several episodes of one series are evidence for one taste, not several
|
|
// independent tastes. Keep the newest occurrence's recency weight and still
|
|
// mark every item/series identifier as seen.
|
|
tasteID := item.ID
|
|
if item.SeriesID != "" {
|
|
tasteID = item.SeriesID
|
|
}
|
|
if !tasteSeen[tasteID] {
|
|
tasteSeen[tasteID] = true
|
|
profile.absorbTaste(item, math.Pow(recencyDecay, float64(i)))
|
|
}
|
|
|
|
// An episode seeds its series, not itself: "Because you watched Severance"
|
|
// reads better than "Because you watched Good News".
|
|
seedID, seedName := item.ID, item.Name
|
|
if item.SeriesID != "" {
|
|
seedID, seedName = item.SeriesID, item.SeriesName
|
|
}
|
|
if seedID != "" && seedName != "" && !seedSeen[seedID] {
|
|
seedSeen[seedID] = true
|
|
profile.Seeds = append(profile.Seeds, Seed{ID: seedID, Name: seedName})
|
|
}
|
|
}
|
|
|
|
for _, item := range favorites {
|
|
profile.absorb(item, favoriteWeight)
|
|
reasonItem := item
|
|
reasonItem.Raw = nil
|
|
reasonID := item.ID
|
|
if reasonID != "" && !reasonSeen[reasonID] {
|
|
reasonSeen[reasonID] = true
|
|
profile.ReasonEvidence = append(profile.ReasonEvidence, ReasonEvidence{
|
|
Item: reasonItem, Favourite: true,
|
|
})
|
|
}
|
|
}
|
|
return profile
|
|
}
|
|
|
|
func (p *Profile) absorb(item Item, weight float64) {
|
|
p.markSeen(item)
|
|
p.absorbTaste(item, weight)
|
|
}
|
|
|
|
func (p *Profile) markSeen(item Item) {
|
|
if item.ID != "" {
|
|
p.Seen[item.ID] = true
|
|
}
|
|
if item.SeriesID != "" {
|
|
p.Seen[item.SeriesID] = true
|
|
}
|
|
if key := item.SeenKey(); key != "" {
|
|
p.SeenTitles[key] = true
|
|
}
|
|
}
|
|
|
|
// absorbTaste learns affinity without marking the item watched. This is used for
|
|
// browsing signals: lingering on a card is meaningful, but must not hide that card.
|
|
func (p *Profile) absorbTaste(item Item, weight float64) {
|
|
for _, genre := range item.Genres {
|
|
if g := strings.TrimSpace(genre); g != "" {
|
|
p.GenreWeights[g] += weight
|
|
}
|
|
}
|
|
for _, studio := range item.Studios {
|
|
if s := strings.TrimSpace(studio.Name); s != "" {
|
|
// Studio is a weaker signal than genre: people follow what a thing *is*
|
|
// more reliably than who made it.
|
|
p.StudioWeights[s] += weight * 0.4
|
|
}
|
|
}
|
|
for _, person := range item.People {
|
|
if !isExplainablePerson(person.Type) {
|
|
continue
|
|
}
|
|
if name := strings.TrimSpace(person.Name); name != "" {
|
|
if p.PersonWeights == nil {
|
|
p.PersonWeights = map[string]float64{}
|
|
}
|
|
p.PersonWeights[name] += weight
|
|
}
|
|
}
|
|
if item.ProductionYear > 0 {
|
|
if p.DecadeWeights == nil {
|
|
p.DecadeWeights = map[int]float64{}
|
|
}
|
|
p.DecadeWeights[item.ProductionYear/10*10] += weight
|
|
}
|
|
}
|
|
|
|
// isExplainablePerson keeps the cast list down to the roles a viewer would recognise as
|
|
// a reason. A gaffer in common is not why anyone picks a film.
|
|
func isExplainablePerson(role string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(role)) {
|
|
case "actor", "director", "writer":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TopGenres returns the n heaviest genres, highest first. Ties break alphabetically so
|
|
// the Emby query — and therefore the cached row — is stable between calls.
|
|
func (p Profile) TopGenres(n int) []string {
|
|
type kv struct {
|
|
genre string
|
|
weight float64
|
|
}
|
|
pairs := make([]kv, 0, len(p.GenreWeights))
|
|
for genre, weight := range p.GenreWeights {
|
|
pairs = append(pairs, kv{genre, weight})
|
|
}
|
|
sort.Slice(pairs, func(i, j int) bool {
|
|
if pairs[i].weight != pairs[j].weight {
|
|
return pairs[i].weight > pairs[j].weight
|
|
}
|
|
return pairs[i].genre < pairs[j].genre
|
|
})
|
|
if n > len(pairs) {
|
|
n = len(pairs)
|
|
}
|
|
out := make([]string, 0, n)
|
|
for _, pair := range pairs[:n] {
|
|
out = append(out, pair.genre)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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 true
|
|
}
|
|
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
|
|
return true
|
|
}
|
|
if p.SeenTitles[candidate.SeenKey()] {
|
|
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)]
|
|
}
|
|
// Divide by sqrt(genre count) so a title tagged with eight genres cannot outrank a
|
|
// focused match simply by touching more of the profile.
|
|
if n := len(candidate.Genres); n > 1 {
|
|
genreScore /= math.Sqrt(float64(n))
|
|
}
|
|
|
|
var studioScore float64
|
|
for _, studio := range candidate.Studios {
|
|
studioScore += p.StudioWeights[strings.TrimSpace(studio.Name)]
|
|
}
|
|
|
|
// A mild quality nudge, capped so a beloved genre still beats a well-rated stranger.
|
|
ratingScore := candidate.CommunityRating / 10 * 0.5
|
|
|
|
// New catalogue arrivals should surface without overpowering established taste.
|
|
// Production year is consistently available from both the imported library and
|
|
// Emby, unlike DateCreated on deliberately narrow recommendation payloads.
|
|
var freshnessScore float64
|
|
age := time.Now().Year() - candidate.ProductionYear
|
|
switch {
|
|
case candidate.ProductionYear <= 0:
|
|
case age <= 1:
|
|
freshnessScore = 0.25
|
|
case age <= 3:
|
|
freshnessScore = 0.12
|
|
}
|
|
|
|
return genreScore + studioScore + ratingScore + freshnessScore
|
|
}
|
|
|
|
// CollectionAffinity decides which curated shelf appears first for this user.
|
|
func (p Profile) CollectionAffinity(genres, studios []string) float64 {
|
|
var score float64
|
|
for _, genre := range genres {
|
|
score += weightFold(p.GenreWeights, genre)
|
|
}
|
|
for _, studio := range studios {
|
|
score += weightFold(p.StudioWeights, studio)
|
|
}
|
|
return score
|
|
}
|
|
|
|
func weightFold(weights map[string]float64, wanted string) float64 {
|
|
for key, value := range weights {
|
|
if strings.EqualFold(strings.TrimSpace(key), strings.TrimSpace(wanted)) {
|
|
return value
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Rank scores, filters and truncates candidates, dropping duplicates by id.
|
|
func Rank(profile Profile, candidates []Item, limit int) []Item {
|
|
return rank(profile, candidates, limit, false)
|
|
}
|
|
|
|
// RankCollection keeps unseen candidates with no affinity/rating score at the end,
|
|
// ensuring a curated shelf remains useful for a new profile or unrated library.
|
|
func RankCollection(profile Profile, candidates []Item, limit int) []Item {
|
|
return rank(profile, candidates, limit, true)
|
|
}
|
|
|
|
func rank(profile Profile, candidates []Item, limit int, includeZero bool) []Item {
|
|
type scored struct {
|
|
item Item
|
|
score float64
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
ranked := make([]scored, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
if seen[candidate.ID] {
|
|
continue
|
|
}
|
|
seen[candidate.ID] = true
|
|
if score := profile.Score(candidate); score > 0 || includeZero && score == 0 {
|
|
ranked = append(ranked, scored{candidate, score})
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(ranked, func(i, j int) bool {
|
|
if ranked[i].score != ranked[j].score {
|
|
return ranked[i].score > ranked[j].score
|
|
}
|
|
return ranked[i].item.Name < ranked[j].item.Name
|
|
})
|
|
|
|
if limit > 0 && len(ranked) > limit {
|
|
ranked = ranked[:limit]
|
|
}
|
|
out := make([]Item, 0, len(ranked))
|
|
for _, entry := range ranked {
|
|
out = append(out, entry.item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FilterUnseen keeps only what the user has not watched, preserving Emby's ordering.
|
|
// Used for "Because you watched …", where Emby's own similarity ranking is better than
|
|
// anything this package would compute.
|
|
func FilterUnseen(profile Profile, candidates []Item, limit int) []Item {
|
|
out := make([]Item, 0, len(candidates))
|
|
seen := map[string]bool{}
|
|
for _, candidate := range candidates {
|
|
if seen[candidate.ID] || profile.Score(candidate) < 0 {
|
|
continue
|
|
}
|
|
seen[candidate.ID] = true
|
|
out = append(out, candidate)
|
|
if limit > 0 && len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Raws unwraps items back to the payloads the TV will receive.
|
|
func Raws(items []Item) []json.RawMessage {
|
|
out := make([]json.RawMessage, 0, len(items))
|
|
for _, item := range items {
|
|
out = append(out, item.Raw)
|
|
}
|
|
return out
|
|
}
|