Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
728 lines
22 KiB
Go
728 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 {
|
||
ID string `json:"Id"`
|
||
Name string `json:"Name"`
|
||
Type string `json:"Type"`
|
||
Role string `json:"Role"`
|
||
PrimaryImageTag string `json:"PrimaryImageTag"`
|
||
}
|
||
|
||
// 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"`
|
||
Prompted bool `json:"prompted,omitempty"`
|
||
Ratings map[string]int `json:"ratings,omitempty"`
|
||
Genres []string `json:"genres,omitempty"`
|
||
Studios []string `json:"studios,omitempty"`
|
||
Actors []string `json:"actors,omitempty"`
|
||
Actresses []string `json:"actresses,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.Actors, preferences.Actresses)
|
||
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)
|
||
}
|