Files
memby/server/internal/recommend/engine.go
T

369 lines
10 KiB
Go
Raw Normal View History

package recommend
import (
"context"
"encoding/json"
"log/slog"
"net/url"
2026-07-27 21:06:51 +12:00
"sort"
"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)
}
2026-07-27 21:06:51 +12:00
// 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)
}
// 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
}
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
2026-07-27 21:06:51 +12:00
CuratedRows []CuratedRow
}
func NewEngine(source Source, log *slog.Logger) *Engine {
return &Engine{
source: source,
log: log,
MinRowItems: 4,
MaxSimilarRows: 2,
RowSize: 20,
2026-07-27 21:06:51 +12:00
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 TV Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Drama"},
},
{
ID: "curated:comedy-shows",
Title: "Comedy TV Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Comedy"},
},
},
}
}
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)
2026-07-27 21:06:51 +12:00
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)
}
}
2026-07-27 21:06:51 +12:00
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
return rows, nil
}
2026-07-27 21:06:51 +12:00
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 {
definitions = append(definitions, rankedDefinition{
definition: definition,
affinity: profile.CollectionAffinity(definition.Genres, definition.Studios),
order: order,
})
}
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))
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
}
items := RankCollection(profile, Decode(raws), e.RowSize)
if len(items) < e.MinRowItems {
continue
}
rows = append(rows, Row{
ID: definition.ID,
Title: definition.Title,
Kind: definition.Kind,
Items: Raws(items),
})
}
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"},
"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
}