package recommend import ( "context" "encoding/json" "log/slog" "net/url" "strconv" "strings" "sync" "github.com/ponzischeme89/memby/server/internal/emby" ) // 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) } // 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) } type Engine struct { source Source log *slog.Logger // Library is optional; nil (or an empty library) falls back to querying Emby. Library LibrarySource // 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 } func NewEngine(source Source, log *slog.Logger) *Engine { return &Engine{ source: source, log: log, MinRowItems: 4, MaxSimilarRows: 2, RowSize: 20, } } 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) if profile.IsEmpty() { // A brand-new user has nothing to recommend from. No rows is the honest answer. return nil, nil } rows := make([]Row, 0, e.MaxSimilarRows+1) 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) } return rows, nil } // 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"}, "Limit": {"20"}, "Fields": {historyFields}, "EnableUserData": {"true"}, "EnableImages": {"false"}, }) fetch(&played, url.Values{ "Filters": {"IsPlayed"}, "IncludeItemTypes": {"Movie,Episode"}, "Recursive": {"true"}, "SortBy": {"DatePlayed"}, "SortOrder": {"Descending"}, "Limit": {"60"}, "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 } // 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 }