727 lines
20 KiB
Go
727 lines
20 KiB
Go
package recommendations
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// SectionKind identifies a single recommendation row reachable via a dedicated
|
|
// "see all" page. Stable strings used in URLs and the discover API response.
|
|
const (
|
|
SectionKindForYouMain = "for-you-main"
|
|
SectionKindCluster = "cluster"
|
|
SectionKindSimilarUsers = "similar-users"
|
|
SectionKindPopular = "popular"
|
|
SectionKindRecentlyAdded = "recently-added"
|
|
SectionKindTopRated = "top-rated"
|
|
SectionKindGenre = "genre"
|
|
)
|
|
|
|
// ProfileRefreshRequester queues a profile-scoped refresh without blocking the caller.
|
|
type ProfileRefreshRequester interface {
|
|
RequestProfileRefresh(ctx context.Context, userID int, profileID string)
|
|
}
|
|
|
|
// Reader assembles recommendation rows from cache-backed data sources.
|
|
type Reader struct {
|
|
repo *Repo
|
|
ratingsRepo *catalog.RatingsRepo
|
|
refresh ProfileRefreshRequester
|
|
signals *SignalReader
|
|
}
|
|
|
|
// NewReader creates a cache-backed recommendations reader.
|
|
func NewReader(repo *Repo, ratingsRepo *catalog.RatingsRepo, refresh ProfileRefreshRequester, storeProvider userstore.UserStoreProvider) *Reader {
|
|
return &Reader{
|
|
repo: repo,
|
|
ratingsRepo: ratingsRepo,
|
|
refresh: refresh,
|
|
signals: NewSignalReader(repo, storeProvider),
|
|
}
|
|
}
|
|
|
|
func (r *Reader) signalReader() *SignalReader {
|
|
if r.signals != nil {
|
|
return r.signals
|
|
}
|
|
return NewSignalReader(r.repo, nil)
|
|
}
|
|
|
|
// GetForYouMain returns the first row the recommendations page should display.
|
|
func (r *Reader) GetForYouMain(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) (*ForYouRow, error) {
|
|
limit = normalizeRecommendationLimit(limit)
|
|
rows, _, err := r.getForYouPageRows(ctx, userID, profileID, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = trimRows(rows, limit)
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
// GetForYouRows returns the remaining recommendations-page rows after the main row.
|
|
func (r *Reader) GetForYouRows(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]ForYouRow, error) {
|
|
limit = normalizeRecommendationLimit(limit)
|
|
rows, _, err := r.getForYouPageRows(ctx, userID, profileID, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = trimRows(rows, limit)
|
|
if len(rows) <= 1 {
|
|
return []ForYouRow{}, nil
|
|
}
|
|
return rows[1:], nil
|
|
}
|
|
|
|
// GetSimilarUsersLiked returns the cached collaborative row for the profile.
|
|
func (r *Reader) GetSimilarUsersLiked(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]ScoredItem, error) {
|
|
limit = normalizeRecommendationLimit(limit)
|
|
|
|
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeSimilarUsersLiked, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
if r.refresh != nil {
|
|
r.refresh.RequestProfileRefresh(ctx, userID, profileID)
|
|
}
|
|
return []ScoredItem{}, nil
|
|
}
|
|
|
|
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{{
|
|
Type: "similar_users_liked",
|
|
Label: "Fans Like You Also Enjoyed",
|
|
Items: items,
|
|
}}, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = trimRows(rows, limit)
|
|
if len(rows) == 0 {
|
|
return []ScoredItem{}, nil
|
|
}
|
|
return rows[0].Items, nil
|
|
}
|
|
|
|
// GetBecauseYouWatched returns a cached because-you-watched row, using the
|
|
// requested source item when provided or the most recent completed items when not.
|
|
func (r *Reader) GetBecauseYouWatched(ctx context.Context, userID int, profileID, sourceItemID string, limit int, filter catalog.AccessFilter) ([]ScoredItem, error) {
|
|
limit = normalizeRecommendationLimit(limit)
|
|
|
|
sourceIDs := []string{}
|
|
if sourceItemID != "" {
|
|
sourceIDs = append(sourceIDs, sourceItemID)
|
|
} else {
|
|
recentCompleted, err := r.signalReader().RecentCompletedItemIDs(ctx, userID, profileID, 3)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sourceIDs = append(sourceIDs, recentCompleted...)
|
|
}
|
|
|
|
for _, sourceID := range sourceIDs {
|
|
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeBecauseWatched, sourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
continue
|
|
}
|
|
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{{
|
|
Type: RecTypeBecauseWatched,
|
|
Label: "Because You Watched",
|
|
Items: items,
|
|
}}, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = trimRows(rows, limit)
|
|
if len(rows) == 0 {
|
|
return []ScoredItem{}, nil
|
|
}
|
|
return rows[0].Items, nil
|
|
}
|
|
|
|
if r.refresh != nil {
|
|
r.refresh.RequestProfileRefresh(ctx, userID, profileID)
|
|
}
|
|
return []ScoredItem{}, nil
|
|
}
|
|
|
|
// GetTasteMatchRow returns the strongest matching personalized cluster row for a genre,
|
|
// falling back to the global genre sampler when no personalized cluster matches.
|
|
func (r *Reader) GetTasteMatchRow(ctx context.Context, userID int, profileID, genre string, limit int, filter catalog.AccessFilter) (*ForYouRow, error) {
|
|
limit = normalizeRecommendationLimit(limit)
|
|
|
|
clusters, err := r.repo.GetTasteClusters(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
matching := make([]TasteCluster, 0, len(clusters))
|
|
for _, cluster := range clusters {
|
|
for _, dominantGenre := range cluster.DominantGenres {
|
|
if dominantGenre == genre {
|
|
matching = append(matching, cluster)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
sort.SliceStable(matching, func(i, j int) bool {
|
|
return matching[i].TotalWeight > matching[j].TotalWeight
|
|
})
|
|
|
|
for _, cluster := range matching {
|
|
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouClusterPrefix+itoa(cluster.ClusterIdx), "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
if r.refresh != nil {
|
|
r.refresh.RequestProfileRefresh(ctx, userID, profileID)
|
|
}
|
|
continue
|
|
}
|
|
|
|
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{clusterRow(cluster, items)}, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = trimRows(rows, limit)
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeGenreSamplerPrefix+genre, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, nil
|
|
}
|
|
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{{
|
|
Type: "genre_sampler",
|
|
Label: "Top " + genre,
|
|
Items: items,
|
|
}}, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = trimRows(rows, limit)
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
func (r *Reader) getForYouPageRows(ctx context.Context, userID int, profileID string, filter catalog.AccessFilter) ([]ForYouRow, bool, error) {
|
|
level := 0
|
|
meta, err := r.repo.GetTasteProfileMeta(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if meta != nil {
|
|
positiveSignals := 0
|
|
for key, count := range meta.SignalCounts {
|
|
switch key {
|
|
case "rated_low", "watch_low":
|
|
default:
|
|
positiveSignals += count
|
|
}
|
|
}
|
|
level = coldStartLevel(positiveSignals)
|
|
}
|
|
|
|
globalRows, err := r.getGlobalRows(ctx)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
clusterRows, missingClusters, err := r.getClusterRows(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
personalRows := make([]ForYouRow, 0, 1+len(clusterRows))
|
|
mainItems, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouMain, "")
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
missingPersonalized := level > 0 && (len(mainItems) == 0 || missingClusters)
|
|
if len(mainItems) > 0 {
|
|
personalRows = append(personalRows, ForYouRow{
|
|
Type: "cluster",
|
|
Label: "For You",
|
|
Items: mainItems,
|
|
})
|
|
}
|
|
personalRows = append(personalRows, clusterRows...)
|
|
if level == 0 && len(personalRows) > 0 {
|
|
level = 3
|
|
}
|
|
|
|
rows := mergePersonalizedAndColdStart(personalRows, globalRows, level)
|
|
rows, err = r.filterRows(ctx, userID, profileID, rows, filter)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if missingPersonalized && r.refresh != nil {
|
|
r.refresh.RequestProfileRefresh(ctx, userID, profileID)
|
|
}
|
|
|
|
return rows, missingPersonalized, nil
|
|
}
|
|
|
|
func (r *Reader) getGlobalRows(ctx context.Context) ([]ForYouRow, error) {
|
|
popular, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypePopular, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
recentlyAdded, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeRecentlyAdded, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
topRated, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeTopRated, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buildColdStartRows(popular, recentlyAdded, topRated, map[string][]ScoredItem{}), nil
|
|
}
|
|
|
|
func (r *Reader) getClusterRows(ctx context.Context, userID int, profileID string) ([]ForYouRow, bool, error) {
|
|
clusters, err := r.repo.GetTasteClusters(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(clusters) == 0 {
|
|
return []ForYouRow{}, false, nil
|
|
}
|
|
|
|
rows := make([]ForYouRow, 0, len(clusters))
|
|
missing := false
|
|
for _, cluster := range clusters {
|
|
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouClusterPrefix+itoa(cluster.ClusterIdx), "")
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(items) == 0 {
|
|
missing = true
|
|
continue
|
|
}
|
|
rows = append(rows, clusterRow(cluster, items))
|
|
}
|
|
return rows, missing, nil
|
|
}
|
|
|
|
func clusterRow(cluster TasteCluster, items []ScoredItem) ForYouRow {
|
|
label := cluster.Label
|
|
if label == "" {
|
|
label = "For You"
|
|
}
|
|
return ForYouRow{
|
|
Type: "cluster",
|
|
Label: "Because you enjoy " + label,
|
|
ClusterIndex: cluster.ClusterIdx,
|
|
Items: items,
|
|
}
|
|
}
|
|
|
|
func (r *Reader) filterRows(ctx context.Context, userID int, profileID string, rows []ForYouRow, filter catalog.AccessFilter) ([]ForYouRow, error) {
|
|
if len(rows) == 0 {
|
|
return rows, nil
|
|
}
|
|
|
|
watchedSet, err := r.signalReader().WatchedItemIDSet(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
itemIDs := make([]string, 0)
|
|
for _, row := range rows {
|
|
for _, item := range row.Items {
|
|
itemIDs = append(itemIDs, item.MediaItemID)
|
|
}
|
|
}
|
|
|
|
lowRatings := map[string]int{}
|
|
if len(itemIDs) > 0 && r.ratingsRepo != nil {
|
|
lowRatings, err = r.ratingsRepo.ListForItems(ctx, userID, profileID, itemIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
accessible := map[string]struct{}{}
|
|
if len(itemIDs) > 0 {
|
|
var err error
|
|
accessible, err = r.repo.FilterAccessibleItemIDs(ctx, itemIDs, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
filteredRows := make([]ForYouRow, 0, len(rows))
|
|
for _, row := range rows {
|
|
filteredItems := make([]ScoredItem, 0, len(row.Items))
|
|
for _, item := range row.Items {
|
|
if _, ok := accessible[item.MediaItemID]; !ok {
|
|
continue
|
|
}
|
|
if _, watched := watchedSet[item.MediaItemID]; watched {
|
|
continue
|
|
}
|
|
if rating, rated := lowRatings[item.MediaItemID]; rated && rating <= 2 {
|
|
continue
|
|
}
|
|
filteredItems = append(filteredItems, item)
|
|
}
|
|
if len(filteredItems) == 0 {
|
|
continue
|
|
}
|
|
row.Items = filteredItems
|
|
filteredRows = append(filteredRows, row)
|
|
}
|
|
|
|
return filteredRows, nil
|
|
}
|
|
|
|
// GetDiscoverRows assembles all rows for the discover/recommendations page.
|
|
// It combines personalized for-you rows, similar-users, and random genre-based
|
|
// popular rows. All data is read from cache — no live aggregation queries.
|
|
func (r *Reader) GetDiscoverRows(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]ForYouRow, error) {
|
|
limit = normalizeRecommendationLimit(limit)
|
|
|
|
// 1. For-you rows (personalized + cold-start blended, already filtered).
|
|
forYouRows, _, err := r.getForYouPageRows(ctx, userID, profileID, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Track seen items for cross-row deduplication.
|
|
seen := make(map[string]struct{})
|
|
for i := range forYouRows {
|
|
forYouRows[i].Items = deduplicateItems(forYouRows[i].Items, seen)
|
|
}
|
|
forYouRows = trimRows(forYouRows, limit)
|
|
// Drop rows emptied by dedup.
|
|
forYouRows = dropEmptyRows(forYouRows)
|
|
|
|
// 2. Similar users row.
|
|
var extraRows []ForYouRow
|
|
similarItems, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeSimilarUsersLiked, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(similarItems) > 0 {
|
|
extraRows = append(extraRows, ForYouRow{
|
|
Type: "similar_users_liked",
|
|
Label: "Users Like You Also Enjoyed",
|
|
Items: similarItems,
|
|
})
|
|
}
|
|
|
|
// 3. Genre sampler rows — pick random genres from global cache.
|
|
genreSamplers, err := r.repo.ListCachedGenreSamplers(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(genreSamplers) > 0 {
|
|
// Exclude genres that overlap with the user's taste clusters.
|
|
excludeGenres := make(map[string]struct{})
|
|
clusters, clusterErr := r.repo.GetTasteClusters(ctx, userID, profileID)
|
|
if clusterErr != nil {
|
|
slog.Warn("GetDiscoverRows: failed to load taste clusters for genre exclusion", "user_id", userID, "profile_id", profileID, "error", clusterErr)
|
|
}
|
|
for _, c := range clusters {
|
|
for _, g := range c.DominantGenres {
|
|
excludeGenres[g] = struct{}{}
|
|
}
|
|
}
|
|
|
|
var availableGenres []string
|
|
for genre := range genreSamplers {
|
|
if _, excluded := excludeGenres[genre]; !excluded {
|
|
availableGenres = append(availableGenres, genre)
|
|
}
|
|
}
|
|
|
|
// Deterministic daily shuffle based on profile + date.
|
|
selected := selectDailyGenres(availableGenres, profileID, 4)
|
|
for _, genre := range selected {
|
|
items := genreSamplers[genre]
|
|
extraRows = append(extraRows, ForYouRow{
|
|
Type: "genre_sampler",
|
|
Label: "Popular in " + genre,
|
|
Items: items,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Filter extra rows (watched + low-rated) and deduplicate across all rows.
|
|
extraRows, err = r.filterRows(ctx, userID, profileID, extraRows, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range extraRows {
|
|
extraRows[i].Items = deduplicateItems(extraRows[i].Items, seen)
|
|
}
|
|
extraRows = trimRows(extraRows, limit)
|
|
extraRows = dropEmptyRows(extraRows)
|
|
|
|
// Interleave: for-you rows first, then genre rows woven after every 2 for-you rows,
|
|
// similar-users at the end.
|
|
var result []ForYouRow
|
|
var genreRows []ForYouRow
|
|
var similarRow *ForYouRow
|
|
for i := range extraRows {
|
|
if extraRows[i].Type == "similar_users_liked" {
|
|
similarRow = &extraRows[i]
|
|
} else {
|
|
genreRows = append(genreRows, extraRows[i])
|
|
}
|
|
}
|
|
|
|
genreIdx := 0
|
|
for i, row := range forYouRows {
|
|
result = append(result, row)
|
|
// Insert a genre row after every 2nd for-you row.
|
|
if (i+1)%2 == 0 && genreIdx < len(genreRows) {
|
|
result = append(result, genreRows[genreIdx])
|
|
genreIdx++
|
|
}
|
|
}
|
|
// Append remaining genre rows.
|
|
for ; genreIdx < len(genreRows); genreIdx++ {
|
|
result = append(result, genreRows[genreIdx])
|
|
}
|
|
// Similar users at the end.
|
|
if similarRow != nil && len(similarRow.Items) > 0 {
|
|
result = append(result, *similarRow)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetSection returns a single recommendation row identified by kind/key, used
|
|
// by dedicated "see all" pages. Returns nil if the row is unknown or empty
|
|
// after filtering. The label and type mirror what the discover/for-you
|
|
// endpoints would produce so consumers can render a consistent header.
|
|
func (r *Reader) GetSection(
|
|
ctx context.Context,
|
|
userID int,
|
|
profileID, kind, key string,
|
|
limit int,
|
|
filter catalog.AccessFilter,
|
|
) (*ForYouRow, error) {
|
|
if limit <= 0 || limit > CacheCandidateLimit {
|
|
limit = CacheCandidateLimit
|
|
}
|
|
|
|
row, err := r.loadSectionRow(ctx, userID, profileID, kind, key)
|
|
if err != nil || row == nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := r.filterRows(ctx, userID, profileID, []ForYouRow{*row}, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
filtered := rows[0]
|
|
if len(filtered.Items) > limit {
|
|
filtered.Items = filtered.Items[:limit]
|
|
}
|
|
return &filtered, nil
|
|
}
|
|
|
|
func (r *Reader) loadSectionRow(ctx context.Context, userID int, profileID, kind, key string) (*ForYouRow, error) {
|
|
switch kind {
|
|
case SectionKindForYouMain:
|
|
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouMain, "")
|
|
if err != nil || len(items) == 0 {
|
|
return nil, err
|
|
}
|
|
return &ForYouRow{Type: "cluster", Label: "For You", Items: items}, nil
|
|
|
|
case SectionKindCluster:
|
|
idx, err := strconv.Atoi(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid cluster index %q: %w", key, err)
|
|
}
|
|
clusters, err := r.repo.GetTasteClusters(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var match *TasteCluster
|
|
for i := range clusters {
|
|
if clusters[i].ClusterIdx == idx {
|
|
match = &clusters[i]
|
|
break
|
|
}
|
|
}
|
|
if match == nil {
|
|
return nil, nil
|
|
}
|
|
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeForYouClusterPrefix+itoa(idx), "")
|
|
if err != nil || len(items) == 0 {
|
|
return nil, err
|
|
}
|
|
row := clusterRow(*match, items)
|
|
return &row, nil
|
|
|
|
case SectionKindSimilarUsers:
|
|
items, err := r.repo.GetRecommendationCache(ctx, userID, profileID, RecTypeSimilarUsersLiked, "")
|
|
if err != nil || len(items) == 0 {
|
|
return nil, err
|
|
}
|
|
return &ForYouRow{
|
|
Type: "similar_users_liked",
|
|
Label: "Users Like You Also Enjoyed",
|
|
Items: items,
|
|
}, nil
|
|
|
|
case SectionKindPopular:
|
|
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypePopular, "")
|
|
if err != nil || len(items) == 0 {
|
|
return nil, err
|
|
}
|
|
return &ForYouRow{Type: RecTypePopular, Label: "Popular on This Server", Items: items}, nil
|
|
|
|
case SectionKindRecentlyAdded:
|
|
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeRecentlyAdded, "")
|
|
if err != nil || len(items) == 0 {
|
|
return nil, err
|
|
}
|
|
return &ForYouRow{Type: RecTypeRecentlyAdded, Label: "Recently Added", Items: items}, nil
|
|
|
|
case SectionKindTopRated:
|
|
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeTopRated, "")
|
|
if err != nil || len(items) == 0 {
|
|
return nil, err
|
|
}
|
|
return &ForYouRow{Type: RecTypeTopRated, Label: "Top Rated", Items: items}, nil
|
|
|
|
case SectionKindGenre:
|
|
if key == "" {
|
|
return nil, fmt.Errorf("genre section requires a key")
|
|
}
|
|
items, err := r.repo.GetRecommendationCache(ctx, GlobalCacheUserID, GlobalCacheProfileID, RecTypeGenreSamplerPrefix+key, "")
|
|
if err != nil || len(items) == 0 {
|
|
return nil, err
|
|
}
|
|
return &ForYouRow{Type: "genre_sampler", Label: "Popular in " + key, Items: items}, nil
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// selectDailyGenres picks up to n genres from the available list using a
|
|
// deterministic daily seed so the selection is stable within a day for a given profile.
|
|
func selectDailyGenres(genres []string, profileID string, n int) []string {
|
|
if len(genres) == 0 {
|
|
return nil
|
|
}
|
|
if n > len(genres) {
|
|
n = len(genres)
|
|
}
|
|
|
|
// Copy to avoid mutating the caller's slice.
|
|
shuffled := make([]string, len(genres))
|
|
copy(shuffled, genres)
|
|
genres = shuffled
|
|
|
|
// Sort for determinism before shuffling.
|
|
sort.Strings(genres)
|
|
|
|
// Simple daily seed from profile ID + date.
|
|
now := time.Now().UTC()
|
|
seed := int64(now.Year())*10000 + int64(now.Month())*100 + int64(now.Day())
|
|
for _, b := range profileID {
|
|
seed = seed*31 + int64(b)
|
|
}
|
|
|
|
// Fisher-Yates shuffle with deterministic seed.
|
|
for i := len(genres) - 1; i > 0; i-- {
|
|
seed = (seed*1103515245 + 12345) & 0x7fffffff
|
|
j := int(seed) % (i + 1)
|
|
genres[i], genres[j] = genres[j], genres[i]
|
|
}
|
|
|
|
return genres[:n]
|
|
}
|
|
|
|
// deduplicateItems removes items whose MediaItemID is already in the seen set,
|
|
// and adds surviving items to the set.
|
|
func deduplicateItems(items []ScoredItem, seen map[string]struct{}) []ScoredItem {
|
|
result := make([]ScoredItem, 0, len(items))
|
|
for _, item := range items {
|
|
if _, ok := seen[item.MediaItemID]; ok {
|
|
continue
|
|
}
|
|
seen[item.MediaItemID] = struct{}{}
|
|
result = append(result, item)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// dropEmptyRows removes rows with no items.
|
|
func dropEmptyRows(rows []ForYouRow) []ForYouRow {
|
|
result := make([]ForYouRow, 0, len(rows))
|
|
for _, row := range rows {
|
|
if len(row.Items) > 0 {
|
|
result = append(result, row)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func trimRows(rows []ForYouRow, limit int) []ForYouRow {
|
|
if limit <= 0 {
|
|
return rows
|
|
}
|
|
for i := range rows {
|
|
if len(rows[i].Items) > limit {
|
|
rows[i].Items = rows[i].Items[:limit]
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func normalizeRecommendationLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return 20
|
|
}
|
|
if limit > 20 {
|
|
return 20
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func itoa(v int) string {
|
|
if v == 0 {
|
|
return "0"
|
|
}
|
|
buf := [20]byte{}
|
|
i := len(buf)
|
|
for v > 0 {
|
|
i--
|
|
buf[i] = byte('0' + v%10)
|
|
v /= 10
|
|
}
|
|
return string(buf[i:])
|
|
}
|