package api import ( "context" "encoding/json" "testing" "time" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) func TestPersonalizeHomeRowsGraduallyDemotesIgnoredRows(t *testing.T) { rows := []recommend.Row{ {ID: "continue"}, {ID: "ignored"}, {ID: "new-row"}, {ID: "engaged"}, } stats := []store.RowStat{ {RowID: "ignored", Impressions: 20}, // Under the five-impression confidence floor, new-row keeps its authored score. {RowID: "new-row", Impressions: 4}, {RowID: "engaged", Impressions: 20, Focuses: 8, Selects: 3, DwellMs: 120_000}, } got := personalizeHomeRows(rows, stats) want := []string{"continue", "engaged", "new-row", "ignored"} for i, id := range want { if got[i].ID != id { t.Fatalf("row %d = %q, want %q; rows=%+v", i, got[i].ID, id, got) } } } func TestContinueWatchingPromotesShowsAiringToday(t *testing.T) { raw := func(value string) json.RawMessage { return json.RawMessage(value) } items := []json.RawMessage{ raw(`{"Id":"ended","Name":"Old episode","Type":"Episode","SeriesName":"Ended Rewatch"}`), raw(`{"Id":"movie","Name":"Paused Movie","Type":"Movie"}`), raw(`{"Id":"today-1","Name":"Episode 4","Type":"Episode","SeriesName":"The Bear"}`), raw(`{"Id":"today-2","Name":"Episode 2","Type":"Episode","SeriesName":"Abbott Elementary"}`), } schedule := &recommend.Row{Items: []json.RawMessage{ raw(`{"Name":"The Bear","MembyAirDayLabel":"Today"}`), raw(`{"Name":"Tomorrow Show","MembyAirDayLabel":"Tomorrow"}`), raw(`{"Name":"Abbott Elementary","MembyAirDayLabel":"Today"}`), }} got := prioritizeAiringTodayContinue(items, schedule) want := []string{"today-1", "today-2", "ended", "movie"} for index, item := range recommend.Decode(got) { if item.ID != want[index] { t.Fatalf("item %d = %q, want %q", index, item.ID, want[index]) } } } func TestDifferentHistoriesChangeRowAndPosterOrdering(t *testing.T) { now := time.Date(2026, 7, 31, 20, 0, 0, 0, time.UTC) item := func(id, name, genre string) recommend.Item { raw, _ := json.Marshal(map[string]any{ "Id": id, "Name": name, "Type": "Movie", "Genres": []string{genre}, "CommunityRating": 7.0, }) return recommend.Item{ ID: id, Name: name, Type: "Movie", Genres: []string{genre}, CommunityRating: 7, Raw: raw, } } dramaA, dramaB := item("drama-a", "Drama A", "Drama"), item("drama-b", "Drama B", "Drama") comedyA, comedyB := item("comedy-a", "Comedy A", "Comedy"), item("comedy-b", "Comedy B", "Comedy") profileFor := func(genre string) recommend.WeightedProfile { return recommend.BuildWeightedProfile([]recommend.ViewingEvidence{ {Item: item("seen-1", "Seen 1", genre), Completion: 1, OccurredAt: now.Add(-time.Hour)}, {Item: item("seen-2", "Seen 2", genre), Completion: 1, OccurredAt: now.Add(-2 * time.Hour)}, }, now, time.UTC) } pageFor := func(profile recommend.WeightedProfile) []recommend.Row { row := func(id string, items []recommend.Item) recommend.Row { ranked := recommend.WeightedRank( profile, items, nil, recommend.RankIntent{Now: now}, recommend.DefaultWeightedConfig(), 10, ) raws := make([]json.RawMessage, 0, len(ranked)) for _, value := range ranked { raws = append(raws, recommend.EnrichRankedItem(value)) } return recommend.Row{ID: id, Items: raws} } return personalizeRowsByTitleScores([]recommend.Row{ row("drama-row", []recommend.Item{dramaA, dramaB}), row("comedy-row", []recommend.Item{comedyA, comedyB}), row("mixed-row", []recommend.Item{comedyA, dramaA}), }) } dramaPage := pageFor(profileFor("Drama")) comedyPage := pageFor(profileFor("Comedy")) if dramaPage[0].ID != "drama-row" || comedyPage[0].ID != "comedy-row" { t.Fatalf("row orders did not personalize: drama=%s comedy=%s", dramaPage[0].ID, comedyPage[0].ID) } firstMixedID := func(rows []recommend.Row) string { for _, row := range rows { if row.ID == "mixed-row" { return recommend.Decode(row.Items)[0].ID } } return "" } if firstMixedID(dramaPage) != "drama-a" || firstMixedID(comedyPage) != "comedy-a" { t.Fatalf("poster orders did not personalize: drama=%s comedy=%s", firstMixedID(dramaPage), firstMixedID(comedyPage)) } } // Continue Watching answers "what was I watching?", so its order belongs to Emby (and to // mergeContinueWatching). Ranking it by taste buried episodes behind films and pushed a // just-watched show past the visible cards, which read on a television as the series // having vanished. func TestProgressRowsKeepEmbyOrder(t *testing.T) { raw := func(id, name, itemType string) json.RawMessage { value, _ := json.Marshal(map[string]any{"Id": id, "Name": name, "Type": itemType}) return value } items := func() []json.RawMessage { return []json.RawMessage{ raw("episode", "Zulu Company", "Episode"), raw("movie", "A Film", "Movie"), } } rows := (&Server{}).personalizeTitles( context.Background(), store.Session{EmbyUserID: "user"}, []recommend.Row{ {ID: "continue", Items: items()}, {ID: "curated:movies:drama", Items: items()}, }, ) if got := recommend.Decode(rows[0].Items); len(got) != 2 || got[0].ID != "episode" || got[1].ID != "movie" { t.Fatalf("continue row was reordered: %+v", got) } // The same items in a discovery shelf are still ranked, which is what makes the // exemption above a deliberate choice rather than dead code. if discovery := recommend.Decode(rows[1].Items); discovery[0].ID != "movie" { t.Fatalf("discovery row was not ranked: %+v", discovery) } } func TestPersonalizeHomeRowsPreservesColdStartDefaults(t *testing.T) { rows := []recommend.Row{{ID: "continue"}, {ID: "favorites"}, {ID: "latest"}} got := personalizeHomeRows(rows, nil) for i := range rows { if got[i].ID != rows[i].ID { t.Fatalf("cold-start order changed: %+v", got) } } } func TestPreparedHomeRowsPromotesAbandonedShowsAndTimeAwarePicks(t *testing.T) { prepared := []recommend.Row{ { ID: "for-you:pick-up", Title: "old pickup title", Kind: "for-you", Items: []json.RawMessage{json.RawMessage(`{"Id":"abandoned"}`)}, }, { ID: "for-you:picks", Title: "old picks title", Kind: "for-you", Items: []json.RawMessage{json.RawMessage(`{"Id":"pick"}`)}, }, {ID: "for-you:genre:drama", Title: "Drama", Kind: "for-you"}, } rows := preparedHomeForYouRows(prepared, homeForYouWindow{ ID: "afternoon", Title: "An hour for your afternoon", Minutes: 60, }) if len(rows) != 2 { t.Fatalf("home For You rows = %+v", rows) } if rows[0].ID != "for-you:pick-up" || rows[0].Title != "Pick this show up again" { t.Fatalf("pickup row = %+v", rows[0]) } if rows[1].ID != "for-you:home:afternoon" || rows[1].Title != "An hour for your afternoon" { t.Fatalf("time-aware row = %+v", rows[1]) } }