package recommendations import ( "context" "fmt" "sort" "time" "github.com/Silo-Server/silo-server/internal/userstore" ) const signalPageSize = 1000 type signalRepo interface { GetWatchedItemIDSet(ctx context.Context, userID int, profileID string) (map[string]struct{}, error) GetWatchProgressForUser(ctx context.Context, userID int, profileID string) ([]WatchProgressRow, error) GetRecentCompletedItemIDs(ctx context.Context, userID int, profileID string, limit int) ([]string, error) GetRewatchCounts(ctx context.Context, userID int, profileID string) ([]RewatchCount, error) ResolveCanonicalItemIDSet(ctx context.Context, contentIDs []string) (map[string]struct{}, error) } // SignalReader centralizes profile-scoped recommendation signals. userstore is // the source of truth when configured; repo SQL is retained for deployments // without a store provider. type SignalReader struct { repo signalRepo storeProvider userstore.UserStoreProvider } func NewSignalReader(repo signalRepo, storeProvider userstore.UserStoreProvider) *SignalReader { return &SignalReader{ repo: repo, storeProvider: storeProvider, } } func (s *SignalReader) storeForUser(ctx context.Context, userID int) (userstore.UserStore, bool, error) { if s == nil || s.storeProvider == nil { return nil, false, nil } store, err := s.storeProvider.ForUser(ctx, userID) if err != nil { return nil, false, fmt.Errorf("open user store for user %d: %w", userID, err) } if store == nil { return nil, false, nil } return store, true, nil } func (s *SignalReader) WatchedItemIDSet(ctx context.Context, userID int, profileID string) (map[string]struct{}, error) { store, ok, err := s.storeForUser(ctx, userID) if err != nil { return nil, err } if !ok { return s.repo.GetWatchedItemIDSet(ctx, userID, profileID) } rawIDs := make([]string, 0, signalPageSize) if err := pageProgress(ctx, store, profileID, "all", func(progress []userstore.WatchProgress) error { for _, wp := range progress { if wp.Completed || watchedProgressThresholdMet(wp.PositionSeconds, wp.DurationSeconds) { rawIDs = append(rawIDs, wp.MediaItemID) } } return nil }); err != nil { return nil, err } return s.repo.ResolveCanonicalItemIDSet(ctx, rawIDs) } func (s *SignalReader) WatchProgressForUser(ctx context.Context, userID int, profileID string) ([]WatchProgressRow, error) { store, ok, err := s.storeForUser(ctx, userID) if err != nil { return nil, err } if !ok { return s.repo.GetWatchProgressForUser(ctx, userID, profileID) } rows := make([]WatchProgressRow, 0, signalPageSize) if err := pageProgress(ctx, store, profileID, "all", func(progress []userstore.WatchProgress) error { for _, wp := range progress { rows = append(rows, WatchProgressRow{ MediaItemID: wp.MediaItemID, PositionSeconds: wp.PositionSeconds, DurationSeconds: wp.DurationSeconds, Completed: wp.Completed, UpdatedAt: parseSignalTime(wp.UpdatedAt, time.Time{}), }) } return nil }); err != nil { return nil, err } return rows, nil } func (s *SignalReader) RecentCompletedItemIDs(ctx context.Context, userID int, profileID string, limit int) ([]string, error) { if limit <= 0 { return []string{}, nil } store, ok, err := s.storeForUser(ctx, userID) if err != nil { return nil, err } if !ok { return s.repo.GetRecentCompletedItemIDs(ctx, userID, profileID, limit) } progress, err := store.ListProgress(ctx, profileID, "completed", limit, 0) if err != nil { return nil, fmt.Errorf("list completed progress from store: %w", err) } sort.SliceStable(progress, func(i, j int) bool { left := parseSignalTime(progress[i].UpdatedAt, time.Time{}) right := parseSignalTime(progress[j].UpdatedAt, time.Time{}) if !left.Equal(right) { return left.After(right) } return progress[i].MediaItemID < progress[j].MediaItemID }) ids := make([]string, 0, min(limit, len(progress))) for _, wp := range progress { if !wp.Completed { continue } ids = append(ids, wp.MediaItemID) if len(ids) == limit { break } } return ids, nil } func (s *SignalReader) RewatchCounts(ctx context.Context, userID int, profileID string) ([]RewatchCount, error) { store, ok, err := s.storeForUser(ctx, userID) if err != nil { return nil, err } if !ok { return s.repo.GetRewatchCounts(ctx, userID, profileID) } counts := make(map[string]*RewatchCount) offset := 0 for { history, err := store.ListCompletedHistory(ctx, userstore.CompletedHistoryQuery{ ProfileID: profileID, Limit: signalPageSize, Offset: offset, }) if err != nil { return nil, fmt.Errorf("list completed history from store: %w", err) } for _, entry := range history { if !entry.Completed { continue } rc := counts[entry.MediaItemID] if rc == nil { rc = &RewatchCount{MediaItemID: entry.MediaItemID} counts[entry.MediaItemID] = rc } rc.Count++ watchedAt := parseSignalTime(entry.WatchedAt, time.Time{}) if watchedAt.After(rc.LastWatchedAt) { rc.LastWatchedAt = watchedAt } } if len(history) < signalPageSize { break } offset += len(history) } result := make([]RewatchCount, 0, len(counts)) for _, rc := range counts { if rc.Count >= 2 { result = append(result, *rc) } } sort.Slice(result, func(i, j int) bool { return result[i].MediaItemID < result[j].MediaItemID }) return result, nil } func pageProgress(ctx context.Context, store userstore.UserStore, profileID, status string, visit func([]userstore.WatchProgress) error) error { offset := 0 for { progress, err := store.ListProgress(ctx, profileID, status, signalPageSize, offset) if err != nil { return fmt.Errorf("list progress from store: %w", err) } if err := visit(progress); err != nil { return err } if len(progress) < signalPageSize { return nil } offset += len(progress) } } func watchedProgressThresholdMet(positionSeconds, durationSeconds float64) bool { return durationSeconds > 0 && positionSeconds/durationSeconds >= 0.5 }