506 lines
17 KiB
Go
506 lines
17 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
type QueryExecutor struct {
|
|
Pool *pgxpool.Pool
|
|
Scope string
|
|
// BaseRelationSQL, when set, replaces the default "media_items mi" source.
|
|
// The relation must already be aliased as "mi".
|
|
BaseRelationSQL string
|
|
// SnapshotAt, when set, restricts results to items created at or before
|
|
// this timestamp. This prevents offset-based pagination drift when new
|
|
// items are inserted between page fetches (e.g. during a scan).
|
|
SnapshotAt *time.Time
|
|
}
|
|
|
|
func (e *QueryExecutor) Preview(ctx context.Context, def QueryDefinition, access AccessFilter, limit int) ([]*models.MediaItem, int, error) {
|
|
items, total, _, err := e.PreviewPage(ctx, def, access, limit, 0, true)
|
|
return items, total, err
|
|
}
|
|
|
|
func (e *QueryExecutor) PreviewPage(
|
|
ctx context.Context,
|
|
def QueryDefinition,
|
|
access AccessFilter,
|
|
limit int,
|
|
offset int,
|
|
includeTotal bool,
|
|
) ([]*models.MediaItem, int, bool, error) {
|
|
if e == nil || e.Pool == nil {
|
|
return nil, 0, false, fmt.Errorf("query executor requires a database pool")
|
|
}
|
|
|
|
build, err := e.buildPreviewPagePlan(def, access, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, false, err
|
|
}
|
|
|
|
pagedSQL, pagedArgs := build.pagedSQL(includeTotal)
|
|
rows, err := e.Pool.Query(ctx, pagedSQL, pagedArgs...)
|
|
if err != nil {
|
|
return nil, 0, false, fmt.Errorf("querying preview items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var (
|
|
items []*models.MediaItem
|
|
total int
|
|
)
|
|
if includeTotal {
|
|
items, total, err = scanItemsWithTotal(rows)
|
|
} else {
|
|
items, err = scanItems(rows)
|
|
}
|
|
if err != nil {
|
|
return nil, 0, false, err
|
|
}
|
|
// The preview path uses itemColumns which scans CreatedAt but not
|
|
// AddedAt (set only by browse queries via MIN(mil.first_seen_at)).
|
|
// Fall back to CreatedAt so the API response includes added_at.
|
|
for _, item := range items {
|
|
if item.AddedAt == nil && !item.CreatedAt.IsZero() {
|
|
t := item.CreatedAt
|
|
item.AddedAt = &t
|
|
}
|
|
}
|
|
|
|
hasMore := false
|
|
if includeTotal {
|
|
// COUNT(*) OVER () emits no rows when the data SELECT is empty, so
|
|
// total stays 0 even when the broader result set has matching rows
|
|
// (e.g. OFFSET past the last page). Re-query the count to give
|
|
// callers the real total. Skip when offset == 0 because in that
|
|
// case an empty page genuinely means total = 0.
|
|
//
|
|
// Use build.offset (normalized in buildPreviewPagePlan) rather than
|
|
// the raw offset parameter — a caller-supplied negative offset is
|
|
// floored to 0 in the plan, and the SQL uses the plan's value, so
|
|
// the fallback condition and HasMore must match.
|
|
if len(items) == 0 && build.offset > 0 {
|
|
countSQL, countArgs := build.countSQL()
|
|
if err := e.Pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil {
|
|
return nil, 0, false, fmt.Errorf("count fallback for empty page: %w", err)
|
|
}
|
|
}
|
|
hasMore = total > build.offset+len(items)
|
|
return items, total, hasMore, nil
|
|
}
|
|
|
|
if len(items) > build.limit {
|
|
hasMore = true
|
|
items = items[:build.limit]
|
|
}
|
|
return items, 0, hasMore, nil
|
|
}
|
|
|
|
// previewPagePlan captures the rendered SQL fragments and bound args for a
|
|
// preview page query. It is produced by buildPreviewPagePlan and consumed by
|
|
// PreviewPage (to execute) and by tests (to inspect the emitted SQL without a
|
|
// database).
|
|
type previewPagePlan struct {
|
|
// ctes holds optional CTE definitions (without the leading "WITH "
|
|
// keyword) prepended to the paged SELECT. cteArgs are bound at the
|
|
// front of the final arg list — the CTE definitions reference $1..$N
|
|
// for cteArgs, and the rest of the SQL has been rebased to start at
|
|
// argIdx (len(cteArgs)+1). Used by the user_last_watched CTE
|
|
// injection (audit 2026-05-01 §3.1 Pattern B).
|
|
ctes []string
|
|
cteArgs []any
|
|
// fromClausePaged is the FROM clause for the paged query (includes any
|
|
// sort-plan joins).
|
|
fromClausePaged string
|
|
whereClause string
|
|
args []any
|
|
orderBy string
|
|
sortArgs []any
|
|
limit int
|
|
offset int
|
|
limitArgIdx int
|
|
}
|
|
|
|
// countSQL renders a count-only query that returns the total number of rows
|
|
// matching the plan's WHERE clause, ignoring LIMIT/OFFSET/ORDER BY. Used as
|
|
// a fallback when pagedSQL(true) returned an empty page past offset 0:
|
|
// COUNT(*) OVER () emits no rows when the data SELECT is empty, so the
|
|
// caller would otherwise see total=0 even when the broader result set has
|
|
// matching rows. Wraps the inner query in `SELECT COUNT(*) FROM (...) sub`
|
|
// so any GROUP BY in the inner query is preserved (we count groups, matching
|
|
// what COUNT(*) OVER () would compute).
|
|
//
|
|
// Bind cteArgs + args + sortArgs in the same order as pagedSQL. fromClausePaged
|
|
// embeds sort-plan join clauses (ORDER BY needs them to project the join
|
|
// columns), and those clauses reference $N placeholders for sortArgs; omitting
|
|
// sortArgs here would break sorts that need bound join args (added_at,
|
|
// progress, date_viewed, plays, resolution, bitrate). LIMIT/OFFSET are
|
|
// intentionally dropped — count is over the full filtered set.
|
|
func (p previewPagePlan) countSQL() (string, []any) {
|
|
args := append([]any{}, p.cteArgs...)
|
|
args = append(args, p.args...)
|
|
args = append(args, p.sortArgs...)
|
|
withClause := ""
|
|
if len(p.ctes) > 0 {
|
|
withClause = "WITH " + strings.Join(p.ctes, ",\n") + "\n"
|
|
}
|
|
sql := fmt.Sprintf(
|
|
"%sSELECT COUNT(*) FROM (SELECT 1 %s %s) sub",
|
|
withClause, p.fromClausePaged, p.whereClause,
|
|
)
|
|
return sql, args
|
|
}
|
|
|
|
// pagedSQL renders the final paged SELECT and returns it together with the
|
|
// fully-bound arg list. When includeTotal is true the SELECT list is appended
|
|
// with COUNT(*) OVER () AS total_count so the caller can read the total from
|
|
// the first scanned row in a single round trip. When includeTotal is false we
|
|
// ask the database for one extra row to detect more pages without an exact
|
|
// count.
|
|
func (p previewPagePlan) pagedSQL(includeTotal bool) (string, []any) {
|
|
queryLimit := p.limit
|
|
if !includeTotal {
|
|
queryLimit++
|
|
}
|
|
args := append([]any{}, p.cteArgs...)
|
|
args = append(args, p.args...)
|
|
args = append(args, p.sortArgs...)
|
|
args = append(args, queryLimit)
|
|
offsetClause := ""
|
|
if p.offset > 0 {
|
|
offsetArgIdx := p.limitArgIdx + 1
|
|
offsetClause = fmt.Sprintf(" OFFSET $%d", offsetArgIdx)
|
|
args = append(args, p.offset)
|
|
}
|
|
selectList := qualifiedListItemColumns("mi")
|
|
if includeTotal {
|
|
selectList += ", COUNT(*) OVER () AS total_count"
|
|
}
|
|
withClause := ""
|
|
if len(p.ctes) > 0 {
|
|
withClause = "WITH " + strings.Join(p.ctes, ",\n") + "\n"
|
|
}
|
|
sql := fmt.Sprintf(
|
|
`%sSELECT %s %s %s %s LIMIT $%d%s`,
|
|
withClause,
|
|
selectList,
|
|
p.fromClausePaged,
|
|
p.whereClause,
|
|
p.orderBy,
|
|
p.limitArgIdx,
|
|
offsetClause,
|
|
)
|
|
return sql, args
|
|
}
|
|
|
|
// buildPreviewPagePlan assembles the WHERE clause, FROM clause, args, and sort
|
|
// plan for a preview-page query. It performs no I/O. PreviewPage uses it to
|
|
// run the actual queries; tests use it (via buildPreviewPageSQL) to assert the
|
|
// emitted SQL.
|
|
func (e *QueryExecutor) buildPreviewPagePlan(
|
|
def QueryDefinition,
|
|
access AccessFilter,
|
|
limit int,
|
|
offset int,
|
|
) (previewPagePlan, error) {
|
|
def = def.Normalize()
|
|
allowPersonalized := strings.TrimSpace(access.ProfileID) != ""
|
|
if err := def.ValidateWithOptions(allowPersonalized, allowPersonalized); err != nil {
|
|
return previewPagePlan{}, err
|
|
}
|
|
|
|
libraryIDs := append([]int(nil), def.LibraryIDs...)
|
|
if access.AllowedLibraryIDs != nil {
|
|
if len(libraryIDs) == 0 {
|
|
libraryIDs = append([]int(nil), access.AllowedLibraryIDs...)
|
|
} else {
|
|
libraryIDs = intersectInts(libraryIDs, access.AllowedLibraryIDs)
|
|
}
|
|
}
|
|
|
|
// Scope can be set externally (e.g. catalog resolver pre-fills it from
|
|
// the request) or implied by the query definition's MediaScope (e.g.
|
|
// section fetchers construct executors without setting Scope and rely
|
|
// on def.MediaScope being authoritative). Without this fallback, an
|
|
// "episode" def routed through a bare executor would keep the default
|
|
// media_items base relation while QueryBuilder emits mi.episode_air_date.
|
|
effectiveScope := e.Scope
|
|
if effectiveScope == "" {
|
|
effectiveScope = def.MediaScope
|
|
}
|
|
|
|
baseRelation := e.BaseRelationSQL
|
|
baseArgs := []any(nil)
|
|
libraryScopeHandledInBaseRelation := false
|
|
if isEpisodeCatalogScope(effectiveScope) {
|
|
var handled bool
|
|
baseRelation, baseArgs, handled = episodeCatalogBaseRelationForLibraries(
|
|
libraryIDs,
|
|
access.DisabledLibraryIDs,
|
|
1,
|
|
)
|
|
libraryScopeHandledInBaseRelation = handled
|
|
}
|
|
if strings.TrimSpace(baseRelation) == "" {
|
|
baseRelation = "media_items mi"
|
|
}
|
|
|
|
builder := NewQueryBuilder("mi").
|
|
WithArgIdx(len(baseArgs)+1).
|
|
WithUserScope(access.UserID, access.ProfileID).
|
|
WithMediaScope(def.MediaScope).
|
|
WithLibraryScope(libraryIDs)
|
|
filterWhere, filterArgs, err := builder.Build(def)
|
|
if err != nil {
|
|
return previewPagePlan{}, err
|
|
}
|
|
filterArgOffset := len(baseArgs)
|
|
filterWhere = rebindSQLPlaceholders(filterWhere, filterArgOffset)
|
|
|
|
var conditions []string
|
|
args := append(append([]any{}, baseArgs...), filterArgs...)
|
|
argIdx := builder.ArgIdx() + filterArgOffset
|
|
|
|
if filterWhere != "" {
|
|
conditions = append(conditions, filterWhere)
|
|
}
|
|
if def.MediaScope != "" && !isEpisodeCatalogScope(def.MediaScope) {
|
|
conditions = append(conditions, fmt.Sprintf("mi.type = $%d", argIdx))
|
|
args = append(args, def.MediaScope)
|
|
argIdx++
|
|
}
|
|
libScopeWhere, libScopeArgs, hasLibraryScope := "", []any(nil), false
|
|
if !libraryScopeHandledInBaseRelation {
|
|
libScopeWhere, libScopeArgs, hasLibraryScope = buildLibraryScopeJoin(
|
|
libraryIDs,
|
|
access.DisabledLibraryIDs,
|
|
argIdx,
|
|
def.MediaScope,
|
|
catalogLibraryContentExprForScope(def.MediaScope, "mi"),
|
|
)
|
|
}
|
|
if hasLibraryScope {
|
|
conditions = append(conditions, libScopeWhere)
|
|
args = append(args, libScopeArgs...)
|
|
argIdx += len(libScopeArgs)
|
|
} else if access.AllowedLibraryIDs != nil && len(access.AllowedLibraryIDs) == 0 {
|
|
conditions = append(conditions, "1 = 0")
|
|
}
|
|
if access.AllowedContentIDs != nil {
|
|
if len(access.AllowedContentIDs) == 0 {
|
|
conditions = append(conditions, "1 = 0")
|
|
} else {
|
|
conditions = append(conditions, fmt.Sprintf("mi.content_id = ANY($%d)", argIdx))
|
|
args = append(args, access.AllowedContentIDs)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
ApplySectionAccessFilter("mi", access, &conditions, &args, &argIdx)
|
|
|
|
if e.SnapshotAt != nil {
|
|
conditions = append(conditions, fmt.Sprintf("mi.created_at <= $%d", argIdx))
|
|
args = append(args, *e.SnapshotAt)
|
|
argIdx++
|
|
}
|
|
|
|
if prefix := strings.TrimSpace(access.NamePrefix); prefix != "" {
|
|
// Dual-column OR matching browse.go and favorites_browse.go: items
|
|
// where a curated sort_title differs from title (e.g. title="The Office",
|
|
// sort_title="Office, The") would be silently lost on prefix="the" if
|
|
// we only checked the COALESCE'd sort-key expression. First arm uses
|
|
// the idx_media_items_sort_key expression (migration 102); second arm
|
|
// uses idx_media_items_search_exact_title on LOWER(title) (migration 001).
|
|
// Both arms are sargable; the planner can BitmapOr them.
|
|
conditions = append(conditions, fmt.Sprintf(
|
|
"(LOWER(COALESCE(NULLIF(BTRIM(mi.sort_title),''), mi.title)) LIKE $%d ESCAPE '\\' OR LOWER(mi.title) LIKE $%d ESCAPE '\\')",
|
|
argIdx, argIdx,
|
|
))
|
|
args = append(args, escapePrefixForLike(prefix)+"%")
|
|
argIdx++
|
|
}
|
|
|
|
whereClause := ""
|
|
if len(conditions) > 0 {
|
|
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
fromClauseBase := "FROM " + baseRelation
|
|
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
sortPlan, err := builder.WithArgIdx(argIdx).BuildSortPlan(def.Sort)
|
|
if err != nil {
|
|
return previewPagePlan{}, err
|
|
}
|
|
fromClausePaged := fromClauseBase
|
|
if len(sortPlan.Joins) > 0 {
|
|
fromClausePaged += " " + strings.Join(sortPlan.Joins, " ")
|
|
}
|
|
limitArgIdx := argIdx + len(sortPlan.Args)
|
|
|
|
var ctes []string
|
|
var cteArgs []any
|
|
// When the filter clause references last_watched, the QueryBuilder flips
|
|
// requireUserHistoryCTE so we splice in the user_last_watched CTE and a
|
|
// LEFT JOIN aliased uhist (audit 2026-05-01 §3.1 Pattern B). The CTE
|
|
// args are bound at $1, $2 — every other placeholder shifts by 2.
|
|
if builder.RequiresUserHistoryCTE() {
|
|
const cteShift = 2
|
|
cteArgs = builder.UserHistoryCTEArgs()
|
|
ctes = []string{UserHistoryCTESQL(1)}
|
|
|
|
fromClausePaged = rebindSQLPlaceholders(fromClausePaged, cteShift)
|
|
whereClause = rebindSQLPlaceholders(whereClause, cteShift)
|
|
sortPlan.OrderBy = rebindSQLPlaceholders(sortPlan.OrderBy, cteShift)
|
|
fromClausePaged += " LEFT JOIN user_last_watched uhist ON uhist.media_item_id = mi.content_id"
|
|
limitArgIdx += cteShift
|
|
}
|
|
|
|
return previewPagePlan{
|
|
ctes: ctes,
|
|
cteArgs: cteArgs,
|
|
fromClausePaged: fromClausePaged,
|
|
whereClause: whereClause,
|
|
args: args,
|
|
orderBy: sortPlan.OrderBy,
|
|
sortArgs: sortPlan.Args,
|
|
limit: limit,
|
|
offset: offset,
|
|
limitArgIdx: limitArgIdx,
|
|
}, nil
|
|
}
|
|
|
|
// buildPreviewPageSQL is a test-friendly facade over buildPreviewPagePlan that
|
|
// returns the rendered paged SELECT plus the bound args. It performs no I/O.
|
|
// When includeTotal is true the emitted SELECT carries COUNT(*) OVER () as a
|
|
// total_count column so PreviewPage can avoid a separate count query.
|
|
func (e *QueryExecutor) buildPreviewPageSQL(
|
|
def QueryDefinition,
|
|
access AccessFilter,
|
|
limit int,
|
|
offset int,
|
|
includeTotal bool,
|
|
) (string, []any, error) {
|
|
build, err := e.buildPreviewPagePlan(def, access, limit, offset)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
sql, args := build.pagedSQL(includeTotal)
|
|
return sql, args, nil
|
|
}
|
|
|
|
// escapePrefixForLike lower-cases the prefix and escapes %, _, and \ so the
|
|
// resulting string is safe to use as a LIKE pattern with ESCAPE '\'.
|
|
func escapePrefixForLike(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, `%`, `\%`)
|
|
s = strings.ReplaceAll(s, `_`, `\_`)
|
|
return s
|
|
}
|
|
|
|
// buildLibraryScopeJoin returns a WHERE clause that scopes the outer query
|
|
// to items whose membership in media_item_libraries (or episode_libraries for
|
|
// the episode catalog scope) matches the allow/deny lists. The clause is an
|
|
// EXISTS / NOT EXISTS semi-join that uses the (content_id, media_folder_id)
|
|
// PRIMARY KEY index directly without fanning out for items present in
|
|
// multiple libraries — Audit Pattern D (2026-05-01 §3 Pattern D). The prior
|
|
// shape wrapped the join in a SELECT DISTINCT subquery to defuse that
|
|
// fanout; the DISTINCT was load-bearing because the PK is on the (content,
|
|
// folder) PAIR, not on content alone. EXISTS is the canonical non-fanout
|
|
// idiom and lets the planner hash-semi-join.
|
|
//
|
|
// Returns the WHERE clause (without a leading "AND"), the bound args, and a
|
|
// boolean indicating whether scoping was applied. The caller appends the
|
|
// clause to its conditions slice and the args to its arg list.
|
|
func buildLibraryScopeJoin(
|
|
allowedLibraryIDs,
|
|
disabledLibraryIDs []int,
|
|
argIdx int,
|
|
mediaScope string,
|
|
itemContentExpr string,
|
|
) (string, []any, bool) {
|
|
if len(allowedLibraryIDs) == 0 && len(disabledLibraryIDs) == 0 {
|
|
return "", nil, false
|
|
}
|
|
|
|
if strings.TrimSpace(itemContentExpr) == "" {
|
|
itemContentExpr = "mi.content_id"
|
|
}
|
|
tableName, keyColumn := catalogLibraryMembershipTableAndKeyForScope(mediaScope)
|
|
|
|
clauses := make([]string, 0, 2)
|
|
args := make([]any, 0, 2)
|
|
|
|
if len(allowedLibraryIDs) > 0 {
|
|
clauses = append(clauses, fmt.Sprintf(
|
|
`EXISTS (SELECT 1 FROM %s mil_scope_in WHERE mil_scope_in.%s = %s AND mil_scope_in.media_folder_id = ANY($%d))`,
|
|
tableName, keyColumn, itemContentExpr, argIdx,
|
|
))
|
|
args = append(args, allowedLibraryIDs)
|
|
argIdx++
|
|
}
|
|
|
|
if len(disabledLibraryIDs) > 0 {
|
|
clauses = append(clauses, fmt.Sprintf(
|
|
`NOT EXISTS (SELECT 1 FROM %s mil_scope_out WHERE mil_scope_out.%s = %s AND mil_scope_out.media_folder_id = ANY($%d))`,
|
|
tableName, keyColumn, itemContentExpr, argIdx,
|
|
))
|
|
args = append(args, disabledLibraryIDs)
|
|
argIdx++
|
|
}
|
|
|
|
return strings.Join(clauses, " AND "), args, true
|
|
}
|
|
|
|
func intersectInts(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
|
|
}
|
|
|
|
var sqlPlaceholderPattern = regexp.MustCompile(`\$(\d+)`)
|
|
|
|
func rebindSQLPlaceholders(sql string, offset int) string {
|
|
if offset <= 0 || strings.TrimSpace(sql) == "" {
|
|
return sql
|
|
}
|
|
return sqlPlaceholderPattern.ReplaceAllStringFunc(sql, func(match string) string {
|
|
value, err := strconv.Atoi(match[1:])
|
|
if err != nil {
|
|
return match
|
|
}
|
|
return fmt.Sprintf("$%d", value+offset)
|
|
})
|
|
}
|