2026-07-27 08:16:20 +12:00
|
|
|
package recommend
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
2026-08-02 22:10:19 +12:00
|
|
|
"hash/fnv"
|
2026-07-27 08:16:20 +12:00
|
|
|
"log/slog"
|
2026-07-29 15:26:27 +12:00
|
|
|
"math"
|
2026-07-27 08:16:20 +12:00
|
|
|
"net/url"
|
2026-07-27 21:06:51 +12:00
|
|
|
"sort"
|
2026-07-27 08:16:20 +12:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
2026-07-29 15:26:27 +12:00
|
|
|
"time"
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
2026-07-29 15:26:27 +12:00
|
|
|
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
2026-07-27 08:16:20 +12:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
// CuratedRow defines one reusable server-side shelf. Filtering determines membership;
|
|
|
|
|
// the user's profile determines both item order and shelf order.
|
|
|
|
|
type CuratedRow struct {
|
2026-07-29 15:26:27 +12:00
|
|
|
ID string
|
|
|
|
|
Title string
|
|
|
|
|
Kind string
|
|
|
|
|
ItemTypes []string
|
|
|
|
|
Genres []string
|
|
|
|
|
Studios []string
|
|
|
|
|
RequireAffinity bool
|
2026-07-27 21:06:51 +12:00
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
type Engine struct {
|
|
|
|
|
source Source
|
|
|
|
|
log *slog.Logger
|
|
|
|
|
|
|
|
|
|
// Library is optional; nil (or an empty library) falls back to querying Emby.
|
2026-07-29 15:26:27 +12:00
|
|
|
Library LibrarySource
|
|
|
|
|
Tracearr TracearrSource
|
|
|
|
|
Behavior BehaviorSource
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
// 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
|
2026-08-07 10:44:17 +12:00
|
|
|
// SeedPool is how far back down the watch history those rows may be anchored. It is
|
|
|
|
|
// deliberately several times MaxSimilarRows: the rows are drawn from bands of this
|
|
|
|
|
// window and rotate within them daily, which is what keeps the launcher moving for a
|
|
|
|
|
// household part-way through one series.
|
|
|
|
|
SeedPool int
|
2026-07-27 08:16:20 +12:00
|
|
|
RowSize int
|
2026-07-27 21:06:51 +12:00
|
|
|
CuratedRows []CuratedRow
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
// 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
|
2026-08-02 22:10:19 +12:00
|
|
|
contextAffinity := NewContextAffinityProfile()
|
2026-07-29 15:26:27 +12:00
|
|
|
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)
|
2026-08-02 22:10:19 +12:00
|
|
|
contextAffinity = e.applyTracearrSignals(&profile, history, sessions)
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-02 22:10:19 +12:00
|
|
|
items := rankForYouAt(
|
|
|
|
|
profile, candidates, options.AvailableMinutes, compatibility, e.RowSize,
|
|
|
|
|
contextAffinity, e.now(), e.Location,
|
|
|
|
|
)
|
2026-07-29 15:26:27 +12:00
|
|
|
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,
|
2026-08-02 22:10:19 +12:00
|
|
|
) ContextAffinityProfile {
|
|
|
|
|
contextAffinity := NewContextAffinityProfile()
|
2026-07-29 15:26:27 +12:00
|
|
|
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)
|
2026-08-02 22:10:19 +12:00
|
|
|
if started, ok := parseTracearrTime(session.StartedAt); ok {
|
|
|
|
|
contextAffinity.Add(item, started, session.Completion(), i, e.Location)
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
return contextAffinity
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-02 22:10:19 +12:00
|
|
|
) []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,
|
2026-07-29 15:26:27 +12:00
|
|
|
) []Item {
|
|
|
|
|
type scored struct {
|
2026-08-02 22:10:19 +12:00
|
|
|
item Item
|
|
|
|
|
score float64
|
|
|
|
|
contextRaw float64
|
|
|
|
|
confidence float64
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
ranked := make([]scored, 0, len(candidates))
|
|
|
|
|
seen := map[string]bool{}
|
2026-08-02 22:10:19 +12:00
|
|
|
var maxContext float64
|
2026-07-29 15:26:27 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
func (e *Engine) now() time.Time {
|
|
|
|
|
if e.Now != nil {
|
|
|
|
|
return e.Now()
|
|
|
|
|
}
|
|
|
|
|
return time.Now()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
func NewEngine(source Source, log *slog.Logger) *Engine {
|
|
|
|
|
return &Engine{
|
|
|
|
|
source: source,
|
|
|
|
|
log: log,
|
|
|
|
|
MinRowItems: 4,
|
2026-08-07 10:44:17 +12:00
|
|
|
MaxSimilarRows: 3,
|
|
|
|
|
SeedPool: 12,
|
2026-07-27 08:16:20 +12:00
|
|
|
RowSize: 20,
|
2026-08-02 22:10:19 +12:00
|
|
|
WeightedConfig: DefaultWeightedConfig(),
|
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",
|
2026-07-29 15:26:27 +12:00
|
|
|
Title: "Drama Shows",
|
2026-07-27 21:06:51 +12:00
|
|
|
Kind: "shows",
|
|
|
|
|
ItemTypes: []string{"Series"},
|
|
|
|
|
Genres: []string{"Drama"},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
ID: "curated:comedy-shows",
|
2026-07-29 15:26:27 +12:00
|
|
|
Title: "Comedy Shows",
|
2026-07-27 21:06:51 +12:00
|
|
|
Kind: "shows",
|
|
|
|
|
ItemTypes: []string{"Series"},
|
|
|
|
|
Genres: []string{"Comedy"},
|
|
|
|
|
},
|
2026-07-29 15:26:27 +12:00
|
|
|
{
|
|
|
|
|
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"),
|
2026-07-27 21:06:51 +12:00
|
|
|
},
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
const (
|
2026-08-02 22:10:19 +12:00
|
|
|
historyFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,SeriesName,ProductionYear,PremiereDate,RunTimeTicks"
|
|
|
|
|
candidateFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
|
2026-07-27 08:16:20 +12:00
|
|
|
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) {
|
2026-08-02 22:10:19 +12:00
|
|
|
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) {
|
2026-07-27 08:16:20 +12:00
|
|
|
history, favorites, err := e.gatherSignals(ctx, cred)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
profile := BuildProfile(history, favorites)
|
2026-08-02 22:10:19 +12:00
|
|
|
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)
|
2026-07-27 08:16:20 +12:00
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
variation := dailySeed(cred.UserID)
|
2026-07-27 21:06:51 +12:00
|
|
|
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
|
|
|
|
|
if !profile.IsEmpty() {
|
2026-08-07 10:44:17 +12:00
|
|
|
for _, seed := range e.seedsFor(profile, variation) {
|
|
|
|
|
row, ok := e.similarRow(ctx, cred, profile, seed, variation)
|
2026-07-27 21:06:51 +12:00
|
|
|
if ok {
|
|
|
|
|
rows = append(rows, row)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if row, ok := e.historyRow(ctx, cred, profile); ok {
|
2026-07-27 08:16:20 +12:00
|
|
|
rows = append(rows, row)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-07 10:44:17 +12:00
|
|
|
rows = append(rows, e.buildCuratedRows(ctx, profile, variation)...)
|
2026-07-27 08:16:20 +12:00
|
|
|
return rows, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed string) []Row {
|
2026-07-27 21:06:51 +12:00
|
|
|
library, ok := e.Library.(CuratedLibrarySource)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
curatedDefinitions := append([]CuratedRow(nil), e.CuratedRows...)
|
|
|
|
|
if genres, supportsGenres := e.Library.(GenreLibrarySource); supportsGenres {
|
|
|
|
|
curatedDefinitions = e.catalogueGenreRows(ctx, genres, curatedDefinitions)
|
|
|
|
|
}
|
2026-07-27 21:06:51 +12:00
|
|
|
type rankedDefinition struct {
|
|
|
|
|
definition CuratedRow
|
|
|
|
|
affinity float64
|
|
|
|
|
order int
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
definitions := make([]rankedDefinition, 0, len(curatedDefinitions))
|
|
|
|
|
for order, definition := range curatedDefinitions {
|
2026-07-29 15:26:27 +12:00
|
|
|
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{
|
2026-07-27 21:06:51 +12:00
|
|
|
definition: definition,
|
2026-07-29 15:26:27 +12:00
|
|
|
affinity: affinity,
|
2026-07-27 21:06:51 +12:00
|
|
|
order: order,
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
if definition.RequireAffinity && ranked.affinity <= 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
definitions = append(definitions, ranked)
|
2026-07-27 21:06:51 +12:00
|
|
|
}
|
|
|
|
|
sort.SliceStable(definitions, func(i, j int) bool {
|
|
|
|
|
if definitions[i].affinity != definitions[j].affinity {
|
|
|
|
|
return definitions[i].affinity > definitions[j].affinity
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
left := stableVariation(seed, definitions[i].definition.ID)
|
|
|
|
|
right := stableVariation(seed, definitions[j].definition.ID)
|
|
|
|
|
if left != right {
|
|
|
|
|
return left < right
|
|
|
|
|
}
|
2026-07-27 21:06:51 +12:00
|
|
|
return definitions[i].order < definitions[j].order
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
rows := make([]Row, 0, len(definitions))
|
2026-07-29 15:26:27 +12:00
|
|
|
// 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{})
|
2026-07-27 21:06:51 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
|
2026-08-02 22:10:19 +12:00
|
|
|
rankedItems = diversifyRanked(rankedItems, seed+":"+definition.ID, 5)
|
2026-07-29 15:26:27 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 21:06:51 +12:00
|
|
|
if len(items) < e.MinRowItems {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
for _, item := range items {
|
|
|
|
|
seenItems[item.ID] = struct{}{}
|
|
|
|
|
}
|
2026-07-27 21:06:51 +12:00
|
|
|
rows = append(rows, Row{
|
|
|
|
|
ID: definition.ID,
|
|
|
|
|
Title: definition.Title,
|
|
|
|
|
Kind: definition.Kind,
|
|
|
|
|
Items: Raws(items),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return rows
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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"},
|
2026-07-29 15:26:27 +12:00
|
|
|
// 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"},
|
2026-07-27 08:16:20 +12:00
|
|
|
})
|
|
|
|
|
fetch(&played, url.Values{
|
|
|
|
|
"Filters": {"IsPlayed"},
|
2026-07-29 15:26:27 +12:00
|
|
|
"IncludeItemTypes": {"Movie,Episode,Series"},
|
2026-07-27 08:16:20 +12:00
|
|
|
"Recursive": {"true"},
|
|
|
|
|
"SortBy": {"DatePlayed"},
|
|
|
|
|
"SortOrder": {"Descending"},
|
2026-07-29 15:26:27 +12:00
|
|
|
"Limit": {"5000"},
|
2026-07-27 08:16:20 +12:00
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
func (e *Engine) seedsFor(profile Profile, variation string) []Seed {
|
|
|
|
|
return selectSeeds(profile.Seeds, e.SeedPool, e.MaxSimilarRows, variation)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// selectSeeds chooses which watched titles anchor "Because you watched …" rows.
|
|
|
|
|
//
|
|
|
|
|
// Taking the freshest few is the obvious rule and is also why the launcher went stale: the
|
|
|
|
|
// head of history is a resumable title and the series somebody is part-way through, and
|
|
|
|
|
// neither moves for weeks — so the same two rows came back day after day. Instead the
|
|
|
|
|
// recent window is cut into equal bands and one seed is drawn from each by a variation
|
|
|
|
|
// that changes daily. Three properties are the point and are unit-tested:
|
|
|
|
|
//
|
|
|
|
|
// - The bands are in recency order, so the first row is still anchored to something
|
|
|
|
|
// watched lately and the rows under it reach further back. It is a rotation within
|
|
|
|
|
// bands, never a shuffle of the whole window.
|
|
|
|
|
// - The same day always yields the same seeds. Rows are rebuilt on every cache miss and
|
|
|
|
|
// a set of rows that re-picked each time would change under somebody browsing.
|
|
|
|
|
// - Nothing new has to be watched for the launcher to move on.
|
|
|
|
|
func selectSeeds(seeds []Seed, pool, max int, variation string) []Seed {
|
|
|
|
|
if max <= 0 || len(seeds) == 0 {
|
|
|
|
|
return nil
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
2026-08-07 10:44:17 +12:00
|
|
|
if pool < max {
|
|
|
|
|
pool = max
|
|
|
|
|
}
|
|
|
|
|
if pool > len(seeds) {
|
|
|
|
|
pool = len(seeds)
|
|
|
|
|
}
|
|
|
|
|
if pool <= max {
|
|
|
|
|
return append([]Seed(nil), seeds[:pool]...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
band := pool / max
|
|
|
|
|
out := make([]Seed, 0, max)
|
|
|
|
|
for index := 0; index < max; index++ {
|
|
|
|
|
start := index * band
|
|
|
|
|
end := start + band
|
|
|
|
|
// The last band takes the remainder, so a pool that does not divide evenly is
|
|
|
|
|
// still drawn from in full rather than having its oldest entries stranded.
|
|
|
|
|
if index == max-1 {
|
|
|
|
|
end = pool
|
|
|
|
|
}
|
|
|
|
|
best := start
|
|
|
|
|
for candidate := start + 1; candidate < end; candidate++ {
|
|
|
|
|
if stableVariation(variation, seeds[candidate].ID) <
|
|
|
|
|
stableVariation(variation, seeds[best].ID) {
|
|
|
|
|
best = candidate
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out = append(out, seeds[best])
|
|
|
|
|
}
|
|
|
|
|
return out
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// similarRow asks Emby what resembles a title the user just watched. Emby's own
|
2026-08-07 10:44:17 +12:00
|
|
|
// similarity scoring beats anything computed here, so this only filters out the seen and
|
|
|
|
|
// varies the order within relevance bands, the way curated shelves do — a row anchored to
|
|
|
|
|
// the same seed on two consecutive days must not present the same posters in the same
|
|
|
|
|
// order, or rotating the seeds is the only thing that ever changes.
|
|
|
|
|
func (e *Engine) similarRow(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
cred emby.Credentials,
|
|
|
|
|
profile Profile,
|
|
|
|
|
seed Seed,
|
|
|
|
|
variation string,
|
|
|
|
|
) (Row, bool) {
|
2026-07-27 08:16:20 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-07 10:44:17 +12:00
|
|
|
items = diversifyRanked(items, variation+":similar:"+seed.ID, 5)
|
2026-07-27 08:16:20 +12:00
|
|
|
return Row{
|
|
|
|
|
ID: "similar:" + seed.ID,
|
|
|
|
|
Title: "Because you watched " + seed.Name,
|
|
|
|
|
Kind: "similar",
|
|
|
|
|
Items: Raws(items),
|
|
|
|
|
}, true
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
// 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
|
2026-08-06 22:33:56 +12:00
|
|
|
// never explain a taste the engine does not hold. Every part of it degrades rather than
|
|
|
|
|
// fails: the only error this returns is the viewer having gone away, because a detail
|
|
|
|
|
// page that cannot explain itself still has to open, and a carousel is worth having
|
|
|
|
|
// without a reason strip above it.
|
2026-08-02 22:10:19 +12:00
|
|
|
func (e *Engine) RelatedTo(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
cred emby.Credentials,
|
|
|
|
|
item Item,
|
|
|
|
|
limit int,
|
|
|
|
|
) (reasons []string, related []Item, err error) {
|
2026-08-06 22:33:56 +12:00
|
|
|
if err := ctx.Err(); err != nil {
|
2026-08-02 22:10:19 +12:00
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
if limit <= 0 {
|
|
|
|
|
limit = e.RowSize
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
|
|
|
|
|
// The profile is the expensive half and the optional one: it costs the same Emby
|
|
|
|
|
// fan-out the home rows pay for, and everything below still works without it. Why
|
|
|
|
|
// falls back to catalogue facts and FilterUnseen keeps everything, so a household
|
|
|
|
|
// whose history request timed out gets a carousel instead of a failed page.
|
|
|
|
|
var profile Profile
|
|
|
|
|
history, favorites, signalErr := e.gatherSignals(ctx, cred)
|
|
|
|
|
switch {
|
|
|
|
|
case signalErr != nil && ctx.Err() != nil:
|
|
|
|
|
return nil, nil, ctx.Err()
|
|
|
|
|
case signalErr != nil:
|
|
|
|
|
e.log.Warn(
|
|
|
|
|
"related profile unavailable; answering without one",
|
|
|
|
|
"item", item.ID, "error", signalErr,
|
|
|
|
|
)
|
|
|
|
|
default:
|
|
|
|
|
profile = BuildProfile(history, favorites)
|
|
|
|
|
}
|
|
|
|
|
reasons = Why(profile, item, ReasonLimit)
|
|
|
|
|
|
|
|
|
|
return reasons, e.relatedCandidates(ctx, cred, profile, item, limit), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// relatedCandidates fills the carousel from the best source that answers, in falling
|
|
|
|
|
// order of quality: Emby's own similarity ranking, then the imported catalogue's titles
|
|
|
|
|
// in the same genres. The second exists because the first has two failure modes that
|
|
|
|
|
// look identical on a TV — Emby erroring, and Emby honestly knowing nothing similar
|
|
|
|
|
// about a title nobody has tagged — and an empty strip on a detail page reads as broken
|
|
|
|
|
// either way.
|
|
|
|
|
func (e *Engine) relatedCandidates(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
cred emby.Credentials,
|
|
|
|
|
profile Profile,
|
|
|
|
|
item Item,
|
|
|
|
|
limit int,
|
|
|
|
|
) []Item {
|
|
|
|
|
var candidates []Item
|
|
|
|
|
result, err := e.source.Similar(ctx, cred, item.ID, url.Values{
|
2026-08-02 22:10:19 +12:00
|
|
|
"UserId": {cred.UserID},
|
|
|
|
|
"Limit": {strconv.Itoa(limit * 2)},
|
|
|
|
|
"Fields": {candidateFields},
|
|
|
|
|
"ImageTypeLimit": {"1"},
|
|
|
|
|
"EnableImages": {"true"},
|
|
|
|
|
"EnableImageTypes": {rowImageTypes},
|
|
|
|
|
"EnableUserData": {"true"},
|
|
|
|
|
})
|
2026-08-06 22:33:56 +12:00
|
|
|
switch {
|
|
|
|
|
case err != nil && ctx.Err() != nil:
|
|
|
|
|
return nil
|
|
|
|
|
case err != nil:
|
|
|
|
|
e.log.Warn("related lookup failed", "item", item.ID, "error", err)
|
|
|
|
|
case result != nil:
|
|
|
|
|
candidates = excludeItem(Decode(result.Items), item.ID)
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
related := FilterUnseen(profile, candidates, limit)
|
2026-08-02 22:10:19 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
if len(related) >= e.MinRowItems {
|
|
|
|
|
return related
|
|
|
|
|
}
|
|
|
|
|
if neighbours := e.genreNeighbours(ctx, profile, item, limit); len(neighbours) > len(related) {
|
|
|
|
|
return neighbours
|
|
|
|
|
}
|
|
|
|
|
return related
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// genreNeighbours is the answer of last resort: the household's own catalogue, in this
|
|
|
|
|
// title's genres, best rated first. It reads from Postgres rather than Emby, so it is
|
|
|
|
|
// also the only half of this that still works while Emby is the thing that is down.
|
|
|
|
|
func (e *Engine) genreNeighbours(ctx context.Context, profile Profile, item Item, limit int) []Item {
|
|
|
|
|
if e.Library == nil || len(item.Genres) == 0 || ctx.Err() != nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
raws, err := e.Library.LibraryCandidates(ctx, item.Genres, limit*6)
|
|
|
|
|
if err != nil {
|
|
|
|
|
e.log.Warn("related genre fallback failed", "item", item.ID, "error", err)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
candidates := excludeItem(Decode(raws), item.ID)
|
|
|
|
|
if unseen := FilterUnseen(profile, candidates, limit); len(unseen) > 0 {
|
|
|
|
|
return unseen
|
|
|
|
|
}
|
|
|
|
|
return trim(candidates, limit)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// excludeItem drops the title the page is about. Emby occasionally returns it in its own
|
|
|
|
|
// similarity list, and the imported catalogue always does.
|
|
|
|
|
func excludeItem(items []Item, id string) []Item {
|
|
|
|
|
out := make([]Item, 0, len(items))
|
|
|
|
|
for _, candidate := range items {
|
|
|
|
|
if candidate.ID == id {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
out = append(out, candidate)
|
|
|
|
|
}
|
|
|
|
|
return out
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func trim(items []Item, limit int) []Item {
|
|
|
|
|
if limit > 0 && len(items) > limit {
|
|
|
|
|
return items[:limit]
|
|
|
|
|
}
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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
|
|
|
|
|
}
|