Files
memby/server/internal/recommend/engine.go
T
2026-07-29 15:26:27 +12:00

850 lines
25 KiB
Go

package recommend
import (
"context"
"encoding/json"
"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)
}
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
}
// 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
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)
}
}
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 := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize)
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,
) {
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)
}
}
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 {
type scored struct {
item Item
score float64
}
ranked := make([]scored, 0, len(candidates))
seen := map[string]bool{}
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
}
ranked = append(ranked, scored{item: candidate, score: score})
}
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 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,
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,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,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) {
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return nil, err
}
profile := BuildProfile(history, favorites)
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)...)
return rows, nil
}
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
library, ok := e.Library.(CuratedLibrarySource)
if !ok {
return nil
}
type rankedDefinition struct {
definition CuratedRow
affinity float64
order int
}
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
for order, definition := range e.CuratedRows {
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
}
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{})
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,
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)
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),
})
if strings.HasPrefix(definition.ID, "curated:movies:genre:") {
movieGenreRows++
}
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
movieStudioRows++
}
}
return rows
}
// 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
}
// 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
}