package recommend import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/url" "slices" "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. type fakeSource struct { mu sync.Mutex itemsByFilter map[string][]json.RawMessage similar map[string][]json.RawMessage itemsErr error similarErr error nextUp []json.RawMessage nextUpErr error genreQueries []string similarSeeds []string } type fakeCuratedLibrary struct { byGenre map[string][]json.RawMessage } type fakeGenreLibrary struct { *fakeCuratedLibrary genresByType map[string][]string } func (f *fakeGenreLibrary) LibraryGenres( _ context.Context, itemTypes []string, _ int, ) ([]string, error) { if len(itemTypes) == 0 { return nil, nil } return f.genresByType[itemTypes[0]], nil } 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, _ int, ) ([]json.RawMessage, error) { return nil, nil } func (f *fakeCuratedLibrary) CuratedCandidates( _ context.Context, _ []string, genres, studios []string, _ int, ) ([]json.RawMessage, error) { key := strings.Join(genres, "|") if len(studios) > 0 { key = "studio:" + studios[0] } return f.byGenre[key], nil } func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) { f.mu.Lock() defer f.mu.Unlock() if f.itemsErr != nil { return nil, f.itemsErr } if genres := params.Get("Genres"); genres != "" { f.genreQueries = append(f.genreQueries, genres) } key := params.Get("Filters") 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) { f.mu.Lock() defer f.mu.Unlock() f.similarSeeds = append(f.similarSeeds, itemID) if f.similarErr != nil { return nil, f.similarErr } 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 TestAbandonedShowsRequireAnEmbyNextUpAndStayInTheFirstSeason(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":"finished-first","Name":"Finished First","Type":"Series"}`), json.RawMessage(`{"Id":"unknown-season","Name":"Unknown Season","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("Finished First", 1, 10, 50), session("Deep Show", 2, 8, 60), session("Complete Show", 4, 10, 50), session("Recent Show", 1, 3, 5), } unknown := session("Unknown Season", 1, 2, 30) unknown.SeasonNumber = nil sessions = append(sessions, unknown) nextUp := Decode([]json.RawMessage{ json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`), json.RawMessage(`{"Id":"finished-next","Type":"Episode","SeriesId":"finished-first","ParentIndexNumber":2,"IndexNumber":1,"RunTimeTicks":18000000000}`), json.RawMessage(`{"Id":"unknown-next","Type":"Episode","SeriesId":"unknown-season","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) != 3 { t.Fatalf("pickup candidates = %+v, want three early-show abandonments", got) } byID := map[string]PreparedCandidate{} for _, candidate := range got { byID[candidate.ItemID] = candidate } if byID["early"].RecommendationReason != "You left this in season 1 · pick it up again" { t.Fatalf("season-one reason = %q", byID["early"].RecommendationReason) } if byID["finished-first"].RecommendationReason != "You finished season 1 · season 2 is waiting" { t.Fatalf("season-two waiting reason = %q", byID["finished-first"].RecommendationReason) } if byID["unknown-season"].RecommendationReason != "You left this in season 1 · pick it up again" { t.Fatalf("inferred first-season reason = %q", byID["unknown-season"].RecommendationReason) } } func raw(id, name, itemType string, genres ...string) json.RawMessage { quoted := make([]string, 0, len(genres)) for _, g := range genres { quoted = append(quoted, `"`+g+`"`) } return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType + `","Genres":[` + strings.Join(quoted, ",") + `],"CommunityRating":7.5}`) } func testEngine(source Source) *Engine { engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil))) engine.MinRowItems = 2 return engine } func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) { source := &fakeSource{ itemsByFilter: map[string][]json.RawMessage{ "IsResumable": {raw("ep1", "Good News", "Episode", "Drama")}, "IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")}, "IsFavorite": {raw("m2", "Arrival", "Movie", "Science Fiction")}, "IsUnplayed": { raw("c1", "Blade Runner", "Movie", "Science Fiction"), raw("c2", "Solaris", "Movie", "Science Fiction"), raw("c3", "Barbie", "Movie", "Comedy"), }, }, similar: map[string][]json.RawMessage{ "ep1": {raw("s1", "Devs", "Series", "Drama"), raw("s2", "Mr Robot", "Series", "Drama")}, "m1": {raw("s3", "Foundation", "Series", "Science Fiction"), raw("s4", "Arrival II", "Movie", "Science Fiction")}, }, } rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) if err != nil { t.Fatalf("BuildRows: %v", err) } if len(rows) != 3 { t.Fatalf("expected 2 similar rows + 1 history row, got %d: %+v", len(rows), rowTitles(rows)) } if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") { t.Fatalf("unexpected first row: %+v", rows[0]) } last := rows[len(rows)-1] if last.Kind != "recommended" || last.Title != "Recommended from your watching history" { t.Fatalf("unexpected history row: %+v", last) } if last.ID != "recommended" { t.Fatalf("history row id should be stable, got %q", last.ID) } } 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{ "IsPlayed": { raw("m1", "Dune", "Movie", "Science Fiction"), raw("m2", "Alien", "Movie", "Science Fiction", "Horror"), }, "IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")}, }, } if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err != nil { t.Fatalf("BuildRows: %v", err) } if len(source.genreQueries) != 1 { t.Fatalf("expected a single OR'd genre query, got %v", source.genreQueries) } // Emby reads "|" as OR, so one query covers every top genre. if !strings.HasPrefix(source.genreQueries[0], "Science Fiction") { t.Fatalf("heaviest genre should lead the query, got %q", source.genreQueries[0]) } } func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) { source := &fakeSource{ itemsByFilter: map[string][]json.RawMessage{ "IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")}, }, similar: map[string][]json.RawMessage{ // Emby suggests something the user already finished; it must not appear. "m1": {raw("m1", "Dune", "Movie", "Science Fiction"), raw("s1", "Foundation", "Series", "Science Fiction")}, }, } engine := testEngine(source) 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 _, item := range row.Items { if strings.Contains(string(item), `"Id":"m1"`) { t.Fatalf("row %q contained an already-watched item", row.ID) } } } } 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 TestEpisodeEvidenceUsesParentSeriesMetadata(t *testing.T) { seriesRaw, _ := json.Marshal(map[string]any{ "Id": "series-1", "Name": "The Show", "Type": "Series", "People": []map[string]string{{"Name": "Lead Actor", "Type": "Actor"}}, "Studios": []map[string]string{{"Name": "Great Studio"}}, "Genres": []string{"Drama"}, }) episodeRaw, _ := json.Marshal(map[string]any{ "Id": "episode-1", "Name": "Pilot", "Type": "Episode", "SeriesId": "series-1", "SeriesName": "The Show", }) series := Decode([]json.RawMessage{seriesRaw})[0] episode := Decode([]json.RawMessage{episodeRaw})[0] got := newCatalogueIndex([]Item{series}).evidenceItem(episode) if got.ID != "series-1" || len(got.People) != 1 || len(got.Studios) != 1 || len(got.Genres) != 1 { t.Fatalf("episode evidence was not enriched from its series: %+v", got) } } func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) { source := &fakeSource{ itemsByFilter: map[string][]json.RawMessage{ "IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")}, "IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")}, }, similar: map[string][]json.RawMessage{ "m1": {raw("s1", "Foundation", "Series", "Science Fiction")}, }, } engine := testEngine(source) engine.MinRowItems = 5 rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) if err != nil { t.Fatalf("BuildRows: %v", err) } if len(rows) != 0 { t.Fatalf("expected short rows to be dropped, got %v", rowTitles(rows)) } } func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) { source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}} rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "new"}) if err != nil { t.Fatalf("BuildRows: %v", err) } if len(rows) != 0 { t.Fatalf("a new user should get no rows, got %v", rowTitles(rows)) } if len(source.similarSeeds) != 0 { t.Fatal("no seeds means no similarity lookups should be attempted") } } func TestCuratedShowRowsAndItemsAreOrderedByViewingAffinity(t *testing.T) { source := &fakeSource{ itemsByFilter: map[string][]json.RawMessage{ "IsPlayed": { raw("history", "Funny History", "Episode", "Comedy"), }, }, similar: map[string][]json.RawMessage{}, } engine := testEngine(source) engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{ "Comedy": { raw("comedy-low", "Lower Rated Match", "Series", "Comedy"), raw("comedy-high", "Higher Rated Match", "Series", "Comedy"), }, "Drama": { raw("drama-1", "Drama One", "Series", "Drama"), raw("drama-2", "Drama Two", "Series", "Drama"), }, }} engine.CuratedRows = []CuratedRow{ {ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}}, {ID: "comedy", Title: "Comedy TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Comedy"}}, } rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) if err != nil { t.Fatal(err) } if len(rows) != 2 { t.Fatalf("expected two curated rows, got %v", rowTitles(rows)) } if rows[0].ID != "comedy" || rows[1].ID != "drama" { t.Fatalf("user's comedy affinity should order shelves, got %v", rowTitles(rows)) } if rows[0].Kind != "shows" { t.Fatalf("curated TV shelf kind = %q", rows[0].Kind) } } 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) engine.MinRowItems = 1 engine.Library = &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{ "Drama": {raw("drama", "Strong Drama", "Series", "Drama")}, }} engine.CuratedRows = []CuratedRow{{ ID: "drama", Title: "Drama TV Shows", Kind: "shows", ItemTypes: []string{"Series"}, Genres: []string{"Drama"}, }} rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"}) if err != nil { t.Fatal(err) } if len(rows) != 1 || rows[0].ID != "drama" { t.Fatalf("new profiles should receive quality-ranked curated rows: %+v", rows) } } func TestCatalogueGenresExpandMovieAndShowShelves(t *testing.T) { source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}} engine := testEngine(source) engine.Library = &fakeGenreLibrary{ fakeCuratedLibrary: &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{ "Western": { raw("western-1", "Western One", "Movie", "Western"), raw("western-2", "Western Two", "Movie", "Western"), }, "Reality": { raw("reality-1", "Reality One", "Series", "Reality"), raw("reality-2", "Reality Two", "Series", "Reality"), }, }}, genresByType: map[string][]string{ "Movie": {"Western"}, "Series": {"Reality"}, }, } rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"}) if err != nil { t.Fatal(err) } got := map[string]bool{} for _, row := range rows { got[row.ID] = true } if !got["curated:movies:genre:western"] || !got["curated:shows:genre:reality"] { t.Fatalf("catalogue-backed genre rows missing: %v", rowTitles(rows)) } } func TestDiversifyRankedIsStableAndOnlyReordersWithinBands(t *testing.T) { items := make([]Item, 0, 12) for i := 0; i < 12; i++ { items = append(items, Item{ID: strconv.Itoa(i)}) } first := diversifyRanked(items, "user:day", 5) second := diversifyRanked(items, "user:day", 5) if fmt.Sprint(first) != fmt.Sprint(second) { t.Fatal("same user/day seed must produce stable poster ordering") } for index, item := range first { if index/5 != mustAtoi(t, item.ID)/5 { t.Fatalf("item %s escaped its relevance band: %+v", item.ID, first) } } } func mustAtoi(t *testing.T, value string) int { t.Helper() parsed, err := strconv.Atoi(value) if err != nil { t.Fatal(err) } return parsed } // A failing similarity lookup is one dead row, not a dead home screen. func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) { source := &fakeSource{ itemsByFilter: map[string][]json.RawMessage{ "IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")}, "IsUnplayed": { raw("c1", "Solaris", "Movie", "Science Fiction"), raw("c2", "Blade Runner", "Movie", "Science Fiction"), }, }, similarErr: errors.New("emby is unwell"), } rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) if err != nil { t.Fatalf("BuildRows should not fail: %v", err) } if len(rows) != 1 || rows[0].Kind != "recommended" { t.Fatalf("expected the history row to survive, got %v", rowTitles(rows)) } } func TestBuildRowsFailsWhenHistoryCannotBeRead(t *testing.T) { source := &fakeSource{itemsErr: errors.New("emby down")} if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err == nil { t.Fatal("expected an error when the history queries fail") } } func rowTitles(rows []Row) []string { out := make([]string, 0, len(rows)) for _, row := range rows { out = append(out, row.Title) } return out } func testSeeds(count int) []Seed { seeds := make([]Seed, 0, count) for index := 0; index < count; index++ { id := "s" + strconv.Itoa(index) seeds = append(seeds, Seed{ID: id, Name: "Title " + id}) } return seeds } func seedIDs(seeds []Seed) []string { out := make([]string, 0, len(seeds)) for _, seed := range seeds { out = append(out, seed.ID) } return out } // The head of a household's history barely moves while they work through one series, so // the seeds must move without it. func TestSelectSeedsRotatesWithoutNewHistory(t *testing.T) { seeds := testSeeds(12) days := map[string]bool{} for day := 1; day <= 14; day++ { chosen := selectSeeds(seeds, 12, 3, "u1:2026-08-"+strconv.Itoa(day)) if len(chosen) != 3 { t.Fatalf("day %d chose %d seeds", day, len(chosen)) } days[strings.Join(seedIDs(chosen), ",")] = true } if len(days) < 4 { t.Fatalf("a fortnight produced only %d distinct seed sets: %v", len(days), days) } } // Rotation is within recency bands, never a shuffle of the whole window: the first row is // still anchored to something watched lately. func TestSelectSeedsKeepsRecencyBands(t *testing.T) { seeds := testSeeds(12) for day := 1; day <= 30; day++ { chosen := selectSeeds(seeds, 12, 3, "u1:day-"+strconv.Itoa(day)) for index, seed := range chosen { position, err := strconv.Atoi(strings.TrimPrefix(seed.ID, "s")) if err != nil { t.Fatalf("unexpected seed id %q", seed.ID) } if position < index*4 || position >= (index+1)*4 { t.Fatalf("row %d drew %s from outside its band", index, seed.ID) } } } } // Rows are rebuilt on every cache miss, so the same day must always yield the same seeds. func TestSelectSeedsIsStableWithinADay(t *testing.T) { seeds := testSeeds(20) first := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07")) for attempt := 0; attempt < 5; attempt++ { if got := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07")); !slices.Equal(got, first) { t.Fatalf("same day produced %v then %v", first, got) } } other := seedIDs(selectSeeds(seeds, 12, 3, "u2:2026-08-07")) if slices.Equal(other, first) { t.Log("two users may coincide; only a smoke check") } } // A short history has nothing to rotate: take what there is, newest first. func TestSelectSeedsFallsBackToRecencyWhenShort(t *testing.T) { if got := seedIDs(selectSeeds(testSeeds(2), 12, 3, "u1:day")); !slices.Equal(got, []string{"s0", "s1"}) { t.Fatalf("short history = %v", got) } if got := selectSeeds(nil, 12, 3, "u1:day"); len(got) != 0 { t.Fatalf("no history should seed nothing, got %v", got) } } // A pool that does not divide evenly must not strand its oldest entries. func TestSelectSeedsLastBandTakesTheRemainder(t *testing.T) { seeds := testSeeds(11) reached := map[string]bool{} for day := 1; day <= 40; day++ { chosen := selectSeeds(seeds, 11, 3, "u1:day-"+strconv.Itoa(day)) reached[chosen[2].ID] = true } if !reached["s10"] { t.Fatalf("the oldest seed was never reachable: %v", reached) } }