Publish current app and server
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WeightedConfig is intentionally data, not code: operators can tune the algorithm
|
||||
// without changing its shape or retraining an opaque model.
|
||||
type WeightedConfig struct {
|
||||
MinimumEvidence int
|
||||
ExplorationRate float64
|
||||
MaxPrimaryGenre int
|
||||
MaxLeadPerson int
|
||||
NewReleaseDays int
|
||||
ImpressionFloor int
|
||||
ImpressionPenalty float64
|
||||
IgnoredPenalty float64
|
||||
CompletionWeight float64
|
||||
AbandonmentWeight float64
|
||||
RecencyHalfLifeDays float64
|
||||
CommunityPriorWeight float64
|
||||
HouseholdPriorWeight float64
|
||||
CompatibilityWeight float64
|
||||
ContextWeight float64
|
||||
RuntimeContextWeight float64
|
||||
ExplicitPositiveBoost float64
|
||||
ExplicitNegativeScore float64
|
||||
}
|
||||
|
||||
func DefaultWeightedConfig() WeightedConfig {
|
||||
return WeightedConfig{
|
||||
MinimumEvidence: 2, ExplorationRate: 0.08,
|
||||
MaxPrimaryGenre: 3, MaxLeadPerson: 2, NewReleaseDays: 180,
|
||||
ImpressionFloor: 3, ImpressionPenalty: 0.12, IgnoredPenalty: 0.28,
|
||||
CompletionWeight: 1, AbandonmentWeight: 0.55, RecencyHalfLifeDays: 45,
|
||||
CommunityPriorWeight: 0.35, HouseholdPriorWeight: 0.45,
|
||||
CompatibilityWeight: 0.7, ContextWeight: 0.8, RuntimeContextWeight: 0.65,
|
||||
ExplicitPositiveBoost: 3.5, ExplicitNegativeScore: -1_000,
|
||||
}
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Role string `json:"Role"`
|
||||
}
|
||||
|
||||
// Affinity retains its evidence count so a single accidental play cannot silently
|
||||
// become a durable preference.
|
||||
type Affinity struct {
|
||||
Weight float64 `json:"weight"`
|
||||
Evidence int `json:"evidence"`
|
||||
}
|
||||
|
||||
type WeightedProfile struct {
|
||||
Genres map[string]Affinity `json:"genres,omitempty"`
|
||||
Studios map[string]Affinity `json:"studios,omitempty"`
|
||||
Actors map[string]Affinity `json:"actors,omitempty"`
|
||||
Directors map[string]Affinity `json:"directors,omitempty"`
|
||||
Franchises map[string]Affinity `json:"franchises,omitempty"`
|
||||
RuntimeRanges map[string]Affinity `json:"runtimeRanges,omitempty"`
|
||||
AgeRatings map[string]Affinity `json:"ageRatings,omitempty"`
|
||||
CommunityRatings map[string]Affinity `json:"communityRatings,omitempty"`
|
||||
ReleasePeriods map[string]Affinity `json:"releasePeriods,omitempty"`
|
||||
ContentTypes map[string]Affinity `json:"contentTypes,omitempty"`
|
||||
|
||||
Seen map[string]bool `json:"seen,omitempty"`
|
||||
ExplicitPositive map[string]bool `json:"explicitPositive,omitempty"`
|
||||
ExplicitNegative map[string]bool `json:"explicitNegative,omitempty"`
|
||||
TypicalSessionMins map[string]float64 `json:"typicalSessionMinutes,omitempty"`
|
||||
SessionEvidence map[string]int `json:"sessionEvidence,omitempty"`
|
||||
SourceEvents int `json:"sourceEvents"`
|
||||
}
|
||||
|
||||
type ViewingEvidence struct {
|
||||
Item Item
|
||||
Completion float64
|
||||
Repeat int
|
||||
OccurredAt time.Time
|
||||
SessionMinutes int
|
||||
Favorite bool
|
||||
}
|
||||
|
||||
type OnboardingPreferences struct {
|
||||
Completed bool `json:"completed"`
|
||||
Ratings map[string]int `json:"ratings,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
Studios []string `json:"studios,omitempty"`
|
||||
Actors []string `json:"actors,omitempty"`
|
||||
Directors []string `json:"directors,omitempty"`
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
}
|
||||
|
||||
func (p *WeightedProfile) ApplyOnboarding(preferences OnboardingPreferences, minimumEvidence int) {
|
||||
p.ensureAffinityMaps()
|
||||
if minimumEvidence < 1 {
|
||||
minimumEvidence = 1
|
||||
}
|
||||
add := func(target map[string]Affinity, values []string) {
|
||||
for _, key := range values {
|
||||
key = normalizeDimension(key)
|
||||
if key != "" {
|
||||
target[key] = Affinity{Weight: 0.8, Evidence: minimumEvidence}
|
||||
}
|
||||
}
|
||||
}
|
||||
add(p.Genres, preferences.Genres)
|
||||
add(p.Studios, preferences.Studios)
|
||||
add(p.Actors, preferences.Actors)
|
||||
add(p.Directors, preferences.Directors)
|
||||
add(p.ContentTypes, preferences.ContentTypes)
|
||||
}
|
||||
|
||||
// ApplyOnboardingRating turns a deliberate 1–5 title rating into immediate profile
|
||||
// evidence. Unlike an accidental play, an explicit rating is trusted enough to satisfy
|
||||
// MinimumEvidence on its own.
|
||||
func (p *WeightedProfile) ApplyOnboardingRating(item Item, rating, minimumEvidence int) {
|
||||
p.ensureAffinityMaps()
|
||||
if p.Seen == nil {
|
||||
p.Seen = map[string]bool{}
|
||||
}
|
||||
if item.ID != "" {
|
||||
p.Seen[item.ID] = true
|
||||
}
|
||||
if item.SeriesID != "" {
|
||||
p.Seen[item.SeriesID] = true
|
||||
}
|
||||
if rating < 1 || rating > 5 || rating == 3 {
|
||||
return
|
||||
}
|
||||
if minimumEvidence < 1 {
|
||||
minimumEvidence = 1
|
||||
}
|
||||
// Two stars either side of neutral maps to a strong but bounded ±2.4 signal.
|
||||
total := float64(rating-3) * 1.2
|
||||
for range minimumEvidence {
|
||||
addItemAffinities(p, item, total/float64(minimumEvidence))
|
||||
}
|
||||
}
|
||||
|
||||
// BuildWeightedProfile treats Tracearr/Emby events as evidence. Completion, repetition
|
||||
// and recency affect strength, while Affinity.Evidence enforces the repeated-pattern
|
||||
// threshold at scoring time.
|
||||
func BuildWeightedProfile(events []ViewingEvidence, now time.Time, location *time.Location) WeightedProfile {
|
||||
return BuildWeightedProfileWithConfig(events, now, location, DefaultWeightedConfig())
|
||||
}
|
||||
|
||||
func BuildWeightedProfileWithConfig(
|
||||
events []ViewingEvidence,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
cfg WeightedConfig,
|
||||
) WeightedProfile {
|
||||
if cfg.MinimumEvidence < 1 {
|
||||
cfg = DefaultWeightedConfig()
|
||||
}
|
||||
p := WeightedProfile{
|
||||
Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
|
||||
Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
|
||||
Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
|
||||
AgeRatings: map[string]Affinity{}, CommunityRatings: map[string]Affinity{},
|
||||
ReleasePeriods: map[string]Affinity{},
|
||||
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
|
||||
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{},
|
||||
TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{},
|
||||
}
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
sessionTotals := map[string]float64{}
|
||||
for _, event := range events {
|
||||
if event.Item.ID == "" {
|
||||
continue
|
||||
}
|
||||
p.SourceEvents++
|
||||
p.Seen[event.Item.ID] = event.Completion > 0
|
||||
if event.Item.SeriesID != "" && event.Completion > 0 {
|
||||
p.Seen[event.Item.SeriesID] = true
|
||||
}
|
||||
completion := clamp01(event.Completion)
|
||||
strength := evidenceStrength(completion, cfg)
|
||||
if !event.OccurredAt.IsZero() {
|
||||
ageDays := math.Max(0, now.Sub(event.OccurredAt).Hours()/24)
|
||||
strength *= math.Pow(0.5, ageDays/math.Max(1, cfg.RecencyHalfLifeDays))
|
||||
}
|
||||
if event.Repeat > 1 {
|
||||
strength *= 1 + math.Min(1.2, math.Log2(float64(event.Repeat))*0.45)
|
||||
}
|
||||
if event.Favorite {
|
||||
strength += 0.8
|
||||
p.ExplicitPositive[event.Item.ID] = true
|
||||
}
|
||||
addItemAffinities(&p, event.Item, strength)
|
||||
if event.SessionMinutes > 0 && !event.OccurredAt.IsZero() {
|
||||
slot := contextSlotKey(event.OccurredAt.In(location).Weekday(), dayPart(event.OccurredAt.In(location).Hour()))
|
||||
sessionTotals[slot] += float64(event.SessionMinutes)
|
||||
p.SessionEvidence[slot]++
|
||||
}
|
||||
}
|
||||
for slot, total := range sessionTotals {
|
||||
p.TypicalSessionMins[slot] = total / float64(p.SessionEvidence[slot])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func evidenceStrength(completion float64, cfg WeightedConfig) float64 {
|
||||
switch {
|
||||
case completion >= 0.9:
|
||||
return cfg.CompletionWeight
|
||||
case completion >= 0.5:
|
||||
return cfg.CompletionWeight * 0.45
|
||||
case completion >= 0.15:
|
||||
return -cfg.AbandonmentWeight
|
||||
default:
|
||||
// A very brief start is weak evidence, not a strong dislike.
|
||||
return -0.08
|
||||
}
|
||||
}
|
||||
|
||||
func addItemAffinities(p *WeightedProfile, item Item, weight float64) {
|
||||
for _, value := range item.Genres {
|
||||
addAffinity(p.Genres, value, weight)
|
||||
}
|
||||
for _, value := range item.Studios {
|
||||
addAffinity(p.Studios, value.Name, weight)
|
||||
}
|
||||
for _, person := range item.People {
|
||||
switch strings.ToLower(strings.TrimSpace(person.Type)) {
|
||||
case "actor":
|
||||
addAffinity(p.Actors, person.Name, weight*0.65)
|
||||
case "director":
|
||||
addAffinity(p.Directors, person.Name, weight*0.8)
|
||||
}
|
||||
}
|
||||
addAffinity(p.Franchises, item.Franchise(), weight*0.85)
|
||||
addAffinity(p.RuntimeRanges, runtimeRange(item.RuntimeMinutes()), weight*0.55)
|
||||
addAffinity(p.AgeRatings, item.OfficialRating, weight*0.45)
|
||||
addAffinity(p.CommunityRatings, communityRatingRange(item.CommunityRating), weight*0.35)
|
||||
addAffinity(p.ReleasePeriods, releasePeriod(item.ProductionYear), weight*0.5)
|
||||
addAffinity(p.ContentTypes, item.Type, weight*0.6)
|
||||
}
|
||||
|
||||
// ApplyExplicitPreference lets More Like This / Not for Me influence adjacent titles.
|
||||
// The item itself is always boosted/excluded; metadata still needs repeated negative
|
||||
// actions before it becomes a broader dislike because normal minimum-evidence rules
|
||||
// remain in force.
|
||||
func (p *WeightedProfile) ApplyExplicitPreference(item Item, positive bool) {
|
||||
p.ensureAffinityMaps()
|
||||
if p.ExplicitPositive == nil {
|
||||
p.ExplicitPositive = map[string]bool{}
|
||||
}
|
||||
if p.ExplicitNegative == nil {
|
||||
p.ExplicitNegative = map[string]bool{}
|
||||
}
|
||||
if positive {
|
||||
p.ExplicitPositive[item.ID] = true
|
||||
addItemAffinities(p, item, 1.5)
|
||||
return
|
||||
}
|
||||
p.ExplicitNegative[item.ID] = true
|
||||
addItemAffinities(p, item, -1.2)
|
||||
}
|
||||
|
||||
func (p *WeightedProfile) ensureAffinityMaps() {
|
||||
if p.Genres == nil {
|
||||
p.Genres = map[string]Affinity{}
|
||||
}
|
||||
if p.Studios == nil {
|
||||
p.Studios = map[string]Affinity{}
|
||||
}
|
||||
if p.Actors == nil {
|
||||
p.Actors = map[string]Affinity{}
|
||||
}
|
||||
if p.Directors == nil {
|
||||
p.Directors = map[string]Affinity{}
|
||||
}
|
||||
if p.Franchises == nil {
|
||||
p.Franchises = map[string]Affinity{}
|
||||
}
|
||||
if p.RuntimeRanges == nil {
|
||||
p.RuntimeRanges = map[string]Affinity{}
|
||||
}
|
||||
if p.AgeRatings == nil {
|
||||
p.AgeRatings = map[string]Affinity{}
|
||||
}
|
||||
if p.CommunityRatings == nil {
|
||||
p.CommunityRatings = map[string]Affinity{}
|
||||
}
|
||||
if p.ReleasePeriods == nil {
|
||||
p.ReleasePeriods = map[string]Affinity{}
|
||||
}
|
||||
if p.ContentTypes == nil {
|
||||
p.ContentTypes = map[string]Affinity{}
|
||||
}
|
||||
}
|
||||
|
||||
func addAffinity(values map[string]Affinity, key string, weight float64) {
|
||||
key = normalizeDimension(key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
value := values[key]
|
||||
value.Weight += weight
|
||||
value.Evidence++
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
type ItemExposure struct {
|
||||
Impressions int `json:"impressions"`
|
||||
Focuses int `json:"focuses"`
|
||||
Selects int `json:"selects"`
|
||||
LastShown time.Time `json:"lastShown,omitempty"`
|
||||
}
|
||||
|
||||
type RankIntent struct {
|
||||
ID string
|
||||
ItemTypes []string
|
||||
UnseenOnly bool
|
||||
NewReleasesOnly bool
|
||||
MaxRuntimeMins int
|
||||
PreferShort bool
|
||||
HiddenLibrary bool
|
||||
SearchRelevance map[string]float64
|
||||
HouseholdScores map[string]float64
|
||||
Compatibility map[string]float64
|
||||
Now time.Time
|
||||
Location *time.Location
|
||||
}
|
||||
|
||||
type ScoreExplanation struct {
|
||||
Total float64 `json:"total"`
|
||||
Components map[string]float64 `json:"components"`
|
||||
Reasons []string `json:"reasonCodes"`
|
||||
Exploration bool `json:"exploration,omitempty"`
|
||||
}
|
||||
|
||||
type RankedItem struct {
|
||||
Item Item
|
||||
Explanation ScoreExplanation
|
||||
}
|
||||
|
||||
// WeightedRank applies the same scoring foundation to any page or row. RankIntent only
|
||||
// changes eligibility and emphasis; it never creates a separate recommendation model.
|
||||
func WeightedRank(
|
||||
profile WeightedProfile,
|
||||
candidates []Item,
|
||||
exposures map[string]ItemExposure,
|
||||
intent RankIntent,
|
||||
cfg WeightedConfig,
|
||||
limit int,
|
||||
) []RankedItem {
|
||||
if cfg.MinimumEvidence < 1 {
|
||||
cfg = DefaultWeightedConfig()
|
||||
}
|
||||
now := intent.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
type scored struct {
|
||||
item Item
|
||||
exp ScoreExplanation
|
||||
}
|
||||
values := make([]scored, 0, len(candidates))
|
||||
seenIDs := map[string]bool{}
|
||||
for _, item := range candidates {
|
||||
if item.ID == "" || seenIDs[item.ID] || !eligibleForIntent(profile, item, intent, cfg, now) {
|
||||
continue
|
||||
}
|
||||
seenIDs[item.ID] = true
|
||||
exp := scoreWeightedItem(profile, item, exposures[item.ID], intent, cfg, now)
|
||||
if exp.Total <= cfg.ExplicitNegativeScore/2 {
|
||||
continue
|
||||
}
|
||||
values = append(values, scored{item: item, exp: exp})
|
||||
}
|
||||
sort.SliceStable(values, func(i, j int) bool {
|
||||
if values[i].exp.Total != values[j].exp.Total {
|
||||
return values[i].exp.Total > values[j].exp.Total
|
||||
}
|
||||
return values[i].item.Name < values[j].item.Name
|
||||
})
|
||||
|
||||
out := make([]RankedItem, 0, minPositive(limit, len(values)))
|
||||
genreCounts, peopleCounts := map[string]int{}, map[string]int{}
|
||||
deferred := make([]scored, 0)
|
||||
for _, value := range values {
|
||||
genre := primaryGenre(value.item)
|
||||
person := leadPerson(value.item)
|
||||
if cfg.MaxPrimaryGenre > 0 && genre != "" && genreCounts[genre] >= cfg.MaxPrimaryGenre ||
|
||||
cfg.MaxLeadPerson > 0 && person != "" && peopleCounts[person] >= cfg.MaxLeadPerson {
|
||||
deferred = append(deferred, value)
|
||||
continue
|
||||
}
|
||||
out = append(out, RankedItem{Item: value.item, Explanation: value.exp})
|
||||
genreCounts[genre]++
|
||||
peopleCounts[person]++
|
||||
if limit > 0 && len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, value := range deferred {
|
||||
if limit > 0 && len(out) == limit {
|
||||
break
|
||||
}
|
||||
out = append(out, RankedItem{Item: value.item, Explanation: value.exp})
|
||||
}
|
||||
applyExploration(out, cfg.ExplorationRate)
|
||||
return out
|
||||
}
|
||||
|
||||
func eligibleForIntent(
|
||||
profile WeightedProfile,
|
||||
item Item,
|
||||
intent RankIntent,
|
||||
cfg WeightedConfig,
|
||||
now time.Time,
|
||||
) bool {
|
||||
if profile.ExplicitNegative[item.ID] {
|
||||
return false
|
||||
}
|
||||
if intent.UnseenOnly && (profile.Seen[item.ID] || item.UserData.Played ||
|
||||
item.UserData.PlaybackPositionTicks > 0) {
|
||||
return false
|
||||
}
|
||||
if len(intent.ItemTypes) > 0 && !containsFold(intent.ItemTypes, item.Type) {
|
||||
return false
|
||||
}
|
||||
if intent.MaxRuntimeMins > 0 && item.RuntimeMinutes() > intent.MaxRuntimeMins {
|
||||
return false
|
||||
}
|
||||
if intent.NewReleasesOnly {
|
||||
released, ok := item.ReleaseDate()
|
||||
if !ok || released.After(now) || released.Before(now.AddDate(0, 0, -cfg.NewReleaseDays)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func scoreWeightedItem(
|
||||
profile WeightedProfile,
|
||||
item Item,
|
||||
exposure ItemExposure,
|
||||
intent RankIntent,
|
||||
cfg WeightedConfig,
|
||||
now time.Time,
|
||||
) ScoreExplanation {
|
||||
c := map[string]float64{}
|
||||
reasons := []string{}
|
||||
c["genre"] = affinitySum(profile.Genres, item.Genres, cfg.MinimumEvidence)
|
||||
c["studio"] = affinitySum(profile.Studios, studioNames(item), cfg.MinimumEvidence)
|
||||
c["actor"] = affinitySum(profile.Actors, peopleNames(item, "actor"), cfg.MinimumEvidence)
|
||||
c["director"] = affinitySum(profile.Directors, peopleNames(item, "director"), cfg.MinimumEvidence)
|
||||
c["franchise"] = affinitySum(profile.Franchises, []string{item.Franchise()}, cfg.MinimumEvidence)
|
||||
c["runtime"] = affinitySum(profile.RuntimeRanges, []string{runtimeRange(item.RuntimeMinutes())}, cfg.MinimumEvidence)
|
||||
c["ageRating"] = affinitySum(profile.AgeRatings, []string{item.OfficialRating}, cfg.MinimumEvidence)
|
||||
c["communityRatingAffinity"] = affinitySum(
|
||||
profile.CommunityRatings,
|
||||
[]string{communityRatingRange(item.CommunityRating)},
|
||||
cfg.MinimumEvidence,
|
||||
)
|
||||
c["releasePeriod"] = affinitySum(profile.ReleasePeriods, []string{releasePeriod(item.ProductionYear)}, cfg.MinimumEvidence)
|
||||
c["contentType"] = affinitySum(profile.ContentTypes, []string{item.Type}, cfg.MinimumEvidence)
|
||||
c["communityRating"] = item.CommunityRating / 10 * cfg.CommunityPriorWeight
|
||||
c["household"] = intent.HouseholdScores[item.ID] * cfg.HouseholdPriorWeight
|
||||
c["compatibility"] = intent.Compatibility[item.ID] * cfg.CompatibilityWeight
|
||||
c["searchRelevance"] = intent.SearchRelevance[item.ID]
|
||||
if profile.ExplicitPositive[item.ID] {
|
||||
c["explicit"] = cfg.ExplicitPositiveBoost
|
||||
reasons = append(reasons, "explicit_more_like_this")
|
||||
}
|
||||
if exposure.Impressions >= cfg.ImpressionFloor {
|
||||
ignored := maxInt(0, exposure.Impressions-exposure.Focuses-exposure.Selects)
|
||||
c["impressionFatigue"] = -float64(exposure.Impressions-cfg.ImpressionFloor+1)*cfg.ImpressionPenalty -
|
||||
float64(ignored)*cfg.IgnoredPenalty
|
||||
reasons = append(reasons, "impression_fatigue")
|
||||
}
|
||||
slot := currentContextSlot(now, intent.Location)
|
||||
if profile.SessionEvidence[slot] >= cfg.MinimumEvidence && item.RuntimeMinutes() > 0 {
|
||||
typical := profile.TypicalSessionMins[slot]
|
||||
delta := math.Abs(float64(item.RuntimeMinutes()) - typical)
|
||||
c["sessionFit"] = math.Max(-1, 1-delta/math.Max(20, typical)) * cfg.RuntimeContextWeight
|
||||
if c["sessionFit"] > 0.25 {
|
||||
reasons = append(reasons, "fits_session_length")
|
||||
}
|
||||
}
|
||||
if intent.PreferShort && item.RuntimeMinutes() > 0 {
|
||||
c["rowIntent"] = 1 / math.Max(1, float64(item.RuntimeMinutes())/30)
|
||||
}
|
||||
if intent.HiddenLibrary && !profile.Seen[item.ID] {
|
||||
c["rowIntent"] += 0.7
|
||||
reasons = append(reasons, "relevant_unseen")
|
||||
}
|
||||
for _, key := range []string{"genre", "studio", "actor", "director", "franchise"} {
|
||||
if c[key] > 0.1 {
|
||||
reasons = append(reasons, "affinity_"+strings.ToLower(key))
|
||||
}
|
||||
}
|
||||
if profile.SourceEvents < cfg.MinimumEvidence {
|
||||
reasons = append(reasons, "cold_start_priors")
|
||||
}
|
||||
total := 0.0
|
||||
for _, value := range c {
|
||||
total += value
|
||||
}
|
||||
return ScoreExplanation{Total: total, Components: c, Reasons: uniqueStrings(reasons)}
|
||||
}
|
||||
|
||||
// EnrichRankedItem keeps diagnostics on the backend response while retaining Emby's
|
||||
// original item contract.
|
||||
func EnrichRankedItem(item RankedItem) json.RawMessage {
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(item.Item.Raw, &payload) != nil || payload == nil {
|
||||
payload = map[string]any{"Id": item.Item.ID, "Name": item.Item.Name, "Type": item.Item.Type}
|
||||
}
|
||||
payload["MembyRecommendationScore"] = item.Explanation.Total
|
||||
payload["MembyRecommendationComponents"] = item.Explanation.Components
|
||||
payload["MembyRecommendationReasonCodes"] = item.Explanation.Reasons
|
||||
payload["MembyExploration"] = item.Explanation.Exploration
|
||||
raw, _ := json.Marshal(payload)
|
||||
return raw
|
||||
}
|
||||
|
||||
func affinitySum(values map[string]Affinity, keys []string, minimum int) float64 {
|
||||
score := 0.0
|
||||
for _, key := range keys {
|
||||
value := values[normalizeDimension(key)]
|
||||
if value.Evidence >= minimum {
|
||||
score += value.Weight / math.Sqrt(float64(value.Evidence))
|
||||
}
|
||||
}
|
||||
if len(keys) > 1 {
|
||||
score /= math.Sqrt(float64(len(keys)))
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func applyExploration(items []RankedItem, rate float64) {
|
||||
if len(items) < 4 || rate <= 0 {
|
||||
return
|
||||
}
|
||||
count := int(math.Round(float64(len(items)) * math.Min(0.2, rate)))
|
||||
for n := 0; n < count; n++ {
|
||||
from := len(items) - 1 - n
|
||||
to := minPositive(3+n*5, from)
|
||||
if from <= to {
|
||||
continue
|
||||
}
|
||||
value := items[from]
|
||||
copy(items[to+1:from+1], items[to:from])
|
||||
value.Explanation.Exploration = true
|
||||
value.Explanation.Reasons = append(value.Explanation.Reasons, "adjacent_exploration")
|
||||
items[to] = value
|
||||
}
|
||||
}
|
||||
|
||||
func (i Item) Franchise() string {
|
||||
if value := strings.TrimSpace(i.CollectionName); value != "" {
|
||||
return value
|
||||
}
|
||||
// A conservative fallback only strips common sequel suffixes. It avoids inventing
|
||||
// franchises from unrelated titles that happen to share one word.
|
||||
parts := strings.Fields(i.Name)
|
||||
if len(parts) > 1 {
|
||||
last := strings.Trim(strings.ToLower(parts[len(parts)-1]), ":.-")
|
||||
if isRomanNumeral(last) || strings.HasPrefix(last, "part") {
|
||||
return strings.Join(parts[:len(parts)-1], " ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (i Item) ReleaseDate() (time.Time, bool) {
|
||||
for _, value := range []string{i.PremiereDate, i.DateCreated} {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
if i.ProductionYear > 0 {
|
||||
return time.Date(i.ProductionYear, 1, 1, 0, 0, 0, 0, time.UTC), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func runtimeRange(minutes int) string {
|
||||
switch {
|
||||
case minutes <= 0:
|
||||
return ""
|
||||
case minutes <= 25:
|
||||
return "short"
|
||||
case minutes <= 50:
|
||||
return "episode"
|
||||
case minutes <= 100:
|
||||
return "feature"
|
||||
case minutes <= 150:
|
||||
return "long-feature"
|
||||
default:
|
||||
return "epic"
|
||||
}
|
||||
}
|
||||
|
||||
func releasePeriod(year int) string {
|
||||
switch {
|
||||
case year <= 0:
|
||||
return ""
|
||||
case year < 1980:
|
||||
return "classic"
|
||||
case year < 2000:
|
||||
return "1980s-1990s"
|
||||
case year < 2015:
|
||||
return "2000s-early-2010s"
|
||||
default:
|
||||
return "recent"
|
||||
}
|
||||
}
|
||||
|
||||
func communityRatingRange(rating float64) string {
|
||||
switch {
|
||||
case rating <= 0:
|
||||
return ""
|
||||
case rating < 6:
|
||||
return "under-6"
|
||||
case rating < 7.5:
|
||||
return "6-to-7.4"
|
||||
case rating < 8.5:
|
||||
return "7.5-to-8.4"
|
||||
default:
|
||||
return "8.5-plus"
|
||||
}
|
||||
}
|
||||
|
||||
func currentContextSlot(now time.Time, location *time.Location) string {
|
||||
if location != nil {
|
||||
now = now.In(location)
|
||||
}
|
||||
return contextSlotKey(now.Weekday(), dayPart(now.Hour()))
|
||||
}
|
||||
|
||||
func normalizeDimension(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
func studioNames(item Item) []string {
|
||||
out := make([]string, 0, len(item.Studios))
|
||||
for _, value := range item.Studios {
|
||||
out = append(out, value.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func peopleNames(item Item, kind string) []string {
|
||||
out := []string{}
|
||||
for _, value := range item.People {
|
||||
if strings.EqualFold(value.Type, kind) {
|
||||
out = append(out, value.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func primaryGenre(item Item) string {
|
||||
if len(item.Genres) == 0 {
|
||||
return ""
|
||||
}
|
||||
return normalizeDimension(item.Genres[0])
|
||||
}
|
||||
func leadPerson(item Item) string {
|
||||
for _, value := range item.People {
|
||||
if strings.EqualFold(value.Type, "actor") {
|
||||
return normalizeDimension(value.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func containsFold(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(value, wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen, out := map[string]bool{}, []string{}
|
||||
for _, value := range values {
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func isRomanNumeral(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !strings.ContainsRune("ivxlcdm", r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func minPositive(a, b int) int {
|
||||
if a <= 0 || b < a {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// stableFraction is kept for deterministic future exploration bucketing.
|
||||
func stableFraction(value string) float64 {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(value))
|
||||
return float64(h.Sum32()) / float64(math.MaxUint32)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user