Publish current app and server
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user