2026-05-22 20:26:11 -04:00
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 )
2026-05-27 14:54:58 -04:00
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"
2026-05-22 20:26:11 -04:00
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,
2026-05-27 14:54:58 -04:00
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,
2026-05-22 20:26:11 -04:00
e.season_number,
e.episode_number,
e.air_date,
si.title,
si.genres,
si.content_rating,
2026-05-27 14:54:58 -04:00
COALESCE(NULLIF(e.still_path, ''), NULLIF(si.backdrop_path, ''), '') AS backdrop_path,
COALESCE(NULLIF(e.still_thumbhash, ''), NULLIF(si.backdrop_thumbhash, ''), '') AS backdrop_thumbhash,
2026-05-22 20:26:11 -04:00
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
}