Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+500 -19
View File
@@ -4,13 +4,16 @@ 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.
@@ -28,6 +31,13 @@ type Source interface {
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 {
@@ -44,15 +54,29 @@ type CuratedLibrarySource interface {
) ([]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
ID string
Title string
Kind string
ItemTypes []string
Genres []string
Studios []string
RequireAffinity bool
}
type Engine struct {
@@ -60,7 +84,9 @@ type Engine struct {
log *slog.Logger
// Library is optional; nil (or an empty library) falls back to querying Emby.
Library LibrarySource
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.
@@ -72,6 +98,333 @@ type Engine struct {
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,
@@ -89,22 +442,81 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
},
{
ID: "curated:drama-shows",
Title: "Drama TV Shows",
Title: "Drama Shows",
Kind: "shows",
ItemTypes: []string{"Series"},
Genres: []string{"Drama"},
},
{
ID: "curated:comedy-shows",
Title: "Comedy TV 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"
@@ -152,11 +564,22 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
}
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
for order, definition := range e.CuratedRows {
definitions = append(definitions, rankedDefinition{
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: profile.CollectionAffinity(definition.Genres, definition.Studios),
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 {
@@ -166,8 +589,23 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
})
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,
@@ -179,16 +617,35 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
e.log.Warn("curated row failed", "row", definition.ID, "error", err)
continue
}
items := RankCollection(profile, Decode(raws), e.RowSize)
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
}
@@ -227,18 +684,20 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {"20"},
"Fields": {historyFields},
"EnableUserData": {"true"},
"EnableImages": {"false"},
// 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"},
"IncludeItemTypes": {"Movie,Episode,Series"},
"Recursive": {"true"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {"60"},
"Limit": {"5000"},
"Fields": {historyFields},
"EnableUserData": {"true"},
"EnableImages": {"false"},
@@ -261,6 +720,28 @@ func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (hist
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) {
+455 -1
View File
@@ -7,11 +7,14 @@ import (
"io"
"log/slog"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
// fakeSource records the queries the engine makes and replays canned answers.
@@ -22,6 +25,8 @@ type fakeSource struct {
similar map[string][]json.RawMessage
itemsErr error
similarErr error
nextUp []json.RawMessage
nextUpErr error
genreQueries []string
similarSeeds []string
@@ -31,6 +36,36 @@ type fakeCuratedLibrary struct {
byGenre map[string][]json.RawMessage
}
type fakeForYouLibrary struct {
items []json.RawMessage
}
func (f *fakeForYouLibrary) AllRecommendationCandidates(
_ context.Context,
) ([]json.RawMessage, error) {
return f.items, nil
}
func (f *fakeForYouLibrary) LibraryCandidates(
_ context.Context,
_ []string,
_ int,
) ([]json.RawMessage, error) {
return f.items, nil
}
type fakeTracearr struct {
sessions []tracearr.Session
}
func (f fakeTracearr) History(
_ context.Context,
_ string,
_ int,
) ([]tracearr.Session, error) {
return f.sessions, nil
}
func (f *fakeCuratedLibrary) LibraryCandidates(
_ context.Context,
_ []string,
@@ -62,7 +97,11 @@ func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Val
f.genreQueries = append(f.genreQueries, genres)
}
key := params.Get("Filters")
return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil
items := f.itemsByFilter[key]
if limit, err := strconv.Atoi(params.Get("Limit")); err == nil && limit > 0 && len(items) > limit {
items = items[:limit]
}
return &emby.ItemsResult{Items: items}, nil
}
func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) {
@@ -75,6 +114,69 @@ func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID strin
return &emby.ItemsResult{Items: f.similar[itemID]}, nil
}
func (f *fakeSource) NextUp(
_ context.Context,
_ emby.Credentials,
_ url.Values,
) (*emby.ItemsResult, error) {
if f.nextUpErr != nil {
return nil, f.nextUpErr
}
return &emby.ItemsResult{Items: f.nextUp}, nil
}
func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) {
now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
catalogue := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
})
session := func(show string, season, episode, daysAgo int) tracearr.Session {
value := tracearr.Session{
ID: "session-" + show, MediaType: "episode", ShowTitle: show,
SeasonNumber: intPointer(season), EpisodeNumber: intPointer(episode),
Watched: true, StoppedAt: now.AddDate(0, 0, -daysAgo).Format(time.RFC3339Nano),
}
return value
}
sessions := []tracearr.Session{
session("Early Show", 1, 2, 40),
session("Deep Show", 2, 8, 60),
session("Complete Show", 4, 10, 50),
session("Recent Show", 1, 3, 5),
}
nextUp := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
})
got := abandonedShowCandidates(
newCatalogueIndex(catalogue),
sessions,
nextUp,
compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
now,
)
if len(got) != 2 {
t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got)
}
if got[0].ItemID != "deep" || got[1].ItemID != "early" {
t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID)
}
if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" {
t.Fatalf("later-season reason = %q", got[0].RecommendationReason)
}
if got[1].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("season-one reason = %q", got[1].RecommendationReason)
}
if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 {
t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes)
}
}
func raw(id, name, itemType string, genres ...string) json.RawMessage {
quoted := make([]string, 0, len(genres))
for _, g := range genres {
@@ -128,6 +230,117 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
}
}
func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
"IsResumable": {},
"IsPlayed": {
json.RawMessage(`{"Id":"seen","Name":"Arrival","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
},
"IsFavorite": {},
},
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
json.RawMessage(`{"Id":"short","Name":"Moon","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000,"MediaStreams":[{"Type":"Video","Codec":"h264"}]}`),
json.RawMessage(`{"Id":"long","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":93000000000,"MediaStreams":[{"Type":"Video","Codec":"hevc"}]}`),
}}
session := tracearr.Session{
MediaType: "movie",
MediaTitle: "Arrival",
Watched: true,
Platform: "Android TV",
SourceVideoCodec: "h264",
VideoDecision: "directplay",
}
engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
engine.MinRowItems = 1
rows, err := engine.BuildForYou(
context.Background(),
emby.Credentials{UserID: "u1"},
"Matt",
ForYouOptions{AvailableMinutes: 100},
)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || len(rows[0].Items) != 1 {
t.Fatalf("rows = %+v", rows)
}
if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason"`) ||
!strings.Contains(string(rows[0].Items[0]), `100-minute window`) {
t.Fatalf("explanation missing: %s", rows[0].Items[0])
}
if strings.Contains(string(rows[0].Items[0]), `"Id":"long"`) {
t.Fatalf("over-budget item was retained: %s", rows[0].Items[0])
}
}
func TestPrepareForYouKeepsAnOverProvisionedPoolAndSpecificEvidence(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsResumable": {},
"IsPlayed": {
json.RawMessage(`{"Id":"watched","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"UserData":{"Played":true}}`),
},
"IsFavorite": {},
}}
catalogue := []json.RawMessage{
json.RawMessage(`{"Id":"arrival","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"],"RunTimeTicks":69600000000}`),
}
for i := 0; i < 30; i++ {
catalogue = append(catalogue, json.RawMessage(
`{"Id":"candidate-`+strconv.Itoa(i)+`","Name":"Candidate `+strconv.Itoa(i)+
`","Type":"Movie","Genres":["Science Fiction"],"RunTimeTicks":54000000000}`,
))
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: catalogue}
session := tracearr.Session{
ID: "trace-1", ServerID: "server-1", MediaTitle: "Arrival",
MediaType: "movie", Year: intPointer(2016), Watched: true,
StartedAt: "2026-07-20T08:00:00Z",
}
session.User.ID = "trace-user"
session.User.Username = "Matt"
result, err := engine.PrepareForYou(
context.Background(), emby.Credentials{UserID: "emby-user"}, "Matt",
[]tracearr.Session{session},
)
if err != nil {
t.Fatalf("PrepareForYou: %v", err)
}
if len(result.Candidates) < 30 {
t.Fatalf("prepared pool was prematurely row-sized: %d candidates", len(result.Candidates))
}
foundSpecific := false
for _, candidate := range result.Candidates {
if strings.Contains(candidate.RecommendationReason, "Because you finished Arrival") {
foundSpecific = true
break
}
}
if !foundSpecific {
t.Fatal("expected a candidate explanation grounded in the completed Tracearr title")
}
if result.Profile.TracearrUserID != "trace-user" || len(result.Mappings) != 1 {
t.Fatalf("profile/mapping = %+v / %+v", result.Profile, result.Mappings)
}
}
func intPointer(value int) *int { return &value }
func TestStableEvidenceIndexDistributesCandidatesAcrossCompletedTitles(t *testing.T) {
seen := map[int]bool{}
for i := 0; i < 20; i++ {
seen[stableEvidenceIndex("candidate-"+strconv.Itoa(i), 3)] = true
}
if len(seen) != 3 {
t.Fatalf("evidence indices = %+v, want all three sources represented", seen)
}
}
func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
@@ -179,6 +392,123 @@ func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) {
}
}
func TestBuildRowsExcludesPlayedTitlesBeyondTheOldHistoryWindow(t *testing.T) {
played := make([]json.RawMessage, 0, 61)
for i := 0; i < 60; i++ {
played = append(played, raw("history-"+strconv.Itoa(i), "History", "Movie", "Drama"))
}
played = append(played, raw("old-watched", "Old Watched", "Movie", "Drama"))
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{"IsPlayed": played},
similar: map[string][]json.RawMessage{},
}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
raw("old-watched", "Old Watched", "Movie", "Drama"),
raw("new-pick", "New Pick", "Movie", "Drama"),
}}
engine.MinRowItems = 1
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatalf("BuildRows: %v", err)
}
for _, row := range rows {
for _, candidate := range row.Items {
if strings.Contains(string(candidate), `"Id":"old-watched"`) {
t.Fatalf("row %q retained a title older than the previous 60-item exclusion window", row.ID)
}
}
}
}
func TestBuildForYouExcludesTracearrCompletedTitleMissingFromEmbyHistory(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("taste", "Taste", "Movie", "Science Fiction")},
}}
engine := testEngine(source)
engine.Library = &fakeForYouLibrary{items: []json.RawMessage{
json.RawMessage(`{"Id":"watched-copy","Name":"Arrival","Type":"Movie","ProductionYear":2016,"Genres":["Science Fiction"]}`),
raw("unseen", "Moon", "Movie", "Science Fiction"),
}}
year := 2016
session := tracearr.Session{
MediaType: "movie", MediaTitle: "Arrival", Year: &year, Watched: true,
}
engine.Tracearr = fakeTracearr{sessions: []tracearr.Session{session}}
engine.MinRowItems = 1
rows, err := engine.BuildForYou(
context.Background(), emby.Credentials{UserID: "u1"}, "FamilyTV", ForYouOptions{},
)
if err != nil {
t.Fatalf("BuildForYou: %v", err)
}
if len(rows) != 1 || len(rows[0].Items) != 1 ||
!strings.Contains(string(rows[0].Items[0]), `"Id":"unseen"`) {
t.Fatalf("Tracearr-completed title was not excluded: %+v", rows)
}
}
func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Drama": 2, "Science Fiction": 1},
Seen: map[string]bool{}, SeenTitles: map[string]bool{},
}
evidence := map[string][]PreparedEvidence{
"drama": {{
ItemID: "arrival", Title: "Arrival",
Genres: []string{"Drama", "Science Fiction"},
}},
}
counts := map[string]int{}
kinds := map[string]int{}
for i := 0; i < 20; i++ {
candidate := item(
"candidate-"+strconv.Itoa(i), "Candidate", "Movie",
[]string{"Drama", "Science Fiction"}, 7,
)
_, _, kind, _, _ := explainPreparedRecommendation(
profile, candidate, compatibilityProfile{}, false, evidence, counts,
)
kinds[kind]++
}
if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 {
t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"])
}
if kinds["genre"] == 0 {
t.Fatalf("reason kinds were not mixed: %+v", kinds)
}
}
func TestPreparedExplanationRejectsOneBroadGenreAsSpecificEvidence(t *testing.T) {
profile := Profile{
GenreWeights: map[string]float64{"Drama": 2},
Seen: map[string]bool{}, SeenTitles: map[string]bool{},
}
evidence := map[string][]PreparedEvidence{
"drama": {{ItemID: "source", Title: "Source", Genres: []string{"Drama"}}},
}
_, _, kind, _, _ := explainPreparedRecommendation(
profile, item("candidate", "Candidate", "Movie", []string{"Drama"}, 7),
compatibilityProfile{}, false, evidence, map[string]int{},
)
if kind != "genre" {
t.Fatalf("one broad shared genre produced %q, want genre", kind)
}
}
func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
sessions := []tracearr.Session{
{MediaTitle: "PreRoll_Swirls"},
{MediaTitle: "A Real Film"},
}
got := recommendationSessions(sessions)
if len(got) != 1 || got[0].MediaTitle != "A Real Film" {
t.Fatalf("recommendation sessions = %+v", got)
}
}
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
@@ -256,6 +586,130 @@ func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) {
}
}
func TestDefaultCuratedRowsIncludePersonalizedShowGenres(t *testing.T) {
engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
got := map[string]string{}
for _, row := range engine.CuratedRows {
got[row.ID] = row.Title
}
for id, title := range map[string]string{
"curated:comedy-shows": "Comedy Shows",
"curated:drama-shows": "Drama Shows",
"curated:horror-shows": "Horror Shows",
} {
if got[id] != title {
t.Fatalf("%s title = %q, want %q", id, got[id], title)
}
}
}
func TestDefaultCuratedRowsIncludeMovieGenresAndStudioFamilies(t *testing.T) {
engine := NewEngine(&fakeSource{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
got := map[string]CuratedRow{}
for _, row := range engine.CuratedRows {
got[row.ID] = row
}
for _, id := range []string{
"curated:movies:genre:science-fiction",
"curated:movies:genre:animation",
"curated:movies:studio:pixar",
"curated:movies:studio:disney",
} {
row, ok := got[id]
if !ok {
t.Fatalf("missing default movie shelf %q", id)
}
if row.Kind != "movies" || !row.RequireAffinity {
t.Fatalf("movie shelf %q = %+v", id, row)
}
}
}
func TestMovieShelvesRequireAffinityAndRankAStudioBeforeItsGenre(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {
json.RawMessage(`{"Id":"watched","Name":"Toy Story","Type":"Movie","Genres":["Comedy"],"Studios":[{"Name":"Pixar Animation Studios"}]}`),
},
}}
engine := testEngine(source)
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("comedy-1", "Comedy One", "Movie", "Comedy"),
raw("comedy-2", "Comedy Two", "Movie", "Comedy"),
},
"Drama": {
raw("drama-1", "Drama One", "Movie", "Drama"),
raw("drama-2", "Drama Two", "Movie", "Drama"),
},
"studio:Pixar": {
raw("pixar-1", "Pixar One", "Movie", "Animation"),
raw("pixar-2", "Pixar Two", "Movie", "Animation"),
},
}}
engine.CuratedRows = []CuratedRow{
movieGenreRow("comedy", "Comedy"),
movieGenreRow("drama", "Drama"),
movieStudioRow("pixar", "Pixar", "Pixar", "Pixar Animation Studios"),
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
curated := make([]Row, 0, len(rows))
for _, row := range rows {
if strings.HasPrefix(row.ID, "curated:movies:") {
curated = append(curated, row)
}
}
if len(curated) != 2 {
t.Fatalf("movie shelves = %+v, want Pixar and Comedy only", rowTitles(curated))
}
if curated[0].ID != "curated:movies:studio:pixar" ||
curated[1].ID != "curated:movies:genre:comedy" {
t.Fatalf("movie shelf order = %+v", rowTitles(curated))
}
}
func TestCuratedRowsDoNotRepeatCardsAcrossGenres(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{
"IsPlayed": {raw("history", "Funny", "Episode", "Comedy")},
}}
engine := testEngine(source)
engine.MinRowItems = 1
engine.RowSize = 3
engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Comedy": {
raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
raw("comedy", "Comedy Only", "Series", "Comedy"),
},
"Drama": {
raw("shared", "Shared Show", "Series", "Comedy", "Drama"),
raw("drama", "Drama Only", "Series", "Drama"),
},
}}
engine.CuratedRows = []CuratedRow{
{ID: "comedy", Title: "Comedy Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}},
{ID: "drama", Title: "Drama Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}},
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"})
if err != nil {
t.Fatal(err)
}
ids := map[string]int{}
for _, row := range rows {
for _, item := range Decode(row.Items) {
ids[item.ID]++
}
}
if ids["shared"] != 1 {
t.Fatalf("shared card appeared %d times across curated rows", ids["shared"])
}
}
func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
engine := testEngine(source)
+85 -12
View File
@@ -10,7 +10,9 @@ import (
"encoding/json"
"math"
"sort"
"strconv"
"strings"
"unicode"
)
// recencyDecay is applied per position down the history list. At 0.94, the 12th item
@@ -26,14 +28,23 @@ const favoriteWeight = 0.6
// Item is the slice of an Emby item this package reasons about. The raw payload rides
// along so rows can be emitted without re-fetching or re-encoding.
type Item struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
Studios []struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
ProductionYear int `json:"ProductionYear"`
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
RunTimeTicks int64 `json:"RunTimeTicks"`
IndexNumber int `json:"IndexNumber"`
ParentIndexNumber int `json:"ParentIndexNumber"`
Container string `json:"Container"`
MediaStreams []struct {
Type string `json:"Type"`
Codec string `json:"Codec"`
} `json:"MediaStreams"`
Studios []struct {
Name string `json:"Name"`
} `json:"Studios"`
UserData struct {
@@ -46,6 +57,38 @@ type Item struct {
Raw json.RawMessage `json:"-"`
}
func (i Item) RuntimeMinutes() int {
if i.RunTimeTicks <= 0 {
return 0
}
return int(i.RunTimeTicks / 600_000_000)
}
func (i Item) TitleKey() string {
value := i.Name
if i.Type == "Episode" && strings.TrimSpace(i.SeriesName) != "" {
value = i.SeriesName
}
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// SeenKey is a title-level fallback for imported catalogue records, whose payloads
// deliberately contain no per-user UserData. Movies include their year so watching an
// older film does not hide a remake with the same name; episodes collapse to series.
func (i Item) SeenKey() string {
key := i.TitleKey()
if key != "" && strings.EqualFold(i.Type, "Movie") && i.ProductionYear > 0 {
return key + "|" + strconv.Itoa(i.ProductionYear)
}
return key
}
// Seed is a title recent enough to anchor a "Because you watched …" row.
type Seed struct {
ID string
@@ -58,8 +101,9 @@ type Profile struct {
StudioWeights map[string]float64
// Seen holds item ids *and* series ids already watched or in progress, so a
// recommendation never suggests something the user is already partway through.
Seen map[string]bool
Seeds []Seed
Seen map[string]bool
SeenTitles map[string]bool
Seeds []Seed
}
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
@@ -87,12 +131,25 @@ func BuildProfile(history, favorites []Item) Profile {
GenreWeights: map[string]float64{},
StudioWeights: map[string]float64{},
Seen: map[string]bool{},
SeenTitles: map[string]bool{},
}
seedSeen := map[string]bool{}
tasteSeen := map[string]bool{}
for i, item := range history {
weight := math.Pow(recencyDecay, float64(i))
profile.absorb(item, weight)
profile.markSeen(item)
// Several episodes of one series are evidence for one taste, not several
// independent tastes. Keep the newest occurrence's recency weight and still
// mark every item/series identifier as seen.
tasteID := item.ID
if item.SeriesID != "" {
tasteID = item.SeriesID
}
if !tasteSeen[tasteID] {
tasteSeen[tasteID] = true
profile.absorbTaste(item, math.Pow(recencyDecay, float64(i)))
}
// An episode seeds its series, not itself: "Because you watched Severance"
// reads better than "Because you watched Good News".
@@ -113,12 +170,25 @@ func BuildProfile(history, favorites []Item) Profile {
}
func (p *Profile) absorb(item Item, weight float64) {
p.markSeen(item)
p.absorbTaste(item, weight)
}
func (p *Profile) markSeen(item Item) {
if item.ID != "" {
p.Seen[item.ID] = true
}
if item.SeriesID != "" {
p.Seen[item.SeriesID] = true
}
if key := item.SeenKey(); key != "" {
p.SeenTitles[key] = true
}
}
// absorbTaste learns affinity without marking the item watched. This is used for
// browsing signals: lingering on a card is meaningful, but must not hide that card.
func (p *Profile) absorbTaste(item Item, weight float64) {
for _, genre := range item.Genres {
if g := strings.TrimSpace(genre); g != "" {
p.GenreWeights[g] += weight
@@ -168,6 +238,9 @@ func (p Profile) Score(candidate Item) float64 {
if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] {
return -1
}
if p.SeenTitles[candidate.SeenKey()] {
return -1
}
if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 {
return -1
}
+17
View File
@@ -59,6 +59,23 @@ func TestBuildProfileDeduplicatesSeeds(t *testing.T) {
}
}
func TestBuildProfileDoesNotCountEveryEpisodeAsAnotherTasteVote(t *testing.T) {
history := []Item{
episode("ep2", "Second", "series", "Series", []string{"Drama"}),
episode("ep1", "First", "series", "Series", []string{"Drama"}),
item("movie", "Movie", "Movie", []string{"Comedy"}, 0),
}
profile := BuildProfile(history, nil)
if profile.GenreWeights["Drama"] != 1 {
t.Fatalf("repeated series weight = %v, want the newest occurrence only",
profile.GenreWeights["Drama"])
}
if !profile.Seen["ep1"] || !profile.Seen["ep2"] || !profile.Seen["series"] {
t.Fatalf("episode/series exclusions were lost: %+v", profile.Seen)
}
}
func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) {
fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil)
fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)})