Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+230
View File
@@ -0,0 +1,230 @@
package recommend
import (
"math"
"strings"
"time"
)
// ContextAffinityProfile captures what a viewer tends to watch in broad local-time
// windows. It is intentionally compact: seven weekdays by four day parts is enough to
// learn household routines without pretending that a small history is a precise model.
type ContextAffinityProfile struct {
Slots map[string]ContextAffinityBucket `json:"slots,omitempty"`
}
type ContextAffinityBucket struct {
Samples int `json:"samples"`
GenreWeights map[string]float64 `json:"genres,omitempty"`
StudioWeights map[string]float64 `json:"studios,omitempty"`
}
func NewContextAffinityProfile() ContextAffinityProfile {
return ContextAffinityProfile{Slots: map[string]ContextAffinityBucket{}}
}
// Add records one matched Tracearr session. Completion and recency determine how much
// taste evidence it contributes, while Samples controls confidence separately.
func (p *ContextAffinityProfile) Add(
item Item,
started time.Time,
completion float64,
recencyPosition int,
location *time.Location,
) {
if started.IsZero() {
return
}
if p.Slots == nil {
p.Slots = map[string]ContextAffinityBucket{}
}
if location == nil {
location = time.Local
}
local := started.In(location)
key := contextSlotKey(local.Weekday(), dayPart(local.Hour()))
bucket := p.Slots[key]
if bucket.GenreWeights == nil {
bucket.GenreWeights = map[string]float64{}
}
if bucket.StudioWeights == nil {
bucket.StudioWeights = map[string]float64{}
}
bucket.Samples++
weight := (0.2 + clamp01(completion)) * math.Pow(0.985, float64(recencyPosition))
for _, genre := range item.Genres {
if genre = strings.TrimSpace(genre); genre != "" {
bucket.GenreWeights[genre] += weight
}
}
for _, studio := range item.Studios {
if name := strings.TrimSpace(studio.Name); name != "" {
bucket.StudioWeights[name] += weight * 0.4
}
}
p.Slots[key] = bucket
}
// Score returns a bounded contextual affinity source score and its confidence. Exact
// weekday/time behavior matters most; neighboring time windows and the same time on
// other days provide progressively weaker fallbacks. No match simply returns zero.
func (p ContextAffinityProfile) Score(
item Item,
now time.Time,
location *time.Location,
) (float64, float64) {
if len(p.Slots) == 0 {
return 0, 0
}
if location == nil {
location = time.Local
}
local := now.In(location)
part := dayPart(local.Hour())
var score, sampleWeight float64
for key, bucket := range p.Slots {
weekday, bucketPart, ok := parseContextSlotKey(key)
if !ok {
continue
}
weight := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
if weight == 0 {
continue
}
var affinity float64
for _, genre := range item.Genres {
affinity += weightFold(bucket.GenreWeights, genre)
}
if n := len(item.Genres); n > 1 {
affinity /= math.Sqrt(float64(n))
}
for _, studio := range item.Studios {
affinity += weightFold(bucket.StudioWeights, studio.Name)
}
score += affinity * weight
sampleWeight += float64(bucket.Samples) * weight
}
if sampleWeight == 0 {
return 0, 0
}
// Five effective sessions are enough for the full (still bounded) contextual nudge.
return score / sampleWeight, math.Min(1, sampleWeight/5)
}
// Contextualized returns a copy of the base profile with a modest current-time taste
// nudge. It is used by dynamic shelves; the prepared For You pool applies the same
// signal directly to candidate placement.
func (p ContextAffinityProfile) Contextualized(
base Profile,
now time.Time,
location *time.Location,
) Profile {
out := base
out.GenreWeights = cloneWeights(base.GenreWeights)
out.StudioWeights = cloneWeights(base.StudioWeights)
probe := Item{Genres: keysFromWeights(out.GenreWeights)}
_, confidence := p.Score(probe, now, location)
if confidence == 0 {
return out
}
local := now
if location != nil {
local = now.In(location)
}
part := dayPart(local.Hour())
for key, bucket := range p.Slots {
weekday, bucketPart, ok := parseContextSlotKey(key)
if !ok {
continue
}
similarity := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
if similarity == 0 {
continue
}
scale := 0.35 * confidence * similarity / math.Max(1, float64(bucket.Samples))
for genre, weight := range bucket.GenreWeights {
out.GenreWeights[genre] += weight * scale
}
for studio, weight := range bucket.StudioWeights {
out.StudioWeights[studio] += weight * scale
}
}
return out
}
func contextSlotSimilarity(currentDay time.Weekday, currentPart int, day time.Weekday, part int) float64 {
dayDistance := int(currentDay) - int(day)
if dayDistance < 0 {
dayDistance = -dayDistance
}
if dayDistance > 3 {
dayDistance = 7 - dayDistance
}
partDistance := currentPart - part
if partDistance < 0 {
partDistance = -partDistance
}
switch {
case dayDistance == 0 && partDistance == 0:
return 1
case dayDistance == 0 && partDistance == 1:
return 0.35
case dayDistance == 1 && partDistance == 0:
return 0.25
case partDistance == 0:
return 0.12
default:
return 0
}
}
func dayPart(hour int) int {
switch {
case hour >= 5 && hour < 11:
return 0
case hour >= 11 && hour < 17:
return 1
case hour >= 17 && hour < 22:
return 2
default:
return 3
}
}
func contextSlotKey(day time.Weekday, part int) string {
return string(rune('0'+day)) + ":" + string(rune('0'+part))
}
func parseContextSlotKey(key string) (time.Weekday, int, bool) {
if len(key) != 3 || key[1] != ':' || key[0] < '0' || key[0] > '6' ||
key[2] < '0' || key[2] > '3' {
return 0, 0, false
}
return time.Weekday(key[0] - '0'), int(key[2] - '0'), true
}
func clamp01(value float64) float64 {
if value < 0 {
return 0
}
if value > 1 {
return 1
}
return value
}
func cloneWeights(source map[string]float64) map[string]float64 {
out := make(map[string]float64, len(source))
for key, value := range source {
out[key] = value
}
return out
}
func keysFromWeights(source map[string]float64) []string {
out := make([]string, 0, len(source))
for key := range source {
out = append(out, key)
}
return out
}
+82
View File
@@ -0,0 +1,82 @@
package recommend
import (
"testing"
"time"
)
func TestContextAffinityPrefersTypicalWeekdayTime(t *testing.T) {
location := time.FixedZone("test", 12*60*60)
profile := NewContextAffinityProfile()
comedy := Item{Genres: []string{"Comedy"}}
drama := Item{Genres: []string{"Drama"}}
for i := 0; i < 6; i++ {
profile.Add(
comedy,
time.Date(2026, 7, 6+i*7, 19, 30, 0, 0, location),
1,
i,
location,
)
profile.Add(
drama,
time.Date(2026, 7, 7+i*7, 13, 0, 0, 0, location),
1,
i,
location,
)
}
now := time.Date(2026, 7, 27, 20, 0, 0, 0, location) // Monday evening.
comedyScore, confidence := profile.Score(comedy, now, location)
dramaScore, _ := profile.Score(drama, now, location)
if comedyScore <= dramaScore {
t.Fatalf("Monday-evening comedy score %.3f <= drama score %.3f", comedyScore, dramaScore)
}
if confidence != 1 {
t.Fatalf("confidence = %.3f, want 1 after repeated matching sessions", confidence)
}
}
func TestContextAffinityUsesNeighboringWindowsAsWeakFallback(t *testing.T) {
location := time.UTC
profile := NewContextAffinityProfile()
item := Item{Genres: []string{"Documentary"}}
profile.Add(
item,
time.Date(2026, 7, 20, 16, 30, 0, 0, location), // Monday afternoon.
0.8,
0,
location,
)
exact, exactConfidence := profile.Score(
item, time.Date(2026, 7, 27, 16, 0, 0, 0, location), location,
)
neighbor, neighborConfidence := profile.Score(
item, time.Date(2026, 7, 27, 18, 0, 0, 0, location), location,
)
if neighbor <= 0 || neighbor*neighborConfidence >= exact*exactConfidence {
t.Fatalf(
"weighted neighbor %.3f should be positive and below exact %.3f",
neighbor*neighborConfidence,
exact*exactConfidence,
)
}
if neighborConfidence >= exactConfidence {
t.Fatalf(
"neighbor confidence %.3f should be below exact %.3f",
neighborConfidence,
exactConfidence,
)
}
}
func TestContextAffinityHasNeutralSparseHistoryFallback(t *testing.T) {
score, confidence := (ContextAffinityProfile{}).Score(
Item{Genres: []string{"Drama"}}, time.Now(), time.UTC,
)
if score != 0 || confidence != 0 {
t.Fatalf("empty profile = score %.3f confidence %.3f, want neutral", score, confidence)
}
}
+266 -30
View File
@@ -3,6 +3,7 @@ package recommend
import (
"context"
"encoding/json"
"hash/fnv"
"log/slog"
"math"
"net/url"
@@ -54,6 +55,12 @@ type CuratedLibrarySource interface {
) ([]json.RawMessage, error)
}
// GenreLibrarySource lets an imported Sonarr/Radarr/Emby catalogue describe itself.
// It is optional so recommendation sources that predate genre discovery still work.
type GenreLibrarySource interface {
LibraryGenres(ctx context.Context, itemTypes []string, minItems int) ([]string, error)
}
type TracearrSource interface {
History(ctx context.Context, username string, limit int) ([]tracearr.Session, error)
}
@@ -96,6 +103,12 @@ type Engine struct {
MaxSimilarRows int
RowSize int
CuratedRows []CuratedRow
WeightedConfig WeightedConfig
// Location defines the viewer's local day/time windows. Now is injectable so
// contextual placement stays deterministic in tests.
Location *time.Location
Now func() time.Time
}
// ForYouOptions are request-scoped constraints chosen on the television.
@@ -125,12 +138,13 @@ func (e *Engine) BuildForYou(
profile := BuildProfile(history, favorites)
var sessions []tracearr.Session
contextAffinity := NewContextAffinityProfile()
if e.Tracearr != nil {
if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr)
} else {
sessions = recommendationSessions(fetched)
e.applyTracearrSignals(&profile, history, sessions)
contextAffinity = e.applyTracearrSignals(&profile, history, sessions)
}
}
@@ -163,7 +177,10 @@ func (e *Engine) BuildForYou(
}
compatibility := buildCompatibilityProfile(sessions)
items := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize)
items := rankForYouAt(
profile, candidates, options.AvailableMinutes, compatibility, e.RowSize,
contextAffinity, e.now(), e.Location,
)
if len(items) < e.MinRowItems {
return []Row{}, nil
}
@@ -199,7 +216,8 @@ func (e *Engine) applyTracearrSignals(
profile *Profile,
history []Item,
sessions []tracearr.Session,
) {
) ContextAffinityProfile {
contextAffinity := NewContextAffinityProfile()
byTitle := make(map[string]Item, len(history))
for _, item := range history {
byTitle[item.TitleKey()] = item
@@ -216,7 +234,11 @@ func (e *Engine) applyTracearrSignals(
// minutes"; recency lets changing tastes move promptly.
weight := (0.2 + session.Completion()) * powDecay(0.985, i)
profile.absorbTaste(item, weight)
if started, ok := parseTracearrTime(session.StartedAt); ok {
contextAffinity.Add(item, started, session.Completion(), i, e.Location)
}
}
return contextAffinity
}
func (e *Engine) libraryCandidatesForYou(
@@ -307,13 +329,32 @@ func rankForYou(
availableMinutes int,
compatibility compatibilityProfile,
limit int,
) []Item {
return rankForYouAt(
profile, candidates, availableMinutes, compatibility, limit,
ContextAffinityProfile{}, time.Time{}, nil,
)
}
func rankForYouAt(
profile Profile,
candidates []Item,
availableMinutes int,
compatibility compatibilityProfile,
limit int,
contextAffinity ContextAffinityProfile,
now time.Time,
location *time.Location,
) []Item {
type scored struct {
item Item
score float64
item Item
score float64
contextRaw float64
confidence float64
}
ranked := make([]scored, 0, len(candidates))
seen := map[string]bool{}
var maxContext float64
for _, candidate := range candidates {
if seen[candidate.ID] {
continue
@@ -333,7 +374,20 @@ func rankForYou(
// allowing runtime to overwhelm taste.
score += float64(runtime) / float64(availableMinutes) * 0.35
}
ranked = append(ranked, scored{item: candidate, score: score})
contextRaw, confidence := contextAffinity.Score(candidate, now, location)
if contextRaw > maxContext {
maxContext = contextRaw
}
ranked = append(ranked, scored{
item: candidate, score: score, contextRaw: contextRaw, confidence: confidence,
})
}
if maxContext > 0 {
for i := range ranked {
// A maximum 1.5-point lift is enough to rearrange similarly relevant
// posters without allowing a routine to overpower taste or quality.
ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
}
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
@@ -351,6 +405,13 @@ func rankForYou(
return out
}
func (e *Engine) now() time.Time {
if e.Now != nil {
return e.Now()
}
return time.Now()
}
func explainRecommendation(
profile Profile,
item Item,
@@ -432,6 +493,7 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
MinRowItems: 4,
MaxSimilarRows: 2,
RowSize: 20,
WeightedConfig: DefaultWeightedConfig(),
CuratedRows: []CuratedRow{
{
ID: "curated:apple-tv",
@@ -518,8 +580,8 @@ func movieStudioRow(id, title string, studios ...string) CuratedRow {
}
const (
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
historyFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,SeriesName,ProductionYear,PremiereDate,RunTimeTicks"
candidateFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
rowImageTypes = "Backdrop,Primary,Logo"
)
@@ -528,12 +590,45 @@ const (
// Cost is a handful of Emby queries, which is why callers cache the result rather than
// computing it on every home load.
func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, error) {
return e.BuildRowsForUser(ctx, cred, "")
}
// BuildRowsForUser combines Emby state with Tracearr playback outcomes, Memby's own
// browsing events and the imported Sonarr/Radarr-backed catalogue. BuildRows remains as
// the compatibility entry point for tests and callers that do not know a username.
func (e *Engine) BuildRowsForUser(
ctx context.Context,
cred emby.Credentials,
username string,
) ([]Row, error) {
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return nil, err
}
profile := BuildProfile(history, favorites)
contextAffinity := NewContextAffinityProfile()
if e.Tracearr != nil && strings.TrimSpace(username) != "" {
if sessions, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
e.log.Warn("tracearr signals unavailable for shelves", "error", traceErr)
} else {
contextAffinity = e.applyTracearrSignals(
&profile, history, recommendationSessions(sessions),
)
}
}
if e.Behavior != nil {
if raws, browseErr := e.Behavior.BrowsingCandidates(
ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 40,
); browseErr != nil {
e.log.Warn("browsing signals unavailable for shelves", "error", browseErr)
} else {
for i, item := range Decode(raws) {
profile.absorbTaste(item, 0.55*powDecay(0.92, i))
}
}
}
profile = contextAffinity.Contextualized(profile, e.now(), e.Location)
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
if !profile.IsEmpty() {
@@ -548,22 +643,26 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
rows = append(rows, row)
}
}
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
rows = append(rows, e.buildCuratedRows(ctx, profile, dailySeed(cred.UserID))...)
return rows, nil
}
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed string) []Row {
library, ok := e.Library.(CuratedLibrarySource)
if !ok {
return nil
}
curatedDefinitions := append([]CuratedRow(nil), e.CuratedRows...)
if genres, supportsGenres := e.Library.(GenreLibrarySource); supportsGenres {
curatedDefinitions = e.catalogueGenreRows(ctx, genres, curatedDefinitions)
}
type rankedDefinition struct {
definition CuratedRow
affinity float64
order int
}
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
for order, definition := range e.CuratedRows {
definitions := make([]rankedDefinition, 0, len(curatedDefinitions))
for order, definition := range curatedDefinitions {
affinity := profile.CollectionAffinity(definition.Genres, definition.Studios)
// Studio signals are intentionally damped while scoring individual titles.
// Restore enough weight at shelf level for a genuinely followed studio to earn
@@ -585,6 +684,11 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
if definitions[i].affinity != definitions[j].affinity {
return definitions[i].affinity > definitions[j].affinity
}
left := stableVariation(seed, definitions[i].definition.ID)
right := stableVariation(seed, definitions[j].definition.ID)
if left != right {
return left < right
}
return definitions[i].order < definitions[j].order
})
@@ -592,20 +696,8 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
// A series may carry several genres. Give it to the user's highest-affinity shelf
// only, so scrolling Shows never reveals the same card again under another label.
seenItems := make(map[string]struct{})
movieGenreRows := 0
movieStudioRows := 0
for _, ranked := range definitions {
definition := ranked.definition
switch {
case strings.HasPrefix(definition.ID, "curated:movies:genre:"):
if movieGenreRows >= 6 {
continue
}
case strings.HasPrefix(definition.ID, "curated:movies:studio:"):
if movieStudioRows >= 3 {
continue
}
}
raws, err := library.CuratedCandidates(
ctx,
definition.ItemTypes,
@@ -618,6 +710,7 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
continue
}
rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
rankedItems = diversifyRanked(rankedItems, seed+":"+definition.ID, 5)
items := make([]Item, 0, e.RowSize)
for _, item := range rankedItems {
if _, seen := seenItems[item.ID]; seen {
@@ -640,16 +733,106 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
Kind: definition.Kind,
Items: Raws(items),
})
if strings.HasPrefix(definition.ID, "curated:movies:genre:") {
movieGenreRows++
}
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
movieStudioRows++
}
}
return rows
}
func (e *Engine) catalogueGenreRows(
ctx context.Context,
source GenreLibrarySource,
definitions []CuratedRow,
) []CuratedRow {
// Ask for extra depth because watched titles and cross-shelf deduplication reduce
// the usable pool. A genre is emitted only if MinRowItems unseen cards survive.
minimumDepth := e.MinRowItems * 2
for _, itemType := range []string{"Series", "Movie"} {
genres, err := source.LibraryGenres(ctx, []string{itemType}, minimumDepth)
if err != nil {
e.log.Warn("library genre discovery failed", "type", itemType, "error", err)
continue
}
for _, genre := range genres {
idPrefix, titleSuffix, kind := "curated:shows:genre:", " Shows", "shows"
if itemType == "Movie" {
idPrefix, titleSuffix, kind = "curated:movies:genre:", " Movies", "movies"
}
id := idPrefix + rowSlug(genre)
replaced := false
for index := range definitions {
existing := &definitions[index]
if existing.Kind == kind && len(existing.Genres) == 1 &&
strings.EqualFold(existing.Genres[0], genre) {
// Catalogue depth proves this can be a useful shelf even when it
// is outside the viewer's established affinity.
existing.RequireAffinity = false
replaced = true
break
}
}
if !replaced {
definitions = append(definitions, CuratedRow{
ID: id, Title: genre + titleSuffix, Kind: kind,
ItemTypes: []string{itemType}, Genres: []string{genre},
})
}
}
}
return definitions
}
func rowSlug(value string) string {
var out strings.Builder
dash := false
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
if dash && out.Len() > 0 {
out.WriteByte('-')
}
out.WriteRune(r)
dash = false
} else {
dash = true
}
}
if out.Len() > 0 {
return out.String()
}
return strconv.FormatUint(stableVariation("", value), 36)
}
func dailySeed(userID string) string {
return userID + ":" + time.Now().UTC().Format("2006-01-02")
}
func stableVariation(seed, value string) uint64 {
hash := fnv.New64a()
_, _ = hash.Write([]byte(seed))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(value))
return hash.Sum64()
}
// diversifyRanked preserves relevance bands while changing the exact poster sequence
// per user and day. Affinity/rating still choose each five-card band; variation only
// decides which equally strong-looking option catches the eye first.
func diversifyRanked(items []Item, seed string, bandSize int) []Item {
if bandSize < 2 || len(items) < 2 {
return items
}
out := append([]Item(nil), items...)
for start := 0; start < len(out); start += bandSize {
end := start + bandSize
if end > len(out) {
end = len(out)
}
sort.SliceStable(out[start:end], func(i, j int) bool {
return stableVariation(seed, out[start+i].ID) <
stableVariation(seed, out[start+j].ID)
})
}
return out
}
// gatherSignals reads what the user has watched and favourited, in parallel.
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
var (
@@ -795,6 +978,59 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
}, true
}
// RelatedTo answers a detail page: why this viewer might enjoy one title, and what else
// in the library is like it.
//
// Both halves come from the same profile the home rows are built from, so the page can
// never explain a taste the engine does not hold. A failed Similar lookup still returns
// the reasons — the strip under the description is worth more than the carousel.
func (e *Engine) RelatedTo(
ctx context.Context,
cred emby.Credentials,
item Item,
limit int,
) (reasons []string, related []Item, err error) {
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return nil, nil, err
}
profile := BuildProfile(history, favorites)
reasons = Why(profile, item, ReasonLimit)
if limit <= 0 {
limit = e.RowSize
}
result, similarErr := e.source.Similar(ctx, cred, item.ID, url.Values{
"UserId": {cred.UserID},
"Limit": {strconv.Itoa(limit * 2)},
"Fields": {candidateFields},
"ImageTypeLimit": {"1"},
"EnableImages": {"true"},
"EnableImageTypes": {rowImageTypes},
"EnableUserData": {"true"},
})
if similarErr != nil {
e.log.Warn("related lookup failed", "item", item.ID, "error", similarErr)
return reasons, nil, nil
}
candidates := Decode(result.Items)
related = FilterUnseen(profile, candidates, limit)
// A carousel of two looks broken. Someone deep into a franchise has seen most of
// what resembles it, so fall back to Emby's unfiltered order rather than a stub.
if len(related) < e.MinRowItems && len(candidates) > len(related) {
related = trim(candidates, limit)
}
return reasons, related, nil
}
func trim(items []Item, limit int) []Item {
if limit > 0 && len(items) > limit {
return items[:limit]
}
return items
}
// historyRow is the genre-affinity row: unwatched titles from the genres the user has
// been spending time in, ranked by how closely they match the whole profile.
func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile Profile) (Row, bool) {
+118 -11
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
@@ -36,6 +37,22 @@ type fakeCuratedLibrary struct {
byGenre map[string][]json.RawMessage
}
type fakeGenreLibrary struct {
*fakeCuratedLibrary
genresByType map[string][]string
}
func (f *fakeGenreLibrary) LibraryGenres(
_ context.Context,
itemTypes []string,
_ int,
) ([]string, error) {
if len(itemTypes) == 0 {
return nil, nil
}
return f.genresByType[itemTypes[0]], nil
}
type fakeForYouLibrary struct {
items []json.RawMessage
}
@@ -125,10 +142,12 @@ func (f *fakeSource) NextUp(
return &emby.ItemsResult{Items: f.nextUp}, nil
}
func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) {
func TestAbandonedShowsRequireAnEmbyNextUpAndStayInTheFirstSeason(t *testing.T) {
now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
catalogue := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
json.RawMessage(`{"Id":"finished-first","Name":"Finished First","Type":"Series"}`),
json.RawMessage(`{"Id":"unknown-season","Name":"Unknown Season","Type":"Series"}`),
json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
@@ -143,12 +162,18 @@ func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T)
}
sessions := []tracearr.Session{
session("Early Show", 1, 2, 40),
session("Finished First", 1, 10, 50),
session("Deep Show", 2, 8, 60),
session("Complete Show", 4, 10, 50),
session("Recent Show", 1, 3, 5),
}
unknown := session("Unknown Season", 1, 2, 30)
unknown.SeasonNumber = nil
sessions = append(sessions, unknown)
nextUp := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"finished-next","Type":"Episode","SeriesId":"finished-first","ParentIndexNumber":2,"IndexNumber":1,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"unknown-next","Type":"Episode","SeriesId":"unknown-season","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
})
@@ -160,20 +185,21 @@ func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T)
compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
now,
)
if len(got) != 2 {
t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got)
if len(got) != 3 {
t.Fatalf("pickup candidates = %+v, want three early-show abandonments", got)
}
if got[0].ItemID != "deep" || got[1].ItemID != "early" {
t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID)
byID := map[string]PreparedCandidate{}
for _, candidate := range got {
byID[candidate.ItemID] = candidate
}
if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" {
t.Fatalf("later-season reason = %q", got[0].RecommendationReason)
if byID["early"].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("season-one reason = %q", byID["early"].RecommendationReason)
}
if got[1].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("season-one reason = %q", got[1].RecommendationReason)
if byID["finished-first"].RecommendationReason != "You finished season 1 · season 2 is waiting" {
t.Fatalf("season-two waiting reason = %q", byID["finished-first"].RecommendationReason)
}
if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 {
t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes)
if byID["unknown-season"].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("inferred first-season reason = %q", byID["unknown-season"].RecommendationReason)
}
}
@@ -509,6 +535,28 @@ func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
}
}
func TestEpisodeEvidenceUsesParentSeriesMetadata(t *testing.T) {
seriesRaw, _ := json.Marshal(map[string]any{
"Id": "series-1", "Name": "The Show", "Type": "Series",
"People": []map[string]string{{"Name": "Lead Actor", "Type": "Actor"}},
"Studios": []map[string]string{{"Name": "Great Studio"}},
"Genres": []string{"Drama"},
})
episodeRaw, _ := json.Marshal(map[string]any{
"Id": "episode-1", "Name": "Pilot", "Type": "Episode",
"SeriesId": "series-1", "SeriesName": "The Show",
})
series := Decode([]json.RawMessage{seriesRaw})[0]
episode := Decode([]json.RawMessage{episodeRaw})[0]
got := newCatalogueIndex([]Item{series}).evidenceItem(episode)
if got.ID != "series-1" || len(got.People) != 1 ||
len(got.Studios) != 1 || len(got.Genres) != 1 {
t.Fatalf("episode evidence was not enriched from its series: %+v", got)
}
}
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
@@ -731,6 +779,65 @@ func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
}
}
func TestCatalogueGenresExpandMovieAndShowShelves(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
engine := testEngine(source)
engine.Library = &fakeGenreLibrary{
fakeCuratedLibrary: &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Western": {
raw("western-1", "Western One", "Movie", "Western"),
raw("western-2", "Western Two", "Movie", "Western"),
},
"Reality": {
raw("reality-1", "Reality One", "Series", "Reality"),
raw("reality-2", "Reality Two", "Series", "Reality"),
},
}},
genresByType: map[string][]string{
"Movie": {"Western"},
"Series": {"Reality"},
},
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, row := range rows {
got[row.ID] = true
}
if !got["curated:movies:genre:western"] || !got["curated:shows:genre:reality"] {
t.Fatalf("catalogue-backed genre rows missing: %v", rowTitles(rows))
}
}
func TestDiversifyRankedIsStableAndOnlyReordersWithinBands(t *testing.T) {
items := make([]Item, 0, 12)
for i := 0; i < 12; i++ {
items = append(items, Item{ID: strconv.Itoa(i)})
}
first := diversifyRanked(items, "user:day", 5)
second := diversifyRanked(items, "user:day", 5)
if fmt.Sprint(first) != fmt.Sprint(second) {
t.Fatal("same user/day seed must produce stable poster ordering")
}
for index, item := range first {
if index/5 != mustAtoi(t, item.ID)/5 {
t.Fatalf("item %s escaped its relevance band: %+v", item.ID, first)
}
}
}
func mustAtoi(t *testing.T, value string) int {
t.Helper()
parsed, err := strconv.Atoi(value)
if err != nil {
t.Fatal(err)
}
return parsed
}
// A failing similarity lookup is one dead row, not a dead home screen.
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
source := &fakeSource{
+124
View File
@@ -0,0 +1,124 @@
package recommend
import (
"sort"
"strconv"
"strings"
"time"
)
// Why a title suits one viewer, in that viewer's own words.
//
// This is deliberately separate from Score. The scorer decides *order* and is allowed to
// be opaque; this decides *wording* and must never claim an affinity the profile did not
// actually learn — every reason below is read straight out of the weights built from real
// history, so a viewer who has watched nothing gets the honest, taste-free ones.
// ReasonLimit is what fits on one line of a detail page without wrapping. Beyond three
// the strip stops reading as an explanation and starts reading as marketing.
const ReasonLimit = 3
// reasonFloor is the weight below which an affinity is a coincidence rather than a
// habit — one stray episode should not put a genre on the screen as a reason.
const reasonFloor = 0.35
// Why returns up to limit short phrases explaining the item to this viewer, strongest
// first. Never nil: a profile with nothing in it still yields the catalogue facts.
func Why(profile Profile, item Item, limit int) []string {
if limit <= 0 {
limit = ReasonLimit
}
reasons := make([]string, 0, limit)
add := func(reason string) {
if len(reasons) < limit && reason != "" {
reasons = append(reasons, reason)
}
}
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor {
add("Because you watch " + genre)
}
if name, weight := heaviestPerson(profile, item); weight >= reasonFloor {
add("You've watched " + name + " before")
}
if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor {
add("More from " + studio)
}
// Catalogue facts, used to fill the strip out. They are true for everyone, which is
// exactly why they come last: they explain the title, not the viewer.
// Kept short deliberately. Three chips share one line on a 960dp TV, and this is the
// one that most often lands third, where a long phrase is the one that gets clipped.
if item.CommunityRating >= 7.5 {
add("Well rated (" + strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64) + ")")
}
if item.ProductionYear > 0 && time.Now().Year()-item.ProductionYear <= 1 {
add("A recent release")
}
if len(reasons) == 0 && len(item.Genres) > 0 {
add(strings.TrimSpace(item.Genres[0]) + " from your library")
}
return reasons
}
// heaviest picks the wanted key with the most weight behind it, matched case-insensitively
// because Emby's own tagging is not consistent about it. Ties break alphabetically so the
// same profile and item always produce the same sentence.
func heaviest(weights map[string]float64, wanted []string) (string, float64) {
best, bestWeight := "", 0.0
for _, candidate := range wanted {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
weight := weightFold(weights, candidate)
if weight <= 0 {
continue
}
if weight > bestWeight || (weight == bestWeight && candidate < best) {
best, bestWeight = candidate, weight
}
}
return best, bestWeight
}
func heaviestPerson(profile Profile, item Item) (string, float64) {
names := make([]string, 0, len(item.People))
for _, person := range item.People {
if isExplainablePerson(person.Type) {
names = append(names, person.Name)
}
}
return heaviest(profile.PersonWeights, names)
}
func round1(value float64) float64 {
return float64(int(value*10+0.5)) / 10
}
// TopPeople is the explanation layer's view of the cast a viewer follows, heaviest first.
// Exported for the admin page, which shows what the engine believes about a household.
func (p Profile) TopPeople(n int) []string {
type kv struct {
name string
weight float64
}
pairs := make([]kv, 0, len(p.PersonWeights))
for name, weight := range p.PersonWeights {
pairs = append(pairs, kv{name, weight})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].weight != pairs[j].weight {
return pairs[i].weight > pairs[j].weight
}
return pairs[i].name < pairs[j].name
})
if n > len(pairs) {
n = len(pairs)
}
out := make([]string, 0, n)
for _, pair := range pairs[:n] {
out = append(out, pair.name)
}
return out
}
+136
View File
@@ -0,0 +1,136 @@
package recommend
import (
"encoding/json"
"strings"
"testing"
)
func explainItem(t *testing.T, payload string) Item {
t.Helper()
items := Decode([]json.RawMessage{json.RawMessage(payload)})
if len(items) != 1 {
t.Fatalf("payload did not decode to one item: %s", payload)
}
return items[0]
}
func explainProfile(t *testing.T, history ...string) Profile {
t.Helper()
items := make([]Item, 0, len(history))
for _, payload := range history {
items = append(items, explainItem(t, payload))
}
return BuildProfile(items, nil)
}
const thrillerHistory = `{
"Id":"h1","Name":"Sicario","Type":"Movie","Genres":["Thriller","Crime"],
"Studios":[{"Name":"Lionsgate"}],
"People":[{"Name":"Denis Villeneuve","Type":"Director"},{"Name":"Emily Blunt","Type":"Actor"}]
}`
func TestWhyNamesTheGenreTheViewerActuallyWatches(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
"Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller"],
"People":[{"Name":"Denis Villeneuve","Type":"Director"}]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if len(reasons) == 0 || reasons[0] != "Because you watch Thriller" {
t.Fatalf("expected the genre reason first, got %v", reasons)
}
if !strings.Contains(strings.Join(reasons, "|"), "Denis Villeneuve") {
t.Fatalf("expected the shared director to be named, got %v", reasons)
}
}
func TestWhyNeverClaimsAnAffinityThatIsNotThere(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
"Id":"c2","Name":"Paddington","Type":"Movie","Genres":["Family"],
"CommunityRating":8.2,
"People":[{"Name":"Paul King","Type":"Director"}]
}`)
reasons := Why(profile, candidate, ReasonLimit)
for _, reason := range reasons {
if strings.HasPrefix(reason, "Because you watch") ||
strings.HasPrefix(reason, "You've watched") {
t.Fatalf("invented a taste the profile does not hold: %v", reasons)
}
}
if len(reasons) == 0 || reasons[0] != "Well rated (8.2)" {
t.Fatalf("expected the catalogue fact to carry the strip, got %v", reasons)
}
}
func TestWhyAlwaysSaysSomething(t *testing.T) {
candidate := explainItem(t, `{"Id":"c3","Name":"Unknown","Type":"Movie","Genres":["Drama"]}`)
reasons := Why(Profile{}, candidate, ReasonLimit)
if len(reasons) != 1 || reasons[0] != "Drama from your library" {
t.Fatalf("an empty profile should still explain the title, got %v", reasons)
}
}
func TestWhyIsCappedAndOrdered(t *testing.T) {
// A second, Thriller-only title so Thriller genuinely outweighs Crime — with one
// history item they tie and the alphabetical tie-break would decide it.
profile := explainProfile(t, thrillerHistory, `{
"Id":"h2","Name":"Nightcrawler","Type":"Movie","Genres":["Thriller"],
"Studios":[{"Name":"Lionsgate"}],
"People":[{"Name":"Emily Blunt","Type":"Actor"}]
}`)
candidate := explainItem(t, `{
"Id":"c4","Name":"Wind River","Type":"Movie","Genres":["Thriller","Crime"],
"Studios":[{"Name":"Lionsgate"}],"CommunityRating":9.1,"ProductionYear":2017,
"People":[{"Name":"Emily Blunt","Type":"Actor"}]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if len(reasons) != ReasonLimit {
t.Fatalf("expected exactly %d reasons, got %v", ReasonLimit, reasons)
}
want := []string{
"Because you watch Thriller",
"You've watched Emily Blunt before",
"More from Lionsgate",
}
for i, reason := range want {
if reasons[i] != reason {
t.Fatalf("reason %d: want %q, got %q (%v)", i, reason, reasons[i], reasons)
}
}
}
func TestWhyIsStableAcrossCalls(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
"Id":"c5","Name":"Hell or High Water","Type":"Movie","Genres":["Crime","Thriller"]
}`)
first := Why(profile, candidate, ReasonLimit)
for i := 0; i < 20; i++ {
if got := Why(profile, candidate, ReasonLimit); !equalStrings(first, got) {
t.Fatalf("reasons changed between calls: %v then %v", first, got)
}
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+80 -8
View File
@@ -49,7 +49,9 @@ type PreparedProfile struct {
GenreAffinity map[string]float64
TitleAffinity map[string]PreparedTitleAffinity
StudioAffinity map[string]float64
ContextAffinity ContextAffinityProfile
CodecOutcomes map[string]map[string]int
Weighted WeightedProfile
SignalsThrough *time.Time
}
@@ -77,6 +79,8 @@ type PreparedResult struct {
var ErrPreparedLibraryUnavailable = errors.New("recommend: prepared library unavailable")
const maxPreparedCandidatePool = 750
// PrepareForYou performs the expensive work outside a television request. It consumes
// locally imported Tracearr sessions and the complete imported catalogue, producing a
// compact profile and an intentionally over-provisioned ranked pool.
@@ -167,6 +171,26 @@ func (e *Engine) PrepareForYou(
tracearrUserID := ""
tracearrUsername := strings.TrimSpace(username)
titleSignalCount := map[string]int{}
contextAffinity := NewContextAffinityProfile()
weightedEvidence := make([]ViewingEvidence, 0, len(history)+len(favorites)+len(sessions))
for _, item := range history {
item = index.evidenceItem(item)
completion := 0.0
if item.UserData.Played {
completion = 1
} else if item.RunTimeTicks > 0 {
completion = float64(item.UserData.PlaybackPositionTicks) / float64(item.RunTimeTicks)
}
weightedEvidence = append(weightedEvidence, ViewingEvidence{
Item: item, Completion: completion, Repeat: maxInt(1, item.UserData.PlayCount),
})
}
for _, item := range favorites {
item = index.evidenceItem(item)
weightedEvidence = append(weightedEvidence, ViewingEvidence{
Item: item, Completion: 0, Favorite: true,
})
}
for i, session := range sessions {
completion := session.Completion()
@@ -225,6 +249,18 @@ func (e *Engine) PrepareForYou(
current.SessionID = session.ID
}
titleAffinity[item.ID] = current
if started, ok := parseTracearrTime(session.StartedAt); ok {
contextAffinity.Add(item, started, completion, i, e.Location)
weightedEvidence = append(weightedEvidence, ViewingEvidence{
Item: item, Completion: completion, Repeat: repeats + 1,
OccurredAt: started, SessionMinutes: int(int64(session.DurationMs) / 60_000),
})
} else {
weightedEvidence = append(weightedEvidence, ViewingEvidence{
Item: item, Completion: completion, Repeat: repeats + 1,
SessionMinutes: int(int64(session.DurationMs) / 60_000),
})
}
if completion >= 0.9 {
addCompletedEvidence(item, session.ID)
@@ -262,9 +298,12 @@ func (e *Engine) PrepareForYou(
return ranked[i].item.Name < ranked[j].item.Name
})
prepared := make([]PreparedCandidate, 0, len(ranked))
prepared := make([]PreparedCandidate, 0, min(len(ranked), maxPreparedCandidatePool))
completedReasonCounts := map[string]int{}
for rank, entry := range ranked {
if len(prepared) == maxPreparedCandidatePool {
break
}
reason, label, kind, genre, evidence := explainPreparedRecommendation(
profile, entry.item, compatibility, browsed[entry.item.ID], evidenceByGenre,
completedReasonCounts,
@@ -294,6 +333,9 @@ func (e *Engine) PrepareForYou(
pickups[i].BaseRank = i + 1
}
prepared = append(pickups, prepared...)
if len(prepared) > maxPreparedCandidatePool {
prepared = prepared[:maxPreparedCandidatePool]
}
}
meanCompletion := 0.0
@@ -310,7 +352,11 @@ func (e *Engine) PrepareForYou(
SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion,
TypicalSessionMinutes: medianInt(durations),
GenreAffinity: profile.GenreWeights, TitleAffinity: titleAffinity,
StudioAffinity: profile.StudioWeights, CodecOutcomes: codecs,
StudioAffinity: profile.StudioWeights, ContextAffinity: contextAffinity,
CodecOutcomes: codecs,
Weighted: BuildWeightedProfileWithConfig(
weightedEvidence, time.Now(), e.Location, e.WeightedConfig,
),
SignalsThrough: signalsThrough,
},
Candidates: prepared,
@@ -390,9 +436,11 @@ func abandonedShowCandidates(
}
if activity.After(current.lastActivity) {
current.lastActivity = activity
if session.SeasonNumber != nil {
current.lastSeason = *session.SeasonNumber
}
}
// Track the furthest season ever reached, not merely the season from the most
// recent replay. Any season-two evidence disqualifies a first-season pickup.
if session.SeasonNumber != nil && *session.SeasonNumber > current.lastSeason {
current.lastSeason = *session.SeasonNumber
}
if session.Completion() >= 0.9 && session.SeasonNumber != nil &&
session.EpisodeNumber != nil {
@@ -420,7 +468,14 @@ func abandonedShowCandidates(
cutoff := now.Add(-abandonedShowAge)
for seriesID, watched := range progress {
next, unfinished := nextBySeries[seriesID]
if !unfinished || watched.lastActivity.After(cutoff) {
// This shelf is intentionally about promising shows abandoned in or just after
// their first season. Later-season lapses are ordinary Next Up material. Some
// Tracearr episode records lack a season number, so a season-one Next Up is also
// sufficient evidence that the viewer is still at the beginning.
firstSeasonAbandonment := watched.lastSeason == 1 &&
next.ParentIndexNumber <= 2 ||
watched.lastSeason == 0 && next.ParentIndexNumber == 1
if !unfinished || watched.lastActivity.After(cutoff) || !firstSeasonAbandonment {
continue
}
eligible = append(eligible, pickup{progress: watched, next: next})
@@ -478,7 +533,7 @@ func abandonedShowReason(lastSeason, nextSeason int) string {
return fmt.Sprintf("You made it through season %d · season %d is waiting", lastSeason, nextSeason)
case lastSeason > 1:
return fmt.Sprintf("You made it to season %d · pick it up again", lastSeason)
case lastSeason == 1:
case lastSeason == 1 || lastSeason == 0 && nextSeason == 1:
return "You left this in season 1 · pick it up again"
default:
return "You left this unfinished · pick it up again"
@@ -596,15 +651,17 @@ type catalogueIndex struct {
movieExact map[string]Item
movieLoose map[string]Item
series map[string]Item
byID map[string]Item
ambiguous map[string]bool
}
func newCatalogueIndex(items []Item) catalogueIndex {
index := catalogueIndex{
movieExact: map[string]Item{}, movieLoose: map[string]Item{},
series: map[string]Item{}, ambiguous: map[string]bool{},
series: map[string]Item{}, byID: map[string]Item{}, ambiguous: map[string]bool{},
}
for _, item := range items {
index.byID[item.ID] = item
key := normalizePreparedTitle(item.Name)
switch item.Type {
case "Movie":
@@ -627,6 +684,21 @@ func newCatalogueIndex(items []Item) catalogueIndex {
return index
}
// evidenceItem promotes episode evidence to its series metadata. Emby episode rows often
// omit People and studio details even when those fields were requested, while the parent
// Series record contains the canonical cast and production metadata.
func (i catalogueIndex) evidenceItem(item Item) Item {
if !strings.EqualFold(item.Type, "Episode") || item.SeriesID == "" {
return item
}
parent, ok := i.byID[item.SeriesID]
if !ok {
return item
}
parent.UserData = item.UserData
return parent
}
func (i catalogueIndex) match(session tracearr.Session) (Item, bool) {
if strings.EqualFold(session.MediaType, "episode") && strings.TrimSpace(session.ShowTitle) != "" {
key := normalizePreparedTitle(session.ShowTitle)
+46 -1
View File
@@ -12,6 +12,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"unicode"
)
@@ -37,6 +38,10 @@ type Item struct {
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
RunTimeTicks int64 `json:"RunTimeTicks"`
OfficialRating string `json:"OfficialRating"`
CollectionName string `json:"CollectionName"`
PremiereDate string `json:"PremiereDate"`
DateCreated string `json:"DateCreated"`
IndexNumber int `json:"IndexNumber"`
ParentIndexNumber int `json:"ParentIndexNumber"`
Container string `json:"Container"`
@@ -47,6 +52,7 @@ type Item struct {
Studios []struct {
Name string `json:"Name"`
} `json:"Studios"`
People []Person `json:"People"`
UserData struct {
Played bool `json:"Played"`
PlayCount int `json:"PlayCount"`
@@ -99,6 +105,10 @@ type Seed struct {
type Profile struct {
GenreWeights map[string]float64
StudioWeights map[string]float64
// PersonWeights is read only by the explanation layer, never by Score. Casting is a
// good reason to *tell* someone about a title and a poor reason to rank by it: two
// films sharing an actor are often nothing alike.
PersonWeights map[string]float64
// Seen holds item ids *and* series ids already watched or in progress, so a
// recommendation never suggests something the user is already partway through.
Seen map[string]bool
@@ -130,6 +140,7 @@ func BuildProfile(history, favorites []Item) Profile {
profile := Profile{
GenreWeights: map[string]float64{},
StudioWeights: map[string]float64{},
PersonWeights: map[string]float64{},
Seen: map[string]bool{},
SeenTitles: map[string]bool{},
}
@@ -201,6 +212,27 @@ func (p *Profile) absorbTaste(item Item, weight float64) {
p.StudioWeights[s] += weight * 0.4
}
}
for _, person := range item.People {
if !isExplainablePerson(person.Type) {
continue
}
if name := strings.TrimSpace(person.Name); name != "" {
if p.PersonWeights == nil {
p.PersonWeights = map[string]float64{}
}
p.PersonWeights[name] += weight
}
}
}
// isExplainablePerson keeps the cast list down to the roles a viewer would recognise as
// a reason. A gaffer in common is not why anyone picks a film.
func isExplainablePerson(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "actor", "director", "writer":
return true
}
return false
}
// TopGenres returns the n heaviest genres, highest first. Ties break alphabetically so
@@ -263,7 +295,20 @@ func (p Profile) Score(candidate Item) float64 {
// A mild quality nudge, capped so a beloved genre still beats a well-rated stranger.
ratingScore := candidate.CommunityRating / 10 * 0.5
return genreScore + studioScore + ratingScore
// New catalogue arrivals should surface without overpowering established taste.
// Production year is consistently available from both the imported library and
// Emby, unlike DateCreated on deliberately narrow recommendation payloads.
var freshnessScore float64
age := time.Now().Year() - candidate.ProductionYear
switch {
case candidate.ProductionYear <= 0:
case age <= 1:
freshnessScore = 0.25
case age <= 3:
freshnessScore = 0.12
}
return genreScore + studioScore + ratingScore + freshnessScore
}
// CollectionAffinity decides which curated shelf appears first for this user.
+722
View File
@@ -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 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)
}
+162
View File
@@ -0,0 +1,162 @@
package recommend
import (
"encoding/json"
"testing"
"time"
)
func rankedFixture(id, name, kind string, genres []string, minutes int) Item {
raw, _ := json.Marshal(map[string]any{
"Id": id, "Name": name, "Type": kind, "Genres": genres,
"RunTimeTicks": int64(minutes) * 600_000_000,
})
item := Item{
ID: id, Name: name, Type: kind, Genres: genres,
RunTimeTicks: int64(minutes) * 600_000_000, CommunityRating: 7,
Raw: raw,
}
return item
}
func TestWeightedRankPersonalizesTwoUsersFromRepeatedCompletedPatterns(t *testing.T) {
now := time.Date(2026, 7, 31, 20, 0, 0, 0, time.UTC)
dramaHistory := []ViewingEvidence{
{Item: rankedFixture("d1", "Drama One", "Movie", []string{"Drama"}, 100), Completion: 1, OccurredAt: now.Add(-24 * time.Hour)},
{Item: rankedFixture("d2", "Drama Two", "Series", []string{"Drama"}, 45), Completion: .96, OccurredAt: now.Add(-48 * time.Hour)},
}
comedyHistory := []ViewingEvidence{
{Item: rankedFixture("c1", "Comedy One", "Movie", []string{"Comedy"}, 95), Completion: 1, OccurredAt: now.Add(-24 * time.Hour)},
{Item: rankedFixture("c2", "Comedy Two", "Series", []string{"Comedy"}, 25), Completion: .98, OccurredAt: now.Add(-48 * time.Hour)},
}
candidates := []Item{
rankedFixture("new-comedy", "A Comedy", "Movie", []string{"Comedy"}, 90),
rankedFixture("new-drama", "A Drama", "Movie", []string{"Drama"}, 90),
}
cfg := DefaultWeightedConfig()
drama := WeightedRank(BuildWeightedProfile(dramaHistory, now, time.UTC), candidates, nil, RankIntent{Now: now}, cfg, 10)
comedy := WeightedRank(BuildWeightedProfile(comedyHistory, now, time.UTC), candidates, nil, RankIntent{Now: now}, cfg, 10)
if drama[0].Item.ID != "new-drama" || comedy[0].Item.ID != "new-comedy" {
t.Fatalf("personalized first items = drama:%s comedy:%s", drama[0].Item.ID, comedy[0].Item.ID)
}
if drama[0].Explanation.Components["genre"] <= 0 ||
len(drama[0].Explanation.Reasons) == 0 {
t.Fatalf("missing explainable component scores: %#v", drama[0].Explanation)
}
}
func TestWeightedRankRequiresRepeatedAffinityEvidence(t *testing.T) {
now := time.Now()
profile := BuildWeightedProfile([]ViewingEvidence{{
Item: rankedFixture("once", "One Accidental Start", "Movie", []string{"Horror"}, 90),
Completion: .05, OccurredAt: now,
}}, now, time.UTC)
ranked := WeightedRank(profile, []Item{
rankedFixture("horror", "Horror", "Movie", []string{"Horror"}, 90),
rankedFixture("other", "Other", "Movie", []string{"Drama"}, 90),
}, nil, RankIntent{Now: now}, DefaultWeightedConfig(), 10)
for _, value := range ranked {
if value.Explanation.Components["genre"] != 0 {
t.Fatalf("one event established genre affinity: %#v", value.Explanation.Components)
}
}
}
func TestOnboardingRatingCreatesImmediateMetadataAffinity(t *testing.T) {
profile := WeightedProfile{}
rated := rankedFixture(
"rated", "Arrival", "Movie", []string{"Science Fiction", "Drama"}, 116,
)
rated.Studios = []struct {
Name string `json:"Name"`
}{{Name: "Paramount"}}
rated.People = []Person{
{Name: "Amy Adams", Type: "Actor"},
{Name: "Denis Villeneuve", Type: "Director"},
}
profile.ApplyOnboardingRating(rated, 5, 2)
candidate := rankedFixture(
"candidate", "Another Arrival", "Movie", []string{"Science Fiction"}, 120,
)
candidate.Studios = rated.Studios
candidate.People = rated.People
ranked := WeightedRank(
profile, []Item{candidate}, nil, RankIntent{}, DefaultWeightedConfig(), 1,
)
if len(ranked) != 1 {
t.Fatal("rated metadata produced no candidate")
}
components := ranked[0].Explanation.Components
for _, key := range []string{"genre", "studio", "actor", "director"} {
if components[key] <= 0 {
t.Fatalf("%s component = %.3f, want positive: %#v", key, components[key], components)
}
}
if !profile.Seen[rated.ID] {
t.Fatal("the explicitly rated title was not marked seen")
}
}
func TestWeightedRankNewReleaseEligibilityPrecedesPersonalization(t *testing.T) {
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
old := rankedFixture("old", "Perfect Old Match", "Movie", []string{"Drama"}, 90)
old.PremiereDate = "2020-01-01T00:00:00Z"
fresh := rankedFixture("fresh", "Fresh Adjacent", "Movie", []string{"Comedy"}, 90)
fresh.PremiereDate = "2026-07-01T00:00:00Z"
profile := BuildWeightedProfile([]ViewingEvidence{
{Item: rankedFixture("d1", "D1", "Movie", []string{"Drama"}, 90), Completion: 1},
{Item: rankedFixture("d2", "D2", "Movie", []string{"Drama"}, 90), Completion: 1},
}, now, time.UTC)
ranked := WeightedRank(profile, []Item{old, fresh}, nil, RankIntent{
Now: now, NewReleasesOnly: true,
}, DefaultWeightedConfig(), 10)
if len(ranked) != 1 || ranked[0].Item.ID != "fresh" {
t.Fatalf("new release eligibility = %#v", ranked)
}
}
func TestWeightedRankExcludesNegativeAndPenalizesIgnoredImpressions(t *testing.T) {
now := time.Now()
profile := 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{}, ReleasePeriods: map[string]Affinity{},
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{"blocked": true},
TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{},
}
items := []Item{
rankedFixture("ignored", "Ignored", "Movie", nil, 90),
rankedFixture("fresh", "Fresh", "Movie", nil, 90),
rankedFixture("blocked", "Blocked", "Movie", nil, 90),
}
ranked := WeightedRank(profile, items, map[string]ItemExposure{
"ignored": {Impressions: 12},
}, RankIntent{Now: now}, DefaultWeightedConfig(), 10)
if len(ranked) != 2 || ranked[0].Item.ID != "fresh" {
t.Fatalf("fatigue/negative ordering = %#v", ranked)
}
}
func TestWeightedRankSessionFitSupportsBeforeBedIntent(t *testing.T) {
now := time.Date(2026, 7, 31, 23, 0, 0, 0, time.UTC)
slot := currentContextSlot(now, time.UTC)
profile := 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{}, ReleasePeriods: map[string]Affinity{},
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{},
TypicalSessionMins: map[string]float64{slot: 28}, SessionEvidence: map[string]int{slot: 5},
}
ranked := WeightedRank(profile, []Item{
rankedFixture("film", "Long Film", "Movie", nil, 125),
rankedFixture("episode", "One Episode", "Series", nil, 27),
}, nil, RankIntent{Now: now, Location: time.UTC, PreferShort: true}, DefaultWeightedConfig(), 10)
if ranked[0].Item.ID != "episode" {
t.Fatalf("bedtime ordering = %s", ranked[0].Item.ID)
}
}