package api import ( "context" "encoding/json" "net/http" "net/url" "sort" "strings" "sync" "time" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/store" ) func recommendationRow(id string) bool { return id == "recommended" || strings.HasPrefix(id, "for-you:") || strings.HasPrefix(id, "curated:") || strings.HasPrefix(id, "similar:") } // filterRecommendationPermissions makes Emby, using this viewer's token, the final // eligibility authority. The shared imported catalogue can suggest candidates but can // never broaden library access or bypass parental controls. func (s *Server) filterRecommendationPermissions( ctx context.Context, sess store.Session, rows []recommend.Row, ) []recommend.Row { ids := []string{} for _, row := range rows { if !recommendationRow(row.ID) { continue } for _, item := range recommend.Decode(row.Items) { ids = append(ids, item.ID) } } if len(ids) == 0 { return rows } // The batches are independent questions about disjoint sets of ids, and there can be // several of them on a launcher full of recommendations. Asking Emby them one after // another put the whole of that latency on the tail of the home response; asking // together costs Emby the same work and the viewer one batch's wait. // // There is deliberately no cap on how many run at once. The count is a hundredth of // the recommendation candidate pool, which is itself bounded, and the Emby client's // transport already keeps a warm pool of connections for them to share. allowed := map[string]bool{} cred := credentials(sess) var ( mu sync.Mutex failed bool wg sync.WaitGroup ) for start := 0; start < len(ids); start += 100 { end := min(start+100, len(ids)) batch := ids[start:end] wg.Add(1) go func() { defer wg.Done() result, err := s.emby.Items(ctx, cred, rowParams(url.Values{ "Ids": {strings.Join(batch, ",")}, "Recursive": {"true"}, "IncludeItemTypes": {"Movie,Series"}, "Limit": {itoa(len(batch))}, }, fieldsRow)) mu.Lock() defer mu.Unlock() if err != nil { s.loggerFor(ctx).Warn("recommendation permission check failed; hiding candidates", "user", sess.EmbyUserID, "error", err) failed = true return } for _, raw := range result.Items { decoded := recommend.Decode([]json.RawMessage{raw}) if len(decoded) == 1 { allowed[decoded[0].ID] = true } } }() } wg.Wait() // One failed batch hides every candidate, exactly as it did when the loop returned // early: this is Emby being the final authority on what a viewer may see, and a // partial answer is not evidence that the rest is permitted. if failed { for index := range rows { if recommendationRow(rows[index].ID) { rows[index].Items = []json.RawMessage{} } } return rows } for index := range rows { if !recommendationRow(rows[index].ID) { continue } filtered := []json.RawMessage{} for _, item := range recommend.Decode(rows[index].Items) { if allowed[item.ID] { filtered = append(filtered, item.Raw) } } rows[index].Items = filtered } return rows } // rankingContext gathers everything the weighted ranker needs about one viewer: their // learned profile, what they have already been shown, and how the household as a whole // has been getting on with the library. // // It is seven Postgres reads and it used to make all seven one after another, on the tail // of the home response — after the Emby fan-out had finished, so nothing else was in // flight and every one of them was pure added latency. Six of the seven depend on nothing // but the viewer's id. They are now issued together, which turns the sum of seven round // trips into roughly the slowest one. // // The two that are genuinely a chain stay a chain — an onboarding document names the // items its ratings refer to, and the actions name theirs — but the two chains run beside // each other, and their results are applied to the profile afterwards in the order they // were applied before. Ordering the *writes* rather than the reads is the whole trick: // ApplyOnboarding and ApplyExplicitPreference are not commutative and this must not // become a place where the ranking depends on which query answered first. func (s *Server) rankingContext( ctx context.Context, userID string, ) (recommend.WeightedProfile, map[string]recommend.ItemExposure, map[string]float64) { profile := recommend.WeightedProfile{} if s.store == nil { return profile, nil, nil } var ( wg sync.WaitGroup profileRaw json.RawMessage onboarding recommend.OnboardingPreferences onboardingOK bool onboardingItems []json.RawMessage actions []store.RecommendationAction actionItems []json.RawMessage exposureValues []store.ItemExposureStat household map[string]float64 ) run := func(fn func()) { wg.Add(1) go func() { defer wg.Done() fn() }() } run(func() { raw, err := s.store.WeightedRecommendationProfile(ctx, userID) if err != nil { s.loggerFor(ctx).Warn("weighted profile unavailable", "user", userID, "error", err) return } profileRaw = raw }) run(func() { raw, err := s.store.RecommendationOnboarding(ctx, userID) if err != nil { return } var preferences recommend.OnboardingPreferences if json.Unmarshal(raw, &preferences) != nil { return } onboarding, onboardingOK = preferences, true ids := make([]string, 0, len(preferences.Ratings)) for id, rating := range preferences.Ratings { if strings.TrimSpace(id) != "" && rating >= 1 && rating <= 5 { ids = append(ids, id) } } if len(ids) == 0 { return } if raws, err := s.store.LibraryItemsByID(ctx, ids); err == nil { onboardingItems = raws } }) run(func() { values, err := s.store.RecommendationActions(ctx, userID) if err != nil { return } actions = values ids := make([]string, 0, len(values)) for _, action := range values { ids = append(ids, action.ItemID) } if len(ids) == 0 { return } if raws, err := s.store.LibraryItemsByID(ctx, ids); err == nil { actionItems = raws } }) run(func() { values, err := s.store.UserItemExposures(ctx, userID, time.Now().Add(-45*24*time.Hour)) if err == nil { exposureValues = values } }) run(func() { household = s.householdCompletionScores(ctx) }) wg.Wait() if len(profileRaw) > 0 { _ = json.Unmarshal(profileRaw, &profile) } if profile.ExplicitPositive == nil { profile.ExplicitPositive = map[string]bool{} } if profile.ExplicitNegative == nil { profile.ExplicitNegative = map[string]bool{} } if onboardingOK { evidence := s.weightedConfig().MinimumEvidence profile.ApplyOnboarding(onboarding, evidence) for _, item := range recommend.Decode(onboardingItems) { profile.ApplyOnboardingRating(item, onboarding.Ratings[item.ID], evidence) } } if len(actions) > 0 { byID := make(map[string]string, len(actions)) for _, action := range actions { byID[action.ItemID] = action.Action switch action.Action { case "more_like_this": profile.ExplicitPositive[action.ItemID] = true case "not_for_me": profile.ExplicitNegative[action.ItemID] = true } } for _, item := range recommend.Decode(actionItems) { profile.ApplyExplicitPreference(item, byID[item.ID] == "more_like_this") } } exposures := make(map[string]recommend.ItemExposure, len(exposureValues)) for _, value := range exposureValues { exposures[value.ItemID] = recommend.ItemExposure{ Impressions: value.Impressions, Focuses: value.Focuses, Selects: value.Selects, LastShown: value.LastShown, } } if household == nil { household = map[string]float64{} } return profile, exposures, household } // householdCompletionScoreTTL is how long one reading of the household's completion // scores is reused. // // The query aggregates six months of viewing across everybody, it is the heaviest read // in rankingContext, and its answer is *the same for every viewer in the house* — so // four televisions refreshing their launchers were running four copies of one // six-month aggregate, none of which could have produced a different number. A minute // is chosen against what it measures: this moves when somebody finishes something, and // nothing on a launcher is different for having learned that a minute sooner. const householdCompletionScoreTTL = time.Minute // householdScores caches that reading. Coalesced as well as cached, so the request that // finds it expired is the only one that pays for the refresh rather than the first of // several that all do. type householdScores struct { mu sync.Mutex value map[string]float64 fetched time.Time } func (s *Server) householdCompletionScores(ctx context.Context) map[string]float64 { s.household.mu.Lock() defer s.household.mu.Unlock() if s.household.value != nil && time.Since(s.household.fetched) < householdCompletionScoreTTL { return s.household.value } scores, err := s.store.HouseholdCompletionScores(ctx, time.Now().Add(-180*24*time.Hour)) if err != nil { // A failed read must not be cached as an empty household: that would suppress // the signal for a whole minute on the strength of one timeout. The previous // reading is the better answer where there is one. if s.household.value != nil { return s.household.value } return map[string]float64{} } s.household.value = scores s.household.fetched = time.Now() return scores } // rankingInputs is one viewer's ranking evidence, gathered. // // It exists so the gathering can happen somewhere other than immediately before the // ranking. On the home path the evidence depends on nothing but the viewer's id, while // the rows it will be applied to take seconds to arrive from Emby — so it is read // beside them rather than after them, and by the time there is anything to rank it is // already in hand. See handleHome. type rankingInputs struct { profile recommend.WeightedProfile exposures map[string]recommend.ItemExposure household map[string]float64 } func (s *Server) rankingInputs(ctx context.Context, userID string) rankingInputs { profile, exposures, household := s.rankingContext(ctx, userID) return rankingInputs{profile: profile, exposures: exposures, household: household} } func (s *Server) personalizeTitles( ctx context.Context, sess store.Session, rows []recommend.Row, ) []recommend.Row { if len(rows) == 0 { return rows } return s.personalizeTitlesWith(rows, s.rankingInputs(ctx, sess.EmbyUserID)) } // personalizeTitlesWith is the ranking itself, over evidence somebody else gathered. func (s *Server) personalizeTitlesWith( rows []recommend.Row, inputs rankingInputs, ) []recommend.Row { if len(rows) == 0 { return rows } profile, exposures, household := inputs.profile, inputs.exposures, inputs.household cfg := s.weightedConfig() now := time.Now() location := s.householdLocation() for index := range rows { row := &rows[index] if progressRow(row.ID) { continue } compatibility := map[string]float64{} for _, raw := range row.Items { var marker struct { ID string `json:"Id"` Compatibility string `json:"MembyCompatibility"` } if json.Unmarshal(raw, &marker) == nil { switch { case strings.Contains(strings.ToLower(marker.Compatibility), "direct"): compatibility[marker.ID] = 1 case strings.Contains(strings.ToLower(marker.Compatibility), "transcod"): compatibility[marker.ID] = -1 } } } intent := recommend.RankIntent{ ID: row.ID, Now: now, Location: location, HouseholdScores: household, Compatibility: compatibility, } switch { case row.ID == "latest-movies": intent.NewReleasesOnly = true case strings.Contains(row.ID, "one-episode"), strings.Contains(row.ID, "late-night"): intent.PreferShort = true intent.MaxRuntimeMins = 60 case strings.Contains(row.ID, "hidden"): intent.HiddenLibrary = true intent.UnseenOnly = true } ranked := recommend.WeightedRank( profile, recommend.Decode(row.Items), exposures, intent, cfg, len(row.Items), ) items := make([]json.RawMessage, 0, len(ranked)) for _, value := range ranked { items = append(items, recommend.EnrichRankedItem(value)) } row.Items = items } return rows } // progressRow marks the row that answers "what was I watching?" rather than "what might // I like?" — and it is the only row whose order is not ours to decide. // // Continue Watching is Emby's resume list interleaved with Next Up, most-recently-watched // first, which is the whole usefulness of it. Ranking it by taste reordered that: an // episode carries none of the studio, cast or collection fields a film's payload does, // its Type has no affinity evidence behind it, and a 22-minute runtime fits a household // session profile built from features badly — so films sorted to the front and the show // somebody was two episodes into sorted past the visible cards. Finishing an episode then // looked like the series had vanished, because the one place it could be found was ordered // by something other than having just been watched. The diversity caps and the exploration // shuffle compound it for the same reason. func progressRow(id string) bool { return id == "continue" } func (s *Server) personalizeSearch( ctx context.Context, sess store.Session, term string, raws []json.RawMessage, limit int, ) []json.RawMessage { profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID) items := recommend.Decode(raws) relevance := make(map[string]float64, len(items)) wanted := strings.ToLower(strings.TrimSpace(term)) for _, item := range items { name := strings.ToLower(strings.TrimSpace(item.Name)) switch { case name == wanted: relevance[item.ID] = 20 case strings.HasPrefix(name, wanted): relevance[item.ID] = 12 case strings.Contains(name, wanted): relevance[item.ID] = 8 default: relevance[item.ID] = 4 } } ranked := recommend.WeightedRank(profile, items, exposures, recommend.RankIntent{ ID: "search", Now: time.Now(), Location: s.cfg.SonarrLocation, SearchRelevance: relevance, HouseholdScores: household, }, s.weightedConfig(), limit) out := make([]json.RawMessage, 0, len(ranked)) for _, value := range ranked { out = append(out, recommend.EnrichRankedItem(value)) } return out } func (s *Server) weightedConfig() recommend.WeightedConfig { cfg := recommend.DefaultWeightedConfig() if s.cfg.RecommendationWeights != "" { _ = json.Unmarshal([]byte(s.cfg.RecommendationWeights), &cfg) } return cfg } // deduplicateRows gives the earliest row ownership of a title. Continue Watching keeps // its landmarks; later discovery shelves fill with their remaining unique posters. func deduplicateRows(rows []recommend.Row) []recommend.Row { seen := map[string]bool{} for rowIndex := range rows { items := recommend.Decode(rows[rowIndex].Items) filtered := make([]json.RawMessage, 0, len(items)) for _, item := range items { key := item.ID if item.SeriesID != "" { key = item.SeriesID } if key == "" || seen[key] { continue } seen[key] = true filtered = append(filtered, item.Raw) } rows[rowIndex].Items = filtered } return rows } // personalizeRowsByTitleScores uses the same title scores to order discovery shelves. // Mandatory shelves receive stable anchors; all other rows compete on the average of // their leading posters, which makes row ordering change with the same profile evidence // that changes poster ordering. func personalizeRowsByTitleScores(rows []recommend.Row) []recommend.Row { type scoredRow struct { row recommend.Row score float64 position int } ranked := make([]scoredRow, 0, len(rows)) for position, row := range rows { score := 0.0 count := 0 for _, raw := range row.Items { var payload struct { Score float64 `json:"MembyRecommendationScore"` } if json.Unmarshal(raw, &payload) == nil { score += payload.Score count++ } if count == 6 { break } } if count > 0 { score /= float64(count) } // A small authored-position prior avoids reshuffling ties and cold starts. score += 0.05 / float64(position+1) switch row.ID { case "continue": score = 1_000 case "latest-movies": score = 90 } ranked = append(ranked, scoredRow{row: row, score: score, position: position}) } 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].position < ranked[j].position }) out := make([]recommend.Row, 0, len(ranked)) for _, value := range ranked { out = append(out, value.row) } return out } func selectPersonalizedRows(rows []recommend.Row) []recommend.Row { out := make([]recommend.Row, 0, len(rows)) for _, row := range rows { switch row.ID { case "continue", "latest-movies", "favorites": out = append(out, row) continue } if len(row.Items) == 0 { continue } total, count := 0.0, 0 for _, raw := range row.Items { var payload struct { Score float64 `json:"MembyRecommendationScore"` } if json.Unmarshal(raw, &payload) == nil { total += payload.Score count++ } if count == 6 { break } } if count > 0 && total/float64(count) < -0.25 { continue } out = append(out, row) } return out } type recommendationActionRequest struct { Action string `json:"action"` } func (s *Server) handleRecommendationAction( w http.ResponseWriter, r *http.Request, sess store.Session, ) { itemID := strings.TrimSpace(r.PathValue("id")) if itemID == "" { writeError(w, http.StatusBadRequest, "item id is required") return } var err error if r.Method == http.MethodDelete { err = s.store.ClearRecommendationAction(r.Context(), sess.EmbyUserID, itemID) } else { var req recommendationActionRequest if decodeErr := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); decodeErr != nil { writeError(w, http.StatusBadRequest, "invalid recommendation action") return } err = s.store.SetRecommendationAction( r.Context(), sess.EmbyUserID, itemID, strings.TrimSpace(req.Action), ) } if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } _ = s.cache.InvalidateUser(r.Context(), viewerKeyOf(r.Context(), sess)) if s.forYou != nil { s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess) s.forYou.RefreshAsync(sess, false) } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleRecommendationPreferences( w http.ResponseWriter, r *http.Request, sess store.Session, ) { if r.Method == http.MethodGet { s.handleRecommendationPreferencesGet(w, r, sess) return } var preferences recommend.OnboardingPreferences if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&preferences); err != nil { writeError(w, http.StatusBadRequest, "invalid onboarding preferences") return } if len(preferences.Ratings) > 40 { writeError(w, http.StatusBadRequest, "too many onboarding ratings") return } for id, rating := range preferences.Ratings { if strings.TrimSpace(id) == "" || rating < 1 || rating > 5 { writeError(w, http.StatusBadRequest, "ratings must be between 1 and 5") return } } peopleCount := len(preferences.Actors) + len(preferences.Actresses) + len(preferences.Directors) if peopleCount > 60 { writeError(w, http.StatusBadRequest, "too many onboarding people") return } for _, names := range [][]string{preferences.Actors, preferences.Actresses, preferences.Directors} { for _, name := range names { if strings.TrimSpace(name) == "" || len(name) > 160 { writeError(w, http.StatusBadRequest, "invalid onboarding person") return } } } preferences.Completed = true preferences.Prompted = true raw, _ := json.Marshal(preferences) if err := s.store.SetRecommendationOnboarding( r.Context(), sess.EmbyUserID, raw, ); err != nil { writeError(w, http.StatusInternalServerError, "could not save onboarding preferences") return } _ = s.cache.InvalidateUser(r.Context(), viewerKeyOf(r.Context(), sess)) if s.forYou != nil { s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess) s.forYou.RefreshAsync(sess, false) } w.WriteHeader(http.StatusNoContent) } type recommendationOnboardingResponse struct { Completed bool `json:"completed"` Prompted bool `json:"prompted"` Ratings map[string]int `json:"ratings"` Items []json.RawMessage `json:"items"` Movies []json.RawMessage `json:"movies"` Shows []json.RawMessage `json:"shows"` Actors []recommendationOnboardingPerson `json:"actors"` Actresses []recommendationOnboardingPerson `json:"actresses"` Directors []recommendationOnboardingPerson `json:"directors"` } type recommendationOnboardingPerson struct { ID string `json:"id"` Name string `json:"name"` ImageTag string `json:"imageTag"` } func (s *Server) handleRecommendationPreferencesGet( w http.ResponseWriter, r *http.Request, sess store.Session, ) { preferences := recommend.OnboardingPreferences{} if raw, err := s.store.RecommendationOnboarding(r.Context(), sess.EmbyUserID); err == nil { _ = json.Unmarshal(raw, &preferences) } if preferences.Ratings == nil { preferences.Ratings = map[string]int{} } // Older TVs only understand completed. Treat an uninvited profile as complete on the // wire so server-side prompt control also suppresses the legacy automatic flow. The // stored value remains false; queueing a prompt changes Prompted and the next request // receives the real incomplete state. if preferences.Completed || !preferences.Prompted { writeJSON(w, http.StatusOK, recommendationOnboardingResponse{ Completed: true, Prompted: preferences.Prompted, Ratings: preferences.Ratings, Items: []json.RawMessage{}, Movies: []json.RawMessage{}, Shows: []json.RawMessage{}, Actors: []recommendationOnboardingPerson{}, Actresses: []recommendationOnboardingPerson{}, Directors: []recommendationOnboardingPerson{}, }) return } raws, err := s.store.AllRecommendationCandidates(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not load rating choices") return } candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 40) row := recommend.Row{ID: "for-you:onboarding", Kind: "for-you"} for _, item := range candidates { row.Items = append(row.Items, item.Raw) } filtered := s.filterRecommendationPermissions( r.Context(), sess, []recommend.Row{row}, ) items := []json.RawMessage{} if len(filtered) == 1 { items = filtered[0].Items if len(items) > 32 { items = items[:32] } } movies, shows := []json.RawMessage{}, []json.RawMessage{} visible := recommend.Decode(items) for _, item := range visible { if strings.EqualFold(item.Type, "Movie") && len(movies) < 16 { movies = append(movies, item.Raw) } if strings.EqualFold(item.Type, "Series") && len(shows) < 16 { shows = append(shows, item.Raw) } } actors, actresses, directors := recommendationOnboardingPeople(visible, 16) writeJSON(w, http.StatusOK, recommendationOnboardingResponse{ Completed: preferences.Completed, Prompted: preferences.Prompted, Ratings: preferences.Ratings, Items: items, Movies: movies, Shows: shows, Actors: actors, Actresses: actresses, Directors: directors, }) } // recommendationOnboardingPeople turns the cast and crew already visible to this user // into recognisable portrait choices. Emby identifies all performers as Actor, so the // actress split uses a deliberately curated, case-insensitive list; unfamiliar names // remain in Actors rather than being guessed from a name. func recommendationOnboardingPeople(items []recommend.Item, limit int) ( []recommendationOnboardingPerson, []recommendationOnboardingPerson, []recommendationOnboardingPerson, ) { type candidate struct { person recommendationOnboardingPerson appearances int bestRating float64 } groups := [3]map[string]*candidate{{}, {}, {}} for _, item := range items { seen := map[string]bool{} for _, person := range item.People { name := strings.TrimSpace(person.Name) key := strings.ToLower(name) if name == "" || seen[key] { continue } seen[key] = true group := -1 switch strings.ToLower(strings.TrimSpace(person.Type)) { case "director": group = 2 case "actor": if onboardingActresses[key] { group = 1 } else { group = 0 } } if group < 0 { continue } value := groups[group][key] if value == nil { value = &candidate{person: recommendationOnboardingPerson{ID: person.ID, Name: name, ImageTag: person.PrimaryImageTag}} groups[group][key] = value } value.appearances++ if item.CommunityRating > value.bestRating { value.bestRating = item.CommunityRating } if value.person.ID == "" && person.ID != "" { value.person.ID, value.person.ImageTag = person.ID, person.PrimaryImageTag } } } output := func(values map[string]*candidate) []recommendationOnboardingPerson { all := make([]*candidate, 0, len(values)) for _, value := range values { all = append(all, value) } sort.Slice(all, func(i, j int) bool { if all[i].appearances != all[j].appearances { return all[i].appearances > all[j].appearances } if all[i].bestRating != all[j].bestRating { return all[i].bestRating > all[j].bestRating } return strings.ToLower(all[i].person.Name) < strings.ToLower(all[j].person.Name) }) if len(all) > limit { all = all[:limit] } out := make([]recommendationOnboardingPerson, 0, len(all)) for _, value := range all { out = append(out, value.person) } return out } return output(groups[0]), output(groups[1]), output(groups[2]) } var onboardingActresses = map[string]bool{ "amy adams": true, "cate blanchett": true, "viola davis": true, "zendaya": true, "michelle yeoh": true, "lupita nyong'o": true, "florence pugh": true, "saoirse ronan": true, "margot robbie": true, "emma stone": true, "scarlett johansson": true, "natalie portman": true, "jessica chastain": true, "octavia spencer": true, "regina king": true, "taraji p. henson": true, "tilda swinton": true, "frances mcdormand": true, "olivia colman": true, "kate winslet": true, "nicole kidman": true, "toni collette": true, "kirsten dunst": true, "rachel weisz": true, "ana de armas": true, "anya taylor-joy": true, "aunjanue ellis-taylor": true, "danai gurira": true, "gemma chan": true, "greta lee": true, "janelle monáe": true, "kerry washington": true, "ming-na wen": true, "rosamund pike": true, "ruth negga": true, "sandra oh": true, "salma hayek": true, "sonoya mizuno": true, "tessa thompson": true, "thandiwe newton": true, "zoë saldaña": true, "meryl streep": true, "jodie foster": true, "sigourney weaver": true, "angela bassett": true, "gillian anderson": true, "elisabeth moss": true, "jennifer coolidge": true, "quinta brunson": true, "ayo edebiri": true, "bella ramsey": true, "emily blunt": true, "jennifer lawrence": true, "anne hathaway": true, "rachel mcadams": true, "charlize theron": true, "halle berry": true, "brie larson": true, "rebecca ferguson": true, "julia roberts": true, "sandra bullock": true, "reese witherspoon": true, "jennifer aniston": true, "laura dern": true, "julianne moore": true, "glenn close": true, "helen mirren": true, "judi dench": true, "maggie smith": true, "kathy bates": true, "carey mulligan": true, "alicia vikander": true, "noomi rapace": true, "marion cotillard": true, "léa seydoux": true, "penélope cruz": true, "deepika padukone": true, "priyanka chopra jonas": true, "awkwafina": true, "constance wu": true, "zoë kravitz": true, "gwendoline christie": true, "emilia clarke": true, "lena headey": true, "sarah snook": true, "jodie comer": true, "issa rae": true, "uzo aduba": true, "natasha lyonne": true, "catherine o'hara": true, "jean smart": true, "melanie lynskey": true, "lucy lawless": true, "rose mciver": true, "thomasin mckenzie": true, "keisha castle-hughes": true, "rena owen": true, "elizabeth debicki": true, "sarah paulson": true, "jenna ortega": true, "hailee steinfeld": true, "millie bobby brown": true, "kristen stewart": true, } // recommendationOnboardingCandidates selects recognisable, well-rated titles while // keeping movies, series and primary genres mixed. It is deterministic so returning to // an unfinished onboarding screen does not reshuffle the choices. func recommendationOnboardingCandidates(items []recommend.Item, limit int) []recommend.Item { sort.SliceStable(items, func(i, j int) bool { if items[i].CommunityRating != items[j].CommunityRating { return items[i].CommunityRating > items[j].CommunityRating } if items[i].ProductionYear != items[j].ProductionYear { return items[i].ProductionYear > items[j].ProductionYear } return items[i].Name < items[j].Name }) buckets := map[string][]recommend.Item{"movie": {}, "series": {}} typeCounts := map[string]int{} genreCounts := map[string]int{} eraCounts := map[string]int{} perType := max(1, limit/2) for _, item := range items { kind := strings.ToLower(strings.TrimSpace(item.Type)) if kind != "movie" && kind != "series" || item.CommunityRating <= 0 || typeCounts[kind] >= perType { continue } genre := "" if len(item.Genres) > 0 { genre = strings.ToLower(strings.TrimSpace(item.Genres[0])) } genreKey := kind + ":" + genre era := "classic" if item.ProductionYear >= 2020 { era = "current" } else if item.ProductionYear >= 2000 { era = "modern" } else if item.ProductionYear >= 1980 { era = "catalogue" } eraKey := kind + ":" + era if genre != "" && genreCounts[genreKey] >= max(2, perType/4) { continue } if eraCounts[eraKey] >= max(3, perType/2) { continue } buckets[kind] = append(buckets[kind], item) typeCounts[kind]++ genreCounts[genreKey]++ eraCounts[eraKey]++ if typeCounts["movie"]+typeCounts["series"] == limit { break } } out := make([]recommend.Item, 0, limit) for index := 0; len(out) < limit; index++ { added := false for _, kind := range []string{"movie", "series"} { if index < len(buckets[kind]) { out = append(out, buckets[kind][index]) added = true } } if !added { break } } return out }