Files
silo-server/internal/recommendations/signals.go
T

216 lines
5.9 KiB
Go

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
}