Files
ponzischeme89andClaude Opus 5 4a4df7a73c App v0.2.26 and gateway 0.1.20
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>
2026-08-06 22:33:56 +12:00

728 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 15 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)
}