package recommend import ( "context" "encoding/json" "errors" "io" "log/slog" "net/url" "strings" "sync" "testing" "github.com/ponzischeme89/memby/server/internal/emby" ) // 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 genreQueries []string similarSeeds []string } type fakeCuratedLibrary struct { byGenre map[string][]json.RawMessage } 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") return &emby.ItemsResult{Items: f.itemsByFilter[key]}, 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 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 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 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 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) } } // 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 }