Files
silo-server/internal/catalog/discovery_repo.go
T

311 lines
10 KiB
Go

package catalog
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
)
// DiscoveryRepository provides catalog query helpers used by discovery section
// recipes (critically_acclaimed, hidden_gems, and similar).
type DiscoveryRepository struct {
pool *pgxpool.Pool
}
// NewDiscoveryRepository creates a DiscoveryRepository backed by pool.
func NewDiscoveryRepository(pool *pgxpool.Pool) *DiscoveryRepository {
return &DiscoveryRepository{pool: pool}
}
// RatingFilter controls the ListByRatingThreshold query.
type RatingFilter struct {
// Min is the minimum rating_imdb value (inclusive). Items with a NULL
// rating_imdb are excluded.
Min float64
// Limit caps the number of rows returned. Zero or negative means no limit.
Limit int
// LibraryID, when non-nil, restricts results to items in that library via
// the media_item_libraries junction table.
LibraryID *int
// Filter carries viewer-level access constraints (content rating ceiling,
// allowed/disabled library sets).
Filter AccessFilter
}
// ListByRatingThreshold returns media items whose rating_imdb is >= f.Min,
// ordered by rating_imdb DESC NULLS LAST. Items without an IMDb rating are
// always excluded.
func (r *DiscoveryRepository) ListByRatingThreshold(ctx context.Context, f RatingFilter) ([]*models.MediaItem, error) {
var conditions []string
var args []any
argIdx := 1
// IMDb rating threshold — NULL ratings are excluded implicitly by >=.
conditions = append(conditions, fmt.Sprintf("mi.rating_imdb >= $%d", argIdx))
args = append(args, f.Min)
argIdx++
// Optional single-library pin.
fromClause := "media_items mi"
if f.LibraryID != nil {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx))
args = append(args, *f.LibraryID)
argIdx++
} else if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return []*models.MediaItem{}, nil
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id IN (%s)", strings.Join(placeholders, ", ")))
}
if len(f.Filter.DisabledLibraryIDs) > 0 {
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
for i, id := range f.Filter.DisabledLibraryIDs {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id NOT IN (%s)", strings.Join(placeholders, ", ")))
}
}
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
itemColumns,
fromClause,
strings.Join(conditions, " AND "),
)
if f.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, f.Limit)
}
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing items by rating threshold: %w", err)
}
defer rows.Close()
items, err := scanItems(rows)
if err != nil {
return nil, err
}
return items, nil
}
// UnplayedFilter controls the ListUnplayedHighRated query.
type UnplayedFilter struct {
// MinRating is the minimum rating_imdb value (inclusive).
MinRating float64
// Limit caps the number of rows returned. Zero or negative means no limit.
Limit int
// UserID and ProfileID identify the viewer whose watch history is checked.
// Both are required; the function returns an error if either is absent.
UserID int
ProfileID string
// Filter carries viewer-level access constraints.
Filter AccessFilter
}
// ListUnplayedHighRated returns high-rated items that the given user/profile has
// never started watching. "Never started" means no row exists in
// user_watch_history for (user_id, profile_id, media_item_id), regardless of
// completion status. Items without an IMDb rating are excluded.
//
// Results are ordered by rating_imdb DESC NULLS LAST.
func (r *DiscoveryRepository) ListUnplayedHighRated(ctx context.Context, f UnplayedFilter) ([]*models.MediaItem, error) {
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
return nil, fmt.Errorf("ListUnplayedHighRated: UserID and ProfileID are required")
}
var conditions []string
var args []any
argIdx := 1
// IMDb rating threshold.
conditions = append(conditions, fmt.Sprintf("mi.rating_imdb >= $%d", argIdx))
args = append(args, f.MinRating)
argIdx++
// LEFT JOIN exclusion: items the user has any watch history entry for are excluded.
// We use a NOT EXISTS subquery rather than an outer join so it composes cleanly
// with library access conditions on the main query.
conditions = append(conditions, fmt.Sprintf(`NOT EXISTS (
SELECT 1
FROM user_watch_history uwh
WHERE uwh.user_id = $%d
AND uwh.profile_id = $%d
AND uwh.media_item_id = mi.content_id
)`, argIdx, argIdx+1))
args = append(args, f.UserID, f.ProfileID)
argIdx += 2
// Library access control.
fromClause := "media_items mi"
if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return []*models.MediaItem{}, nil
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id IN (%s)", strings.Join(placeholders, ", ")))
}
if len(f.Filter.DisabledLibraryIDs) > 0 {
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
for i, id := range f.Filter.DisabledLibraryIDs {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id NOT IN (%s)", strings.Join(placeholders, ", ")))
}
}
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
itemColumns,
fromClause,
strings.Join(conditions, " AND "),
)
if f.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, f.Limit)
}
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing unplayed high-rated items: %w", err)
}
defer rows.Close()
items, err := scanItems(rows)
if err != nil {
return nil, err
}
return items, nil
}
// ForgottenFavoritesFilter controls the ListForgottenFavorites query.
type ForgottenFavoritesFilter struct {
// LookbackDays is the number of days in the past beyond which a watch
// event is considered "forgotten". Items last watched more recently than
// this threshold are excluded. Must be > 0.
LookbackDays int
// Limit caps the number of rows returned. Zero or negative means no limit.
Limit int
// UserID and ProfileID identify the viewer whose watch history is checked.
// Both are required; the function returns an error if either is absent.
UserID int
ProfileID string
// Filter carries viewer-level access constraints.
Filter AccessFilter
}
// ListForgottenFavorites returns high-rated items (rating_imdb >= 7.0) that the
// user/profile either has never watched OR last watched more than LookbackDays
// ago. Results are ordered by rating_imdb DESC NULLS LAST.
func (r *DiscoveryRepository) ListForgottenFavorites(ctx context.Context, f ForgottenFavoritesFilter) ([]*models.MediaItem, error) {
if f.UserID <= 0 || strings.TrimSpace(f.ProfileID) == "" {
return nil, fmt.Errorf("ListForgottenFavorites: UserID and ProfileID are required")
}
if f.LookbackDays <= 0 {
f.LookbackDays = 365
}
var conditions []string
var args []any
argIdx := 1
// Only items with an IMDb rating of at least 7.0.
conditions = append(conditions, "mi.rating_imdb >= 7.0")
// Items the user has never watched, or last watched before the lookback window.
conditions = append(conditions, fmt.Sprintf(`NOT EXISTS (
SELECT 1
FROM user_watch_history uwh
WHERE uwh.user_id = $%d
AND uwh.profile_id = $%d
AND uwh.media_item_id = mi.content_id
AND uwh.watched_at >= NOW() - ($%d || ' days')::interval
)`, argIdx, argIdx+1, argIdx+2))
args = append(args, f.UserID, f.ProfileID, f.LookbackDays)
argIdx += 3
// Library access control.
fromClause := "media_items mi"
if f.Filter.AllowedLibraryIDs != nil || len(f.Filter.DisabledLibraryIDs) > 0 {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
if f.Filter.AllowedLibraryIDs != nil {
if len(f.Filter.AllowedLibraryIDs) == 0 {
return []*models.MediaItem{}, nil
}
placeholders := make([]string, len(f.Filter.AllowedLibraryIDs))
for i, id := range f.Filter.AllowedLibraryIDs {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id IN (%s)", strings.Join(placeholders, ", ")))
}
if len(f.Filter.DisabledLibraryIDs) > 0 {
placeholders := make([]string, len(f.Filter.DisabledLibraryIDs))
for i, id := range f.Filter.DisabledLibraryIDs {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
args = append(args, id)
argIdx++
}
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id NOT IN (%s)", strings.Join(placeholders, ", ")))
}
}
applyAccessFilter("mi", f.Filter, &conditions, &args, &argIdx)
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC",
itemColumns,
fromClause,
strings.Join(conditions, " AND "),
)
if f.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, f.Limit)
}
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("listing forgotten favorites: %w", err)
}
defer rows.Close()
items, err := scanItems(rows)
if err != nil {
return nil, err
}
return items, nil
}