2026-07-27 08:16:20 +12:00
|
|
|
package recommend
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/url"
|
2026-07-29 15:26:27 +12:00
|
|
|
"strconv"
|
2026-07-27 08:16:20 +12:00
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
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
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-07-29 15:26:27 +12:00
|
|
|
nextUp []json.RawMessage
|
|
|
|
|
nextUpErr error
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
genreQueries []string
|
|
|
|
|
similarSeeds []string
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
type fakeCuratedLibrary struct {
|
|
|
|
|
byGenre map[string][]json.RawMessage
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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")
|
2026-07-29 15:26:27 +12:00
|
|
|
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
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
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"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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
|
|
|
|
|
}
|