Files
silo-server/internal/catalog/catalog_resolver.go
T
QuickandClaude Fable 5 28340ad6f5 feat(watchlist): hide fully-watched series instead of removing them
Removing a series from the watchlist on full watch stranded it once new
episodes aired: nothing ever re-added it. Split the behavior by type:

- watchlist.Maintainer now auto-removes only fully-watched movies (still
  propagating removals to connected providers).
- Series stay on the watchlist; the new catalog.WatchlistVisibility
  filter hides series whose available episodes are all completed on the
  display surfaces (sections rail, catalog watchlist source, GET
  /watchlist). A newly added episode makes the series reappear on the
  next fetch, and nothing is synced upstream since the entry never
  leaves the list.

Sync, recommendations, notifications, and the watchlist check endpoint
intentionally keep seeing the full list. The filter honors the existing
per-profile remove-watched preference and uses batch lookups only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:04:42 -04:00

2788 lines
86 KiB
Go

package catalog
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/sync/errgroup"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
var ErrInvalidCatalogRequest = errors.New("invalid catalog request")
var ErrCatalogSourceNotFound = errors.New("catalog source not found")
type CatalogResult struct {
Items []*models.MediaItem
Total int
HasMore bool
TotalExact bool
SnapshotAt time.Time // pagination fence timestamp
// Provider, Mode, SemanticUsed, FallbackReason and IndexPendingEvents are
// per-query search diagnostics. They are only populated on the direct-search
// path (where a CatalogSearchProvider actually ran); browse / preview /
// grouped paths leave them zero-valued so the handler omits
// search_diagnostics.
Provider string
Mode string
SemanticUsed bool
FallbackReason string
IndexPendingEvents int
}
type CatalogFiltersResult struct {
Genres []string
Studios []string
Networks []string
Countries []string
OriginalLanguages []string
ContentRatings []string
Resolutions []string
AudioLanguages []string
SubtitleLanguages []string
// Authors, Narrators and Series are book-native facets. Authors and
// Narrators use item_people; Series uses the active book series table.
// Video-only scopes return these empty.
Authors []string
Narrators []string
Series []string
}
type CatalogFilterOptions struct {
IncludeTechnical bool
}
// facetFetcher is the seam used by ListFiltersWithOptions to load each facet
// (genres, studios, networks, …). The production implementation queries the
// pgx pool; tests substitute a stub that records concurrent invocations.
type facetFetcher interface {
DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
DistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// PeopleByKind returns distinct people names for the given PersonKind
// across the scoped result set. Used for Authors / Narrators facets.
PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// AudiobookSeries returns distinct series_name values from the active
// book series table joined onto the scoped result set.
AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// SearchDistinctArrayColumn prefix-searches an array-column facet
// (genres, studios, networks, countries). Returns up to limit
// alphabetical matches and hasMore=true when more would be available.
SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchDistinctScalarColumn is the scalar-column variant (e.g.
// content_rating, original_language).
SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchPeopleByKind is the typeahead equivalent of PeopleByKind.
SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchAudiobookSeries is the typeahead equivalent of AudiobookSeries.
SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
}
// pgxFacetFetcher is the production facetFetcher. It dispatches each method to
// the existing package-level helpers using the supplied pool.
type pgxFacetFetcher struct {
pool *pgxpool.Pool
}
func (f *pgxFacetFetcher) DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctArrayColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) DistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctScalarColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listResolutionsWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctJSONBLanguageWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listSubtitleLanguagesWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctPeopleByKindWithSource(ctx, f.pool, kind, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctArrayColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctScalarColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctPeopleByKindWithSource(ctx, f.pool, kind, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope, prefix, limit)
}
// previewExecutor is the seam consumed by previewQuerySource so tests can
// substitute a stub that records call counts without touching the database.
// The production implementation is *QueryExecutor.
type previewExecutor interface {
PreviewPage(ctx context.Context, def QueryDefinition, access AccessFilter, limit int, offset int, includeTotal bool) ([]*models.MediaItem, int, bool, error)
}
type CatalogResolver struct {
browseRepo *BrowseRepository
itemRepo *ItemRepository
episodeRepo *EpisodeRepository
searchProvider CatalogSearchProvider
storeProvider userstore.UserStoreProvider
facets facetFetcher
// previewExecutorForScope, when non-nil, is used by previewQuerySource
// instead of the default queryExecutorForScope. Tests inject a stub here
// to observe how many times the executor is asked for a result page.
previewExecutorForScope func(scope string, snapshot *time.Time) previewExecutor
}
func NewCatalogResolver(browseRepo *BrowseRepository, itemRepo *ItemRepository) *CatalogResolver {
r := &CatalogResolver{
browseRepo: browseRepo,
itemRepo: itemRepo,
searchProvider: NewPostgresSearchProvider(itemRepo),
}
if browseRepo != nil {
r.facets = &pgxFacetFetcher{pool: browseRepo.pool}
}
return r
}
func (r *CatalogResolver) WithUserStoreProvider(provider userstore.UserStoreProvider) *CatalogResolver {
if r == nil {
return nil
}
r.storeProvider = provider
return r
}
func (r *CatalogResolver) WithEpisodeRepository(repo *EpisodeRepository) *CatalogResolver {
if r == nil {
return nil
}
r.episodeRepo = repo
return r
}
func (r *CatalogResolver) WithSearchProvider(provider CatalogSearchProvider) *CatalogResolver {
if r == nil {
return nil
}
if provider != nil {
r.searchProvider = provider
}
return r
}
func (r *CatalogResolver) queryExecutorForScope(scope string, snapshot *time.Time) *QueryExecutor {
return &QueryExecutor{
Pool: r.itemRepo.pool,
Scope: scope,
BaseRelationSQL: catalogBaseRelationForScope(scope),
SnapshotAt: snapshot,
}
}
func (r *CatalogResolver) Resolve(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
if r == nil || r.browseRepo == nil || r.itemRepo == nil {
return nil, fmt.Errorf("catalog resolver requires browse and item repositories")
}
switch req.Source {
case CatalogSourceQuery:
if err := validateCatalogQueryRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
return r.resolveQuerySource(ctx, req, access)
case CatalogSourceSection:
if err := validateCatalogSectionRequest(req); err != nil {
return nil, err
}
return r.resolveSectionSource(ctx, req, access)
case CatalogSourceLibraryCollection:
if err := validateCatalogCollectionRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
return r.resolveLibraryCollectionSource(ctx, req, access)
case CatalogSourceUserCollection:
if err := validateCatalogCollectionRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
return r.resolveUserCollectionSource(ctx, req, access)
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
if err := validateCatalogPersonalRequest(req); err != nil {
return nil, err
}
return r.resolvePersonalSource(ctx, req, access)
case CatalogSourcePerson:
if err := validateCatalogPersonRequest(req); err != nil {
return nil, err
}
return r.resolvePersonSource(ctx, req, access)
default:
return nil, fmt.Errorf("%w: unsupported catalog source %q", ErrInvalidCatalogRequest, req.Source)
}
}
func (r *CatalogResolver) resolveQuerySource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
if strings.TrimSpace(req.SearchQuery) == "" {
return r.previewQuerySource(ctx, req, access)
}
if useDirectSearchPath(req) {
return r.resolveDirectSearchSource(ctx, req, access)
}
items, err := r.fetchAllSearchCandidates(ctx, req, access)
if err != nil {
return nil, err
}
if NormalizeQuerySort(req.Query.Sort).Field == "relevance" {
items = filterCatalogSearchItems(items, req.SearchQuery)
items = filterCatalogNamePrefix(items, req.NamePrefix)
items = filterCatalogItems(items, req.Query)
total := len(items)
paged := paginateCatalogItems(items, req.Offset, req.Limit)
return &CatalogResult{
Items: paged,
Total: total,
HasMore: req.Offset+len(paged) < total,
TotalExact: true,
}, nil
}
return r.resolveCandidateItemsWithQuery(ctx, req, access, items, true)
}
func (r *CatalogResolver) resolveDirectSearchSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
searchAccess, itemTypes, earlyEmpty := catalogSearchAccess(req, access)
if earlyEmpty {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
provider := r.searchProvider
if provider == nil {
provider = NewPostgresSearchProvider(r.itemRepo)
}
result, err := provider.Search(ctx, CatalogSearchRequest{
Query: req.SearchQuery,
ItemTypes: itemTypes,
Limit: req.Limit,
Offset: req.Offset,
Access: searchAccess,
SkipTotal: req.SkipTotal,
})
if err != nil {
return nil, fmt.Errorf("searching catalog items: %w", err)
}
return &CatalogResult{
Items: result.Items,
Total: result.Total,
HasMore: result.HasMore,
TotalExact: result.TotalExact,
Provider: result.Provider,
Mode: result.Mode,
SemanticUsed: result.SemanticUsed,
FallbackReason: result.FallbackReason,
IndexPendingEvents: result.IndexPendingEvents,
}, nil
}
func (r *CatalogResolver) previewQuerySource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
snapshot := time.Now()
if req.SnapshotAt != nil {
snapshot = *req.SnapshotAt
}
var executor previewExecutor
if r.previewExecutorForScope != nil {
executor = r.previewExecutorForScope(req.Query.MediaScope, &snapshot)
} else {
executor = r.queryExecutorForScope(req.Query.MediaScope, &snapshot)
}
// Push NamePrefix into the SQL WHERE clause so the database can use
// idx_media_items_sort_key instead of returning every match for an
// in-memory filter pass.
access.NamePrefix = req.NamePrefix
items, total, hasMore, err := executor.PreviewPage(ctx, req.Query, access, req.Limit, req.Offset, !req.SkipTotal)
if err != nil {
return nil, err
}
return &CatalogResult{
Items: items,
Total: total,
HasMore: hasMore,
TotalExact: !req.SkipTotal,
SnapshotAt: snapshot,
}, nil
}
func (r *CatalogResolver) resolveSectionSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
section, err := r.loadCatalogSection(ctx, req)
if err != nil {
return nil, err
}
switch section.SectionType {
case "collection":
// User collection takes precedence when set.
if userCollID := strings.TrimSpace(section.UserCollectionID); userCollID != "" {
return r.resolveUserCollectionSource(ctx, CatalogRequest{
Source: CatalogSourceUserCollection,
CollectionID: userCollID,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
UseSourceOrder: true,
}, access)
}
collectionID := strings.TrimSpace(section.CollectionID)
if collectionID == "" {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
return r.resolveLibraryCollectionSource(ctx, CatalogRequest{
Source: CatalogSourceLibraryCollection,
CollectionID: collectionID,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
UseSourceOrder: true,
}, access)
case "favorites", "watchlist":
source := CatalogSourceFavorites
if section.SectionType == "watchlist" {
source = CatalogSourceWatchlist
}
// Personal list sections may carry optional type/library filters and a
// sort; apply them as an overlay query so the "see all" page matches
// the rail. Without a configured sort the stored list order is kept.
sectionFilters := parseCatalogSectionFilters(section.Config)
query := QueryDefinition{
MediaScope: sectionFilters.FilterType,
LibraryIDs: append([]int(nil), sectionFilters.LibraryIDs...),
}.Normalize()
if section.Scope == "library" && section.LibraryID != nil {
query.LibraryIDs = []int{*section.LibraryID}
}
useSourceOrder := true
if qs, ok := NormalizePersonalListSort(parseCatalogSectionSort(section.Config)); ok {
query.Sort = qs
// added_at (date added to the list) is applied by
// loadPersonalSourceIDs, which keeps the source order path;
// metadata sorts go through the query executor instead.
useSourceOrder = qs.Field == "added_at"
}
return r.resolvePersonalSource(ctx, CatalogRequest{
Source: source,
Query: query,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
UseSourceOrder: useSourceOrder,
}, access)
case "recently_added":
return r.resolveSectionBrowseSource(ctx, req, access, section, "added_at", "desc")
case "recently_released":
return r.resolveSectionBrowseSource(ctx, req, access, section, "release_date", "desc")
case "random":
return r.resolveSectionBrowseSource(ctx, req, access, section, "random", "desc")
case "genre", "custom_filter":
def, err := parseCatalogSectionQueryDefinition(section.Config)
if err != nil {
return nil, fmt.Errorf("%w: parsing section query definition: %v", ErrInvalidCatalogRequest, err)
}
if section.Scope == "library" && section.LibraryID != nil {
def.LibraryIDs = []int{*section.LibraryID}
}
return r.resolveQuerySource(ctx, CatalogRequest{
Source: CatalogSourceQuery,
Query: def,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
}, stripCatalogUserScope(access))
default:
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
}
func (r *CatalogResolver) resolveSectionBrowseSource(ctx context.Context, req CatalogRequest, access AccessFilter, section catalogPageSection, sort, order string) (*CatalogResult, error) {
snapshot := time.Now()
if req.SnapshotAt != nil {
snapshot = *req.SnapshotAt
}
sectionFilters := parseCatalogSectionFilters(section.Config)
query := QueryDefinition{
MediaScope: sectionFilters.FilterType,
LibraryIDs: append([]int(nil), sectionFilters.LibraryIDs...),
Sort: QuerySort{
Field: sort,
Order: order,
},
}.Normalize()
if section.Scope == "library" && section.LibraryID != nil {
query.LibraryIDs = []int{*section.LibraryID}
}
browseReq := CatalogRequest{
Source: CatalogSourceQuery,
Query: query,
Limit: req.Limit,
Offset: req.Offset,
NamePrefix: req.NamePrefix,
SnapshotAt: req.SnapshotAt,
}
browseAccess := stripCatalogUserScope(access)
filters, earlyEmpty, err := catalogBrowseFilters(browseReq, browseAccess)
if err != nil {
return nil, err
}
if earlyEmpty {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
filters.Sort = sort
filters.Order = order
filters.Limit = req.Limit
filters.Offset = req.Offset
filters.SnapshotAt = &snapshot
result, err := r.browseRepo.BrowsePage(ctx, filters, !req.SkipTotal)
if err != nil {
return nil, fmt.Errorf("browsing section source: %w", err)
}
return &CatalogResult{
Items: result.Items,
Total: result.Total,
HasMore: result.HasMore,
TotalExact: !req.SkipTotal,
SnapshotAt: snapshot,
}, nil
}
func (r *CatalogResolver) resolveLibraryCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
collectionRepo := NewLibraryCollectionRepository(r.itemRepo.pool)
collection, err := collectionRepo.GetByID(ctx, req.CollectionID)
if err != nil || collection.Visibility != "visible" {
return nil, ErrCatalogSourceNotFound
}
if IsLiveQueryType(collection.CollectionType) {
return r.resolveLiveLibraryCollectionSource(ctx, req, access, collection)
}
if catalogCollectionUsesLiveQuery(collection.QueryDefinition) {
return r.resolveLiveLibraryCollectionSource(ctx, req, access, collection)
}
collectionItems, err := collectionRepo.ListItems(ctx, collection.ID)
if err != nil {
return nil, err
}
contentIDs := make([]string, 0, len(collectionItems))
for _, item := range collectionItems {
contentIDs = append(contentIDs, item.MediaItemID)
}
return r.resolveExactOrderedItems(ctx, contentIDs, req, access)
}
func (r *CatalogResolver) resolveLiveLibraryCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter, collection *models.LibraryCollection) (*CatalogResult, error) {
def, err := parseCatalogCollectionQueryDefinition(collection.QueryDefinition)
if err != nil {
return nil, fmt.Errorf("%w: parsing library collection query_definition: %v", ErrInvalidCatalogRequest, err)
}
if len(collection.LibraryIDs) > 0 {
def.LibraryIDs = intersectCatalogDefinitionLibraries(def.LibraryIDs, collection.LibraryIDs)
} else if collection.LibraryID > 0 {
def.LibraryIDs = intersectCatalogDefinitionLibraries(def.LibraryIDs, []int{collection.LibraryID})
}
def = ApplySmartCollectionItemLimit(def)
if catalogRequestHasOverlay(req) {
items, err := r.resolveCollectionQueryBaseItems(ctx, def, stripCatalogUserScope(access))
if err != nil {
return nil, err
}
return r.resolveExactOrderedMediaItems(ctx, items, req, access)
}
return r.resolveQuerySource(ctx, CatalogRequest{
Source: CatalogSourceQuery,
Query: def,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
}, stripCatalogUserScope(access))
}
func (r *CatalogResolver) resolveUserCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
collection, err := store.GetCollection(ctx, req.CollectionID)
if err != nil || !catalogProfileCanAccessCollection(collection, access.ProfileID) {
return nil, ErrCatalogSourceNotFound
}
if IsLiveQueryType(collection.CollectionType) {
def, err := parseCatalogCollectionQueryDefinition([]byte(collection.QueryDefinition))
if err != nil {
return nil, fmt.Errorf("%w: parsing user collection query_definition: %v", ErrInvalidCatalogRequest, err)
}
def = ApplySmartCollectionItemLimit(def)
if catalogRequestHasOverlay(req) || strings.TrimSpace(collection.DisplayQueryDefinition) != "" {
items, err := r.resolveCollectionQueryBaseItems(ctx, def, access)
if err != nil {
return nil, err
}
items, err = FilterCollectionItemsByDisplayQuery(ctx, r.itemRepo.pool, items, collection.DisplayQueryDefinition, access)
if err != nil {
return nil, err
}
return r.resolveExactOrderedMediaItems(ctx, items, req, access)
}
return r.resolveQuerySource(ctx, CatalogRequest{
Source: CatalogSourceQuery,
Query: def,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
}, access)
}
items, err := store.ListCollectionItems(ctx, collection.ID)
if err != nil {
return nil, err
}
contentIDs := make([]string, 0, len(items))
for _, item := range items {
contentIDs = append(contentIDs, item.MediaItemID)
}
mediaItems, err := r.fetchAccessibleItemsByID(ctx, contentIDs, catalogBaseCollectionRequest(req), access)
if err != nil {
return nil, err
}
mediaItems, err = FilterCollectionItemsByDisplayQuery(ctx, r.itemRepo.pool, mediaItems, collection.DisplayQueryDefinition, access)
if err != nil {
return nil, err
}
return r.resolveExactOrderedMediaItems(ctx, mediaItems, req, access)
}
func (r *CatalogResolver) resolvePersonalSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
if historySourceCanUseOptimizedPageQuery(req) {
return r.resolveHistorySourcePage(ctx, req, access)
}
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
contentIDs, err := r.loadPersonalSourceIDs(ctx, store, req, access.ProfileID)
if err != nil {
return nil, err
}
return r.resolveExactOrderedItems(ctx, contentIDs, req, access)
}
func (r *CatalogResolver) resolvePersonSource(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
filters, earlyEmpty, err := catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
if earlyEmpty {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
filters.PersonID = req.PersonID
items, err := r.fetchAllBrowseCandidatesByFilters(ctx, filters)
if err != nil {
return nil, err
}
return r.resolveCandidateItemsWithQuery(ctx, req, access, items, true)
}
func (r *CatalogResolver) resolveExactOrderedItems(ctx context.Context, contentIDs []string, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
fetchReq := req
if req.Source == CatalogSourceLibraryCollection || req.Source == CatalogSourceUserCollection {
fetchReq = catalogBaseCollectionRequest(req)
}
items, err := r.fetchAccessibleItemsByID(ctx, contentIDs, fetchReq, access)
if err != nil {
return nil, err
}
return r.resolveExactOrderedMediaItems(ctx, items, req, access)
}
func (r *CatalogResolver) resolveExactOrderedMediaItems(ctx context.Context, items []*models.MediaItem, req CatalogRequest, access AccessFilter) (*CatalogResult, error) {
req = normalizeExactCollectionOverlayRequest(req, items)
items = filterCatalogSearchItems(items, req.SearchQuery)
items = filterCatalogNamePrefix(items, req.NamePrefix)
if req.UseSourceOrder {
var err error
items, err = r.filterExactSourceItemsByQuery(ctx, items, req.Query, access)
if err != nil {
return nil, err
}
total := len(items)
paged := paginateCatalogItems(items, req.Offset, req.Limit)
return &CatalogResult{
Items: paged,
Total: total,
HasMore: req.Offset+len(paged) < total,
TotalExact: true,
}, nil
}
return r.resolveCandidateItemsWithQuery(ctx, req, access, items, false)
}
func normalizeExactCollectionOverlayRequest(req CatalogRequest, items []*models.MediaItem) CatalogRequest {
if req.Source != CatalogSourceLibraryCollection && req.Source != CatalogSourceUserCollection {
return req
}
if !isEpisodeCatalogScope(req.Query.MediaScope) {
return req
}
for _, item := range items {
if item != nil && item.Type == "episode" {
return req
}
}
req.Query.MediaScope = ""
return req
}
func (r *CatalogResolver) resolveCollectionQueryBaseItems(ctx context.Context, def QueryDefinition, access AccessFilter) ([]*models.MediaItem, error) {
limit := DefaultSmartCollectionItemLimit
if def.Limit != nil && *def.Limit > 0 {
limit = *def.Limit
}
executor := r.queryExecutorForScope(def.MediaScope, nil)
items, _, err := executor.Preview(ctx, def, access, limit)
if err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogResolver) filterExactSourceItemsByQuery(ctx context.Context, items []*models.MediaItem, def QueryDefinition, access AccessFilter) ([]*models.MediaItem, error) {
if !catalogQueryHasFilter(def) || len(items) == 0 {
return items, nil
}
ids := make([]string, 0, len(items))
for _, item := range items {
if item != nil && strings.TrimSpace(item.ContentID) != "" {
ids = append(ids, item.ContentID)
}
}
if len(ids) == 0 {
return []*models.MediaItem{}, nil
}
filterDef := def
filterDef.Sort = QuerySort{}
filterDef.Limit = nil
queryAccess := access
queryAccess.AllowedContentIDs = ids
executor := r.queryExecutorForScope(filterDef.MediaScope, nil)
matched, _, err := executor.Preview(ctx, filterDef, queryAccess, len(ids))
if err != nil {
return nil, err
}
matchedIDs := make(map[string]struct{}, len(matched))
for _, item := range matched {
if item != nil {
matchedIDs[item.ContentID] = struct{}{}
}
}
filtered := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
if _, ok := matchedIDs[item.ContentID]; ok {
filtered = append(filtered, item)
}
}
if def.Limit != nil && *def.Limit > 0 && len(filtered) > *def.Limit {
filtered = filtered[:*def.Limit]
}
return filtered, nil
}
func (r *CatalogResolver) resolveCandidateItemsWithQuery(
ctx context.Context,
req CatalogRequest,
access AccessFilter,
items []*models.MediaItem,
applyNamePrefixAfterQuery bool,
) (*CatalogResult, error) {
contentIDs := make([]string, 0, len(items))
for _, item := range items {
if item == nil || strings.TrimSpace(item.ContentID) == "" {
continue
}
contentIDs = append(contentIDs, item.ContentID)
}
if len(contentIDs) == 0 {
return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil
}
queryAccess := access
queryAccess.AllowedContentIDs = contentIDs
executor := r.queryExecutorForScope(req.Query.MediaScope, nil)
if applyNamePrefixAfterQuery && strings.TrimSpace(req.NamePrefix) != "" {
sorted, _, err := executor.Preview(ctx, req.Query, queryAccess, len(contentIDs))
if err != nil {
return nil, err
}
sorted = filterCatalogNamePrefix(sorted, req.NamePrefix)
total := len(sorted)
paged := paginateCatalogItems(sorted, req.Offset, req.Limit)
return &CatalogResult{
Items: paged,
Total: total,
HasMore: req.Offset+len(paged) < total,
TotalExact: true,
}, nil
}
limit := req.Limit
if req.Offset > 0 {
limit = req.Offset + req.Limit
}
sorted, total, err := executor.Preview(ctx, req.Query, queryAccess, limit)
if err != nil {
return nil, err
}
paged := paginateCatalogItems(sorted, req.Offset, req.Limit)
return &CatalogResult{
Items: paged,
Total: total,
HasMore: req.Offset+len(paged) < total,
TotalExact: true,
}, nil
}
func (r *CatalogResolver) fetchAllBrowseCandidatesByFilters(ctx context.Context, filters BrowseFilters) ([]*models.MediaItem, error) {
allItems := make([]*models.MediaItem, 0)
page := filters
page.Limit = 100
page.Offset = 0
page.Sort = "created_at"
page.Order = "desc"
for {
result, err := r.browseRepo.Browse(ctx, page)
if err != nil {
return nil, fmt.Errorf("browsing catalog items: %w", err)
}
allItems = append(allItems, result.Items...)
if len(allItems) >= result.Total || len(result.Items) == 0 {
break
}
page.Offset += page.Limit
}
return allItems, nil
}
func (r *CatalogResolver) ListFilters(ctx context.Context, req CatalogRequest, access AccessFilter) (*CatalogFiltersResult, error) {
return r.ListFiltersWithOptions(ctx, req, access, CatalogFilterOptions{IncludeTechnical: true})
}
func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req CatalogRequest, access AccessFilter, options CatalogFilterOptions) (*CatalogFiltersResult, error) {
if r == nil || r.browseRepo == nil {
return nil, fmt.Errorf("catalog resolver requires a browse repository")
}
var (
filters BrowseFilters
earlyEmpty bool
err error
)
switch req.Source {
case CatalogSourceQuery:
if err := validateCatalogQueryRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
if err := validateCatalogPersonalRequest(req); err != nil {
return nil, err
}
if access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal")
}
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
contentIDs, err := r.loadPersonalSourceIDs(ctx, store, req, access.ProfileID)
if err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.ContentIDs = contentIDs
case CatalogSourcePerson:
if err := validateCatalogPersonRequest(req); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.PersonID = req.PersonID
case CatalogSourceLibraryCollection, CatalogSourceUserCollection:
if err := validateCatalogCollectionRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
contentIDs, err := r.loadCollectionSourceIDs(ctx, req, access)
if err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.ContentIDs = contentIDs
default:
return nil, fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if earlyEmpty {
return &CatalogFiltersResult{}, nil
}
if isEpisodeCatalogScope(req.Query.MediaScope) {
return r.listFiltersForSource(ctx, filters, options, episodeCatalogBaseRelation, req.Query.MediaScope)
}
return r.listFiltersForSource(ctx, filters, options, "media_items mi", req.Query.MediaScope)
}
// CatalogFacetSearchResult is the typed return for SearchFacet. Matches
// is in alphabetical order; HasMore is true when the underlying result
// set held more rows than the supplied limit.
type CatalogFacetSearchResult struct {
Matches []string
HasMore bool
}
// catalogFacetSearchMaxLimit caps how many matches a single typeahead
// query can request. 50 is enough for a dropdown without overwhelming
// the page; clients that need more pages should narrow the prefix.
const catalogFacetSearchMaxLimit = 50
// SearchFacet powers /api/v1/catalog/filters/search. The request scope
// (libraries / access / media scope) mirrors ListFiltersWithOptions;
// the additional facet + prefix + limit arguments come from the URL.
// Empty or whitespace-only prefix returns no matches — the caller
// should fall back to the bulk /catalog/filters endpoint for the
// initial dropdown render.
func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, access AccessFilter, facet string, prefix string, limit int) (*CatalogFacetSearchResult, error) {
if r == nil || r.browseRepo == nil {
return nil, fmt.Errorf("catalog resolver requires a browse repository")
}
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return &CatalogFacetSearchResult{Matches: []string{}}, nil
}
if limit <= 0 {
limit = 20
}
if limit > catalogFacetSearchMaxLimit {
limit = catalogFacetSearchMaxLimit
}
var (
filters BrowseFilters
earlyEmpty bool
err error
)
switch req.Source {
case CatalogSourceQuery:
if err := validateCatalogQueryRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
if err := validateCatalogPersonalRequest(req); err != nil {
return nil, err
}
if access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal")
}
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
contentIDs, err := r.loadPersonalSourceIDs(ctx, store, req, access.ProfileID)
if err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.ContentIDs = contentIDs
case CatalogSourcePerson:
if err := validateCatalogPersonRequest(req); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.PersonID = req.PersonID
case CatalogSourceLibraryCollection, CatalogSourceUserCollection:
if err := validateCatalogCollectionRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
contentIDs, err := r.loadCollectionSourceIDs(ctx, req, access)
if err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.ContentIDs = contentIDs
default:
return nil, fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if earlyEmpty {
return &CatalogFacetSearchResult{Matches: []string{}}, nil
}
baseRelation := "media_items mi"
mediaScope := req.Query.MediaScope
if isEpisodeCatalogScope(req.Query.MediaScope) {
baseRelation = episodeCatalogBaseRelation
}
facets := r.facets
if facets == nil {
facets = &pgxFacetFetcher{pool: r.browseRepo.pool}
}
matches, hasMore, err := dispatchFacetSearch(ctx, facets, facet, filters, baseRelation, mediaScope, prefix, limit)
if err != nil {
return nil, err
}
return &CatalogFacetSearchResult{Matches: matches, HasMore: hasMore}, nil
}
// dispatchFacetSearch routes a facet name to the right facetFetcher
// method. The set of supported facet names mirrors what
// /api/v1/catalog/filters returns; anything else is rejected as an
// invalid request so callers learn about typos at the API boundary.
func dispatchFacetSearch(ctx context.Context, facets facetFetcher, facet string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
switch facet {
case "genre":
return facets.SearchDistinctArrayColumn(ctx, "genres", filters, baseRelation, mediaScope, prefix, limit)
case "studio":
return facets.SearchDistinctArrayColumn(ctx, "studios", filters, baseRelation, mediaScope, prefix, limit)
case "network":
return facets.SearchDistinctArrayColumn(ctx, "networks", filters, baseRelation, mediaScope, prefix, limit)
case "country":
return facets.SearchDistinctArrayColumn(ctx, "countries", filters, baseRelation, mediaScope, prefix, limit)
case "original_language":
return facets.SearchDistinctScalarColumn(ctx, "original_language", filters, baseRelation, mediaScope, prefix, limit)
case "content_rating":
return facets.SearchDistinctScalarColumn(ctx, "content_rating", filters, baseRelation, mediaScope, prefix, limit)
case "author":
return facets.SearchPeopleByKind(ctx, models.PersonKindAuthor, filters, baseRelation, mediaScope, prefix, limit)
case "narrator":
if mediaScope == "ebook" {
return nil, false, fmt.Errorf("%w: narrator facet is not available for ebook scope", ErrInvalidCatalogRequest)
}
return facets.SearchPeopleByKind(ctx, models.PersonKindNarrator, filters, baseRelation, mediaScope, prefix, limit)
case "series":
return facets.SearchAudiobookSeries(ctx, filters, baseRelation, mediaScope, prefix, limit)
default:
return nil, false, fmt.Errorf("%w: unknown facet %q", ErrInvalidCatalogRequest, facet)
}
}
// catalogFacetConcurrency caps how many facet queries run in parallel for a
// single ListFiltersWithOptions invocation. With nine independent facet
// lookups and a default pgx pool of ten connections, capping at six leaves
// headroom for other concurrent work and avoids saturating the pool.
const catalogFacetConcurrency = 6
// catalogFacetMaxValues caps how many distinct values each facet query
// returns. Above ~1000 entries a typeahead UI is the only sensible way to
// present a dropdown — and the audiobook library on this server has
// >88k distinct authors / >92k narrators / >161k series, which produced
// an 11.7 MB /api/v1/catalog/filters response before this cap landed.
// The SearchableSelect dropdown stays responsive at 1000 client-side
// entries; beyond that, the filter response should switch to a server-
// side typeahead surface (out of scope for the cap-only fix).
const catalogFacetMaxValues = 1000
func (r *CatalogResolver) listFiltersForSource(
ctx context.Context,
filters BrowseFilters,
options CatalogFilterOptions,
baseRelation string,
mediaScope string,
) (*CatalogFiltersResult, error) {
facets := r.facets
if facets == nil {
facets = &pgxFacetFetcher{pool: r.browseRepo.pool}
}
var (
genres []string
studios []string
networks []string
countries []string
originalLanguages []string
contentRatings []string
resolutions []string
audioLanguages []string
subtitleLanguages []string
authors []string
narrators []string
series []string
)
eg, gctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, catalogFacetConcurrency)
withLimit := func(fn func() error) func() error {
return func() error {
select {
case sem <- struct{}{}:
case <-gctx.Done():
return gctx.Err()
}
defer func() { <-sem }()
return fn()
}
}
eg.Go(withLimit(func() error {
out, err := facets.DistinctArrayColumn(gctx, "genres", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog genres: %w", err)
}
genres = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctArrayColumn(gctx, "studios", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog studios: %w", err)
}
studios = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctArrayColumn(gctx, "networks", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog networks: %w", err)
}
networks = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctArrayColumn(gctx, "countries", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog countries: %w", err)
}
countries = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctScalarColumn(gctx, "original_language", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog original languages: %w", err)
}
originalLanguages = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.DistinctScalarColumn(gctx, "content_rating", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog content ratings: %w", err)
}
contentRatings = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindAuthor, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog authors: %w", err)
}
authors = out
return nil
}))
if mediaScope != "ebook" {
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog narrators: %w", err)
}
narrators = out
return nil
}))
}
eg.Go(withLimit(func() error {
out, err := facets.AudiobookSeries(gctx, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog audiobook series: %w", err)
}
series = out
return nil
}))
if options.IncludeTechnical {
eg.Go(withLimit(func() error {
out, err := facets.Resolutions(gctx, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog resolutions: %w", err)
}
resolutions = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.JSONBLanguages(gctx, "audio_tracks", filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog audio languages: %w", err)
}
audioLanguages = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.SubtitleLanguages(gctx, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog subtitle languages: %w", err)
}
subtitleLanguages = out
return nil
}))
}
if err := eg.Wait(); err != nil {
return nil, err
}
result := &CatalogFiltersResult{
Genres: genres,
Studios: studios,
Networks: networks,
Countries: countries,
OriginalLanguages: originalLanguages,
ContentRatings: contentRatings,
Authors: authors,
Narrators: narrators,
Series: series,
}
if options.IncludeTechnical {
result.Resolutions = resolutions
result.AudioLanguages = audioLanguages
result.SubtitleLanguages = subtitleLanguages
}
return result, nil
}
func validateCatalogQueryRequest(req CatalogRequest, allowPersonalizedSorts bool) error {
if req.Source != CatalogSourceQuery {
return fmt.Errorf("%w: source %q is not supported yet", ErrInvalidCatalogRequest, req.Source)
}
return validateCatalogOverlayQuery(
req.SearchQuery,
req.Query,
catalogQueryRuleFields,
QuerySortFieldSet(allowPersonalizedSorts),
true,
)
}
func validateCatalogPersonalRequest(req CatalogRequest) error {
switch req.Source {
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
default:
return fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
return validateCatalogOverlayQuery(req.SearchQuery, req.Query, catalogPersonalRuleFields, catalogPersonalSortFields(), false)
}
func validateCatalogPersonRequest(req CatalogRequest) error {
if req.Source != CatalogSourcePerson {
return fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if req.PersonID <= 0 {
return fmt.Errorf("%w: person_id is required", ErrInvalidCatalogRequest)
}
return validateCatalogOverlayQuery(req.SearchQuery, req.Query, catalogQueryRuleFields, catalogQuerySortFields(), false)
}
func validateCatalogSectionRequest(req CatalogRequest) error {
if req.Source != CatalogSourceSection {
return fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if strings.TrimSpace(req.SectionID) == "" {
return fmt.Errorf("%w: section_id is required", ErrInvalidCatalogRequest)
}
if req.Scope != "home" && req.Scope != "library" {
return fmt.Errorf("%w: scope must be 'home' or 'library'", ErrInvalidCatalogRequest)
}
if req.Scope == "library" && req.LibraryID <= 0 {
return fmt.Errorf("%w: library_id is required for library sections", ErrInvalidCatalogRequest)
}
return nil
}
func validateCatalogExactCollectionRequest(req CatalogRequest) error {
switch req.Source {
case CatalogSourceLibraryCollection, CatalogSourceUserCollection:
default:
return fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if strings.TrimSpace(req.CollectionID) == "" {
return fmt.Errorf("%w: collection_id is required", ErrInvalidCatalogRequest)
}
return nil
}
func validateCatalogCollectionRequest(req CatalogRequest, allowPersonalizedSorts bool) error {
if err := validateCatalogExactCollectionRequest(req); err != nil {
return err
}
return validateCatalogOverlayQuery(req.SearchQuery, req.Query, catalogPersonalRuleFields, QuerySortFieldSet(allowPersonalizedSorts), false)
}
func catalogRequestHasOverlay(req CatalogRequest) bool {
return strings.TrimSpace(req.SearchQuery) != "" ||
strings.TrimSpace(req.NamePrefix) != "" ||
catalogQueryHasFilter(req.Query) ||
strings.TrimSpace(req.Query.Sort.Field) != ""
}
func catalogQueryHasFilter(def QueryDefinition) bool {
return strings.TrimSpace(def.MediaScope) != "" ||
len(def.LibraryIDs) > 0 ||
len(def.Groups) > 0 ||
def.Limit != nil
}
func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error {
if !IsValidMediaScope(def.MediaScope) {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', 'manga', or 'video'", ErrInvalidCatalogRequest)
}
if def.Match != "" && def.Match != "all" && def.Match != "any" {
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
}
for _, id := range def.LibraryIDs {
if id <= 0 {
return fmt.Errorf("%w: library_ids must contain positive IDs", ErrInvalidCatalogRequest)
}
}
for i, group := range def.Groups {
if group.Match != "" && group.Match != "all" && group.Match != "any" {
return fmt.Errorf("%w: groups[%d].match must be 'all' or 'any'", ErrInvalidCatalogRequest, i)
}
for j, rule := range group.Rules {
if !ruleFields[rule.Field] {
return fmt.Errorf("%w: groups[%d].rules[%d].field %q is not supported", ErrInvalidCatalogRequest, i, j, rule.Field)
}
def, ok := queryFieldDefs[rule.Field]
if !ok || !def.validOps[rule.Op] {
return fmt.Errorf("%w: groups[%d].rules[%d] is invalid", ErrInvalidCatalogRequest, i, j)
}
}
}
if def.Sort.Field == "" {
return nil
}
if def.Sort.Order != "" && def.Sort.Order != "asc" && def.Sort.Order != "desc" {
return fmt.Errorf("%w: sort.order must be 'asc' or 'desc'", ErrInvalidCatalogRequest)
}
if def.Sort.Field == "relevance" {
if !allowRelevance {
return fmt.Errorf("%w: relevance sort is only supported for query source", ErrInvalidCatalogRequest)
}
if strings.TrimSpace(searchQuery) == "" {
return fmt.Errorf("%w: relevance sort requires q", ErrInvalidCatalogRequest)
}
return nil
}
if !sortFields[def.Sort.Field] {
return fmt.Errorf("%w: sort.field %q is not supported", ErrInvalidCatalogRequest, def.Sort.Field)
}
return nil
}
var catalogQueryRuleFields = map[string]bool{
"type": true,
"genre": true,
"year": true,
"rating_imdb": true,
"studio": true,
"network": true,
"country": true,
"original_language": true,
"content_rating": true,
"added_at": true,
"release_date": true,
"status": true,
"actor": true,
"director": true,
"writer": true,
"producer": true,
"author": true,
"narrator": true,
"series": true,
"watched": true,
"favorited": true,
"in_watchlist": true,
"in_progress": true,
"last_watched": true,
"resolution": true,
"hdr": true,
"dolby_vision": true,
"bitrate": true,
"audio_language": true,
"subtitle_language": true,
}
var catalogPersonalRuleFields = map[string]bool{
"type": true,
"genre": true,
"year": true,
"rating_imdb": true,
"studio": true,
"network": true,
"country": true,
"original_language": true,
"content_rating": true,
"added_at": true,
"release_date": true,
"status": true,
"actor": true,
"director": true,
"writer": true,
"producer": true,
"author": true,
"narrator": true,
"series": true,
"watched": true,
"favorited": true,
"in_watchlist": true,
"in_progress": true,
"last_watched": true,
"resolution": true,
"hdr": true,
"dolby_vision": true,
"bitrate": true,
"audio_language": true,
"subtitle_language": true,
}
func catalogQuerySortFields() map[string]bool {
return QuerySortFieldSet(false)
}
func catalogPersonalSortFields() map[string]bool {
return QuerySortFieldSet(false)
}
func requiresAdvancedQueryExecution(def QueryDefinition) bool {
if def.Sort.Field == "bitrate" {
return true
}
for _, group := range def.Groups {
for _, rule := range group.Rules {
switch rule.Field {
case "actor", "director", "writer", "producer", "author", "narrator", "series", "watched", "favorited", "in_watchlist", "in_progress", "last_watched", "resolution", "hdr", "dolby_vision", "bitrate", "audio_language", "subtitle_language":
return true
}
}
}
return false
}
func useDirectSearchPath(req CatalogRequest) bool {
if req.Source != CatalogSourceQuery {
return false
}
if strings.TrimSpace(req.SearchQuery) == "" || strings.TrimSpace(req.NamePrefix) != "" {
return false
}
if len(req.Query.Groups) > 0 || requiresAdvancedQueryExecution(req.Query) {
return false
}
return req.Query.Sort.Field == "relevance" && req.Query.Sort.Order == "desc"
}
type catalogPageSection struct {
ID string
Scope string
LibraryID *int
SectionType string
Title string
ItemLimit int
Config json.RawMessage
CollectionID string // library collection ID
UserCollectionID string // personal user collection ID
}
type catalogSectionFilters struct {
FilterType string `json:"filter_type"`
FilterLibraryID *int `json:"filter_library_id"`
FilterLibraryIDs []int `json:"filter_library_ids"`
LibraryIDs []int
}
func defaultCatalogLibrarySection(libraryID int, sectionID string) (catalogPageSection, bool) {
sections := []catalogPageSection{
{
ID: "default-continue-watching",
Scope: "library",
LibraryID: &libraryID,
SectionType: "continue_watching",
Title: "Continue Watching",
ItemLimit: 20,
Config: json.RawMessage(`{}`),
},
{
ID: "default-recently-added",
Scope: "library",
LibraryID: &libraryID,
SectionType: "recently_added",
Title: "Recently Added",
ItemLimit: 20,
Config: json.RawMessage(`{}`),
},
{
ID: "default-recently-released",
Scope: "library",
LibraryID: &libraryID,
SectionType: "recently_released",
Title: "Recently Released",
ItemLimit: 20,
Config: json.RawMessage(`{}`),
},
}
for _, section := range sections {
if section.ID == sectionID {
return section, true
}
}
return catalogPageSection{}, false
}
func (r *CatalogResolver) loadCatalogSection(ctx context.Context, req CatalogRequest) (catalogPageSection, error) {
var section catalogPageSection
query := `
SELECT id, scope, library_id, section_type, title, item_limit, config
FROM page_sections
WHERE id = $1 AND scope = $2 AND enabled = true
`
args := []any{req.SectionID, req.Scope}
if req.Scope == "library" {
query += " AND library_id = $3"
args = append(args, req.LibraryID)
} else {
query += " AND library_id IS NULL"
}
err := r.itemRepo.pool.QueryRow(ctx, query, args...).Scan(
&section.ID,
&section.Scope,
&section.LibraryID,
&section.SectionType,
&section.Title,
&section.ItemLimit,
&section.Config,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if req.Scope == "library" {
if fallback, ok := defaultCatalogLibrarySection(req.LibraryID, req.SectionID); ok {
return fallback, nil
}
}
return catalogPageSection{}, ErrCatalogSourceNotFound
}
return catalogPageSection{}, err
}
var collectionCfg struct {
LibraryCollectionID string `json:"library_collection_id"`
UserCollectionID string `json:"user_collection_id"`
}
if len(section.Config) > 0 {
_ = json.Unmarshal(section.Config, &collectionCfg)
}
section.CollectionID = collectionCfg.LibraryCollectionID
section.UserCollectionID = collectionCfg.UserCollectionID
return section, nil
}
func parseCatalogSectionFilters(config json.RawMessage) catalogSectionFilters {
var filters catalogSectionFilters
if len(config) > 0 {
_ = json.Unmarshal(config, &filters)
}
filters.LibraryIDs = normalizeCatalogSectionLibraryIDs(filters.FilterLibraryID, filters.FilterLibraryIDs)
if filters.FilterType == "" && len(filters.LibraryIDs) == 0 {
if def, err := parseCatalogSectionQueryDefinition(config); err == nil {
filters.FilterType = def.MediaScope
filters.LibraryIDs = append([]int(nil), def.LibraryIDs...)
}
}
return filters
}
// parseCatalogSectionSort extracts the optional flat sort/order keys from a
// watchlist/favorites section config.
func parseCatalogSectionSort(config json.RawMessage) (string, string) {
var cfg struct {
Sort string `json:"sort"`
Order string `json:"order"`
}
if len(config) > 0 {
_ = json.Unmarshal(config, &cfg)
}
return cfg.Sort, cfg.Order
}
func normalizeCatalogSectionLibraryIDs(single *int, multiple []int) []int {
seen := map[int]struct{}{}
result := make([]int, 0, len(multiple)+1)
for _, id := range multiple {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
if single != nil && *single > 0 {
if _, ok := seen[*single]; !ok {
result = append(result, *single)
}
}
return result
}
func parseCatalogSectionQueryDefinition(config json.RawMessage) (QueryDefinition, error) {
return parseCatalogCollectionQueryDefinition(config)
}
func parseCatalogCollectionQueryDefinition(config json.RawMessage) (QueryDefinition, error) {
if len(bytesTrimSpace(config)) == 0 || string(bytesTrimSpace(config)) == "{}" || string(bytesTrimSpace(config)) == "null" {
return QueryDefinition{}.Normalize(), nil
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(config, &probe); err != nil {
return QueryDefinition{}, err
}
if _, ok := probe["filter_type"]; ok {
return NormalizeLegacySectionFilter(config)
}
if _, ok := probe["filter_library_id"]; ok {
return NormalizeLegacySectionFilter(config)
}
if _, ok := probe["filter_library_ids"]; ok {
return NormalizeLegacySectionFilter(config)
}
var def QueryDefinition
if err := json.Unmarshal(config, &def); err != nil {
return QueryDefinition{}, err
}
def = def.Normalize()
if err := def.Validate(); err != nil {
return QueryDefinition{}, err
}
return def, nil
}
func bytesTrimSpace(raw []byte) []byte {
return []byte(strings.TrimSpace(string(raw)))
}
func catalogCollectionUsesLiveQuery(raw json.RawMessage) bool {
trimmed := strings.TrimSpace(string(raw))
return trimmed != "" && trimmed != "{}" && trimmed != "null"
}
func intersectCatalogDefinitionLibraries(existing, required []int) []int {
if len(required) == 0 {
return existing
}
if len(existing) == 0 {
return append([]int(nil), required...)
}
return intersectInts(existing, required)
}
func stripCatalogUserScope(access AccessFilter) AccessFilter {
access.UserID = 0
access.ProfileID = ""
return access
}
func (r *CatalogResolver) catalogStoreForAccess(ctx context.Context, access AccessFilter) (userstore.UserStore, error) {
if r.storeProvider == nil || access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal")
}
store, err := r.storeProvider.ForUser(ctx, access.UserID)
if err != nil {
return nil, err
}
if store == nil {
return nil, ErrCatalogSourceNotFound
}
return store, nil
}
func catalogProfileCanAccessCollection(collection *userstore.Collection, profileID string) bool {
if collection == nil || strings.TrimSpace(profileID) == "" {
return false
}
for _, allowed := range collection.AllowedProfileIDs {
if allowed == profileID {
return true
}
}
return false
}
// PersonalListEntry pairs a list member with the timestamp it was added to
// the list (watchlist/favorites), as stored by the user store.
type PersonalListEntry struct {
ID string
AddedAt string
}
// OrderPersonalListIDs returns the entry IDs, reordered by list added_at when
// that sort is requested; any other (or no) sort keeps the stored list order.
// AddedAt strings from one store share a format, so lexicographic comparison
// preserves chronology; entries with no timestamp sort last.
func OrderPersonalListIDs(entries []PersonalListEntry, qs QuerySort) []string {
if qs.Field == "added_at" {
asc := qs.Order == "asc"
slices.SortStableFunc(entries, func(a, b PersonalListEntry) int {
if (a.AddedAt == "") != (b.AddedAt == "") {
if a.AddedAt != "" {
return -1
}
return 1
}
cmp := strings.Compare(a.AddedAt, b.AddedAt)
if !asc {
cmp = -cmp
}
return cmp
})
}
ids := make([]string, 0, len(entries))
for _, entry := range entries {
ids = append(ids, entry.ID)
}
return ids
}
// watchlistVisibility builds the fully-watched-series display filter, falling
// back to a pool-backed episode repository when none was injected (mirroring
// the history path below).
func (r *CatalogResolver) watchlistVisibility() *WatchlistVisibility {
episodes := r.episodeRepo
if episodes == nil && r.itemRepo != nil {
episodes = NewEpisodeRepository(r.itemRepo.pool)
}
return NewWatchlistVisibilityFromRepos(r.itemRepo, episodes)
}
func (r *CatalogResolver) loadPersonalSourceIDs(ctx context.Context, store userstore.UserStore, req CatalogRequest, profileID string) ([]string, error) {
switch req.Source {
case CatalogSourceFavorites:
entries, err := store.ListFavorites(ctx, profileID, 10000, 0)
if err != nil {
return nil, err
}
listed := make([]PersonalListEntry, 0, len(entries))
for _, entry := range entries {
listed = append(listed, PersonalListEntry{ID: entry.MediaItemID, AddedAt: entry.AddedAt})
}
return OrderPersonalListIDs(listed, req.Query.Sort), nil
case CatalogSourceWatchlist:
entries, err := store.ListWatchlist(ctx, profileID, 10000, 0)
if err != nil {
return nil, err
}
entryIDs := make([]string, 0, len(entries))
for _, entry := range entries {
entryIDs = append(entryIDs, entry.MediaItemID)
}
hidden, err := r.watchlistVisibility().HiddenSeriesIDs(ctx, store, profileID, entryIDs)
if err != nil {
return nil, fmt.Errorf("filtering watched watchlist series: %w", err)
}
listed := make([]PersonalListEntry, 0, len(entries))
for _, entry := range entries {
if _, ok := hidden[entry.MediaItemID]; ok {
continue
}
listed = append(listed, PersonalListEntry{ID: entry.MediaItemID, AddedAt: entry.AddedAt})
}
return OrderPersonalListIDs(listed, req.Query.Sort), nil
case CatalogSourceHistory:
entries, err := store.ListHistory(ctx, profileID, 10000, 0)
if err != nil {
return nil, err
}
// The episode scope shows history at episode granularity; every other
// scope collapses episode watch events into their series display item.
if isEpisodeCatalogScope(req.Query.MediaScope) {
return HistoryEpisodeScopeIDs(entries), nil
}
return ResolveHistoryDisplayIDs(ctx, entries, NewEpisodeRepository(r.itemRepo.pool))
default:
return nil, fmt.Errorf("%w: source %q is not a personal source", ErrInvalidCatalogRequest, req.Source)
}
}
func (r *CatalogResolver) loadCollectionSourceIDs(ctx context.Context, req CatalogRequest, access AccessFilter) ([]string, error) {
items, err := r.loadCollectionSourceBaseItems(ctx, req, access)
if err != nil {
return nil, err
}
return contentIDsFromMediaItems(items), nil
}
func (r *CatalogResolver) loadCollectionSourceBaseItems(ctx context.Context, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) {
switch req.Source {
case CatalogSourceLibraryCollection:
collectionRepo := NewLibraryCollectionRepository(r.itemRepo.pool)
collection, err := collectionRepo.GetByID(ctx, req.CollectionID)
if err != nil || collection.Visibility != "visible" {
return nil, ErrCatalogSourceNotFound
}
if IsLiveQueryType(collection.CollectionType) || catalogCollectionUsesLiveQuery(collection.QueryDefinition) {
def, err := parseCatalogCollectionQueryDefinition(collection.QueryDefinition)
if err != nil {
return nil, fmt.Errorf("%w: parsing library collection query_definition: %v", ErrInvalidCatalogRequest, err)
}
if len(collection.LibraryIDs) > 0 {
def.LibraryIDs = intersectCatalogDefinitionLibraries(def.LibraryIDs, collection.LibraryIDs)
} else if collection.LibraryID > 0 {
def.LibraryIDs = intersectCatalogDefinitionLibraries(def.LibraryIDs, []int{collection.LibraryID})
}
return r.resolveCollectionQueryBaseItems(ctx, ApplySmartCollectionItemLimit(def), stripCatalogUserScope(access))
}
collectionItems, err := collectionRepo.ListItems(ctx, collection.ID)
if err != nil {
return nil, err
}
contentIDs := make([]string, 0, len(collectionItems))
for _, item := range collectionItems {
contentIDs = append(contentIDs, item.MediaItemID)
}
return r.fetchAccessibleItemsByID(ctx, contentIDs, catalogBaseCollectionRequest(req), access)
case CatalogSourceUserCollection:
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
collection, err := store.GetCollection(ctx, req.CollectionID)
if err != nil || !catalogProfileCanAccessCollection(collection, access.ProfileID) {
return nil, ErrCatalogSourceNotFound
}
var items []*models.MediaItem
if IsLiveQueryType(collection.CollectionType) {
def, err := parseCatalogCollectionQueryDefinition([]byte(collection.QueryDefinition))
if err != nil {
return nil, fmt.Errorf("%w: parsing user collection query_definition: %v", ErrInvalidCatalogRequest, err)
}
items, err = r.resolveCollectionQueryBaseItems(ctx, ApplySmartCollectionItemLimit(def), access)
if err != nil {
return nil, err
}
} else {
collectionItems, err := store.ListCollectionItems(ctx, collection.ID)
if err != nil {
return nil, err
}
contentIDs := make([]string, 0, len(collectionItems))
for _, item := range collectionItems {
contentIDs = append(contentIDs, item.MediaItemID)
}
items, err = r.fetchAccessibleItemsByID(ctx, contentIDs, catalogBaseCollectionRequest(req), access)
if err != nil {
return nil, err
}
}
return FilterCollectionItemsByDisplayQuery(ctx, r.itemRepo.pool, items, collection.DisplayQueryDefinition, access)
default:
return nil, fmt.Errorf("%w: source %q is not a collection source", ErrInvalidCatalogRequest, req.Source)
}
}
func catalogBaseCollectionRequest(req CatalogRequest) CatalogRequest {
return CatalogRequest{
Source: req.Source,
CollectionID: req.CollectionID,
Limit: req.Limit,
Offset: req.Offset,
SkipTotal: req.SkipTotal,
UseSourceOrder: true,
}
}
func contentIDsFromMediaItems(items []*models.MediaItem) []string {
ids := make([]string, 0, len(items))
for _, item := range items {
if item == nil || strings.TrimSpace(item.ContentID) == "" {
continue
}
ids = append(ids, item.ContentID)
}
return ids
}
func (r *CatalogResolver) fetchAccessibleItemsByID(ctx context.Context, contentIDs []string, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) {
if len(contentIDs) == 0 {
return []*models.MediaItem{}, nil
}
if isEpisodeCatalogScope(req.Query.MediaScope) {
return r.fetchAccessibleEpisodeItemsByID(ctx, contentIDs, req, access)
}
filters, earlyEmpty, err := catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
if earlyEmpty {
return []*models.MediaItem{}, nil
}
filters.ContentIDs = contentIDs
items, err := r.fetchAllBrowseCandidatesByFilters(ctx, filters)
if err != nil {
return nil, err
}
byID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
byID[item.ContentID] = item
}
ordered := make([]*models.MediaItem, 0, len(contentIDs))
for _, contentID := range contentIDs {
item, ok := byID[contentID]
if !ok {
continue
}
ordered = append(ordered, item)
}
return ordered, nil
}
// fetchAccessibleEpisodeItemsByID is the episode-scope counterpart of
// fetchAccessibleItemsByID. Episode rows are not present in media_items — they
// hydrate through the episode catalog relation — so the browse-repository path
// (which scans media_items and would match nothing) is replaced by the episode
// query executor with the requested ids as the allowed-content set. Query
// filters are applied in the same pass; sort and limit are stripped because
// the caller re-imposes source order or its own sort downstream.
func (r *CatalogResolver) fetchAccessibleEpisodeItemsByID(ctx context.Context, contentIDs []string, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) {
filterDef := req.Query
filterDef.Sort = QuerySort{}
filterDef.Limit = nil
queryAccess := access
if access.AllowedContentIDs != nil {
queryAccess.AllowedContentIDs = intersectContentIDs(contentIDs, access.AllowedContentIDs)
} else {
queryAccess.AllowedContentIDs = contentIDs
}
executor := r.queryExecutorForScope(filterDef.MediaScope, nil)
items, _, err := executor.Preview(ctx, filterDef, queryAccess, len(contentIDs))
if err != nil {
return nil, err
}
byID := make(map[string]*models.MediaItem, len(items))
for _, item := range items {
if item != nil {
byID[item.ContentID] = item
}
}
ordered := make([]*models.MediaItem, 0, len(contentIDs))
for _, contentID := range contentIDs {
if item, ok := byID[contentID]; ok {
ordered = append(ordered, item)
}
}
return ordered, nil
}
func intersectContentIDs(ids []string, allowed []string) []string {
allowedSet := make(map[string]struct{}, len(allowed))
for _, id := range allowed {
allowedSet[id] = struct{}{}
}
intersection := make([]string, 0, len(ids))
for _, id := range ids {
if _, ok := allowedSet[id]; ok {
intersection = append(intersection, id)
}
}
return intersection
}
func (r *CatalogResolver) fetchAllBrowseCandidates(ctx context.Context, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) {
filters, earlyEmpty, err := catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
if earlyEmpty {
return []*models.MediaItem{}, nil
}
allItems := make([]*models.MediaItem, 0)
page := filters
page.Limit = 100
page.Offset = 0
page.Sort = "created_at"
page.Order = "desc"
for {
result, err := r.browseRepo.Browse(ctx, page)
if err != nil {
return nil, fmt.Errorf("browsing catalog items: %w", err)
}
allItems = append(allItems, result.Items...)
if len(allItems) >= result.Total || len(result.Items) == 0 {
break
}
page.Offset += page.Limit
}
return allItems, nil
}
func (r *CatalogResolver) fetchAllSearchCandidates(ctx context.Context, req CatalogRequest, access AccessFilter) ([]*models.MediaItem, error) {
searchAccess, itemTypes, earlyEmpty := catalogSearchAccess(req, access)
if earlyEmpty {
return []*models.MediaItem{}, nil
}
_, total, err := r.itemRepo.Search(ctx, req.SearchQuery, itemTypes, 1, 0, searchAccess)
if err != nil {
return nil, fmt.Errorf("counting catalog search results: %w", err)
}
if total == 0 {
return []*models.MediaItem{}, nil
}
items := make([]*models.MediaItem, 0, total)
for offset := 0; offset < total; offset += 100 {
limit := min(100, total-offset)
page, _, err := r.itemRepo.Search(ctx, req.SearchQuery, itemTypes, limit, offset, searchAccess)
if err != nil {
return nil, fmt.Errorf("searching catalog items: %w", err)
}
items = append(items, page...)
}
return items, nil
}
func catalogSearchAccess(req CatalogRequest, access AccessFilter) (AccessFilter, []string, bool) {
allowedLibraryIDs, earlyEmpty := effectiveCatalogLibraryIDs(req.Query.LibraryIDs, access)
if earlyEmpty {
return AccessFilter{}, nil, true
}
searchAccess := AccessFilter{
AllowedLibraryIDs: allowedLibraryIDs,
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
MaxContentRating: access.MaxContentRating,
}
return searchAccess, MediaScopeItemTypes(req.Query.MediaScope), false
}
func catalogBrowseFilters(req CatalogRequest, access AccessFilter) (BrowseFilters, bool, error) {
allowedLibraryIDs, earlyEmpty := effectiveCatalogLibraryIDs(req.Query.LibraryIDs, access)
if earlyEmpty {
return BrowseFilters{}, true, nil
}
filters := BrowseFilters{
// BrowseFilters.Type accepts a comma-separated type list, so group
// scopes like "video" expand here rather than leaking downstream.
Type: strings.Join(MediaScopeItemTypes(req.Query.MediaScope), ","),
NamePrefix: req.NamePrefix,
DisabledLibraryIDs: effectiveCatalogDisabledLibraryIDs(req.Query.LibraryIDs, access.DisabledLibraryIDs),
MaxContentRating: access.MaxContentRating,
}
applyCatalogBrowseOverlayRules(&filters, req.Query)
if len(allowedLibraryIDs) == 1 {
filters.LibraryID = allowedLibraryIDs[0]
} else if len(allowedLibraryIDs) > 1 {
filters.LibraryIDs = allowedLibraryIDs
} else if access.AllowedLibraryIDs != nil && len(req.Query.LibraryIDs) == 0 {
filters.LibraryIDs = []int{}
}
return filters, false, nil
}
func applyCatalogBrowseOverlayRules(filters *BrowseFilters, def QueryDefinition) {
if filters == nil {
return
}
for _, group := range def.Groups {
for _, rule := range group.Rules {
switch rule.Field {
case "genre":
if filters.Genre == "" && rule.Op == "contains" {
if value, ok := catalogStringValue(rule.Value); ok {
filters.Genre = value
}
}
case "status":
if filters.Status == "" && rule.Op == "is" {
if value, ok := catalogStringValue(rule.Value); ok {
filters.Status = value
}
}
case "content_rating":
if rule.Op == "is" {
if value, ok := catalogStringValue(rule.Value); ok && value != "" {
filters.ContentRating = append(filters.ContentRating, value)
}
}
case "year":
switch rule.Op {
case "between":
if values := catalogIntValues(rule.Value); len(values) >= 2 {
if filters.YearMin == 0 || values[0] > filters.YearMin {
filters.YearMin = values[0]
}
if filters.YearMax == 0 || values[1] < filters.YearMax {
filters.YearMax = values[1]
}
}
case "gte", "gt", "is":
if value, ok := catalogIntValue(rule.Value); ok {
if rule.Op == "gt" {
value++
}
if filters.YearMin == 0 || value > filters.YearMin {
filters.YearMin = value
}
if rule.Op == "is" && (filters.YearMax == 0 || value < filters.YearMax) {
filters.YearMax = value
}
}
case "lte", "lt":
if value, ok := catalogIntValue(rule.Value); ok {
if rule.Op == "lt" {
value--
}
if filters.YearMax == 0 || value < filters.YearMax {
filters.YearMax = value
}
}
}
}
}
}
if len(filters.ContentRating) > 1 {
filters.ContentRating = slices.Compact(filters.ContentRating)
}
}
func effectiveCatalogLibraryIDs(requestIDs []int, access AccessFilter) ([]int, bool) {
if len(requestIDs) == 0 {
if access.AllowedLibraryIDs != nil {
ids := append([]int(nil), access.AllowedLibraryIDs...)
ids = removeCatalogLibraryIDs(ids, access.DisabledLibraryIDs)
if len(ids) == 0 {
return nil, true
}
return ids, false
}
return nil, false
}
ids := append([]int(nil), requestIDs...)
if access.AllowedLibraryIDs != nil {
ids = intersectInts(ids, access.AllowedLibraryIDs)
}
ids = removeCatalogLibraryIDs(ids, access.DisabledLibraryIDs)
if len(ids) == 0 {
return nil, true
}
return ids, false
}
func effectiveCatalogDisabledLibraryIDs(requestIDs, disabled []int) []int {
if len(requestIDs) > 0 {
return nil
}
return append([]int(nil), disabled...)
}
func removeCatalogLibraryIDs(ids, remove []int) []int {
if len(ids) == 0 || len(remove) == 0 {
return ids
}
blocked := make(map[int]struct{}, len(remove))
for _, id := range remove {
blocked[id] = struct{}{}
}
filtered := ids[:0]
for _, id := range ids {
if _, ok := blocked[id]; ok {
continue
}
filtered = append(filtered, id)
}
return filtered
}
func filterCatalogItems(items []*models.MediaItem, def QueryDefinition) []*models.MediaItem {
filtered := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
if !MediaScopeMatchesItemType(def.MediaScope, item.Type) {
continue
}
if catalogDefinitionMatchesItem(item, def) {
filtered = append(filtered, item)
}
}
return filtered
}
func filterCatalogSearchItems(items []*models.MediaItem, raw string) []*models.MediaItem {
query := strings.TrimSpace(raw)
if query == "" {
return items
}
parsed := parseSearchQuery(query)
needle := normalizeTitleForComparison(firstNonEmptySearchValue(parsed.Text, query))
tokens := strings.Fields(needle)
if len(tokens) == 0 {
return items
}
filtered := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
haystack := normalizeTitleForComparison(strings.Join([]string{
item.Title,
item.SortTitle,
item.OriginalTitle,
item.Overview,
}, " "))
matched := true
for _, token := range tokens {
if !strings.Contains(haystack, token) {
matched = false
break
}
}
if matched {
filtered = append(filtered, item)
}
}
return filtered
}
func filterCatalogNamePrefix(items []*models.MediaItem, raw string) []*models.MediaItem {
prefix := strings.ToLower(strings.TrimSpace(raw))
if prefix == "" {
return items
}
filtered := make([]*models.MediaItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
title := strings.ToLower(strings.TrimSpace(item.Title))
sortTitle := strings.ToLower(strings.TrimSpace(item.SortTitle))
if strings.HasPrefix(title, prefix) || (sortTitle != "" && strings.HasPrefix(sortTitle, prefix)) {
filtered = append(filtered, item)
}
}
return filtered
}
func catalogStringValue(value any) (string, bool) {
str, ok := value.(string)
if !ok {
return "", false
}
str = strings.TrimSpace(str)
return str, str != ""
}
func catalogIntValue(value any) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int32:
return int(v), true
case int64:
return int(v), true
case float32:
return int(v), true
case float64:
return int(v), true
default:
return 0, false
}
}
func catalogIntValues(value any) []int {
values, ok := value.([]any)
if !ok {
return nil
}
result := make([]int, 0, len(values))
for _, entry := range values {
if normalized, ok := catalogIntValue(entry); ok {
result = append(result, normalized)
}
}
return result
}
func catalogDefinitionMatchesItem(item *models.MediaItem, def QueryDefinition) bool {
matchMode := def.Match
if matchMode == "" {
matchMode = "all"
}
matches := 0
for _, group := range def.Groups {
if catalogGroupMatchesItem(item, group) {
matches++
if matchMode == "any" {
return true
}
} else if matchMode == "all" {
return false
}
}
if matchMode == "any" {
return matches > 0
}
return true
}
func catalogGroupMatchesItem(item *models.MediaItem, group QueryGroup) bool {
matchMode := group.Match
if matchMode == "" {
matchMode = "all"
}
matches := 0
for _, rule := range group.Rules {
if catalogRuleMatchesItem(item, rule) {
matches++
if matchMode == "any" {
return true
}
} else if matchMode == "all" {
return false
}
}
if matchMode == "any" {
return matches > 0
}
return true
}
func catalogRuleMatchesItem(item *models.MediaItem, rule QueryRule) bool {
switch rule.Field {
case "type":
return compareCatalogString(item.Type, rule.Op, rule.Value)
case "genre":
return compareCatalogStringSlice(item.Genres, rule.Op, rule.Value)
case "studio":
return compareCatalogStringSlice(item.Studios, rule.Op, rule.Value)
case "network":
return compareCatalogStringSlice(item.Networks, rule.Op, rule.Value)
case "country":
return compareCatalogStringSlice(item.Countries, rule.Op, rule.Value)
case "original_language":
return compareCatalogString(item.OriginalLanguage, rule.Op, rule.Value)
case "content_rating":
return compareCatalogString(item.ContentRating, rule.Op, rule.Value)
case "status":
return compareCatalogString(item.Status, rule.Op, rule.Value)
case "year":
return compareCatalogNumeric(float64(item.Year), rule.Op, rule.Value)
case "rating_imdb":
if item.RatingIMDB == nil {
return false
}
return compareCatalogNumeric(*item.RatingIMDB, rule.Op, rule.Value)
case "added_at":
addedAt := item.CreatedAt
if item.AddedAt != nil {
addedAt = *item.AddedAt
}
return compareCatalogTime(addedAt, rule.Op, rule.Value)
case "release_date":
releaseDate := catalogItemReleaseDate(item)
if releaseDate == "" {
return false
}
return compareCatalogStringDate(releaseDate, rule.Op, rule.Value)
default:
return false
}
}
func compareCatalogString(actual, op string, value any) bool {
expected := strings.TrimSpace(strings.ToLower(fmt.Sprint(value)))
actual = strings.TrimSpace(strings.ToLower(actual))
switch op {
case "is":
return actual == expected
case "is_not":
return actual != expected
default:
return false
}
}
func compareCatalogStringSlice(actual []string, op string, value any) bool {
expected := strings.TrimSpace(strings.ToLower(fmt.Sprint(value)))
found := false
for _, entry := range actual {
if strings.TrimSpace(strings.ToLower(entry)) == expected {
found = true
break
}
}
switch op {
case "contains", "is":
return found
case "is_not":
return !found
default:
return false
}
}
func compareCatalogNumeric(actual float64, op string, value any) bool {
switch op {
case "is":
expected, ok := catalogFloat(value)
return ok && actual == expected
case "is_not":
expected, ok := catalogFloat(value)
return ok && actual != expected
case "gt":
expected, ok := catalogFloat(value)
return ok && actual > expected
case "gte":
expected, ok := catalogFloat(value)
return ok && actual >= expected
case "lt":
expected, ok := catalogFloat(value)
return ok && actual < expected
case "lte":
expected, ok := catalogFloat(value)
return ok && actual <= expected
case "between":
values, ok := catalogFloatRange(value)
return ok && actual >= values[0] && actual <= values[1]
default:
return false
}
}
func compareCatalogStringDate(actual, op string, value any) bool {
actualTime, err := time.Parse("2006-01-02", actual)
if err != nil {
return false
}
switch op {
case "gt", "gte", "lt", "lte", "between", "is", "is_not", "in_last":
default:
return false
}
if op == "in_last" {
duration, ok := catalogStringValue(value)
if !ok {
return false
}
spec, err := parseDurationSpec(duration)
if err != nil {
return false
}
cutoff := catalogDateOnly(spec.cutoffTime(time.Now().UTC()))
return !actualTime.Before(cutoff)
}
if op == "between" {
values, ok := catalogStringRange(value)
if !ok {
return false
}
start, err := time.Parse("2006-01-02", values[0])
if err != nil {
return false
}
end, err := time.Parse("2006-01-02", values[1])
if err != nil {
return false
}
return !actualTime.Before(start) && !actualTime.After(end)
}
expected := strings.TrimSpace(fmt.Sprint(value))
expectedTime, err := time.Parse("2006-01-02", expected)
if err != nil {
return false
}
switch op {
case "is":
return actualTime.Equal(expectedTime)
case "is_not":
return !actualTime.Equal(expectedTime)
case "gt":
return actualTime.After(expectedTime)
case "gte":
return actualTime.After(expectedTime) || actualTime.Equal(expectedTime)
case "lt":
return actualTime.Before(expectedTime)
case "lte":
return actualTime.Before(expectedTime) || actualTime.Equal(expectedTime)
default:
return false
}
}
func compareCatalogTime(actual time.Time, op string, value any) bool {
if actual.IsZero() {
return false
}
switch op {
case "gt", "gte", "lt", "lte", "between", "in_last":
default:
return false
}
if op == "in_last" {
duration, ok := catalogStringValue(value)
if !ok {
return false
}
spec, err := parseDurationSpec(duration)
if err != nil {
return false
}
return !actual.Before(spec.cutoffTime(time.Now()))
}
if op == "between" {
values, ok := catalogStringRange(value)
if !ok {
return false
}
start, ok := catalogTimeValue(values[0])
if !ok {
return false
}
end, ok := catalogTimeValue(values[1])
if !ok {
return false
}
return !actual.Before(start) && !actual.After(end)
}
expected, ok := catalogTimeValue(value)
if !ok {
return false
}
switch op {
case "gt":
return actual.After(expected)
case "gte":
return actual.After(expected) || actual.Equal(expected)
case "lt":
return actual.Before(expected)
case "lte":
return actual.Before(expected) || actual.Equal(expected)
default:
return false
}
}
func catalogTimeValue(value any) (time.Time, bool) {
switch v := value.(type) {
case time.Time:
return v, !v.IsZero()
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return time.Time{}, false
}
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return parsed, true
}
if parsed, err := time.Parse("2006-01-02", trimmed); err == nil {
return parsed, true
}
return time.Time{}, false
default:
return time.Time{}, false
}
}
func catalogDateOnly(value time.Time) time.Time {
utc := value.UTC()
return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
}
func catalogFloat(value any) (float64, bool) {
switch v := value.(type) {
case int:
return float64(v), true
case int64:
return float64(v), true
case float64:
return v, true
case float32:
return float64(v), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
return parsed, err == nil
default:
return 0, false
}
}
func catalogFloatRange(value any) ([2]float64, bool) {
switch v := value.(type) {
case []any:
if len(v) != 2 {
return [2]float64{}, false
}
start, ok := catalogFloat(v[0])
if !ok {
return [2]float64{}, false
}
end, ok := catalogFloat(v[1])
if !ok {
return [2]float64{}, false
}
return [2]float64{start, end}, true
default:
return [2]float64{}, false
}
}
func catalogStringRange(value any) ([2]string, bool) {
switch v := value.(type) {
case []any:
if len(v) != 2 {
return [2]string{}, false
}
return [2]string{fmt.Sprint(v[0]), fmt.Sprint(v[1])}, true
default:
return [2]string{}, false
}
}
func catalogItemReleaseDate(item *models.MediaItem) string {
if item.ReleaseDate != nil && strings.TrimSpace(*item.ReleaseDate) != "" {
return strings.TrimSpace(*item.ReleaseDate)
}
if item.FirstAirDate != nil {
return strings.TrimSpace(*item.FirstAirDate)
}
return ""
}
func sortCatalogItems(items []*models.MediaItem, sortConfig QuerySort) {
slices.SortStableFunc(items, func(a, b *models.MediaItem) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return 1
}
if b == nil {
return -1
}
direction := 1
if sortConfig.Order == "desc" {
direction = -1
}
switch sortConfig.Field {
case "title":
left := strings.ToLower(firstNonEmptySearchValue(a.SortTitle, a.Title))
right := strings.ToLower(firstNonEmptySearchValue(b.SortTitle, b.Title))
if left == right {
left = strings.ToLower(a.Title)
right = strings.ToLower(b.Title)
}
if cmp := strings.Compare(left, right); cmp != 0 {
return cmp * direction
}
case "year":
if a.Year != b.Year {
if a.Year < b.Year {
return -1 * direction
}
return 1 * direction
}
case "rating_imdb":
left := -1.0
right := -1.0
if a.RatingIMDB != nil {
left = *a.RatingIMDB
}
if b.RatingIMDB != nil {
right = *b.RatingIMDB
}
if left != right {
if left < right {
return -1 * direction
}
return 1 * direction
}
case "release_date":
left := catalogItemReleaseDate(a)
right := catalogItemReleaseDate(b)
if left != right {
return strings.Compare(left, right) * direction
}
case "last_air_date":
var left, right string
if a.LastAirDate != nil {
left = strings.TrimSpace(*a.LastAirDate)
}
if b.LastAirDate != nil {
right = strings.TrimSpace(*b.LastAirDate)
}
if left == "" && right != "" {
return 1
}
if left != "" && right == "" {
return -1
}
if left != right {
return strings.Compare(left, right) * direction
}
case "added_at":
left := a.CreatedAt
right := b.CreatedAt
if a.AddedAt != nil {
left = *a.AddedAt
}
if b.AddedAt != nil {
right = *b.AddedAt
}
if !left.Equal(right) {
if left.Before(right) {
return -1 * direction
}
return 1 * direction
}
}
return strings.Compare(a.ContentID, b.ContentID)
})
}
func paginateCatalogItems(items []*models.MediaItem, offset, limit int) []*models.MediaItem {
if offset < 0 {
offset = 0
}
if offset >= len(items) {
return []*models.MediaItem{}
}
if limit <= 0 {
limit = 20
}
end := min(len(items), offset+limit)
return append([]*models.MediaItem(nil), items[offset:end]...)
}