package recommend import ( "context" "encoding/json" "hash/fnv" "log/slog" "math" "net/url" "sort" "strconv" "strings" "sync" "time" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/tracearr" ) // Row is one horizontal strip on the TV home screen. type Row struct { ID string `json:"id"` Title string `json:"title"` Kind string `json:"kind"` Items []json.RawMessage `json:"items"` } // Source is the slice of the Emby client this package needs, narrowed so tests can // supply a fake without a server. type Source interface { Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error) Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error) } // NextUpSource is optional because the on-demand engine and small test sources do not // need it. The production Emby client implements it; prepared rebuilds use one Next Up // request to prove that an abandoned programme still has an unwatched episode. type NextUpSource interface { NextUp(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error) } // LibrarySource is the imported catalogue. When present, the candidate pool comes from // Postgres instead of Emby, which takes the rebuild off Emby entirely. type LibrarySource interface { LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error) } // CuratedLibrarySource is the optional richer catalogue query used for server-authored // collections. Keeping it separate preserves compatibility with simpler test sources. type CuratedLibrarySource interface { CuratedCandidates( ctx context.Context, itemTypes, genres, studios []string, limit int, ) ([]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) } type BehaviorSource interface { BrowsingCandidates( ctx context.Context, userID string, since time.Time, limit int, ) ([]json.RawMessage, error) } // CuratedRow defines one reusable server-side shelf. Filtering determines membership; // the user's profile determines both item order and shelf order. type CuratedRow struct { ID string Title string Kind string ItemTypes []string Genres []string Studios []string RequireAffinity bool } type Engine struct { source Source log *slog.Logger // Library is optional; nil (or an empty library) falls back to querying Emby. Library LibrarySource Tracearr TracearrSource Behavior BehaviorSource // MinRowItems is the shortest row worth showing. A two-item "Recommended" strip // looks broken next to full rows, so short rows are dropped entirely. MinRowItems int // MaxSimilarRows caps "Because you watched …" rows so the home screen stays a home // screen rather than a wall of near-duplicates. 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. type ForYouOptions struct { // AvailableMinutes is zero for no time limit. AvailableMinutes int } type compatibilityProfile struct { directCodecs map[string]int transcodeCodecs map[string]int } // BuildForYou creates the dedicated, explainable recommendation area. Emby supplies // catalogue metadata, Tracearr supplies completion and real device/playback outcomes, // and Memby's own row analytics supplies browsing intent. func (e *Engine) BuildForYou( ctx context.Context, cred emby.Credentials, username string, options ForYouOptions, ) ([]Row, error) { history, favorites, err := e.gatherSignals(ctx, cred) if err != nil { return nil, err } 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) contextAffinity = e.applyTracearrSignals(&profile, history, sessions) } } browsed := map[string]bool{} if e.Behavior != nil { raws, browseErr := e.Behavior.BrowsingCandidates( ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 30, ) if browseErr != nil { e.log.Warn("browsing signals unavailable", "error", browseErr) } else { for i, item := range Decode(raws) { weight := 0.55 * powDecay(0.92, i) profile.absorbTaste(item, weight) browsed[item.ID] = true } } } genres := profile.TopGenres(4) if len(genres) == 0 { return []Row{}, nil } candidates, ok := e.libraryCandidatesForYou(ctx, cred, genres) if !ok { return []Row{}, nil } compatibility := buildCompatibilityProfile(sessions) items := rankForYouAt( profile, candidates, options.AvailableMinutes, compatibility, e.RowSize, contextAffinity, e.now(), e.Location, ) if len(items) < e.MinRowItems { return []Row{}, nil } raws := make([]json.RawMessage, 0, len(items)) for _, item := range items { reason, compatibilityLabel := explainRecommendation( profile, item, options.AvailableMinutes, compatibility, browsed[item.ID], ) raws = append(raws, enrichRecommendation(item.Raw, reason, compatibilityLabel)) } title := "Top picks for you" if options.AvailableMinutes > 0 { title = "Top picks that fit in " + strconv.Itoa(options.AvailableMinutes) + " minutes" } return []Row{{ ID: "for-you:picks", Title: title, Kind: "for-you", Items: raws, }}, nil } func powDecay(base float64, position int) float64 { return math.Pow(base, float64(position)) } 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 } for i, session := range sessions { if session.Completion() > 0 { profile.SeenTitles[tracearrSeenKey(session)] = true } item, ok := byTitle[session.TitleKey()] if !ok { continue } // Completion distinguishes "finished it twice" from "abandoned after ten // 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( ctx context.Context, cred emby.Credentials, genres []string, ) ([]Item, bool) { if e.Library != nil { raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*12) if err == nil && len(raws) > 0 { return Decode(raws), true } if err != nil { e.log.Warn("for-you library candidates failed; falling back to emby", "error", err) } } result, err := e.source.Items(ctx, cred, url.Values{ "IncludeItemTypes": {"Movie,Series"}, "Recursive": {"true"}, "Filters": {"IsUnplayed"}, "Genres": {strings.Join(genres, "|")}, "SortBy": {"CommunityRating"}, "SortOrder": {"Descending"}, "Limit": {strconv.Itoa(e.RowSize * 8)}, "Fields": {candidateFields + ",MediaStreams,Container"}, "ImageTypeLimit": {"1"}, "EnableImages": {"true"}, "EnableImageTypes": {rowImageTypes}, "EnableUserData": {"true"}, }) if err != nil { e.log.Warn("for-you emby candidates failed", "error", err) return nil, false } return Decode(result.Items), len(result.Items) > 0 } func buildCompatibilityProfile(sessions []tracearr.Session) compatibilityProfile { profile := compatibilityProfile{ directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}, } for _, session := range sessions { if !session.IsTelevisionSession() { continue } for _, codec := range []string{session.SourceVideoCodec, session.SourceAudioCodec} { codec = strings.ToLower(strings.TrimSpace(codec)) if codec == "" { continue } if session.IsTranscode || strings.EqualFold(session.VideoDecision, "transcode") || strings.EqualFold(session.AudioDecision, "transcode") { profile.transcodeCodecs[codec]++ } else { profile.directCodecs[codec]++ } } } return profile } func compatibilityScore(item Item, profile compatibilityProfile) float64 { var score float64 var known int for _, stream := range item.MediaStreams { if !strings.EqualFold(stream.Type, "Video") && !strings.EqualFold(stream.Type, "Audio") { continue } codec := strings.ToLower(strings.TrimSpace(stream.Codec)) direct, transcode := profile.directCodecs[codec], profile.transcodeCodecs[codec] if direct+transcode == 0 { continue } known++ score += float64(direct-transcode) / float64(direct+transcode) } if known == 0 { return 0 } return score / float64(known) } func rankForYou( profile Profile, candidates []Item, 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 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 } seen[candidate.ID] = true base := profile.Score(candidate) if base < 0 { continue } runtime := candidate.RuntimeMinutes() if availableMinutes > 0 && (runtime <= 0 || runtime > availableMinutes) { continue } score := base + compatibilityScore(candidate, compatibility)*1.4 if availableMinutes > 0 { // Prefer a satisfying fit over something dramatically shorter, without // allowing runtime to overwhelm taste. score += float64(runtime) / float64(availableMinutes) * 0.35 } 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 { return ranked[i].score > ranked[j].score } return ranked[i].item.Name < ranked[j].item.Name }) if limit > 0 && len(ranked) > limit { ranked = ranked[:limit] } out := make([]Item, 0, len(ranked)) for _, entry := range ranked { out = append(out, entry.item) } return out } func (e *Engine) now() time.Time { if e.Now != nil { return e.Now() } return time.Now() } func explainRecommendation( profile Profile, item Item, availableMinutes int, compatibility compatibilityProfile, browsed bool, ) (string, string) { top := profile.TopGenres(5) matched := "" for _, wanted := range top { for _, genre := range item.Genres { if strings.EqualFold(wanted, genre) { matched = genre break } } if matched != "" { break } } reasons := make([]string, 0, 3) if browsed { reasons = append(reasons, "You explored this recently") } else if matched != "" { reasons = append(reasons, "Matches your "+matched+" viewing") } else if len(profile.Seeds) > 0 { reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name) } else { reasons = append(reasons, "Matches your recent viewing") } if availableMinutes > 0 && item.RuntimeMinutes() > 0 { reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window") } compatibilityLabel := "" switch score := compatibilityScore(item, compatibility); { case score > 0.2: compatibilityLabel = "Direct plays well on this TV" reasons = append(reasons, compatibilityLabel) case score < -0.2: compatibilityLabel = "May need transcoding on this TV" default: compatibilityLabel = "TV compatibility not yet learned" } return strings.Join(reasons, " · "), compatibilityLabel } func enrichRecommendation(raw json.RawMessage, reason, compatibility string) json.RawMessage { var item map[string]any if err := json.Unmarshal(raw, &item); err != nil { return raw } item["MembyRecommendationReason"] = reason item["MembyCompatibility"] = compatibility enriched, err := json.Marshal(item) if err != nil { return raw } return enriched } // EnrichPreparedRecommendation adds request-time details to a candidate selected from // the prepared PostgreSQL pool. The stored reason remains stable; only the viewer's // current time budget is appended here. func EnrichPreparedRecommendation( raw json.RawMessage, reason, compatibility string, availableMinutes int, ) json.RawMessage { if availableMinutes > 0 { reason += " · fits your " + strconv.Itoa(availableMinutes) + "-minute window" } return enrichRecommendation(raw, reason, compatibility) } func NewEngine(source Source, log *slog.Logger) *Engine { return &Engine{ source: source, log: log, MinRowItems: 4, MaxSimilarRows: 2, RowSize: 20, WeightedConfig: DefaultWeightedConfig(), CuratedRows: []CuratedRow{ { ID: "curated:apple-tv", Title: "Apple TV+ Shows", Kind: "shows", ItemTypes: []string{"Series"}, Studios: []string{"Apple TV+", "Apple TV Plus", "Apple Studios"}, }, { ID: "curated:drama-shows", Title: "Drama Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}, }, { ID: "curated:comedy-shows", Title: "Comedy Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}, }, { ID: "curated:horror-shows", Title: "Horror Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Horror"}, }, // Movie shelves are deliberately numerous definitions but sparse output: // only the user's six strongest matching genres and three strongest studio // families survive buildCuratedRows. movieGenreRow("action", "Action"), movieGenreRow("adventure", "Adventure"), movieGenreRow("animation", "Animation"), movieGenreRow("comedy", "Comedy"), movieGenreRow("crime", "Crime"), movieGenreRow("documentary", "Documentary"), movieGenreRow("drama", "Drama"), movieGenreRow("family", "Family"), movieGenreRow("fantasy", "Fantasy"), movieGenreRow("horror", "Horror"), movieGenreRow("mystery", "Mystery"), movieGenreRow("romance", "Romance"), movieGenreRow("science-fiction", "Science Fiction"), movieGenreRow("thriller", "Thriller"), movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"), movieStudioRow( "disney", "Disney", "Disney", "Walt Disney Pictures", "Walt Disney Animation Studios", ), movieStudioRow("marvel", "Marvel Studios", "Marvel Studios"), movieStudioRow("lucasfilm", "Lucasfilm", "Lucasfilm", "Lucasfilm Ltd."), movieStudioRow( "dreamworks", "DreamWorks", "DreamWorks", "DreamWorks Pictures", "DreamWorks Animation", ), movieStudioRow( "warner-bros", "Warner Bros.", "Warner Bros.", "Warner Bros. Pictures", "Warner Brothers", ), movieStudioRow( "universal", "Universal", "Universal Pictures", "Universal Studios", ), movieStudioRow("a24", "A24", "A24"), movieStudioRow("studio-ghibli", "Studio Ghibli", "Studio Ghibli"), }, } } func movieGenreRow(id, genre string) CuratedRow { return CuratedRow{ ID: "curated:movies:genre:" + id, Title: genre + " Movies", Kind: "movies", ItemTypes: []string{"Movie"}, Genres: []string{genre}, RequireAffinity: true, } } func movieStudioRow(id, title string, studios ...string) CuratedRow { return CuratedRow{ ID: "curated:movies:studio:" + id, Title: "More from " + title, Kind: "movies", ItemTypes: []string{"Movie"}, Studios: studios, RequireAffinity: true, } } const ( 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" ) // BuildRows produces the recommendation rows for one user. // // 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() { for _, seed := range e.seedsFor(profile) { row, ok := e.similarRow(ctx, cred, profile, seed) if ok { rows = append(rows, row) } } if row, ok := e.historyRow(ctx, cred, profile); ok { rows = append(rows, row) } } rows = append(rows, e.buildCuratedRows(ctx, profile, dailySeed(cred.UserID))...) return rows, nil } 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(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 // a row before genre shelves consume all of its films during deduplication. if strings.HasPrefix(definition.ID, "curated:movies:studio:") { affinity *= 3 } ranked := rankedDefinition{ definition: definition, affinity: affinity, order: order, } if definition.RequireAffinity && ranked.affinity <= 0 { continue } definitions = append(definitions, ranked) } sort.SliceStable(definitions, func(i, j int) bool { 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 }) rows := make([]Row, 0, len(definitions)) // 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{}) for _, ranked := range definitions { definition := ranked.definition raws, err := library.CuratedCandidates( ctx, definition.ItemTypes, definition.Genres, definition.Studios, e.RowSize*6, ) if err != nil { e.log.Warn("curated row failed", "row", definition.ID, "error", err) 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 { continue } items = append(items, item) if len(items) == e.RowSize { break } } if len(items) < e.MinRowItems { continue } for _, item := range items { seenItems[item.ID] = struct{}{} } rows = append(rows, Row{ ID: definition.ID, Title: definition.Title, Kind: definition.Kind, Items: Raws(items), }) } 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 ( wg sync.WaitGroup mu sync.Mutex firstErr error ) fetch := func(dest *[]Item, params url.Values) { wg.Add(1) go func() { defer wg.Done() result, fetchErr := e.source.Items(ctx, cred, params) mu.Lock() defer mu.Unlock() if fetchErr != nil { if firstErr == nil { firstErr = fetchErr } return } *dest = Decode(result.Items) }() } // In-progress titles are the strongest signal available, so they lead the history // list and pick up the heaviest recency weights. var resumable, played []Item fetch(&resumable, url.Values{ "Filters": {"IsResumable"}, "IncludeItemTypes": {"Movie,Episode"}, "Recursive": {"true"}, "SortBy": {"DatePlayed"}, "SortOrder": {"Descending"}, // Imported catalogue rows have no user state, so this query is also the // exclusion set. Household libraries are small enough to fetch it completely. "Limit": {"5000"}, "Fields": {historyFields}, "EnableUserData": {"true"}, "EnableImages": {"false"}, }) fetch(&played, url.Values{ "Filters": {"IsPlayed"}, "IncludeItemTypes": {"Movie,Episode,Series"}, "Recursive": {"true"}, "SortBy": {"DatePlayed"}, "SortOrder": {"Descending"}, "Limit": {"5000"}, "Fields": {historyFields}, "EnableUserData": {"true"}, "EnableImages": {"false"}, }) fetch(&favorites, url.Values{ "Filters": {"IsFavorite"}, "IncludeItemTypes": {"Movie,Series"}, "Recursive": {"true"}, "SortBy": {"SortName"}, "Limit": {"40"}, "Fields": {historyFields}, "EnableUserData": {"true"}, "EnableImages": {"false"}, }) wg.Wait() if firstErr != nil { return nil, nil, firstErr } return append(resumable, played...), favorites, nil } func recommendationSessions(sessions []tracearr.Session) []tracearr.Session { out := make([]tracearr.Session, 0, len(sessions)) for _, session := range sessions { // Prerolls are delivery mechanics, not a viewer choice. Treating dozens of // completed prerolls as taste evidence overwhelms real household history. if strings.HasPrefix(session.TitleKey(), "preroll") { continue } out = append(out, session) } return out } func tracearrSeenKey(session tracearr.Session) string { key := session.TitleKey() if key != "" && strings.EqualFold(session.MediaType, "movie") && session.Year != nil && *session.Year > 0 { return key + "|" + strconv.Itoa(*session.Year) } return key } // libraryCandidates reads the pool from the imported library. Returns ok=false when // there is no library, it is empty, or it errors — every one of which means "ask Emby". func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) { if e.Library == nil { return nil, false } raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*6) if err != nil { e.log.Warn("library candidates failed; falling back to emby", "error", err) return nil, false } if len(raws) == 0 { return nil, false } return Decode(raws), true } func (e *Engine) seedsFor(profile Profile) []Seed { if len(profile.Seeds) <= e.MaxSimilarRows { return profile.Seeds } return profile.Seeds[:e.MaxSimilarRows] } // similarRow asks Emby what resembles a title the user just watched. Emby's own // similarity scoring beats anything computed here, so this only filters out the seen. func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile Profile, seed Seed) (Row, bool) { result, err := e.source.Similar(ctx, cred, seed.ID, url.Values{ "UserId": {cred.UserID}, "Limit": {strconv.Itoa(e.RowSize * 2)}, "Fields": {candidateFields}, "ImageTypeLimit": {"1"}, "EnableImageTypes": {rowImageTypes}, "EnableUserData": {"true"}, }) if err != nil { // One dead row should never sink the home screen. e.log.Warn("similar lookup failed", "seed", seed.ID, "error", err) return Row{}, false } items := FilterUnseen(profile, Decode(result.Items), e.RowSize) if len(items) < e.MinRowItems { return Row{}, false } return Row{ ID: "similar:" + seed.ID, Title: "Because you watched " + seed.Name, Kind: "similar", Items: Raws(items), }, 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) { genres := profile.TopGenres(3) if len(genres) == 0 { return Row{}, false } if candidates, ok := e.libraryCandidates(ctx, genres); ok { items := Rank(profile, candidates, e.RowSize) if len(items) < e.MinRowItems { return Row{}, false } return Row{ ID: "recommended", Title: "Recommended from your watching history", Kind: "recommended", Items: Raws(items), }, true } // Emby treats "|" as OR in a Genres filter, so one query covers every top genre. result, err := e.source.Items(ctx, cred, url.Values{ "IncludeItemTypes": {"Movie,Series"}, "Recursive": {"true"}, "Filters": {"IsUnplayed"}, "Genres": {strings.Join(genres, "|")}, "SortBy": {"CommunityRating"}, "SortOrder": {"Descending"}, "Limit": {"120"}, "Fields": {candidateFields}, "ImageTypeLimit": {"1"}, "EnableImages": {"true"}, "EnableImageTypes": {rowImageTypes}, "EnableUserData": {"true"}, }) if err != nil { e.log.Warn("recommendation candidates failed", "error", err) return Row{}, false } items := Rank(profile, Decode(result.Items), e.RowSize) if len(items) < e.MinRowItems { return Row{}, false } return Row{ ID: "recommended", Title: "Recommended from your watching history", Kind: "recommended", Items: Raws(items), }, true }