package recommend import ( "encoding/json" "hash/fnv" "math" "sort" "strings" "time" ) // WeightedConfig is intentionally data, not code: operators can tune the algorithm // without changing its shape or retraining an opaque model. type WeightedConfig struct { MinimumEvidence int ExplorationRate float64 MaxPrimaryGenre int MaxLeadPerson int NewReleaseDays int ImpressionFloor int ImpressionPenalty float64 IgnoredPenalty float64 CompletionWeight float64 AbandonmentWeight float64 RecencyHalfLifeDays float64 CommunityPriorWeight float64 HouseholdPriorWeight float64 CompatibilityWeight float64 ContextWeight float64 RuntimeContextWeight float64 ExplicitPositiveBoost float64 ExplicitNegativeScore float64 } func DefaultWeightedConfig() WeightedConfig { return WeightedConfig{ MinimumEvidence: 2, ExplorationRate: 0.08, MaxPrimaryGenre: 3, MaxLeadPerson: 2, NewReleaseDays: 180, ImpressionFloor: 3, ImpressionPenalty: 0.12, IgnoredPenalty: 0.28, CompletionWeight: 1, AbandonmentWeight: 0.55, RecencyHalfLifeDays: 45, CommunityPriorWeight: 0.35, HouseholdPriorWeight: 0.45, CompatibilityWeight: 0.7, ContextWeight: 0.8, RuntimeContextWeight: 0.65, ExplicitPositiveBoost: 3.5, ExplicitNegativeScore: -1_000, } } type Person struct { Name string `json:"Name"` Type string `json:"Type"` Role string `json:"Role"` } // Affinity retains its evidence count so a single accidental play cannot silently // become a durable preference. type Affinity struct { Weight float64 `json:"weight"` Evidence int `json:"evidence"` } type WeightedProfile struct { Genres map[string]Affinity `json:"genres,omitempty"` Studios map[string]Affinity `json:"studios,omitempty"` Actors map[string]Affinity `json:"actors,omitempty"` Directors map[string]Affinity `json:"directors,omitempty"` Franchises map[string]Affinity `json:"franchises,omitempty"` RuntimeRanges map[string]Affinity `json:"runtimeRanges,omitempty"` AgeRatings map[string]Affinity `json:"ageRatings,omitempty"` CommunityRatings map[string]Affinity `json:"communityRatings,omitempty"` ReleasePeriods map[string]Affinity `json:"releasePeriods,omitempty"` ContentTypes map[string]Affinity `json:"contentTypes,omitempty"` Seen map[string]bool `json:"seen,omitempty"` ExplicitPositive map[string]bool `json:"explicitPositive,omitempty"` ExplicitNegative map[string]bool `json:"explicitNegative,omitempty"` TypicalSessionMins map[string]float64 `json:"typicalSessionMinutes,omitempty"` SessionEvidence map[string]int `json:"sessionEvidence,omitempty"` SourceEvents int `json:"sourceEvents"` } type ViewingEvidence struct { Item Item Completion float64 Repeat int OccurredAt time.Time SessionMinutes int Favorite bool } type OnboardingPreferences struct { Completed bool `json:"completed"` Ratings map[string]int `json:"ratings,omitempty"` Genres []string `json:"genres,omitempty"` Studios []string `json:"studios,omitempty"` Actors []string `json:"actors,omitempty"` Directors []string `json:"directors,omitempty"` ContentTypes []string `json:"contentTypes,omitempty"` } func (p *WeightedProfile) ApplyOnboarding(preferences OnboardingPreferences, minimumEvidence int) { p.ensureAffinityMaps() if minimumEvidence < 1 { minimumEvidence = 1 } add := func(target map[string]Affinity, values []string) { for _, key := range values { key = normalizeDimension(key) if key != "" { target[key] = Affinity{Weight: 0.8, Evidence: minimumEvidence} } } } add(p.Genres, preferences.Genres) add(p.Studios, preferences.Studios) add(p.Actors, preferences.Actors) add(p.Directors, preferences.Directors) add(p.ContentTypes, preferences.ContentTypes) } // ApplyOnboardingRating turns a deliberate 1–5 title rating into immediate profile // evidence. Unlike an accidental play, an explicit rating is trusted enough to satisfy // MinimumEvidence on its own. func (p *WeightedProfile) ApplyOnboardingRating(item Item, rating, minimumEvidence int) { p.ensureAffinityMaps() if p.Seen == nil { p.Seen = map[string]bool{} } if item.ID != "" { p.Seen[item.ID] = true } if item.SeriesID != "" { p.Seen[item.SeriesID] = true } if rating < 1 || rating > 5 || rating == 3 { return } if minimumEvidence < 1 { minimumEvidence = 1 } // Two stars either side of neutral maps to a strong but bounded ±2.4 signal. total := float64(rating-3) * 1.2 for range minimumEvidence { addItemAffinities(p, item, total/float64(minimumEvidence)) } } // BuildWeightedProfile treats Tracearr/Emby events as evidence. Completion, repetition // and recency affect strength, while Affinity.Evidence enforces the repeated-pattern // threshold at scoring time. func BuildWeightedProfile(events []ViewingEvidence, now time.Time, location *time.Location) WeightedProfile { return BuildWeightedProfileWithConfig(events, now, location, DefaultWeightedConfig()) } func BuildWeightedProfileWithConfig( events []ViewingEvidence, now time.Time, location *time.Location, cfg WeightedConfig, ) WeightedProfile { if cfg.MinimumEvidence < 1 { cfg = DefaultWeightedConfig() } p := WeightedProfile{ Genres: map[string]Affinity{}, Studios: map[string]Affinity{}, Actors: map[string]Affinity{}, Directors: map[string]Affinity{}, Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{}, AgeRatings: map[string]Affinity{}, CommunityRatings: map[string]Affinity{}, ReleasePeriods: map[string]Affinity{}, ContentTypes: map[string]Affinity{}, Seen: map[string]bool{}, ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{}, TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{}, } if location == nil { location = time.Local } sessionTotals := map[string]float64{} for _, event := range events { if event.Item.ID == "" { continue } p.SourceEvents++ p.Seen[event.Item.ID] = event.Completion > 0 if event.Item.SeriesID != "" && event.Completion > 0 { p.Seen[event.Item.SeriesID] = true } completion := clamp01(event.Completion) strength := evidenceStrength(completion, cfg) if !event.OccurredAt.IsZero() { ageDays := math.Max(0, now.Sub(event.OccurredAt).Hours()/24) strength *= math.Pow(0.5, ageDays/math.Max(1, cfg.RecencyHalfLifeDays)) } if event.Repeat > 1 { strength *= 1 + math.Min(1.2, math.Log2(float64(event.Repeat))*0.45) } if event.Favorite { strength += 0.8 p.ExplicitPositive[event.Item.ID] = true } addItemAffinities(&p, event.Item, strength) if event.SessionMinutes > 0 && !event.OccurredAt.IsZero() { slot := contextSlotKey(event.OccurredAt.In(location).Weekday(), dayPart(event.OccurredAt.In(location).Hour())) sessionTotals[slot] += float64(event.SessionMinutes) p.SessionEvidence[slot]++ } } for slot, total := range sessionTotals { p.TypicalSessionMins[slot] = total / float64(p.SessionEvidence[slot]) } return p } func evidenceStrength(completion float64, cfg WeightedConfig) float64 { switch { case completion >= 0.9: return cfg.CompletionWeight case completion >= 0.5: return cfg.CompletionWeight * 0.45 case completion >= 0.15: return -cfg.AbandonmentWeight default: // A very brief start is weak evidence, not a strong dislike. return -0.08 } } func addItemAffinities(p *WeightedProfile, item Item, weight float64) { for _, value := range item.Genres { addAffinity(p.Genres, value, weight) } for _, value := range item.Studios { addAffinity(p.Studios, value.Name, weight) } for _, person := range item.People { switch strings.ToLower(strings.TrimSpace(person.Type)) { case "actor": addAffinity(p.Actors, person.Name, weight*0.65) case "director": addAffinity(p.Directors, person.Name, weight*0.8) } } addAffinity(p.Franchises, item.Franchise(), weight*0.85) addAffinity(p.RuntimeRanges, runtimeRange(item.RuntimeMinutes()), weight*0.55) addAffinity(p.AgeRatings, item.OfficialRating, weight*0.45) addAffinity(p.CommunityRatings, communityRatingRange(item.CommunityRating), weight*0.35) addAffinity(p.ReleasePeriods, releasePeriod(item.ProductionYear), weight*0.5) addAffinity(p.ContentTypes, item.Type, weight*0.6) } // ApplyExplicitPreference lets More Like This / Not for Me influence adjacent titles. // The item itself is always boosted/excluded; metadata still needs repeated negative // actions before it becomes a broader dislike because normal minimum-evidence rules // remain in force. func (p *WeightedProfile) ApplyExplicitPreference(item Item, positive bool) { p.ensureAffinityMaps() if p.ExplicitPositive == nil { p.ExplicitPositive = map[string]bool{} } if p.ExplicitNegative == nil { p.ExplicitNegative = map[string]bool{} } if positive { p.ExplicitPositive[item.ID] = true addItemAffinities(p, item, 1.5) return } p.ExplicitNegative[item.ID] = true addItemAffinities(p, item, -1.2) } func (p *WeightedProfile) ensureAffinityMaps() { if p.Genres == nil { p.Genres = map[string]Affinity{} } if p.Studios == nil { p.Studios = map[string]Affinity{} } if p.Actors == nil { p.Actors = map[string]Affinity{} } if p.Directors == nil { p.Directors = map[string]Affinity{} } if p.Franchises == nil { p.Franchises = map[string]Affinity{} } if p.RuntimeRanges == nil { p.RuntimeRanges = map[string]Affinity{} } if p.AgeRatings == nil { p.AgeRatings = map[string]Affinity{} } if p.CommunityRatings == nil { p.CommunityRatings = map[string]Affinity{} } if p.ReleasePeriods == nil { p.ReleasePeriods = map[string]Affinity{} } if p.ContentTypes == nil { p.ContentTypes = map[string]Affinity{} } } func addAffinity(values map[string]Affinity, key string, weight float64) { key = normalizeDimension(key) if key == "" { return } value := values[key] value.Weight += weight value.Evidence++ values[key] = value } type ItemExposure struct { Impressions int `json:"impressions"` Focuses int `json:"focuses"` Selects int `json:"selects"` LastShown time.Time `json:"lastShown,omitempty"` } type RankIntent struct { ID string ItemTypes []string UnseenOnly bool NewReleasesOnly bool MaxRuntimeMins int PreferShort bool HiddenLibrary bool SearchRelevance map[string]float64 HouseholdScores map[string]float64 Compatibility map[string]float64 Now time.Time Location *time.Location } type ScoreExplanation struct { Total float64 `json:"total"` Components map[string]float64 `json:"components"` Reasons []string `json:"reasonCodes"` Exploration bool `json:"exploration,omitempty"` } type RankedItem struct { Item Item Explanation ScoreExplanation } // WeightedRank applies the same scoring foundation to any page or row. RankIntent only // changes eligibility and emphasis; it never creates a separate recommendation model. func WeightedRank( profile WeightedProfile, candidates []Item, exposures map[string]ItemExposure, intent RankIntent, cfg WeightedConfig, limit int, ) []RankedItem { if cfg.MinimumEvidence < 1 { cfg = DefaultWeightedConfig() } now := intent.Now if now.IsZero() { now = time.Now() } type scored struct { item Item exp ScoreExplanation } values := make([]scored, 0, len(candidates)) seenIDs := map[string]bool{} for _, item := range candidates { if item.ID == "" || seenIDs[item.ID] || !eligibleForIntent(profile, item, intent, cfg, now) { continue } seenIDs[item.ID] = true exp := scoreWeightedItem(profile, item, exposures[item.ID], intent, cfg, now) if exp.Total <= cfg.ExplicitNegativeScore/2 { continue } values = append(values, scored{item: item, exp: exp}) } sort.SliceStable(values, func(i, j int) bool { if values[i].exp.Total != values[j].exp.Total { return values[i].exp.Total > values[j].exp.Total } return values[i].item.Name < values[j].item.Name }) out := make([]RankedItem, 0, minPositive(limit, len(values))) genreCounts, peopleCounts := map[string]int{}, map[string]int{} deferred := make([]scored, 0) for _, value := range values { genre := primaryGenre(value.item) person := leadPerson(value.item) if cfg.MaxPrimaryGenre > 0 && genre != "" && genreCounts[genre] >= cfg.MaxPrimaryGenre || cfg.MaxLeadPerson > 0 && person != "" && peopleCounts[person] >= cfg.MaxLeadPerson { deferred = append(deferred, value) continue } out = append(out, RankedItem{Item: value.item, Explanation: value.exp}) genreCounts[genre]++ peopleCounts[person]++ if limit > 0 && len(out) == limit { break } } for _, value := range deferred { if limit > 0 && len(out) == limit { break } out = append(out, RankedItem{Item: value.item, Explanation: value.exp}) } applyExploration(out, cfg.ExplorationRate) return out } func eligibleForIntent( profile WeightedProfile, item Item, intent RankIntent, cfg WeightedConfig, now time.Time, ) bool { if profile.ExplicitNegative[item.ID] { return false } if intent.UnseenOnly && (profile.Seen[item.ID] || item.UserData.Played || item.UserData.PlaybackPositionTicks > 0) { return false } if len(intent.ItemTypes) > 0 && !containsFold(intent.ItemTypes, item.Type) { return false } if intent.MaxRuntimeMins > 0 && item.RuntimeMinutes() > intent.MaxRuntimeMins { return false } if intent.NewReleasesOnly { released, ok := item.ReleaseDate() if !ok || released.After(now) || released.Before(now.AddDate(0, 0, -cfg.NewReleaseDays)) { return false } } return true } func scoreWeightedItem( profile WeightedProfile, item Item, exposure ItemExposure, intent RankIntent, cfg WeightedConfig, now time.Time, ) ScoreExplanation { c := map[string]float64{} reasons := []string{} c["genre"] = affinitySum(profile.Genres, item.Genres, cfg.MinimumEvidence) c["studio"] = affinitySum(profile.Studios, studioNames(item), cfg.MinimumEvidence) c["actor"] = affinitySum(profile.Actors, peopleNames(item, "actor"), cfg.MinimumEvidence) c["director"] = affinitySum(profile.Directors, peopleNames(item, "director"), cfg.MinimumEvidence) c["franchise"] = affinitySum(profile.Franchises, []string{item.Franchise()}, cfg.MinimumEvidence) c["runtime"] = affinitySum(profile.RuntimeRanges, []string{runtimeRange(item.RuntimeMinutes())}, cfg.MinimumEvidence) c["ageRating"] = affinitySum(profile.AgeRatings, []string{item.OfficialRating}, cfg.MinimumEvidence) c["communityRatingAffinity"] = affinitySum( profile.CommunityRatings, []string{communityRatingRange(item.CommunityRating)}, cfg.MinimumEvidence, ) c["releasePeriod"] = affinitySum(profile.ReleasePeriods, []string{releasePeriod(item.ProductionYear)}, cfg.MinimumEvidence) c["contentType"] = affinitySum(profile.ContentTypes, []string{item.Type}, cfg.MinimumEvidence) c["communityRating"] = item.CommunityRating / 10 * cfg.CommunityPriorWeight c["household"] = intent.HouseholdScores[item.ID] * cfg.HouseholdPriorWeight c["compatibility"] = intent.Compatibility[item.ID] * cfg.CompatibilityWeight c["searchRelevance"] = intent.SearchRelevance[item.ID] if profile.ExplicitPositive[item.ID] { c["explicit"] = cfg.ExplicitPositiveBoost reasons = append(reasons, "explicit_more_like_this") } if exposure.Impressions >= cfg.ImpressionFloor { ignored := maxInt(0, exposure.Impressions-exposure.Focuses-exposure.Selects) c["impressionFatigue"] = -float64(exposure.Impressions-cfg.ImpressionFloor+1)*cfg.ImpressionPenalty - float64(ignored)*cfg.IgnoredPenalty reasons = append(reasons, "impression_fatigue") } slot := currentContextSlot(now, intent.Location) if profile.SessionEvidence[slot] >= cfg.MinimumEvidence && item.RuntimeMinutes() > 0 { typical := profile.TypicalSessionMins[slot] delta := math.Abs(float64(item.RuntimeMinutes()) - typical) c["sessionFit"] = math.Max(-1, 1-delta/math.Max(20, typical)) * cfg.RuntimeContextWeight if c["sessionFit"] > 0.25 { reasons = append(reasons, "fits_session_length") } } if intent.PreferShort && item.RuntimeMinutes() > 0 { c["rowIntent"] = 1 / math.Max(1, float64(item.RuntimeMinutes())/30) } if intent.HiddenLibrary && !profile.Seen[item.ID] { c["rowIntent"] += 0.7 reasons = append(reasons, "relevant_unseen") } for _, key := range []string{"genre", "studio", "actor", "director", "franchise"} { if c[key] > 0.1 { reasons = append(reasons, "affinity_"+strings.ToLower(key)) } } if profile.SourceEvents < cfg.MinimumEvidence { reasons = append(reasons, "cold_start_priors") } total := 0.0 for _, value := range c { total += value } return ScoreExplanation{Total: total, Components: c, Reasons: uniqueStrings(reasons)} } // EnrichRankedItem keeps diagnostics on the backend response while retaining Emby's // original item contract. func EnrichRankedItem(item RankedItem) json.RawMessage { var payload map[string]any if json.Unmarshal(item.Item.Raw, &payload) != nil || payload == nil { payload = map[string]any{"Id": item.Item.ID, "Name": item.Item.Name, "Type": item.Item.Type} } payload["MembyRecommendationScore"] = item.Explanation.Total payload["MembyRecommendationComponents"] = item.Explanation.Components payload["MembyRecommendationReasonCodes"] = item.Explanation.Reasons payload["MembyExploration"] = item.Explanation.Exploration raw, _ := json.Marshal(payload) return raw } func affinitySum(values map[string]Affinity, keys []string, minimum int) float64 { score := 0.0 for _, key := range keys { value := values[normalizeDimension(key)] if value.Evidence >= minimum { score += value.Weight / math.Sqrt(float64(value.Evidence)) } } if len(keys) > 1 { score /= math.Sqrt(float64(len(keys))) } return score } func applyExploration(items []RankedItem, rate float64) { if len(items) < 4 || rate <= 0 { return } count := int(math.Round(float64(len(items)) * math.Min(0.2, rate))) for n := 0; n < count; n++ { from := len(items) - 1 - n to := minPositive(3+n*5, from) if from <= to { continue } value := items[from] copy(items[to+1:from+1], items[to:from]) value.Explanation.Exploration = true value.Explanation.Reasons = append(value.Explanation.Reasons, "adjacent_exploration") items[to] = value } } func (i Item) Franchise() string { if value := strings.TrimSpace(i.CollectionName); value != "" { return value } // A conservative fallback only strips common sequel suffixes. It avoids inventing // franchises from unrelated titles that happen to share one word. parts := strings.Fields(i.Name) if len(parts) > 1 { last := strings.Trim(strings.ToLower(parts[len(parts)-1]), ":.-") if isRomanNumeral(last) || strings.HasPrefix(last, "part") { return strings.Join(parts[:len(parts)-1], " ") } } return "" } func (i Item) ReleaseDate() (time.Time, bool) { for _, value := range []string{i.PremiereDate, i.DateCreated} { if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)); err == nil { return parsed, true } } if i.ProductionYear > 0 { return time.Date(i.ProductionYear, 1, 1, 0, 0, 0, 0, time.UTC), true } return time.Time{}, false } func runtimeRange(minutes int) string { switch { case minutes <= 0: return "" case minutes <= 25: return "short" case minutes <= 50: return "episode" case minutes <= 100: return "feature" case minutes <= 150: return "long-feature" default: return "epic" } } func releasePeriod(year int) string { switch { case year <= 0: return "" case year < 1980: return "classic" case year < 2000: return "1980s-1990s" case year < 2015: return "2000s-early-2010s" default: return "recent" } } func communityRatingRange(rating float64) string { switch { case rating <= 0: return "" case rating < 6: return "under-6" case rating < 7.5: return "6-to-7.4" case rating < 8.5: return "7.5-to-8.4" default: return "8.5-plus" } } func currentContextSlot(now time.Time, location *time.Location) string { if location != nil { now = now.In(location) } return contextSlotKey(now.Weekday(), dayPart(now.Hour())) } func normalizeDimension(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func studioNames(item Item) []string { out := make([]string, 0, len(item.Studios)) for _, value := range item.Studios { out = append(out, value.Name) } return out } func peopleNames(item Item, kind string) []string { out := []string{} for _, value := range item.People { if strings.EqualFold(value.Type, kind) { out = append(out, value.Name) } } return out } func primaryGenre(item Item) string { if len(item.Genres) == 0 { return "" } return normalizeDimension(item.Genres[0]) } func leadPerson(item Item) string { for _, value := range item.People { if strings.EqualFold(value.Type, "actor") { return normalizeDimension(value.Name) } } return "" } func containsFold(values []string, wanted string) bool { for _, value := range values { if strings.EqualFold(value, wanted) { return true } } return false } func uniqueStrings(values []string) []string { seen, out := map[string]bool{}, []string{} for _, value := range values { if value != "" && !seen[value] { seen[value] = true out = append(out, value) } } return out } func isRomanNumeral(value string) bool { if value == "" { return false } for _, r := range value { if !strings.ContainsRune("ivxlcdm", r) { return false } } return true } func minPositive(a, b int) int { if a <= 0 || b < a { return b } return a } func maxInt(a, b int) int { if a > b { return a } return b } // stableFraction is kept for deterministic future exploration bucketing. func stableFraction(value string) float64 { h := fnv.New32a() _, _ = h.Write([]byte(value)) return float64(h.Sum32()) / float64(math.MaxUint32) }