723 lines
22 KiB
Go
723 lines
22 KiB
Go
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)
|
|||
|
|
}
|