2026-05-22 20:26:11 -04:00
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.
2026-07-07 10:47:20 -04:00
// An empty genre auto-picks: every taste cluster qualifies (strongest first),
// and the global fallback uses the server-wide top genre.
2026-05-22 20:26:11 -04:00
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 {
2026-07-07 10:47:20 -04:00
if genre == "" {
matching = append ( matching , cluster )
continue
}
2026-05-22 20:26:11 -04:00
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 {
2026-07-07 10:47:20 -04:00
// This cluster's cached items were entirely filtered out (e.g.
// access restrictions) — try the next-strongest cluster instead of
// giving up before the global fallback below.
continue
2026-05-22 20:26:11 -04:00
}
return & rows [ 0 ], nil
}
2026-07-07 10:47:20 -04:00
if genre == "" {
topGenres , err := r . repo . GetTopGenres ( ctx , 1 )
if err != nil || len ( topGenres ) == 0 {
return nil , err
}
genre = topGenres [ 0 ]
}
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( ctx , "GetDiscoverRows: failed to load taste clusters for genre exclusion" , "component" , "recommendations" , "user_id" , userID , "profile_id" , profileID , "error" , clusterErr )
2026-05-22 20:26:11 -04:00
}
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 :])
}