Files
silo-server/internal/sections/fetcher.go
T

2848 lines
88 KiB
Go

package sections
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/singleflight"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/overlays"
"github.com/Silo-Server/silo-server/internal/recommendations"
"github.com/Silo-Server/silo-server/internal/sections/recipes"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// SectionWithItems is a resolved section populated with query results.
type SectionWithItems struct {
ResolvedSection
Items []*models.MediaItem `json:"items"`
TotalCount int `json:"total_count"`
ItemMeta map[string]SectionItemMeta
}
// SectionItemMeta carries optional per-item metadata for richer section UIs.
type SectionItemMeta struct {
SeriesID *string
SeriesTitle string
SeasonNumber *int
EpisodeNumber *int
Badges []string
PositionSeconds *float64
DurationSeconds *float64
ProgressUpdatedAt *string
ItemSource string // "in_progress" or "next_up"
SortTimestamp time.Time // when the preceding episode was completed (for ordering)
}
const recentSeasonPremiereBadgeWindowDays = 14
const editorialCandidateCacheTTL = 24 * time.Hour
const fetchAllMaxConcurrency = 4
const slowSectionFetchThreshold = 500 * time.Millisecond
const slowAggregateFetchThreshold = time.Second
type recommendationReader interface {
GetForYouMain(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) (*recommendations.ForYouRow, error)
GetBecauseYouWatched(ctx context.Context, userID int, profileID, sourceItemID string, limit int, filter catalog.AccessFilter) ([]recommendations.ScoredItem, error)
GetSimilarUsersLiked(ctx context.Context, userID int, profileID string, limit int, filter catalog.AccessFilter) ([]recommendations.ScoredItem, error)
GetTasteMatchRow(ctx context.Context, userID int, profileID, genre string, limit int, filter catalog.AccessFilter) (*recommendations.ForYouRow, error)
}
// Fetcher runs section queries against the database.
type Fetcher struct {
pool *pgxpool.Pool
StoreProvider userstore.UserStoreProvider
CollectionRepo *catalog.LibraryCollectionRepository
RecommendationRepo *recommendations.Repo // retained for non-reader call sites
RecommendationReader recommendationReader
NextUpRepo *catalog.NextUpRepository
candidateCacheMu sync.Mutex
candidateCache *editorialCandidateCache
candidateGroup singleflight.Group
// Clock returns the current time. Defaults to recipes.RealClock{}.
// Tests inject recipes.FixedClock for deterministic seasonal/editorial behavior.
Clock recipes.Clock
}
// NewFetcher creates a new section Fetcher.
func NewFetcher(pool *pgxpool.Pool) *Fetcher {
return &Fetcher{pool: pool, Clock: recipes.RealClock{}}
}
type editorialCandidateLoader func(context.Context, string, *int, []int, catalog.AccessFilter) ([]string, error)
type editorialCandidateCache struct {
mu sync.RWMutex
entries map[string]editorialCandidateCacheEntry
}
type editorialCandidateCacheEntry struct {
candidates []string
expiresAt time.Time
}
func newEditorialCandidateCache() *editorialCandidateCache {
return &editorialCandidateCache{entries: make(map[string]editorialCandidateCacheEntry)}
}
func (c *editorialCandidateCache) get(key string, now time.Time) ([]string, bool) {
if c == nil {
return nil, false
}
c.mu.RLock()
entry, ok := c.entries[key]
c.mu.RUnlock()
if !ok || !now.Before(entry.expiresAt) {
return nil, false
}
return append([]string(nil), entry.candidates...), true
}
func (c *editorialCandidateCache) set(key string, candidates []string, expiresAt time.Time) {
if c == nil {
return
}
c.mu.Lock()
c.entries[key] = editorialCandidateCacheEntry{
candidates: append([]string(nil), candidates...),
expiresAt: expiresAt,
}
c.mu.Unlock()
}
func (f *Fetcher) ensureEditorialCandidateCache() *editorialCandidateCache {
f.candidateCacheMu.Lock()
defer f.candidateCacheMu.Unlock()
if f.candidateCache == nil {
f.candidateCache = newEditorialCandidateCache()
}
return f.candidateCache
}
func (f *Fetcher) now() time.Time {
if f != nil && f.Clock != nil {
return f.Clock.Now()
}
return time.Now()
}
func (f *Fetcher) cachedEditorialCandidates(ctx context.Context, subjectType string, libraryID *int, libraryIDs []int, filter catalog.AccessFilter, ttl time.Duration, loader editorialCandidateLoader) ([]string, error) {
if ttl <= 0 {
return loader(ctx, subjectType, libraryID, libraryIDs, filter)
}
cache := f.ensureEditorialCandidateCache()
key := editorialCandidateCacheKey(subjectType, libraryID, libraryIDs, filter)
now := f.now()
if candidates, ok := cache.get(key, now); ok {
return candidates, nil
}
value, err, _ := f.candidateGroup.Do(key, func() (any, error) {
now := f.now()
if candidates, ok := cache.get(key, now); ok {
return candidates, nil
}
candidates, err := loader(ctx, subjectType, libraryID, libraryIDs, filter)
if err != nil {
return nil, err
}
cache.set(key, candidates, now.Add(ttl))
return append([]string(nil), candidates...), nil
})
if err != nil {
return nil, err
}
candidates, _ := value.([]string)
return append([]string(nil), candidates...), nil
}
func editorialCandidateCacheKey(subjectType string, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) string {
var b strings.Builder
b.WriteString("subject=")
b.WriteString(strings.ToLower(strings.TrimSpace(subjectType)))
b.WriteString("|library=")
if libraryID == nil {
b.WriteString("all")
} else {
b.WriteString(strconv.Itoa(*libraryID))
}
b.WriteString("|libraries=")
writeOptionalSortedInts(&b, libraryIDs)
b.WriteString("|disabled=")
writeSortedInts(&b, filter.DisabledLibraryIDs)
b.WriteString("|rating=")
b.WriteString(filter.MaxContentRating)
return b.String()
}
func writeOptionalSortedInts(b *strings.Builder, values []int) {
if values == nil {
b.WriteString("<nil>")
return
}
if len(values) == 0 {
b.WriteString("<empty>")
return
}
writeSortedInts(b, values)
}
func writeSortedInts(b *strings.Builder, values []int) {
if len(values) == 0 {
return
}
sorted := append([]int(nil), values...)
sort.Ints(sorted)
for i, value := range sorted {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(strconv.Itoa(value))
}
}
// FetchOne runs one section query and returns it with items.
func (f *Fetcher) FetchOne(ctx context.Context, resolved ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) (SectionWithItems, error) {
start := time.Now()
var result SectionWithItems
var err error
defer func() {
f.logSlowSectionFetch(resolved, libraryID, libraryIDs, result, time.Since(start), err)
}()
if resolved.SectionType == SectionContinueWatching {
result, err = f.fetchContinueWatchingSection(ctx, resolved, libraryID, libraryIDs, userID, profileID, filter)
return result, err
}
if resolved.SectionType == SectionNextUp {
result, err = f.fetchNextUpSection(ctx, resolved, libraryID, libraryIDs, userID, profileID, filter)
return result, err
}
if resolved.SectionType == SectionEditorialSpotlight {
var items []*models.MediaItem
var total int
var title string
items, total, title, err = f.fetchEditorialSpotlightWithTitle(ctx, resolved, libraryID, libraryIDs, filter)
if err != nil {
return SectionWithItems{}, err
}
if title != "" {
resolved.Title = title
}
result = SectionWithItems{
ResolvedSection: resolved,
Items: items,
TotalCount: total,
}
return result, nil
}
var items []*models.MediaItem
var total int
items, total, err = f.fetchSection(ctx, resolved, libraryID, libraryIDs, userID, profileID, filter)
if err != nil {
return SectionWithItems{}, err
}
// Apply seasonal title override when the active theme has a custom name.
// Done here (rather than inside fetchSeasonalThemed) so the section's
// stored Title is preserved as the fallback used by callers that bypass
// SectionWithItems construction.
if resolved.SectionType == SectionSeasonalThemed && len(items) > 0 {
if title := f.seasonalTitleOverride(resolved); title != "" {
resolved.Title = title
}
}
result = SectionWithItems{
ResolvedSection: resolved,
Items: items,
TotalCount: total,
}
return result, nil
}
func (f *Fetcher) logSlowSectionFetch(resolved ResolvedSection, libraryID *int, libraryIDs []int, result SectionWithItems, duration time.Duration, err error) {
if duration < slowSectionFetchThreshold {
return
}
attrs := []any{
"section_id", resolved.ID,
"type", resolved.SectionType,
"title", resolved.Title,
"item_count", len(result.Items),
"total_count", result.TotalCount,
"duration_ms", duration.Milliseconds(),
}
if libraryID != nil {
attrs = append(attrs, "library_id", *libraryID)
}
if libraryIDs != nil {
attrs = append(attrs, "library_ids", append([]int(nil), libraryIDs...))
}
if err != nil {
attrs = append(attrs, "error", err)
}
slog.Warn("slow section fetch", attrs...)
}
// seasonalTitleOverride returns the per-theme display name override for a
// seasonal section, or "" when no override applies.
func (f *Fetcher) seasonalTitleOverride(resolved ResolvedSection) string {
if len(resolved.Config) == 0 {
return ""
}
var p recipes.SeasonalThemedParams
if err := json.Unmarshal(resolved.Config, &p); err != nil {
return ""
}
if len(p.ThemeTitles) == 0 {
return ""
}
now := time.Now()
if f.Clock != nil {
now = f.Clock.Now()
}
return recipes.SeasonalTitleOverride(p, now)
}
func (f *Fetcher) fetchContinueWatchingSection(ctx context.Context, resolved ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) (SectionWithItems, error) {
if f.StoreProvider == nil || userID <= 0 || profileID == "" {
return SectionWithItems{
ResolvedSection: resolved,
Items: []*models.MediaItem{},
TotalCount: 0,
ItemMeta: map[string]SectionItemMeta{},
}, nil
}
store, err := f.StoreProvider.ForUser(ctx, userID)
if err != nil {
return SectionWithItems{}, fmt.Errorf("getting user store: %w", err)
}
limit := resolved.ItemLimit
if limit <= 0 {
limit = 20
}
cfgFilters := ParseConfigFilters(resolved.Config)
configLibraryIDs := cfgFilters.LibraryIDs()
effectiveLibID := libraryID
effectiveLibraryIDs := libraryIDs
if effectiveLibID == nil && len(configLibraryIDs) > 0 {
effectiveLibraryIDs = configLibraryIDs
}
progressEntries, err := store.ListProgress(ctx, profileID, "in_progress", limit, 0)
if err != nil {
return SectionWithItems{}, fmt.Errorf("listing progress: %w", err)
}
progressEntries = f.filterContinueWatchingDismissals(ctx, store, profileID, progressEntries)
// Build in-progress items
var orderedItems []*models.MediaItem
itemMeta := make(map[string]SectionItemMeta)
if len(progressEntries) > 0 {
contentIDs := make([]string, 0, len(progressEntries))
for _, entry := range progressEntries {
contentIDs = append(contentIDs, entry.MediaItemID)
}
mediaItems, err := f.fetchItemsByContentIDs(ctx, contentIDs, effectiveLibID, effectiveLibraryIDs, filter)
if err != nil {
return SectionWithItems{}, err
}
episodeItems, episodeMeta, err := f.fetchEpisodeTargetsByContentIDs(ctx, contentIDs, effectiveLibID, effectiveLibraryIDs, filter)
if err != nil {
return SectionWithItems{}, err
}
itemByID := make(map[string]*models.MediaItem, len(mediaItems)+len(episodeItems))
for _, item := range mediaItems {
itemByID[item.ContentID] = item
}
for _, item := range episodeItems {
itemByID[item.ContentID] = item
}
maps.Copy(itemMeta, episodeMeta)
supersededEpisodeProgress, err := f.fetchSupersededEpisodeProgressIDs(ctx, store, profileID, progressEntries)
if err != nil {
return SectionWithItems{}, err
}
progressEntries = filterSupersededEpisodeProgressEntries(progressEntries, supersededEpisodeProgress)
for _, entry := range progressEntries {
meta := itemMeta[entry.MediaItemID]
position := entry.PositionSeconds
duration := entry.DurationSeconds
meta.PositionSeconds = &position
meta.DurationSeconds = &duration
meta.ProgressUpdatedAt = &entry.UpdatedAt
meta.ItemSource = "in_progress"
if updatedAt, parseErr := time.Parse(time.RFC3339, entry.UpdatedAt); parseErr == nil {
meta.SortTimestamp = updatedAt
}
itemMeta[entry.MediaItemID] = meta
}
orderedItems = make([]*models.MediaItem, 0, len(progressEntries))
for _, entry := range progressEntries {
item, ok := itemByID[entry.MediaItemID]
if !ok {
continue
}
orderedItems = append(orderedItems, item)
}
}
// Fetch next-up items (combined mode by default)
nextUpMode, _ := store.GetSetting(ctx, "next_up_mode")
if nextUpMode == "" {
nextUpMode = "combined"
}
if nextUpMode == "combined" {
nextUpItems, nextUpMeta, nextUpErr := f.FetchNextUpItems(ctx, userID, profileID, effectiveLibID, effectiveLibraryIDs, filter, limit)
if nextUpErr != nil {
slog.Error("fetching next-up items", "error", nextUpErr)
} else {
orderedItems = append(orderedItems, nextUpItems...)
maps.Copy(itemMeta, nextUpMeta)
}
}
// Apply type filter after next-up items are appended
if cfgFilters.FilterType != "" {
filtered := make([]*models.MediaItem, 0, len(orderedItems))
for _, item := range orderedItems {
if (cfgFilters.FilterType == "movie" && item.Type == "movie") ||
(cfgFilters.FilterType == "series" && item.Type == "episode") {
filtered = append(filtered, item)
}
}
orderedItems = filtered
}
if nextUpMode == "combined" && len(orderedItems) > 1 {
orderedItems = collapseContinueWatchingSeriesCandidates(orderedItems, itemMeta)
}
if nextUpMode == "combined" && len(orderedItems) > 1 {
sort.SliceStable(orderedItems, func(i, j int) bool {
left := itemMeta[orderedItems[i].ContentID].SortTimestamp
right := itemMeta[orderedItems[j].ContentID].SortTimestamp
return left.After(right)
})
}
if orderedItems == nil {
orderedItems = []*models.MediaItem{}
}
return SectionWithItems{
ResolvedSection: resolved,
Items: orderedItems,
TotalCount: len(orderedItems),
ItemMeta: itemMeta,
}, nil
}
func (f *Fetcher) fetchNextUpSection(ctx context.Context, resolved ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) (SectionWithItems, error) {
emptyResult := SectionWithItems{
ResolvedSection: resolved,
Items: []*models.MediaItem{},
TotalCount: 0,
ItemMeta: map[string]SectionItemMeta{},
}
if f.StoreProvider == nil || userID <= 0 || profileID == "" {
return emptyResult, nil
}
store, err := f.StoreProvider.ForUser(ctx, userID)
if err != nil {
return SectionWithItems{}, fmt.Errorf("getting user store: %w", err)
}
// Only resolve if user preference is "separate"
nextUpMode, _ := store.GetSetting(ctx, "next_up_mode")
if nextUpMode != "separate" {
return emptyResult, nil
}
limit := resolved.ItemLimit
if limit <= 0 {
limit = 20
}
items, meta, err := f.FetchNextUpItems(ctx, userID, profileID, libraryID, libraryIDs, filter, limit)
if err != nil {
return SectionWithItems{}, err
}
if items == nil {
items = []*models.MediaItem{}
}
if meta == nil {
meta = map[string]SectionItemMeta{}
}
return SectionWithItems{
ResolvedSection: resolved,
Items: items,
TotalCount: len(items),
ItemMeta: meta,
}, nil
}
// FetchNextUpItems returns the next unwatched episode per series for the given user profile.
// It delegates to NextUpRepository for the optimized LATERAL JOIN query, then resolves
// the full MediaItem objects via fetchEpisodeTargetsByContentIDs.
func (f *Fetcher) FetchNextUpItems(ctx context.Context, userID int, profileID string, libraryID *int, libraryIDs []int, filter catalog.AccessFilter, limit int) ([]*models.MediaItem, map[string]SectionItemMeta, error) {
if f.NextUpRepo == nil {
return nil, nil, nil
}
results, err := f.NextUpRepo.ListNextUp(ctx, catalog.NextUpQuery{
UserID: userID,
ProfileID: profileID,
LibraryID: libraryID,
LibraryIDs: libraryIDs,
Limit: limit,
})
if err != nil {
return nil, nil, err
}
if len(results) == 0 {
return nil, nil, nil
}
results = f.filterNextUpDismissals(ctx, userID, profileID, results)
if len(results) == 0 {
return nil, nil, nil
}
contentIDs := make([]string, len(results))
resultByID := make(map[string]catalog.NextUpResult, len(results))
for i, res := range results {
contentIDs[i] = res.ContentID
resultByID[res.ContentID] = res
}
episodeItems, episodeMeta, err := f.fetchEpisodeTargetsByContentIDs(ctx, contentIDs, libraryID, libraryIDs, filter)
if err != nil {
return nil, nil, fmt.Errorf("fetching episode targets for next-up: %w", err)
}
itemByID := make(map[string]*models.MediaItem, len(episodeItems))
for _, item := range episodeItems {
itemByID[item.ContentID] = item
}
meta := make(map[string]SectionItemMeta, len(results))
orderedItems := make([]*models.MediaItem, 0, len(results))
for _, res := range results {
m, ok := episodeMeta[res.ContentID]
if !ok {
continue
}
m.ItemSource = "next_up"
m.SortTimestamp = res.CompletedAt
meta[res.ContentID] = m
item, ok := itemByID[res.ContentID]
if !ok {
continue
}
orderedItems = append(orderedItems, item)
}
return orderedItems, meta, nil
}
type progressLister interface {
ListProgress(ctx context.Context, profileID, status string, limit, offset int) ([]userstore.WatchProgress, error)
}
const supersededProgressPageSize = 500
type progressSnapshot struct {
ContentID string
UpdatedAt time.Time
}
func (f *Fetcher) fetchSupersededEpisodeProgressIDs(ctx context.Context, store progressLister, profileID string, entries []userstore.WatchProgress) (map[string]struct{}, error) {
inProgress := progressSnapshots(entries)
if len(inProgress) == 0 {
return map[string]struct{}{}, nil
}
completed, err := completedProgressSnapshots(ctx, store, profileID)
if err != nil {
return nil, err
}
if len(completed) == 0 {
return map[string]struct{}{}, nil
}
inProgressIDs, inProgressUpdatedAts := splitProgressSnapshots(inProgress)
completedIDs, completedUpdatedAts := splitProgressSnapshots(completed)
query := buildSupersededEpisodeProgressQuery()
rows, err := f.pool.Query(ctx, query, inProgressIDs, inProgressUpdatedAts, completedIDs, completedUpdatedAts)
if err != nil {
return nil, fmt.Errorf("querying superseded episode progress: %w", err)
}
defer rows.Close()
superseded := make(map[string]struct{})
for rows.Next() {
var mediaItemID string
if err := rows.Scan(&mediaItemID); err != nil {
return nil, fmt.Errorf("scanning superseded episode progress: %w", err)
}
superseded[mediaItemID] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating superseded episode progress: %w", err)
}
return superseded, nil
}
func completedProgressSnapshots(ctx context.Context, store progressLister, profileID string) ([]progressSnapshot, error) {
seen := make(map[string]struct{})
snapshots := make([]progressSnapshot, 0)
for offset := 0; ; offset += supersededProgressPageSize {
entries, err := store.ListProgress(ctx, profileID, "completed", supersededProgressPageSize, offset)
if err != nil {
return nil, fmt.Errorf("listing completed progress for superseded episodes: %w", err)
}
for _, snapshot := range progressSnapshots(entries) {
contentID := snapshot.ContentID
if _, ok := seen[contentID]; ok {
continue
}
seen[contentID] = struct{}{}
snapshots = append(snapshots, snapshot)
}
if len(entries) < supersededProgressPageSize {
return snapshots, nil
}
}
}
func progressSnapshots(entries []userstore.WatchProgress) []progressSnapshot {
snapshots := make([]progressSnapshot, 0, len(entries))
for _, entry := range entries {
contentID := strings.TrimSpace(entry.MediaItemID)
if contentID == "" {
continue
}
updatedAt, err := time.Parse(time.RFC3339, entry.UpdatedAt)
if err != nil || updatedAt.IsZero() {
continue
}
snapshots = append(snapshots, progressSnapshot{
ContentID: contentID,
UpdatedAt: updatedAt.UTC(),
})
}
return snapshots
}
func splitProgressSnapshots(snapshots []progressSnapshot) ([]string, []time.Time) {
contentIDs := make([]string, len(snapshots))
updatedAts := make([]time.Time, len(snapshots))
for i, snapshot := range snapshots {
contentIDs[i] = snapshot.ContentID
updatedAts[i] = snapshot.UpdatedAt
}
return contentIDs, updatedAts
}
func buildSupersededEpisodeProgressQuery() string {
return `
WITH in_progress(content_id, updated_at) AS (
SELECT * FROM unnest($1::text[], $2::timestamptz[])
),
completed(content_id, updated_at) AS (
SELECT * FROM unnest($3::text[], $4::timestamptz[])
)
SELECT DISTINCT ip.content_id
FROM in_progress ip_progress
JOIN episodes ip ON ip.content_id = ip_progress.content_id
JOIN episodes done
ON done.series_id = ip.series_id
AND (done.season_number, done.episode_number) > (ip.season_number, ip.episode_number)
JOIN completed done_progress
ON done_progress.content_id = done.content_id
WHERE done_progress.updated_at > ip_progress.updated_at`
}
func filterSupersededEpisodeProgressEntries(entries []userstore.WatchProgress, superseded map[string]struct{}) []userstore.WatchProgress {
if len(entries) == 0 || len(superseded) == 0 {
return entries
}
filtered := make([]userstore.WatchProgress, 0, len(entries))
for _, entry := range entries {
if _, ok := superseded[entry.MediaItemID]; ok {
continue
}
filtered = append(filtered, entry)
}
return filtered
}
func collapseContinueWatchingSeriesCandidates(items []*models.MediaItem, meta map[string]SectionItemMeta) []*models.MediaItem {
selectedBySeries := make(map[string]int)
result := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
itemMeta := meta[item.ContentID]
if itemMeta.SeriesID == nil || *itemMeta.SeriesID == "" {
result = append(result, item)
continue
}
seriesID := *itemMeta.SeriesID
selectedIndex, ok := selectedBySeries[seriesID]
if !ok {
selectedBySeries[seriesID] = len(result)
result = append(result, item)
continue
}
current := result[selectedIndex]
if continueWatchingCandidatePreferred(itemMeta, meta[current.ContentID]) {
result[selectedIndex] = item
}
}
return result
}
func continueWatchingCandidatePreferred(candidate, current SectionItemMeta) bool {
candidateInProgress := candidate.ItemSource == "in_progress"
currentInProgress := current.ItemSource == "in_progress"
if candidateInProgress != currentInProgress {
return candidateInProgress
}
if candidateInProgress {
if !candidate.SortTimestamp.Equal(current.SortTimestamp) {
return candidate.SortTimestamp.After(current.SortTimestamp)
}
return episodeOrdinal(candidate) > episodeOrdinal(current)
}
return candidate.SortTimestamp.After(current.SortTimestamp)
}
func episodeOrdinal(meta SectionItemMeta) int {
season := 0
if meta.SeasonNumber != nil {
season = *meta.SeasonNumber
}
episode := 0
if meta.EpisodeNumber != nil {
episode = *meta.EpisodeNumber
}
return season*100000 + episode
}
func (f *Fetcher) filterContinueWatchingDismissals(ctx context.Context, store userstore.UserStore, profileID string, entries []userstore.WatchProgress) []userstore.WatchProgress {
if len(entries) == 0 {
return entries
}
dismissals, err := store.ListHomeDismissals(ctx, profileID, userstore.HomeSurfaceContinueWatching)
if err != nil {
slog.Error("listing continue watching dismissals", "profile_id", profileID, "error", err)
return entries
}
if len(dismissals) == 0 {
return entries
}
dismissalByItemID := make(map[string]userstore.HomeItemDismissal, len(dismissals))
for _, dismissal := range dismissals {
dismissalByItemID[dismissal.MediaItemID] = dismissal
}
filtered := make([]userstore.WatchProgress, 0, len(entries))
for _, entry := range entries {
dismissal, ok := dismissalByItemID[entry.MediaItemID]
if !ok || dismissal.ProgressUpdatedAt == nil || *dismissal.ProgressUpdatedAt != entry.UpdatedAt {
filtered = append(filtered, entry)
}
}
return filtered
}
func (f *Fetcher) filterNextUpDismissals(ctx context.Context, userID int, profileID string, results []catalog.NextUpResult) []catalog.NextUpResult {
if len(results) == 0 || f.StoreProvider == nil || userID <= 0 || profileID == "" {
return results
}
store, err := f.StoreProvider.ForUser(ctx, userID)
if err != nil {
slog.Error("getting user store for next-up dismissals", "profile_id", profileID, "error", err)
return results
}
dismissals, err := store.ListHomeDismissals(ctx, profileID, userstore.HomeSurfaceNextUp)
if err != nil {
slog.Error("listing next-up dismissals", "profile_id", profileID, "error", err)
return results
}
if len(dismissals) == 0 {
return results
}
dismissedIDs := make(map[string]struct{}, len(dismissals))
for _, dismissal := range dismissals {
dismissedIDs[dismissal.MediaItemID] = struct{}{}
}
filtered := make([]catalog.NextUpResult, 0, len(results))
for _, result := range results {
if _, dismissed := dismissedIDs[result.ContentID]; dismissed {
continue
}
filtered = append(filtered, result)
}
return filtered
}
// FetchAll runs all section queries in parallel and returns sections with items.
func (f *Fetcher) FetchAll(ctx context.Context, resolved []ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) []SectionWithItems {
start := time.Now()
results := fetchAllWithRunner(ctx, resolved, fetchAllMaxConcurrency, func(ctx context.Context, sec ResolvedSection) (SectionWithItems, error) {
return f.FetchOne(ctx, sec, libraryID, libraryIDs, userID, profileID, filter)
})
duration := time.Since(start)
if duration >= slowAggregateFetchThreshold {
attrs := []any{
"section_count", len(resolved),
"duration_ms", duration.Milliseconds(),
}
if libraryID != nil {
attrs = append(attrs, "library_id", *libraryID)
}
if libraryIDs != nil {
attrs = append(attrs, "library_ids", append([]int(nil), libraryIDs...))
}
slog.Warn("slow aggregate section fetch", attrs...)
}
return results
}
type sectionFetchRunner func(context.Context, ResolvedSection) (SectionWithItems, error)
func fetchAllWithRunner(ctx context.Context, resolved []ResolvedSection, maxConcurrency int, runner sectionFetchRunner) []SectionWithItems {
if maxConcurrency <= 0 {
maxConcurrency = 1
}
results := make([]SectionWithItems, len(resolved))
sem := make(chan struct{}, maxConcurrency)
var wg sync.WaitGroup
for i, sec := range resolved {
i, sec := i, sec
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
result, err := runner(ctx, sec)
if err != nil {
slog.Error("fetching section items", "section_id", sec.ID, "type", sec.SectionType, "error", err)
result = SectionWithItems{
ResolvedSection: sec,
Items: []*models.MediaItem{},
}
}
results[i] = result
}()
}
wg.Wait()
return results
}
// FetchItemsByContentIDs resolves content IDs to full MediaItem objects,
// applying the provided access filter across libraries.
func (f *Fetcher) FetchItemsByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, error) {
return f.fetchItemsByContentIDs(ctx, contentIDs, nil, nil, filter)
}
// FetchEpisodesByContentIDs resolves episode content IDs to MediaItem objects
// with their parent series metadata. Returns the items and per-episode metadata
// (series title, season/episode numbers). Non-episode content IDs are silently ignored.
func (f *Fetcher) FetchEpisodesByContentIDs(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) ([]*models.MediaItem, map[string]SectionItemMeta, error) {
return f.fetchEpisodeTargetsByContentIDs(ctx, contentIDs, nil, nil, filter)
}
// ListOverlaySummaries batches file lookups for section cards and derives the
// compact overlay summary per content ID.
func (f *Fetcher) ListOverlaySummaries(ctx context.Context, contentIDs []string, filter catalog.AccessFilter) (map[string]*models.OverlaySummary, error) {
summaries := make(map[string]*models.OverlaySummary, len(contentIDs))
if len(contentIDs) == 0 {
return summaries, nil
}
rows, err := f.pool.Query(ctx, `
SELECT content_id, episode_id, file_path, resolution, codec_audio, audio_tracks, hdr, video_tracks,
codec_video, audio_channels, container, subtitle_tracks, external_subtitles, edition_key
FROM media_files
WHERE (content_id = ANY($1) OR episode_id = ANY($1)) AND missing_since IS NULL
ORDER BY content_id ASC, episode_id ASC, id ASC
`, contentIDs)
if err != nil {
return nil, fmt.Errorf("querying overlay summaries: %w", err)
}
defer rows.Close()
requested := make(map[string]struct{}, len(contentIDs))
for _, contentID := range contentIDs {
requested[contentID] = struct{}{}
}
grouped := make(map[string][]*models.MediaFile, len(contentIDs))
for rows.Next() {
var contentID string
var episodeID *string
var filePath string
var resolution *string
var codecAudio *string
var audioTracksJSON []byte
var hdr bool
var videoTracksJSON []byte
var codecVideo *string
var audioChannels *int
var container *string
var subtitleTracksJSON []byte
var externalSubtitlesJSON []byte
var editionKey *string
if err := rows.Scan(
&contentID, &episodeID, &filePath, &resolution, &codecAudio, &audioTracksJSON, &hdr, &videoTracksJSON,
&codecVideo, &audioChannels, &container, &subtitleTracksJSON, &externalSubtitlesJSON, &editionKey,
); err != nil {
return nil, fmt.Errorf("scanning overlay summary row: %w", err)
}
file := &models.MediaFile{
ContentID: contentID,
FilePath: filePath,
HDR: hdr,
}
if episodeID != nil {
file.EpisodeID = *episodeID
}
if resolution != nil {
file.Resolution = *resolution
}
if codecAudio != nil {
file.CodecAudio = *codecAudio
}
if codecVideo != nil {
file.CodecVideo = *codecVideo
}
if audioChannels != nil {
file.AudioChannels = *audioChannels
}
if container != nil {
file.Container = *container
}
if editionKey != nil {
file.EditionKey = *editionKey
}
if len(audioTracksJSON) > 0 {
if err := json.Unmarshal(audioTracksJSON, &file.AudioTracks); err != nil {
return nil, fmt.Errorf("unmarshaling overlay audio tracks: %w", err)
}
}
if len(videoTracksJSON) > 0 {
if err := json.Unmarshal(videoTracksJSON, &file.VideoTracks); err != nil {
return nil, fmt.Errorf("unmarshaling overlay video tracks: %w", err)
}
}
if len(subtitleTracksJSON) > 0 {
if err := json.Unmarshal(subtitleTracksJSON, &file.SubtitleTracks); err != nil {
return nil, fmt.Errorf("unmarshaling overlay subtitle tracks: %w", err)
}
}
if len(externalSubtitlesJSON) > 0 {
if err := json.Unmarshal(externalSubtitlesJSON, &file.ExternalSubtitles); err != nil {
return nil, fmt.Errorf("unmarshaling overlay external subtitles: %w", err)
}
}
groupKey := contentID
if episodeID != nil {
if _, ok := requested[*episodeID]; ok {
groupKey = *episodeID
}
}
grouped[groupKey] = append(grouped[groupKey], file)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating overlay summary rows: %w", err)
}
for contentID, files := range grouped {
files = catalog.FilterMediaFilesByAccess(files, filter)
if summary := overlays.BuildSummary(files); summary != nil {
summaries[contentID] = summary
}
}
return summaries, nil
}
func (f *Fetcher) fetchSection(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
switch s.SectionType {
case SectionRecentlyAdded:
return f.fetchRecentlyAdded(ctx, s, libraryID, libraryIDs, filter)
case SectionRecentlyReleased:
return f.fetchRecentlyReleased(ctx, s, libraryID, libraryIDs, filter)
case SectionGenre, SectionCustomFilter:
return f.fetchFiltered(ctx, s, libraryID, libraryIDs, filter)
case SectionRandom:
return f.fetchRandom(ctx, s, libraryID, libraryIDs, filter)
case SectionCollection:
return f.fetchCollection(ctx, s, libraryID, libraryIDs, userID, profileID, filter)
case SectionRecommendedForYou, SectionBecauseYouWatched, SectionSimilarUsersLiked, SectionTasteMatch:
return f.fetchRecommendationSection(ctx, s, libraryID, libraryIDs, userID, profileID, filter)
case SectionHiddenGems:
return f.fetchHiddenGems(ctx, s, libraryID, libraryIDs, userID, profileID, filter)
case SectionCriticallyAcclaimed:
return f.fetchCriticallyAcclaimed(ctx, s, libraryID, libraryIDs, filter)
case SectionAwardWinners:
return f.fetchAwardWinners(ctx, s)
case SectionForgottenFavorites:
return f.fetchForgottenFavorites(ctx, s, libraryID, libraryIDs, userID, profileID, filter)
case SectionFormatShowcase:
return f.fetchFormatShowcase(ctx, s, libraryID, libraryIDs, filter)
case SectionEditorialSpotlight:
return f.fetchEditorialSpotlight(ctx, s, libraryID, libraryIDs, filter)
case SectionSeasonalThemed:
return f.fetchSeasonalThemed(ctx, s, libraryID, libraryIDs, filter)
case SectionMoodCollection:
return f.fetchMoodCollection(ctx, s, libraryID, libraryIDs, filter)
case SectionTrendingOnServer:
return f.fetchTrending(ctx, s, libraryID, libraryIDs, filter)
case SectionProfileActivityFeed:
return f.fetchProfileActivityFeed(ctx, s, libraryID, libraryIDs, profileID, filter)
case SectionNewToLibrary:
return f.fetchNewToLibrary(ctx, s, libraryID, libraryIDs, filter)
case SectionMostWatched:
return f.fetchMostWatched(ctx, s, libraryID, libraryIDs, filter)
case SectionAdminCuratedList:
return f.fetchAdminCuratedList(ctx, s, libraryID, libraryIDs, filter)
default:
// Profile-scoped types (continue_watching, watchlist, favorites)
// will be wired later when user store integration is added.
return nil, 0, fmt.Errorf("unsupported section type: %s", s.SectionType)
}
}
func (f *Fetcher) fetchCollection(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
cfg := ParseCollectionConfig(s.Config)
// User collection path: resolve from the user's personal store.
if userCollID := strings.TrimSpace(cfg.UserCollectionID); userCollID != "" {
return f.fetchUserCollection(ctx, s, libraryID, libraryIDs, userID, profileID, filter, userCollID)
}
// Library collection path (existing behaviour).
if f.CollectionRepo == nil {
return nil, 0, fmt.Errorf("collection sections require a collection repository")
}
if strings.TrimSpace(cfg.LibraryCollectionID) == "" {
return []*models.MediaItem{}, 0, nil
}
if _, err := f.CollectionRepo.GetByID(ctx, cfg.LibraryCollectionID); err != nil {
return nil, 0, fmt.Errorf("loading library collection: %w", err)
}
collectionItems, err := f.CollectionRepo.ListItems(ctx, cfg.LibraryCollectionID)
if err != nil {
return nil, 0, fmt.Errorf("listing collection items: %w", err)
}
if len(collectionItems) == 0 {
return []*models.MediaItem{}, 0, nil
}
limit := s.ItemLimit
if limit <= 0 || limit > len(collectionItems) {
limit = len(collectionItems)
}
contentIDs := make([]string, 0, limit)
for _, item := range collectionItems[:limit] {
contentIDs = append(contentIDs, item.MediaItemID)
}
items, err := f.fetchItemsByContentIDs(ctx, contentIDs, libraryID, libraryIDs, filter)
if err != nil {
return nil, 0, err
}
itemByID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
itemByID[item.ContentID] = item
}
orderedItems := make([]*models.MediaItem, 0, len(contentIDs))
for _, contentID := range contentIDs {
item, ok := itemByID[contentID]
if !ok {
continue
}
orderedItems = append(orderedItems, item)
}
return orderedItems, len(orderedItems), nil
}
// fetchUserCollection resolves a personal (profile-scoped) user collection.
// For smart collections it delegates to fetchFiltered; for manual ones it
// fetches the stored item list and looks up the media items by content ID.
func (f *Fetcher) fetchUserCollection(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter, collectionID string) ([]*models.MediaItem, int, error) {
if f.StoreProvider == nil || userID <= 0 || profileID == "" {
return []*models.MediaItem{}, 0, nil
}
store, err := f.StoreProvider.ForUser(ctx, userID)
if err != nil {
return nil, 0, fmt.Errorf("getting user store for collection: %w", err)
}
collection, err := store.GetCollection(ctx, collectionID)
if err != nil {
return nil, 0, fmt.Errorf("loading user collection: %w", err)
}
// Verify the requesting profile can access this collection.
if collection.ProfileID != profileID && collection.CreatorProfileID != profileID {
canAccess := false
for _, allowed := range collection.AllowedProfileIDs {
if allowed == profileID {
canAccess = true
break
}
}
if !canAccess {
return []*models.MediaItem{}, 0, nil
}
}
// Smart / live-query collection: parse the query definition and use the
// filtered fetch path, mirroring resolveUserCollectionSource in catalog_resolver.
isSmartType := strings.EqualFold(strings.TrimSpace(collection.CollectionType), "smart")
hasQueryDef := len(strings.TrimSpace(collection.QueryDefinition)) > 0 &&
strings.TrimSpace(collection.QueryDefinition) != "{}" &&
strings.TrimSpace(collection.QueryDefinition) != "null"
if isSmartType || hasQueryDef {
var qd catalog.QueryDefinition
if err := json.Unmarshal([]byte(collection.QueryDefinition), &qd); err != nil {
return nil, 0, fmt.Errorf("parsing user collection query_definition: %w", err)
}
qd = qd.Normalize()
// Build a synthetic resolved section with the query definition as config.
cfgBytes, _ := json.Marshal(qd)
synth := ResolvedSection{
ID: s.ID,
SectionType: SectionCustomFilter,
Title: s.Title,
Featured: s.Featured,
ItemLimit: s.ItemLimit,
Config: cfgBytes,
Position: s.Position,
}
return f.fetchFiltered(ctx, synth, libraryID, libraryIDs, filter)
}
// Manual collection: fetch stored items.
collectionItems, err := store.ListCollectionItems(ctx, collectionID)
if err != nil {
return nil, 0, fmt.Errorf("listing user collection items: %w", err)
}
if len(collectionItems) == 0 {
return []*models.MediaItem{}, 0, nil
}
limit := s.ItemLimit
if limit <= 0 || limit > len(collectionItems) {
limit = len(collectionItems)
}
contentIDs := make([]string, 0, limit)
for _, item := range collectionItems[:limit] {
contentIDs = append(contentIDs, item.MediaItemID)
}
items, err := f.fetchItemsByContentIDs(ctx, contentIDs, libraryID, libraryIDs, filter)
if err != nil {
return nil, 0, err
}
itemByID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
itemByID[item.ContentID] = item
}
orderedItems := make([]*models.MediaItem, 0, len(contentIDs))
for _, contentID := range contentIDs {
item, ok := itemByID[contentID]
if !ok {
continue
}
orderedItems = append(orderedItems, item)
}
return orderedItems, len(orderedItems), nil
}
func (f *Fetcher) fetchRecommendationSection(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
if f.RecommendationReader == nil {
return []*models.MediaItem{}, 0, nil // graceful degradation
}
cfg := parseRecommendationSectionConfig(s.Config)
var scoredItems []recommendations.ScoredItem
switch s.SectionType {
case SectionRecommendedForYou:
row, err := f.RecommendationReader.GetForYouMain(ctx, userID, profileID, s.ItemLimit, filter)
if err != nil || row == nil {
return []*models.MediaItem{}, 0, err
}
scoredItems = row.Items
case SectionBecauseYouWatched:
items, err := f.RecommendationReader.GetBecauseYouWatched(ctx, userID, profileID, cfg.SourceItemID, s.ItemLimit, filter)
if err != nil {
return []*models.MediaItem{}, 0, err
}
scoredItems = items
case SectionSimilarUsersLiked:
items, err := f.RecommendationReader.GetSimilarUsersLiked(ctx, userID, profileID, s.ItemLimit, filter)
if err != nil {
return []*models.MediaItem{}, 0, err
}
scoredItems = items
case SectionTasteMatch:
if strings.TrimSpace(cfg.Genre) == "" {
return []*models.MediaItem{}, 0, nil
}
row, err := f.RecommendationReader.GetTasteMatchRow(ctx, userID, profileID, cfg.Genre, s.ItemLimit, filter)
if err != nil || row == nil {
return []*models.MediaItem{}, 0, err
}
scoredItems = row.Items
default:
return []*models.MediaItem{}, 0, nil
}
if len(scoredItems) == 0 {
return []*models.MediaItem{}, 0, nil
}
// Resolve item IDs to full MediaItem objects
itemIDs := make([]string, len(scoredItems))
for i, item := range scoredItems {
itemIDs[i] = item.MediaItemID
}
mediaItems, err := f.fetchItemsByContentIDs(ctx, itemIDs, libraryID, libraryIDs, filter)
if err != nil {
return nil, 0, err
}
orderedItems := orderMediaItems(mediaItems, itemIDs)
return orderedItems, len(orderedItems), nil
}
func (f *Fetcher) fetchHiddenGems(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.HiddenGemsParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
if p.MinRating == 0 {
p.MinRating = 7.5
}
// Require a valid user/profile to exclude watched items.
if userID <= 0 || profileID == "" {
return []*models.MediaItem{}, 0, nil
}
repo := catalog.NewDiscoveryRepository(f.pool)
items, err := repo.ListUnplayedHighRated(ctx, catalog.UnplayedFilter{
MinRating: p.MinRating,
Limit: s.ItemLimit,
UserID: userID,
ProfileID: profileID,
Filter: filter,
})
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
func (f *Fetcher) fetchCriticallyAcclaimed(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.CriticallyAcclaimedParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
if p.MinScore == 0 {
p.MinScore = 8.0
}
repo := catalog.NewDiscoveryRepository(f.pool)
items, err := repo.ListByRatingThreshold(ctx, catalog.RatingFilter{
Min: p.MinScore,
Limit: s.ItemLimit,
LibraryID: libraryID,
Filter: filter,
})
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
func (f *Fetcher) fetchAwardWinners(_ context.Context, s ResolvedSection) ([]*models.MediaItem, int, error) {
// TODO(awards-data): wire up once award metadata exists
return []*models.MediaItem{}, 0, nil
}
func (f *Fetcher) fetchForgottenFavorites(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.ForgottenFavoritesParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
if p.LookbackDays <= 0 {
p.LookbackDays = 365
}
// Require a valid user/profile to check watch history.
if userID <= 0 || profileID == "" {
return []*models.MediaItem{}, 0, nil
}
repo := catalog.NewDiscoveryRepository(f.pool)
items, err := repo.ListForgottenFavorites(ctx, catalog.ForgottenFavoritesFilter{
LookbackDays: p.LookbackDays,
Limit: s.ItemLimit,
UserID: userID,
ProfileID: profileID,
Filter: filter,
})
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
// fetchFormatShowcase returns items that have at least one media file matching the
// requested stream format. Filtering is done against media_files columns:
// - 4k: resolution IN ('4k','uhd','2160p')
// - dolby_vision: EXISTS a video_tracks element with a non-empty dolby_vision field
// - hdr: hdr = true
//
// When no format is supplied all items with any of the above formats are returned.
func (f *Fetcher) fetchFormatShowcase(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.FormatShowcaseParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
// Build the per-format EXISTS subquery condition against media_files.
var formatCond string
switch p.Format {
case "4k":
formatCond = `EXISTS (
SELECT 1 FROM media_files mf
WHERE (mf.content_id = mi.content_id OR mf.episode_id = mi.content_id)
AND mf.missing_since IS NULL
AND LOWER(mf.resolution) IN ('4k','uhd','2160p')
)`
case "dolby_vision":
formatCond = `EXISTS (
SELECT 1 FROM media_files mf
WHERE (mf.content_id = mi.content_id OR mf.episode_id = mi.content_id)
AND mf.missing_since IS NULL
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(COALESCE(mf.video_tracks, '[]'::jsonb)) AS vt
WHERE vt->>'dolby_vision' IS NOT NULL AND vt->>'dolby_vision' != ''
)
)`
case "hdr":
formatCond = `EXISTS (
SELECT 1 FROM media_files mf
WHERE (mf.content_id = mi.content_id OR mf.episode_id = mi.content_id)
AND mf.missing_since IS NULL
AND mf.hdr = true
)`
default:
// No format specified: return items matching any of the above formats.
formatCond = `EXISTS (
SELECT 1 FROM media_files mf
WHERE (mf.content_id = mi.content_id OR mf.episode_id = mi.content_id)
AND mf.missing_since IS NULL
AND (
LOWER(mf.resolution) IN ('4k','uhd','2160p')
OR mf.hdr = true
OR EXISTS (
SELECT 1 FROM jsonb_array_elements(COALESCE(mf.video_tracks, '[]'::jsonb)) AS vt
WHERE vt->>'dolby_vision' IS NOT NULL AND vt->>'dolby_vision' != ''
)
)
)`
}
var conditions []string
var args []any
argIdx := 1
conditions = append(conditions, formatCond)
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
whereClause := "WHERE " + strings.Join(conditions, " AND ")
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
query := fmt.Sprintf(
`SELECT %s FROM %s %s ORDER BY mi.rating_imdb DESC NULLS LAST, mi.content_id ASC LIMIT $%d`,
itemColumns("mi"), fromClause, whereClause, argIdx,
)
args = append(args, limit)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("fetching format showcase: %w", err)
}
defer rows.Close()
items, err := scanMediaItems(rows)
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
func (f *Fetcher) fetchAdminCuratedList(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.AdminCuratedListParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
if len(p.ItemIDs) == 0 {
return []*models.MediaItem{}, 0, nil
}
// Build conditions: content_id IN curated set + library scope + access filter.
var conditions []string
var args []any
argIdx := 1
conditions = append(conditions, fmt.Sprintf("mi.content_id = ANY($%d)", argIdx))
args = append(args, p.ItemIDs)
argIdx++
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
whereClause := "WHERE " + strings.Join(conditions, " AND ")
// Order by position in the curated array. Reuse the item-IDs parameter
// index (1) for array_position; do NOT pass the slice twice.
query := fmt.Sprintf(
`SELECT %s FROM %s %s ORDER BY array_position($1::text[], mi.content_id)`,
itemColumns("mi"), fromClause, whereClause,
)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("fetching admin curated list: %w", err)
}
defer rows.Close()
items, err := scanMediaItems(rows)
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
// fetchEditorialSpotlight returns items for an editorial_spotlight section.
// When auto_rotate is true it selects a subject from a candidate list using a
// deterministic ISO-week hash; otherwise it uses the configured subject directly.
func (f *Fetcher) fetchEditorialSpotlight(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
items, total, _, err := f.fetchEditorialSpotlightWithTitle(ctx, s, libraryID, libraryIDs, filter)
return items, total, err
}
func (f *Fetcher) fetchEditorialSpotlightWithTitle(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, string, error) {
var p recipes.EditorialSpotlightParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
if p.SubjectType == "" {
return []*models.MediaItem{}, 0, "", nil
}
subject := p.Subject
if p.AutoRotate {
cands, err := f.cachedEditorialCandidates(ctx, p.SubjectType, libraryID, libraryIDs, filter, editorialCandidateCacheTTL, f.editorialCandidates)
if err != nil {
return nil, 0, "", fmt.Errorf("editorial_spotlight candidates: %w", err)
}
if len(cands) == 0 {
return []*models.MediaItem{}, 0, "", nil
}
days := editorialCadenceDays(p.RotationCadence)
// Build a value-based rotation key. Using `%v` on the *int would print
// the pointer address, which changes on every process restart and
// would break the deterministic-rotation contract.
libKey := "all"
if libraryID != nil {
libKey = strconv.Itoa(*libraryID)
}
key := fmt.Sprintf("%s|%s", p.SubjectType, libKey)
idx := recipes.RotationIndex(f.now(), key, len(cands), days)
subject = cands[idx]
}
if subject == "" {
return []*models.MediaItem{}, 0, "", nil
}
// Build a QueryDefinition with the subject filter and run it via the executor.
def, err := editorialBuildQueryDef(p.SubjectType, subject)
if err != nil {
return nil, 0, "", fmt.Errorf("editorial_spotlight build query: %w", err)
}
switch {
case libraryID != nil:
def.LibraryIDs = []int{*libraryID}
case libraryIDs != nil:
if len(def.LibraryIDs) == 0 {
def.LibraryIDs = append([]int(nil), libraryIDs...)
} else {
def.LibraryIDs = intersectLibraryIDs(def.LibraryIDs, libraryIDs)
}
}
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
def.Limit = &limit
executor := &catalog.QueryExecutor{Pool: f.pool}
items, _, _, err := executor.PreviewPage(ctx, def, filter, limit, 0, false)
if err != nil {
return nil, 0, "", fmt.Errorf("editorial_spotlight query: %w", err)
}
return items, len(items), editorialSpotlightDisplayTitle(s.Title, subject), nil
}
func editorialSpotlightDisplayTitle(baseTitle, subject string) string {
baseTitle = strings.TrimSpace(baseTitle)
subject = strings.TrimSpace(subject)
if subject == "" {
return baseTitle
}
if baseTitle == "" || strings.EqualFold(baseTitle, subject) {
return subject
}
suffix := " - " + subject
if strings.HasSuffix(baseTitle, suffix) {
return baseTitle
}
return baseTitle + suffix
}
// editorialCadenceDays converts a RotationCadence to a number of days for RotationIndex.
func editorialCadenceDays(c recipes.RotationCadence) int {
switch c {
case recipes.CadenceDaily:
return 1
case recipes.CadenceMonthly:
return 30
default: // weekly or empty
return 7
}
}
// editorialBuildQueryDef builds a QueryDefinition for the given subject type and subject value.
func editorialBuildQueryDef(subjectType, subject string) (catalog.QueryDefinition, error) {
var rule catalog.QueryRule
switch subjectType {
case "director":
rule = catalog.QueryRule{Field: "director", Op: "is", Value: subject}
case "actor":
rule = catalog.QueryRule{Field: "actor", Op: "is", Value: subject}
case "studio":
rule = catalog.QueryRule{Field: "studio", Op: "is", Value: subject}
case "era":
startYear, endYear, err := eraToYearRange(subject)
if err != nil {
return catalog.QueryDefinition{}, err
}
// release_date between uses string dates ("YYYY-01-01", "YYYY-12-31").
startISO := fmt.Sprintf("%d-01-01", startYear)
endISO := fmt.Sprintf("%d-12-31", endYear)
rule = catalog.QueryRule{Field: "release_date", Op: "between", Value: []any{startISO, endISO}}
default:
return catalog.QueryDefinition{}, fmt.Errorf("editorial_spotlight: unsupported subject_type %q", subjectType)
}
def := catalog.QueryDefinition{
Match: "all",
Groups: []catalog.QueryGroup{
{Match: "all", Rules: []catalog.QueryRule{rule}},
},
}
return def.Normalize(), nil
}
// eraToYearRange converts an era label like "1980s" to its start and end years.
func eraToYearRange(era string) (int, int, error) {
switch era {
case "1970s":
return 1970, 1979, nil
case "1980s":
return 1980, 1989, nil
case "1990s":
return 1990, 1999, nil
case "2000s":
return 2000, 2009, nil
case "2010s":
return 2010, 2019, nil
case "2020s":
return 2020, 2029, nil
default:
return 0, 0, fmt.Errorf("editorial_spotlight: unknown era %q", era)
}
}
// editorialCandidates returns the top subject names for auto-rotation, applying
// library scope and access filter so results are scoped to the user's libraries.
func (f *Fetcher) editorialCandidates(ctx context.Context, subjectType string, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]string, error) {
switch subjectType {
case "director":
return f.topPersonCandidates(ctx, 2 /* PersonKindDirector */, libraryID, libraryIDs, filter)
case "actor":
return f.topPersonCandidates(ctx, 1 /* PersonKindActor */, libraryID, libraryIDs, filter)
case "studio":
return f.topStudioCandidates(ctx, libraryID, libraryIDs, filter)
case "era":
// Auto-rotate excludes the current decade — incomplete data + stylistic skew.
// "2020s" is still accepted as a manually-pinned subject via eraToYearRange.
return []string{"1970s", "1980s", "1990s", "2000s", "2010s"}, nil
case "franchise":
// TODO: franchise data not yet available; auto-rotate returns empty.
return nil, nil
default:
return nil, fmt.Errorf("editorial_spotlight: unsupported subject_type %q", subjectType)
}
}
const editorialCandidateLimit = 50
// topPersonCandidates returns the top N person names by item count for the given kind.
func (f *Fetcher) topPersonCandidates(ctx context.Context, kind int, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]string, error) {
var conditions []string
var args []any
argIdx := 1
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
whereClause := "WHERE 1=1"
if len(conditions) > 0 {
whereClause += " AND " + strings.Join(conditions, " AND ")
}
args = append(args, kind, editorialCandidateLimit)
// Weighted billing: SUM(1/(sort_order+1)) favors top-billed credits
// (TMDB convention: 0 = lead). HAVING MIN(sort_order) <= 5 excludes
// people who only ever appear in deep supporting roles.
query := fmt.Sprintf(`
SELECT p.name
FROM people p
JOIN item_people ip ON ip.person_id = p.id AND ip.kind = $%d
JOIN %s ON mi.content_id = ip.content_id
%s
GROUP BY p.name
HAVING MIN(ip.sort_order) <= 5
ORDER BY SUM(1.0 / (ip.sort_order + 1)) DESC, COUNT(*) DESC
LIMIT $%d
`, argIdx, fromClause, whereClause, argIdx+1)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying top persons (kind=%d): %w", kind, err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("scanning person name: %w", err)
}
names = append(names, name)
}
return names, rows.Err()
}
// topStudioCandidates returns the top N studio names by item count.
func (f *Fetcher) topStudioCandidates(ctx context.Context, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]string, error) {
var conditions []string
var args []any
argIdx := 1
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
whereClause := ""
if len(conditions) > 0 {
whereClause = "AND " + strings.Join(conditions, " AND ")
}
args = append(args, editorialCandidateLimit)
query := fmt.Sprintf(`
SELECT studio
FROM (
SELECT unnest(mi.studios) AS studio
FROM %s
WHERE mi.studios IS NOT NULL
%s
) sub
GROUP BY studio
ORDER BY COUNT(*) DESC
LIMIT $%d
`, fromClause, whereClause, argIdx)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying top studios: %w", err)
}
defer rows.Close()
var studios []string
for rows.Next() {
var studio string
if err := rows.Scan(&studio); err != nil {
return nil, fmt.Errorf("scanning studio name: %w", err)
}
studios = append(studios, studio)
}
return studios, rows.Err()
}
type recommendationSectionConfig struct {
SourceItemID string `json:"source_item_id"`
Genre string `json:"genre"`
}
func parseRecommendationSectionConfig(config json.RawMessage) recommendationSectionConfig {
var cfg recommendationSectionConfig
if len(config) == 0 {
return cfg
}
_ = json.Unmarshal(config, &cfg)
return cfg
}
func orderMediaItems(items []*models.MediaItem, orderedIDs []string) []*models.MediaItem {
if len(items) <= 1 {
return items
}
byID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
byID[item.ContentID] = item
}
ordered := make([]*models.MediaItem, 0, len(items))
for _, id := range orderedIDs {
item, ok := byID[id]
if !ok {
continue
}
ordered = append(ordered, item)
}
return ordered
}
func (f *Fetcher) fetchRecentlyAdded(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
query, args := buildRecentlyAddedQuery(s, libraryID, libraryIDs, filter)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanMediaItems(rows)
return items, len(items), err
}
type sectionQuery struct {
sql string
args []any
}
func buildRecentlyAddedQuery(s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) (string, []any) {
cfgFilters := ParseConfigFilters(s.Config)
// Backwards compat: support legacy "types" config field
var legacyCfg struct {
Types []string `json:"types"`
}
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &legacyCfg)
}
if cfgFilters.FilterType == "" && len(legacyCfg.Types) > 0 {
cfgFilters.FilterType = legacyCfg.Types[0]
}
if query, ok := buildRecentlyAddedSingleLibraryQuery(s, cfgFilters, libraryID, libraryIDs, filter); ok {
return query.sql, query.args
}
var conditions []string
var args []any
argIdx := 1
applyConfigTypeFilter("mi", cfgFilters.FilterType, &conditions, &args, &argIdx)
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, cfgFilters.LibraryIDs(), filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
query := fmt.Sprintf(
`SELECT %s FROM %s %s ORDER BY mi.created_at DESC, mi.content_id ASC LIMIT $%d`,
itemColumns("mi"), fromClause, whereClause, argIdx,
)
args = append(args, s.ItemLimit)
return query, args
}
func buildRecentlyAddedSingleLibraryQuery(s ResolvedSection, cfgFilters SectionConfigFilters, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) (sectionQuery, bool) {
configLibraryIDs := cfgFilters.LibraryIDs()
var singleLibraryID int
switch {
case libraryID != nil:
singleLibraryID = *libraryID
case len(configLibraryIDs) == 1:
singleLibraryID = configLibraryIDs[0]
default:
return sectionQuery{}, false
}
if singleLibraryID <= 0 {
return sectionQuery{}, false
}
var conditions []string
var args []any
argIdx := 1
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx))
args = append(args, singleLibraryID)
argIdx++
if libraryIDs != nil {
if len(libraryIDs) == 0 {
conditions = append(conditions, "1 = 0")
} else {
placeholders := make([]string, len(libraryIDs))
for i, id := range libraryIDs {
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(filter.DisabledLibraryIDs) > 0 {
placeholders := make([]string, len(filter.DisabledLibraryIDs))
for i, id := range 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, ", ")))
}
applyConfigTypeFilter("mi", cfgFilters.FilterType, &conditions, &args, &argIdx)
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
whereClause := "WHERE " + strings.Join(conditions, " AND ")
query := fmt.Sprintf(
`SELECT %s FROM media_item_libraries mil JOIN media_items mi ON mi.content_id = mil.content_id %s ORDER BY mil.first_seen_at DESC, mil.content_id ASC LIMIT $%d`,
itemColumns("mi"), whereClause, argIdx,
)
args = append(args, s.ItemLimit)
return sectionQuery{sql: query, args: args}, true
}
func (f *Fetcher) fetchRecentlyReleased(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
cfgFilters := ParseConfigFilters(s.Config)
var conditions []string
var args []any
argIdx := 1
applyConfigTypeFilter("mi", cfgFilters.FilterType, &conditions, &args, &argIdx)
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, cfgFilters.LibraryIDs(), filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
query := fmt.Sprintf(
`SELECT %s FROM %s %s ORDER BY mi.year DESC, mi.created_at DESC LIMIT $%d`,
itemColumns("mi"), fromClause, whereClause, argIdx,
)
args = append(args, s.ItemLimit)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanMediaItems(rows)
return items, len(items), err
}
func (f *Fetcher) fetchFiltered(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
def, err := ParseQueryDefinition(s.Config)
if err != nil {
return nil, 0, fmt.Errorf("parsing query definition: %w", err)
}
switch {
case libraryID != nil:
def.LibraryIDs = []int{*libraryID}
case libraryIDs != nil:
if len(def.LibraryIDs) == 0 {
def.LibraryIDs = append([]int(nil), libraryIDs...)
} else {
def.LibraryIDs = intersectLibraryIDs(def.LibraryIDs, libraryIDs)
}
}
if s.ItemLimit > 0 {
limit := s.ItemLimit
def.Limit = &limit
}
executor := &catalog.QueryExecutor{Pool: f.pool}
items, _, hasMore, err := executor.PreviewPage(ctx, def, filter, s.ItemLimit, 0, false)
if err != nil {
return nil, 0, err
}
total := len(items)
if hasMore && s.ItemLimit > 0 && total <= s.ItemLimit {
total = s.ItemLimit + 1
}
return items, total, nil
}
func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
cfgFilters := ParseConfigFilters(s.Config)
var conditions []string
var args []any
argIdx := 1
applyConfigTypeFilter("mi", cfgFilters.FilterType, &conditions, &args, &argIdx)
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, cfgFilters.LibraryIDs(), filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
queryLimit := limit + 1
query := fmt.Sprintf(
`SELECT candidate.content_id
FROM (
SELECT DISTINCT mi.content_id
FROM %s
%s
) candidate
ORDER BY RANDOM()
LIMIT $%d`,
fromClause, whereClause, argIdx,
)
args = append(args, queryLimit)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var contentIDs []string
for rows.Next() {
var contentID string
if err := rows.Scan(&contentID); err != nil {
return nil, 0, fmt.Errorf("scanning random section content id: %w", err)
}
contentIDs = append(contentIDs, contentID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
hasMore := len(contentIDs) > limit
if hasMore {
contentIDs = contentIDs[:limit]
}
items, err := f.fetchItemsByContentIDs(ctx, contentIDs, libraryID, libraryIDs, filter)
if err != nil {
return nil, 0, err
}
ordered := orderMediaItems(items, contentIDs)
total := len(ordered)
if hasMore && total <= limit {
total = limit + 1
}
return ordered, total, nil
}
func (f *Fetcher) fetchItemsByContentIDs(ctx context.Context, contentIDs []string, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, error) {
if len(contentIDs) == 0 {
return []*models.MediaItem{}, nil
}
var conditions []string
var args []any
argIdx := 1
placeholders := make([]string, len(contentIDs))
for i, contentID := range contentIDs {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
args = append(args, contentID)
argIdx++
}
conditions = append(conditions, fmt.Sprintf("mi.content_id IN (%s)", strings.Join(placeholders, ", ")))
effectiveLibraryIDs := effectiveFetchLibraryIDs(libraryIDs, filter)
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, effectiveLibraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
query := fmt.Sprintf(
`SELECT %s FROM %s WHERE %s`,
itemColumns("mi"), fromClause, strings.Join(conditions, " AND "),
)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMediaItems(rows)
}
func (f *Fetcher) fetchEpisodeTargetsByContentIDs(ctx context.Context, contentIDs []string, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, map[string]SectionItemMeta, error) {
if len(contentIDs) == 0 {
return []*models.MediaItem{}, map[string]SectionItemMeta{}, nil
}
var conditions []string
var args []any
argIdx := 1
placeholders := make([]string, len(contentIDs))
for i, contentID := range contentIDs {
placeholders[i] = fmt.Sprintf("$%d", argIdx)
args = append(args, contentID)
argIdx++
}
conditions = append(conditions, fmt.Sprintf("e.content_id IN (%s)", strings.Join(placeholders, ", ")))
effectiveLibraryIDs := effectiveFetchLibraryIDs(libraryIDs, filter)
fromClause := "episodes e JOIN media_items si ON e.series_id = si.content_id LEFT JOIN seasons s ON s.content_id = e.season_id"
if libraryID != nil || effectiveLibraryIDs != nil {
fromClause += " JOIN media_item_libraries mil ON si.content_id = mil.content_id"
}
if libraryID != nil {
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx))
args = append(args, *libraryID)
argIdx++
}
if effectiveLibraryIDs != nil {
if len(effectiveLibraryIDs) == 0 {
return []*models.MediaItem{}, map[string]SectionItemMeta{}, nil
}
placeholders = make([]string, len(effectiveLibraryIDs))
for i, id := range effectiveLibraryIDs {
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, ", ")))
}
catalog.ApplySectionAccessFilter("si", filter, &conditions, &args, &argIdx)
query := fmt.Sprintf(`
SELECT
e.content_id,
e.series_id,
e.title,
e.overview,
e.runtime,
e.rating_imdb,
COALESCE(NULLIF(s.poster_path, ''), NULLIF(si.poster_path, ''), NULLIF(e.still_path, ''), '') AS poster_path,
COALESCE(NULLIF(s.poster_thumbhash, ''), NULLIF(si.poster_thumbhash, ''), NULLIF(e.still_thumbhash, ''), '') AS poster_thumbhash,
e.season_number,
e.episode_number,
e.air_date,
si.title,
si.genres,
si.content_rating,
COALESCE(NULLIF(e.still_path, ''), NULLIF(si.backdrop_path, ''), '') AS backdrop_path,
COALESCE(NULLIF(e.still_thumbhash, ''), NULLIF(si.backdrop_thumbhash, ''), '') AS backdrop_thumbhash,
si.logo_path,
si.status
FROM %s
WHERE %s
`, fromClause, strings.Join(conditions, " AND "))
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, nil, err
}
defer rows.Close()
items := []*models.MediaItem{}
itemMeta := map[string]SectionItemMeta{}
for rows.Next() {
var (
item models.MediaItem
seriesID string
seasonNumber int
episodeNumber int
seriesTitle string
airDate *time.Time
)
item.Type = "episode"
err := rows.Scan(
&item.ContentID,
&seriesID,
&item.Title,
&item.Overview,
&item.Runtime,
&item.RatingIMDB,
&item.PosterPath,
&item.PosterThumbhash,
&seasonNumber,
&episodeNumber,
&airDate,
&seriesTitle,
&item.Genres,
&item.ContentRating,
&item.BackdropPath,
&item.BackdropThumbhash,
&item.LogoPath,
&item.Status,
)
if err != nil {
return nil, nil, fmt.Errorf("scanning episode section item: %w", err)
}
items = append(items, &item)
itemMeta[item.ContentID] = SectionItemMeta{
SeriesID: &seriesID,
SeriesTitle: seriesTitle,
SeasonNumber: &seasonNumber,
EpisodeNumber: &episodeNumber,
Badges: recentSeasonPremiereBadges(seasonNumber, episodeNumber, airDate),
}
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return items, itemMeta, nil
}
func effectiveFetchLibraryIDs(libraryIDs []int, filter catalog.AccessFilter) []int {
if libraryIDs != nil {
return libraryIDs
}
if filter.AllowedLibraryIDs != nil {
return filter.AllowedLibraryIDs
}
return nil
}
func recentSeasonPremiereBadges(seasonNumber, episodeNumber int, airDate *time.Time) []string {
if seasonNumber <= 1 || episodeNumber != 1 || airDate == nil {
return nil
}
nowDate := utcDay(time.Now().UTC())
premiereDate := utcDay(airDate.UTC())
windowStart := nowDate.AddDate(0, 0, -(recentSeasonPremiereBadgeWindowDays - 1))
if premiereDate.Before(windowStart) || premiereDate.After(nowDate) {
return nil
}
return []string{"season_premiere"}
}
func utcDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
// itemColumns returns the SELECT column list matching scanMediaItems.
// Mirrors catalog.browseItemColumns.
func itemColumns(alias string) string {
cols := []string{
"content_id", "type", "title", "sort_title", "original_title", "year", "genres",
"content_rating", "runtime", "overview", "tagline",
"rating_imdb", "rating_tmdb", "rating_rt_critic", "rating_rt_audience",
"imdb_id", "tmdb_id", "tvdb_id",
"poster_path", "poster_thumbhash", "backdrop_path", "backdrop_thumbhash", "logo_path",
"metadata_s3_path", "metadata_etag", "season_count",
"studios", "networks", "countries", "first_air_date", "last_air_date",
"show_status",
"matched_at", "status", "created_at", "updated_at",
}
prefixed := make([]string, len(cols))
for i, c := range cols {
prefixed[i] = alias + "." + c
}
return strings.Join(prefixed, ", ")
}
// scanMediaItems scans rows into MediaItem slices. Must match itemColumns order.
func scanMediaItems(rows pgx.Rows) ([]*models.MediaItem, error) {
var items []*models.MediaItem
for rows.Next() {
var item models.MediaItem
err := rows.Scan(
&item.ContentID, &item.Type, &item.Title, &item.SortTitle, &item.OriginalTitle,
&item.Year, &item.Genres, &item.ContentRating, &item.Runtime, &item.Overview, &item.Tagline,
&item.RatingIMDB, &item.RatingTMDB, &item.RatingRTCritic, &item.RatingRTAudience,
&item.ImdbID, &item.TmdbID, &item.TvdbID,
&item.PosterPath, &item.PosterThumbhash, &item.BackdropPath, &item.BackdropThumbhash, &item.LogoPath,
&item.MetadataS3Path, &item.MetadataEtag, &item.SeasonCount,
&item.Studios, &item.Networks, &item.Countries, &item.FirstAirDate, &item.LastAirDate,
&item.ShowStatus,
&item.MatchedAt, &item.Status, &item.CreatedAt, &item.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("scanning item: %w", err)
}
items = append(items, &item)
}
return items, rows.Err()
}
func (f *Fetcher) fetchTrending(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.TrendingParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
interval := "7 days"
switch p.Window {
case "24h":
interval = "24 hours"
case "7d", "":
interval = "7 days"
case "30d":
interval = "30 days"
}
var conditions []string
var args []any
argIdx := 1
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
conditions = append(conditions, fmt.Sprintf("uwh.watched_at > NOW() - $%d::interval", argIdx))
args = append(args, interval)
argIdx++
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
whereClause := "WHERE " + strings.Join(conditions, " AND ")
query := fmt.Sprintf(
`SELECT %s FROM %s JOIN user_watch_history uwh ON uwh.media_item_id = mi.content_id %s GROUP BY mi.content_id ORDER BY COUNT(DISTINCT uwh.profile_id) DESC, COUNT(*) DESC LIMIT $%d`,
itemColumns("mi"), fromClause, whereClause, argIdx,
)
args = append(args, limit)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("fetching trending: %w", err)
}
defer rows.Close()
items, err := scanMediaItems(rows)
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
func (f *Fetcher) fetchProfileActivityFeed(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, profileID string, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.ProfileActivityFeedParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
target := p.ProfileID
// Household mode (target == "") leaks all history when caller is unauthenticated.
if target == "" && profileID == "" {
return []*models.MediaItem{}, 0, nil
}
var args []any
argIdx := 1
// CTE deduplicates per media_item_id and keeps the most-recent watched_at,
// so each item appears once and is ordered by latest-watch DESC.
var cteCond string
var cteWindow string
if target == "" {
cteCond = fmt.Sprintf("profile_id <> $%d", argIdx)
args = append(args, profileID)
argIdx++
cteWindow = "INTERVAL '7 days'"
} else {
cteCond = fmt.Sprintf("profile_id = $%d", argIdx)
args = append(args, target)
argIdx++
cteWindow = "INTERVAL '30 days'"
}
var conditions []string
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
query := fmt.Sprintf(
`WITH most_recent AS (
SELECT media_item_id, MAX(watched_at) AS latest
FROM user_watch_history
WHERE %s AND watched_at > NOW() - %s
GROUP BY media_item_id
)
SELECT %s
FROM %s
JOIN most_recent mr ON mr.media_item_id = mi.content_id
%s
ORDER BY mr.latest DESC
LIMIT $%d`,
cteCond, cteWindow,
itemColumns("mi"), fromClause, whereClause, argIdx,
)
args = append(args, limit)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("fetching profile activity feed: %w", err)
}
defer rows.Close()
items, err := scanMediaItems(rows)
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
func (f *Fetcher) fetchNewToLibrary(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.NewToLibraryParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
days := p.LookbackDays
if days <= 0 {
days = 30
}
var conditions []string
var args []any
argIdx := 1
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
conditions = append(conditions, fmt.Sprintf("mi.created_at > NOW() - ($%d || ' days')::interval", argIdx))
args = append(args, days)
argIdx++
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
whereClause := "WHERE " + strings.Join(conditions, " AND ")
query := fmt.Sprintf(
`SELECT %s FROM %s %s ORDER BY mi.created_at DESC LIMIT $%d`,
itemColumns("mi"), fromClause, whereClause, argIdx,
)
args = append(args, limit)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("fetching new to library: %w", err)
}
defer rows.Close()
items, err := scanMediaItems(rows)
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
func (f *Fetcher) fetchMostWatched(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.MostWatchedParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
interval := "7 days"
if p.Window == "month" {
interval = "30 days"
}
var conditions []string
var args []any
argIdx := 1
fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, libraryIDs, nil, filter.DisabledLibraryIDs, argIdx)
conditions = append(conditions, libConditions...)
args = append(args, libArgs...)
argIdx = newArgIdx
catalog.ApplySectionAccessFilter("mi", filter, &conditions, &args, &argIdx)
conditions = append(conditions, fmt.Sprintf("uwh.watched_at > NOW() - $%d::interval", argIdx))
args = append(args, interval)
argIdx++
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
whereClause := "WHERE " + strings.Join(conditions, " AND ")
query := fmt.Sprintf(
`SELECT %s FROM %s JOIN user_watch_history uwh ON uwh.media_item_id = mi.content_id %s GROUP BY mi.content_id ORDER BY COUNT(*) DESC LIMIT $%d`,
itemColumns("mi"), fromClause, whereClause, argIdx,
)
args = append(args, limit)
rows, err := f.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("fetching most watched: %w", err)
}
defer rows.Close()
items, err := scanMediaItems(rows)
if err != nil {
return nil, 0, err
}
return items, len(items), nil
}
func buildLibraryScope(libraryID *int, libraryIDs []int, configLibraryIDs []int, disabledLibraryIDs []int, argIdx int) (string, []string, []any, int) {
fromClause := "media_items mi"
var conditions []string
var args []any
needsJoin := libraryID != nil || libraryIDs != nil || len(configLibraryIDs) > 0 || len(disabledLibraryIDs) > 0
if needsJoin {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
}
if libraryID != nil {
conditions = append(conditions, fmt.Sprintf("mil.media_folder_id = $%d", argIdx))
args = append(args, *libraryID)
argIdx++
}
if len(configLibraryIDs) > 0 && libraryID == nil {
placeholders := make([]string, len(configLibraryIDs))
for i, id := range configLibraryIDs {
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 libraryIDs != nil {
if len(libraryIDs) == 0 {
conditions = append(conditions, "1 = 0")
return fromClause, conditions, args, argIdx
}
placeholders := make([]string, len(libraryIDs))
for i, id := range libraryIDs {
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(disabledLibraryIDs) > 0 {
placeholders := make([]string, len(disabledLibraryIDs))
for i, id := range 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, ", ")))
}
return fromClause, conditions, args, argIdx
}
func fetchSortClause(sort, order string) string {
dir := "DESC"
if order == "asc" {
dir = "ASC"
}
switch sort {
case "rating":
return fmt.Sprintf("ORDER BY mi.rating_imdb %s NULLS LAST", dir)
case "year":
return fmt.Sprintf("ORDER BY mi.year %s", dir)
case "title":
return fmt.Sprintf("ORDER BY mi.sort_title %s", dir)
default:
return fmt.Sprintf("ORDER BY mi.created_at %s", dir)
}
}
func intersectLibraryIDs(a, b []int) []int {
if len(a) == 0 || len(b) == 0 {
return nil
}
allowed := make(map[int]struct{}, len(b))
for _, value := range b {
allowed[value] = struct{}{}
}
var result []int
seen := make(map[int]struct{})
for _, value := range a {
if _, ok := allowed[value]; !ok {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
// applyConfigTypeFilter adds a WHERE condition for the config's filter_type.
func applyConfigTypeFilter(alias string, filterType string, conditions *[]string, args *[]any, argIdx *int) {
if filterType == "" {
return
}
*conditions = append(*conditions, fmt.Sprintf("%s.type = $%d", alias, *argIdx))
*args = append(*args, filterType)
*argIdx++
}
// fetchSeasonalThemed returns items for a seasonal_themed section.
//
// Multi-theme mode (EnabledThemes set): picks the highest-priority enabled
// theme whose predicate matches now and queries items for that theme. Off-
// season (no enabled theme matches) the section is empty and gets suppressed
// by the API dispatcher.
//
// Legacy single-theme mode (Theme set, EnabledThemes empty): mode controls
// suppression — "auto" hides off-season, "pinned" always renders.
func (f *Fetcher) fetchSeasonalThemed(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.SeasonalThemedParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
now := time.Now()
if f.Clock != nil {
now = f.Clock.Now()
}
// Resolve which theme to use. Multi-theme mode wins when populated.
var theme string
switch {
case len(p.EnabledThemes) > 0:
theme = recipes.ActiveSeasonalTheme(p.EnabledThemes, now)
if theme == "" {
// No enabled theme is currently in season — hide the section.
return []*models.MediaItem{}, 0, nil
}
case p.Theme != "":
// Legacy single-theme path with mode-based suppression.
pred, ok := recipes.SeasonalPredicates[p.Theme]
if !ok {
return []*models.MediaItem{}, 0, nil
}
mode := p.Mode
if mode == "" {
mode = "auto"
}
if mode == "auto" && !pred(now) {
return []*models.MediaItem{}, 0, nil
}
theme = p.Theme
default:
return []*models.MediaItem{}, 0, nil
}
def, hasQuery := seasonalQueryDef(theme)
if !hasQuery {
// Theme exists but has no executable genre query yet (christmas,
// st_patricks, thanksgiving need keyword/tag column support).
// Return empty until that data lands.
return []*models.MediaItem{}, 0, nil
}
// Apply library scope the same way fetchFiltered / fetchEditorialSpotlight do.
switch {
case libraryID != nil:
def.LibraryIDs = []int{*libraryID}
case libraryIDs != nil:
if len(def.LibraryIDs) == 0 {
def.LibraryIDs = append([]int(nil), libraryIDs...)
} else {
def.LibraryIDs = intersectLibraryIDs(def.LibraryIDs, libraryIDs)
}
}
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
def.Limit = &limit
executor := &catalog.QueryExecutor{Pool: f.pool}
items, _, _, err := executor.PreviewPage(ctx, def, filter, limit, 0, false)
if err != nil {
return nil, 0, fmt.Errorf("seasonal_themed query: %w", err)
}
return items, len(items), nil
}
// seasonalQueryDef returns a catalog.QueryDefinition for the given theme and
// whether an executable query exists. Themes that require a keyword/tag column
// (christmas, st_patricks, thanksgiving) return false until that data lands.
func seasonalQueryDef(theme string) (catalog.QueryDefinition, bool) {
newDef := func(rules ...catalog.QueryRule) catalog.QueryDefinition {
return catalog.QueryDefinition{
Match: "all",
Groups: []catalog.QueryGroup{
{Match: "any", Rules: rules},
},
}
}
rule := func(field, op, value string) catalog.QueryRule {
return catalog.QueryRule{Field: field, Op: op, Value: value}
}
switch theme {
case "halloween":
return newDef(
rule("genre", "contains", "Horror"),
rule("genre", "contains", "Thriller"),
), true
case "valentines":
return newDef(
rule("genre", "contains", "Romance"),
), true
case "summer", "summer_blockbuster":
return newDef(
rule("genre", "contains", "Action"),
rule("genre", "contains", "Adventure"),
), true
case "saturday_morning":
return newDef(
rule("genre", "contains", "Animation"),
rule("genre", "contains", "Family"),
), true
case "christmas", "st_patricks", "thanksgiving":
// TODO: needs keyword/tag column to filter by theme-specific keywords.
return catalog.QueryDefinition{}, false
default:
return catalog.QueryDefinition{}, false
}
}
// fetchMoodCollection returns items for a mood_collection section.
func (f *Fetcher) fetchMoodCollection(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) {
var p recipes.MoodCollectionParams
if len(s.Config) > 0 {
_ = json.Unmarshal(s.Config, &p)
}
info, ok := recipes.MoodByKey(p.Mood)
if !ok {
return []*models.MediaItem{}, 0, nil
}
// Build genre rules (OR'd together).
genreRules := make([]catalog.QueryRule, 0, len(info.GenresAny))
for _, genre := range info.GenresAny {
genreRules = append(genreRules, catalog.QueryRule{Field: "genre", Op: "contains", Value: genre})
}
def := catalog.QueryDefinition{
Match: "all",
Groups: []catalog.QueryGroup{
{Match: "any", Rules: genreRules},
{Match: "all", Rules: []catalog.QueryRule{
{Field: "rating_imdb", Op: "gte", Value: info.MinRating},
}},
},
}
switch {
case libraryID != nil:
def.LibraryIDs = []int{*libraryID}
case libraryIDs != nil:
if len(def.LibraryIDs) == 0 {
def.LibraryIDs = append([]int(nil), libraryIDs...)
} else {
def.LibraryIDs = intersectLibraryIDs(def.LibraryIDs, libraryIDs)
}
}
limit := s.ItemLimit
if limit <= 0 {
limit = 20
}
def.Limit = &limit
executor := &catalog.QueryExecutor{Pool: f.pool}
items, _, _, err := executor.PreviewPage(ctx, def, filter, limit, 0, false)
if err != nil {
return nil, 0, fmt.Errorf("mood_collection query: %w", err)
}
return items, len(items), nil
}