// 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 // 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 } 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{}, Seen: map[string]bool{}, SeenTitles: map[string]bool{}, } seedSeen := map[string]bool{} tasteSeen := map[string]bool{} for i, item := range history { profile.markSeen(item) // 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) } 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 } } } // 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 }