1058 lines
31 KiB
Go
1058 lines
31 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
type episodeCatalogEntryPageRow struct {
|
|
episodeID string
|
|
addedAt time.Time
|
|
}
|
|
|
|
type episodeCatalogUserStatePlan struct {
|
|
entryDef QueryDefinition
|
|
source string
|
|
alias string
|
|
positiveSource bool
|
|
userClauses []string
|
|
userArgs []any
|
|
sortField string
|
|
sortOrder string
|
|
sortOnly bool
|
|
requiresFullPageGate bool
|
|
requireProgressRatio bool
|
|
}
|
|
|
|
func (e *QueryExecutor) tryEpisodeCatalogUserStatePreviewPage(
|
|
ctx context.Context,
|
|
def QueryDefinition,
|
|
access AccessFilter,
|
|
limit int,
|
|
offset int,
|
|
includeTotal bool,
|
|
) ([]*models.MediaItem, int, bool, bool, error) {
|
|
def = def.Normalize()
|
|
effectiveScope := e.Scope
|
|
if effectiveScope == "" {
|
|
effectiveScope = def.MediaScope
|
|
}
|
|
if !isEpisodeCatalogScope(effectiveScope) {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
if access.UserID == 0 || strings.TrimSpace(access.ProfileID) == "" {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
|
|
plan, ok, err := extractEpisodeCatalogUserStatePlan(def)
|
|
if err != nil || !ok {
|
|
return nil, 0, false, ok, err
|
|
}
|
|
if includeTotal && plan.sortOnly {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
|
|
libraryID, empty, ok := singleEpisodeCatalogLibraryID(def, access)
|
|
if !ok {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
if empty {
|
|
return []*models.MediaItem{}, 0, false, true, nil
|
|
}
|
|
|
|
limit, offset, maxResults := normalizePreviewPageBounds(def, limit, offset)
|
|
|
|
ctes := []string{episodeCatalogUserStateCTE(plan)}
|
|
args := []any{access.UserID, access.ProfileID, libraryID}
|
|
argIdx := 4
|
|
whereParts := []string{"ece.media_folder_id = $3"}
|
|
|
|
if access.AllowedContentIDs != nil {
|
|
if len(access.AllowedContentIDs) == 0 {
|
|
return []*models.MediaItem{}, 0, false, true, nil
|
|
}
|
|
whereParts = append(whereParts, fmt.Sprintf("ece.episode_id = ANY($%d)", argIdx))
|
|
args = append(args, access.AllowedContentIDs)
|
|
argIdx++
|
|
}
|
|
|
|
ApplySectionAccessFilter("ece", access, &whereParts, &args, &argIdx)
|
|
|
|
if e.SnapshotAt != nil {
|
|
whereParts = append(whereParts, fmt.Sprintf("ece.episode_created_at <= $%d", argIdx))
|
|
args = append(args, *e.SnapshotAt)
|
|
argIdx++
|
|
}
|
|
|
|
if prefix := strings.TrimSpace(access.NamePrefix); prefix != "" {
|
|
whereParts = append(whereParts, fmt.Sprintf(
|
|
"(ece.sort_key LIKE $%d ESCAPE '\\' OR LOWER(ece.title) LIKE $%d ESCAPE '\\')",
|
|
argIdx,
|
|
argIdx,
|
|
))
|
|
args = append(args, escapePrefixForLike(prefix)+"%")
|
|
argIdx++
|
|
}
|
|
|
|
queryWhere, queryArgs, nextArgIdx, ok, err := buildEpisodeCatalogEntryQueryWhere(plan.entryDef, argIdx)
|
|
if err != nil || !ok {
|
|
return nil, 0, false, ok, err
|
|
}
|
|
if queryWhere != "" {
|
|
whereParts = append(whereParts, queryWhere)
|
|
args = append(args, queryArgs...)
|
|
argIdx = nextArgIdx
|
|
}
|
|
|
|
userClauses := rebindUserStateClauses(plan.userClauses, argIdx)
|
|
whereParts = append(whereParts, userClauses...)
|
|
args = append(args, plan.userArgs...)
|
|
argIdx += len(plan.userArgs)
|
|
|
|
fromClause := episodeCatalogUserStateFromClause(plan)
|
|
orderBy, ok := episodeCatalogUserStateOrderBy(plan)
|
|
if !ok {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
whereClause := "WHERE " + strings.Join(whereParts, " AND ")
|
|
|
|
pageLimit := limit
|
|
if maxResults <= 0 || offset+limit < maxResults {
|
|
pageLimit++
|
|
}
|
|
pageArgs := append([]any{}, args...)
|
|
limitArgIdx := argIdx
|
|
pageArgs = append(pageArgs, pageLimit)
|
|
offsetClause := ""
|
|
if offset > 0 {
|
|
offsetClause = fmt.Sprintf(" OFFSET $%d", limitArgIdx+1)
|
|
pageArgs = append(pageArgs, offset)
|
|
}
|
|
|
|
pageSQL := fmt.Sprintf(
|
|
`WITH %s
|
|
SELECT ece.episode_id, ece.added_at
|
|
%s
|
|
%s
|
|
%s
|
|
LIMIT $%d%s`,
|
|
strings.Join(ctes, ",\n"),
|
|
fromClause,
|
|
whereClause,
|
|
orderBy,
|
|
limitArgIdx,
|
|
offsetClause,
|
|
)
|
|
|
|
pageRows, err := e.queryEpisodeCatalogEntryPage(ctx, pageSQL, pageArgs...)
|
|
if err != nil {
|
|
if episodeCatalogEntriesUnavailable(err) {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
return nil, 0, false, true, err
|
|
}
|
|
if plan.requiresFullPageGate && len(pageRows) < pageLimit {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
|
|
hasMore := len(pageRows) > limit
|
|
if hasMore {
|
|
pageRows = pageRows[:limit]
|
|
}
|
|
|
|
items, err := e.hydrateEpisodeCatalogEntryPage(ctx, pageRows)
|
|
if err != nil {
|
|
if episodeCatalogEntriesUnavailable(err) {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
return nil, 0, false, true, err
|
|
}
|
|
|
|
total := 0
|
|
if includeTotal {
|
|
countSQL, countArgs := episodeCatalogCountSQL(
|
|
strings.Join(ctes, ",\n"),
|
|
fromClause,
|
|
whereClause,
|
|
args,
|
|
maxResults,
|
|
)
|
|
if err := e.Pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil {
|
|
if episodeCatalogEntriesUnavailable(err) {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
return nil, 0, false, true, fmt.Errorf("counting episode user-state catalog entries: %w", err)
|
|
}
|
|
hasMore = total > offset+len(items)
|
|
}
|
|
|
|
return items, total, hasMore, true, nil
|
|
}
|
|
|
|
func (e *QueryExecutor) tryEpisodeCatalogEntriesPreviewPage(
|
|
ctx context.Context,
|
|
def QueryDefinition,
|
|
access AccessFilter,
|
|
limit int,
|
|
offset int,
|
|
includeTotal bool,
|
|
) ([]*models.MediaItem, int, bool, bool, error) {
|
|
def = def.Normalize()
|
|
effectiveScope := e.Scope
|
|
if effectiveScope == "" {
|
|
effectiveScope = def.MediaScope
|
|
}
|
|
if !isEpisodeCatalogScope(effectiveScope) {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
|
|
allowPersonalized := strings.TrimSpace(access.ProfileID) != ""
|
|
if err := def.ValidateWithOptions(allowPersonalized, allowPersonalized); err != nil {
|
|
return nil, 0, false, true, err
|
|
}
|
|
|
|
libraryID, empty, ok := singleEpisodeCatalogLibraryID(def, access)
|
|
if !ok {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
if empty {
|
|
return []*models.MediaItem{}, 0, false, true, nil
|
|
}
|
|
|
|
limit, offset, maxResults := normalizePreviewPageBounds(def, limit, offset)
|
|
|
|
whereParts := []string{"ece.media_folder_id = $1"}
|
|
args := []any{libraryID}
|
|
argIdx := 2
|
|
|
|
if access.AllowedContentIDs != nil {
|
|
if len(access.AllowedContentIDs) == 0 {
|
|
return []*models.MediaItem{}, 0, false, true, nil
|
|
}
|
|
whereParts = append(whereParts, fmt.Sprintf("ece.episode_id = ANY($%d)", argIdx))
|
|
args = append(args, access.AllowedContentIDs)
|
|
argIdx++
|
|
}
|
|
|
|
ApplySectionAccessFilter("ece", access, &whereParts, &args, &argIdx)
|
|
|
|
if e.SnapshotAt != nil {
|
|
whereParts = append(whereParts, fmt.Sprintf("ece.episode_created_at <= $%d", argIdx))
|
|
args = append(args, *e.SnapshotAt)
|
|
argIdx++
|
|
}
|
|
|
|
if prefix := strings.TrimSpace(access.NamePrefix); prefix != "" {
|
|
whereParts = append(whereParts, fmt.Sprintf(
|
|
"(ece.sort_key LIKE $%d ESCAPE '\\' OR LOWER(ece.title) LIKE $%d ESCAPE '\\')",
|
|
argIdx,
|
|
argIdx,
|
|
))
|
|
args = append(args, escapePrefixForLike(prefix)+"%")
|
|
argIdx++
|
|
}
|
|
|
|
queryWhere, queryArgs, nextArgIdx, ok, err := buildEpisodeCatalogEntryQueryWhere(def, argIdx)
|
|
if err != nil {
|
|
return nil, 0, false, true, err
|
|
}
|
|
if !ok {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
if queryWhere != "" {
|
|
whereParts = append(whereParts, queryWhere)
|
|
args = append(args, queryArgs...)
|
|
argIdx = nextArgIdx
|
|
}
|
|
|
|
orderBy, ok := episodeCatalogEntryOrderBy(def.Sort)
|
|
if !ok {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
|
|
whereClause := "WHERE " + strings.Join(whereParts, " AND ")
|
|
pageLimit := limit
|
|
if maxResults <= 0 || offset+limit < maxResults {
|
|
pageLimit++
|
|
}
|
|
pageArgs := append([]any{}, args...)
|
|
limitArgIdx := argIdx
|
|
pageArgs = append(pageArgs, pageLimit)
|
|
offsetClause := ""
|
|
if offset > 0 {
|
|
offsetClause = fmt.Sprintf(" OFFSET $%d", limitArgIdx+1)
|
|
pageArgs = append(pageArgs, offset)
|
|
}
|
|
|
|
pageSQL := fmt.Sprintf(
|
|
`SELECT ece.episode_id, ece.added_at
|
|
FROM episode_catalog_entries ece
|
|
%s
|
|
%s
|
|
LIMIT $%d%s`,
|
|
whereClause,
|
|
orderBy,
|
|
limitArgIdx,
|
|
offsetClause,
|
|
)
|
|
|
|
pageRows, err := e.queryEpisodeCatalogEntryPage(ctx, pageSQL, pageArgs...)
|
|
if err != nil {
|
|
if episodeCatalogEntriesUnavailable(err) {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
return nil, 0, false, true, err
|
|
}
|
|
|
|
hasMore := len(pageRows) > limit
|
|
if hasMore {
|
|
pageRows = pageRows[:limit]
|
|
}
|
|
|
|
items, err := e.hydrateEpisodeCatalogEntryPage(ctx, pageRows)
|
|
if err != nil {
|
|
if episodeCatalogEntriesUnavailable(err) {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
return nil, 0, false, true, err
|
|
}
|
|
|
|
total := 0
|
|
if includeTotal {
|
|
countSQL, countArgs := episodeCatalogCountSQL(
|
|
"",
|
|
"FROM episode_catalog_entries ece",
|
|
whereClause,
|
|
args,
|
|
maxResults,
|
|
)
|
|
if err := e.Pool.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil {
|
|
if episodeCatalogEntriesUnavailable(err) {
|
|
return nil, 0, false, false, nil
|
|
}
|
|
return nil, 0, false, true, fmt.Errorf("counting episode catalog entries: %w", err)
|
|
}
|
|
hasMore = total > offset+len(items)
|
|
}
|
|
|
|
return items, total, hasMore, true, nil
|
|
}
|
|
|
|
func episodeCatalogCountSQL(
|
|
withCTEs string,
|
|
fromClause string,
|
|
whereClause string,
|
|
args []any,
|
|
maxResults int,
|
|
) (string, []any) {
|
|
countArgs := append([]any{}, args...)
|
|
withClause := ""
|
|
if strings.TrimSpace(withCTEs) != "" {
|
|
withClause = "WITH " + withCTEs + "\n"
|
|
}
|
|
if maxResults > 0 {
|
|
limitArgIdx := len(countArgs) + 1
|
|
countArgs = append(countArgs, maxResults)
|
|
return fmt.Sprintf(
|
|
`%sSELECT COUNT(*)
|
|
FROM (SELECT 1 %s %s LIMIT $%d) sub`,
|
|
withClause,
|
|
fromClause,
|
|
whereClause,
|
|
limitArgIdx,
|
|
), countArgs
|
|
}
|
|
return fmt.Sprintf(
|
|
`%sSELECT COUNT(*)
|
|
%s
|
|
%s`,
|
|
withClause,
|
|
fromClause,
|
|
whereClause,
|
|
), countArgs
|
|
}
|
|
|
|
func extractEpisodeCatalogUserStatePlan(def QueryDefinition) (episodeCatalogUserStatePlan, bool, error) {
|
|
plan := episodeCatalogUserStatePlan{
|
|
entryDef: def,
|
|
sortField: NormalizeQuerySort(def.Sort).Field,
|
|
sortOrder: NormalizeQuerySort(def.Sort).Order,
|
|
}
|
|
switch plan.sortField {
|
|
case "date_viewed", "plays":
|
|
plan.source = "viewed"
|
|
plan.alias = "uv"
|
|
plan.positiveSource = true
|
|
plan.sortOnly = true
|
|
plan.requiresFullPageGate = true
|
|
case "progress":
|
|
plan.source = "progress"
|
|
plan.alias = "up"
|
|
plan.positiveSource = true
|
|
plan.sortOnly = true
|
|
plan.requiresFullPageGate = true
|
|
}
|
|
|
|
if len(def.Groups) == 0 {
|
|
if plan.source == "" {
|
|
return episodeCatalogUserStatePlan{}, false, nil
|
|
}
|
|
plan.requireProgressRatio = plan.source == "progress" && plan.sortOnly
|
|
return plan, true, nil
|
|
}
|
|
if def.Match != "all" {
|
|
return episodeCatalogUserStatePlan{}, false, nil
|
|
}
|
|
|
|
filteredGroups := make([]QueryGroup, 0, len(def.Groups))
|
|
for _, group := range def.Groups {
|
|
if group.Match != "all" {
|
|
for _, rule := range group.Rules {
|
|
if episodeCatalogUserStateRule(rule) {
|
|
return episodeCatalogUserStatePlan{}, false, nil
|
|
}
|
|
}
|
|
filteredGroups = append(filteredGroups, group)
|
|
continue
|
|
}
|
|
|
|
filteredRules := make([]QueryRule, 0, len(group.Rules))
|
|
for _, rule := range group.Rules {
|
|
source, alias, positive, clause, args, ok, err := buildEpisodeCatalogUserStateRule(rule)
|
|
if err != nil {
|
|
return episodeCatalogUserStatePlan{}, true, err
|
|
}
|
|
if !ok {
|
|
filteredRules = append(filteredRules, rule)
|
|
continue
|
|
}
|
|
if plan.source != "" && plan.source != source {
|
|
return episodeCatalogUserStatePlan{}, false, nil
|
|
}
|
|
plan.source = source
|
|
plan.alias = alias
|
|
plan.positiveSource = positive
|
|
plan.userClauses = append(plan.userClauses, clause)
|
|
plan.userArgs = append(plan.userArgs, args...)
|
|
plan.sortOnly = false
|
|
plan.requiresFullPageGate = false
|
|
}
|
|
if len(filteredRules) > 0 {
|
|
group.Rules = filteredRules
|
|
filteredGroups = append(filteredGroups, group)
|
|
}
|
|
}
|
|
if plan.source == "" {
|
|
return episodeCatalogUserStatePlan{}, false, nil
|
|
}
|
|
plan.entryDef.Groups = filteredGroups
|
|
plan.requireProgressRatio = plan.source == "progress" && plan.sortOnly
|
|
return plan, true, nil
|
|
}
|
|
|
|
func episodeCatalogUserStateRule(rule QueryRule) bool {
|
|
switch rule.Field {
|
|
case "watched", "in_progress", "last_watched":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func buildEpisodeCatalogUserStateRule(rule QueryRule) (string, string, bool, string, []any, bool, error) {
|
|
switch rule.Field {
|
|
case "watched":
|
|
value, ok := rule.Value.(bool)
|
|
if !ok {
|
|
return "", "", false, "", nil, true, fmt.Errorf("watched requires a boolean value")
|
|
}
|
|
if value {
|
|
return "viewed", "uv", true, "uv.episode_id IS NOT NULL", nil, true, nil
|
|
}
|
|
return "viewed", "uv", false, "uv.episode_id IS NULL", nil, true, nil
|
|
case "in_progress":
|
|
value, ok := rule.Value.(bool)
|
|
if !ok {
|
|
return "", "", false, "", nil, true, fmt.Errorf("in_progress requires a boolean value")
|
|
}
|
|
if value {
|
|
return "progress", "up", true, "up.episode_id IS NOT NULL", nil, true, nil
|
|
}
|
|
return "progress", "up", false, "up.episode_id IS NULL", nil, true, nil
|
|
case "last_watched":
|
|
switch rule.Op {
|
|
case "gt", "gte", "between", "in_last":
|
|
default:
|
|
return "", "", false, "", nil, false, nil
|
|
}
|
|
clause, args, err := buildEpisodeCatalogLastWatchedClause(rule)
|
|
if err != nil {
|
|
return "", "", false, "", nil, true, err
|
|
}
|
|
return "viewed", "uv", true, clause, args, true, nil
|
|
default:
|
|
return "", "", false, "", nil, false, nil
|
|
}
|
|
}
|
|
|
|
func buildEpisodeCatalogLastWatchedClause(rule QueryRule) (string, []any, error) {
|
|
switch rule.Op {
|
|
case "gt", "gte":
|
|
operator := map[string]string{"gt": ">", "gte": ">="}[rule.Op]
|
|
return "uv.last_watched " + operator + " $%d::timestamptz", []any{rule.Value}, nil
|
|
case "between":
|
|
values, err := toBetweenValues(rule.Value)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("between requires [min, max] array: %w", err)
|
|
}
|
|
return "uv.last_watched >= $%d::timestamptz AND uv.last_watched <= $%d::timestamptz", []any{values[0], values[1]}, nil
|
|
case "in_last":
|
|
duration, ok := rule.Value.(string)
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("in_last requires a duration string like '30d'")
|
|
}
|
|
interval, err := parseDuration(duration)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return "uv.last_watched >= NOW() - INTERVAL '" + interval + "'", nil, nil
|
|
default:
|
|
return "", nil, fmt.Errorf("unsupported last_watched operator %q", rule.Op)
|
|
}
|
|
}
|
|
|
|
func rebindUserStateClauses(clauses []string, argIdx int) []string {
|
|
if len(clauses) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, len(clauses))
|
|
nextArg := argIdx
|
|
for i, clause := range clauses {
|
|
for strings.Contains(clause, "$%d") {
|
|
clause = strings.Replace(clause, "$%d", "$"+strconv.Itoa(nextArg), 1)
|
|
nextArg++
|
|
}
|
|
out[i] = clause
|
|
}
|
|
return out
|
|
}
|
|
|
|
func episodeCatalogUserStateCTE(plan episodeCatalogUserStatePlan) string {
|
|
switch plan.source {
|
|
case "progress":
|
|
progressRatioGate := ""
|
|
if plan.requireProgressRatio {
|
|
progressRatioGate = `
|
|
AND COALESCE(uwp.duration_seconds, 0) > 0`
|
|
}
|
|
return fmt.Sprintf(`user_progress AS (
|
|
SELECT uwp.media_item_id AS episode_id,
|
|
uwp.position_seconds::double precision / NULLIF(uwp.duration_seconds, 0) AS progress_ratio
|
|
FROM user_watch_progress uwp
|
|
LEFT JOIN user_history_hidden_items hhi
|
|
ON hhi.user_id = $1
|
|
AND hhi.profile_id = $2
|
|
AND hhi.media_item_id = uwp.media_item_id
|
|
WHERE uwp.user_id = $1
|
|
AND uwp.profile_id = $2
|
|
AND uwp.completed = FALSE
|
|
AND uwp.position_seconds > 0%s
|
|
AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before)
|
|
)`, progressRatioGate)
|
|
default:
|
|
return `user_viewed AS (
|
|
SELECT src.episode_id,
|
|
MAX(src.last_watched) AS last_watched,
|
|
NULLIF(GREATEST(
|
|
COALESCE(MAX(src.history_play_count), 0),
|
|
COALESCE(MAX(src.progress_play_count), 0)
|
|
), 0) AS play_count
|
|
FROM (
|
|
SELECT uwh.media_item_id AS episode_id,
|
|
MAX(uwh.watched_at) AS last_watched,
|
|
COUNT(*)::integer AS history_play_count,
|
|
NULL::integer AS progress_play_count
|
|
FROM user_watch_history uwh
|
|
LEFT JOIN user_history_hidden_items hhi
|
|
ON hhi.user_id = $1
|
|
AND hhi.profile_id = $2
|
|
AND hhi.media_item_id = uwh.media_item_id
|
|
WHERE uwh.user_id = $1
|
|
AND uwh.profile_id = $2
|
|
AND uwh.completed = TRUE
|
|
AND (hhi.media_item_id IS NULL OR uwh.watched_at > hhi.hidden_before)
|
|
GROUP BY uwh.media_item_id
|
|
UNION ALL
|
|
SELECT uwp.media_item_id AS episode_id,
|
|
uwp.updated_at AS last_watched,
|
|
NULL::integer AS history_play_count,
|
|
1 AS progress_play_count
|
|
FROM user_watch_progress uwp
|
|
LEFT JOIN user_history_hidden_items hhi
|
|
ON hhi.user_id = $1
|
|
AND hhi.profile_id = $2
|
|
AND hhi.media_item_id = uwp.media_item_id
|
|
WHERE uwp.user_id = $1
|
|
AND uwp.profile_id = $2
|
|
AND uwp.completed = TRUE
|
|
AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before)
|
|
) src
|
|
GROUP BY src.episode_id
|
|
)`
|
|
}
|
|
}
|
|
|
|
func episodeCatalogUserStateFromClause(plan episodeCatalogUserStatePlan) string {
|
|
tableName := "user_viewed"
|
|
if plan.source == "progress" {
|
|
tableName = "user_progress"
|
|
}
|
|
if plan.positiveSource {
|
|
return fmt.Sprintf(
|
|
"FROM %s %s JOIN episode_catalog_entries ece ON ece.episode_id = %s.episode_id",
|
|
tableName,
|
|
plan.alias,
|
|
plan.alias,
|
|
)
|
|
}
|
|
return fmt.Sprintf(
|
|
"FROM episode_catalog_entries ece LEFT JOIN %s %s ON %s.episode_id = ece.episode_id",
|
|
tableName,
|
|
plan.alias,
|
|
plan.alias,
|
|
)
|
|
}
|
|
|
|
func episodeCatalogUserStateOrderBy(plan episodeCatalogUserStatePlan) (string, bool) {
|
|
dir := "DESC"
|
|
if plan.sortOrder == "asc" {
|
|
dir = "ASC"
|
|
}
|
|
switch plan.sortField {
|
|
case "date_viewed":
|
|
return fmt.Sprintf("ORDER BY uv.last_watched %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "plays":
|
|
return fmt.Sprintf("ORDER BY uv.play_count %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "progress":
|
|
return fmt.Sprintf("ORDER BY up.progress_ratio %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
default:
|
|
return episodeCatalogEntryOrderBy(QuerySort{Field: plan.sortField, Order: plan.sortOrder})
|
|
}
|
|
}
|
|
|
|
func (e *QueryExecutor) queryEpisodeCatalogEntryPage(
|
|
ctx context.Context,
|
|
sql string,
|
|
args ...any,
|
|
) ([]episodeCatalogEntryPageRow, error) {
|
|
rows, err := e.Pool.Query(ctx, sql, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying episode catalog entry page: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
pageRows := make([]episodeCatalogEntryPageRow, 0)
|
|
for rows.Next() {
|
|
var row episodeCatalogEntryPageRow
|
|
if err := rows.Scan(&row.episodeID, &row.addedAt); err != nil {
|
|
return nil, fmt.Errorf("scanning episode catalog entry page: %w", err)
|
|
}
|
|
pageRows = append(pageRows, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating episode catalog entry page: %w", err)
|
|
}
|
|
return pageRows, nil
|
|
}
|
|
|
|
func (e *QueryExecutor) hydrateEpisodeCatalogEntryPage(
|
|
ctx context.Context,
|
|
pageRows []episodeCatalogEntryPageRow,
|
|
) ([]*models.MediaItem, error) {
|
|
if len(pageRows) == 0 {
|
|
return []*models.MediaItem{}, nil
|
|
}
|
|
|
|
ids := make([]string, 0, len(pageRows))
|
|
addedAtByID := make(map[string]time.Time, len(pageRows))
|
|
for _, row := range pageRows {
|
|
ids = append(ids, row.episodeID)
|
|
addedAtByID[row.episodeID] = row.addedAt
|
|
}
|
|
|
|
relation := fmt.Sprintf(episodeCatalogSelectBody, "e.content_id = ANY($1)")
|
|
sql := "SELECT " + qualifiedListItemColumns("mi") + " FROM " + relation
|
|
rows, err := e.Pool.Query(ctx, sql, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hydrating episode catalog entry page: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
hydrated, err := scanItems(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
itemsByID := make(map[string]*models.MediaItem, len(hydrated))
|
|
for _, item := range hydrated {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
if addedAt, ok := addedAtByID[item.ContentID]; ok {
|
|
t := addedAt
|
|
item.AddedAt = &t
|
|
} else if item.AddedAt == nil && !item.CreatedAt.IsZero() {
|
|
t := item.CreatedAt
|
|
item.AddedAt = &t
|
|
}
|
|
itemsByID[item.ContentID] = item
|
|
}
|
|
|
|
ordered := make([]*models.MediaItem, 0, len(pageRows))
|
|
for _, row := range pageRows {
|
|
if item := itemsByID[row.episodeID]; item != nil {
|
|
ordered = append(ordered, item)
|
|
}
|
|
}
|
|
return ordered, nil
|
|
}
|
|
|
|
func singleEpisodeCatalogLibraryID(def QueryDefinition, access AccessFilter) (int, bool, bool) {
|
|
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)
|
|
}
|
|
}
|
|
if len(libraryIDs) == 0 {
|
|
if access.AllowedLibraryIDs != nil {
|
|
return 0, true, true
|
|
}
|
|
return 0, false, false
|
|
}
|
|
if len(libraryIDs) != 1 {
|
|
return 0, false, false
|
|
}
|
|
libraryID := libraryIDs[0]
|
|
if libraryID <= 0 {
|
|
return 0, true, true
|
|
}
|
|
for _, disabledID := range access.DisabledLibraryIDs {
|
|
if disabledID == libraryID {
|
|
return 0, true, true
|
|
}
|
|
}
|
|
return libraryID, false, true
|
|
}
|
|
|
|
func buildEpisodeCatalogEntryQueryWhere(def QueryDefinition, argIdx int) (string, []any, int, bool, error) {
|
|
if len(def.Groups) == 0 {
|
|
return "", nil, argIdx, true, nil
|
|
}
|
|
|
|
topJoiner := " AND "
|
|
if def.Match == "any" {
|
|
topJoiner = " OR "
|
|
}
|
|
|
|
var args []any
|
|
var groupClauses []string
|
|
for _, group := range def.Groups {
|
|
clause, groupArgs, nextArgIdx, ok, err := buildEpisodeCatalogEntryGroupWhere(group, argIdx)
|
|
if err != nil || !ok {
|
|
return "", nil, argIdx, ok, err
|
|
}
|
|
argIdx = nextArgIdx
|
|
args = append(args, groupArgs...)
|
|
if clause != "" {
|
|
groupClauses = append(groupClauses, clause)
|
|
}
|
|
}
|
|
|
|
switch len(groupClauses) {
|
|
case 0:
|
|
return "", args, argIdx, true, nil
|
|
case 1:
|
|
return groupClauses[0], args, argIdx, true, nil
|
|
default:
|
|
wrapped := make([]string, len(groupClauses))
|
|
for i, clause := range groupClauses {
|
|
wrapped[i] = "(" + clause + ")"
|
|
}
|
|
return strings.Join(wrapped, topJoiner), args, argIdx, true, nil
|
|
}
|
|
}
|
|
|
|
func buildEpisodeCatalogEntryGroupWhere(group QueryGroup, argIdx int) (string, []any, int, bool, error) {
|
|
if len(group.Rules) == 0 {
|
|
return "", nil, argIdx, true, nil
|
|
}
|
|
if group.Match == "all" && countSameFileTechnicalRules(group.Rules) > 1 {
|
|
return "", nil, argIdx, false, nil
|
|
}
|
|
|
|
joiner := " AND "
|
|
if group.Match == "any" {
|
|
joiner = " OR "
|
|
}
|
|
|
|
var args []any
|
|
var clauses []string
|
|
for _, rule := range group.Rules {
|
|
clause, ruleArgs, nextArgIdx, ok, err := buildEpisodeCatalogEntryRuleWhere(rule, argIdx)
|
|
if err != nil || !ok {
|
|
return "", nil, argIdx, ok, err
|
|
}
|
|
argIdx = nextArgIdx
|
|
args = append(args, ruleArgs...)
|
|
if clause != "" {
|
|
clauses = append(clauses, clause)
|
|
}
|
|
}
|
|
if len(clauses) == 0 {
|
|
return "", args, argIdx, true, nil
|
|
}
|
|
return strings.Join(clauses, joiner), args, argIdx, true, nil
|
|
}
|
|
|
|
func buildEpisodeCatalogEntryRuleWhere(rule QueryRule, argIdx int) (string, []any, int, bool, error) {
|
|
switch rule.Field {
|
|
case "type":
|
|
return buildEpisodeCatalogTypeClause(rule, argIdx)
|
|
case "genre":
|
|
return buildEpisodeCatalogArrayClause("ece.genres", rule, argIdx)
|
|
case "studio":
|
|
return buildEpisodeCatalogArrayClause("ece.studios", rule, argIdx)
|
|
case "network":
|
|
return buildEpisodeCatalogArrayClause("ece.networks", rule, argIdx)
|
|
case "country":
|
|
return buildEpisodeCatalogArrayClause("ece.countries", rule, argIdx)
|
|
case "year":
|
|
return buildEpisodeCatalogComparisonClause("ece.year", rule, argIdx, "")
|
|
case "rating_imdb":
|
|
return buildEpisodeCatalogComparisonClause("ece.rating_imdb", rule, argIdx, "")
|
|
case "original_language":
|
|
return buildEpisodeCatalogEqualityClause("ece.original_language", rule, argIdx)
|
|
case "content_rating":
|
|
return buildEpisodeCatalogEqualityClause("ece.content_rating", rule, argIdx)
|
|
case "added_at":
|
|
return buildEpisodeCatalogComparisonClause("ece.episode_created_at", rule, argIdx, "timestamptz")
|
|
case "release_date":
|
|
return buildEpisodeCatalogComparisonClause("ece.episode_air_date", rule, argIdx, "date")
|
|
case "status":
|
|
return buildEpisodeCatalogEqualityClause("ece.status", rule, argIdx)
|
|
case "resolution":
|
|
return buildEpisodeCatalogResolutionClause(rule, argIdx)
|
|
case "hdr":
|
|
return buildEpisodeCatalogBoolPresenceClause("ece.has_hdr", "ece.has_non_hdr", rule, argIdx)
|
|
case "dolby_vision":
|
|
return buildEpisodeCatalogBoolPresenceClause("ece.has_dolby_vision", "ece.has_non_dolby_vision", rule, argIdx)
|
|
case "bitrate":
|
|
return buildEpisodeCatalogBitrateClause(rule, argIdx)
|
|
case "audio_language":
|
|
return buildEpisodeCatalogLanguageArrayClause("ece.audio_language_codes", rule, argIdx)
|
|
case "subtitle_language":
|
|
return buildEpisodeCatalogLanguageArrayClause("ece.subtitle_language_codes", rule, argIdx)
|
|
case "actor", "director", "writer", "producer", "watched", "favorited", "in_watchlist", "in_progress", "last_watched":
|
|
return "", nil, argIdx, false, nil
|
|
default:
|
|
return "", nil, argIdx, false, nil
|
|
}
|
|
}
|
|
|
|
func buildEpisodeCatalogTypeClause(rule QueryRule, argIdx int) (string, []any, int, bool, error) {
|
|
value, ok := rule.Value.(string)
|
|
if !ok {
|
|
return "", nil, argIdx, true, fmt.Errorf("type requires a string value")
|
|
}
|
|
isEpisode := strings.EqualFold(strings.TrimSpace(value), "episode")
|
|
switch rule.Op {
|
|
case "is":
|
|
if isEpisode {
|
|
return "1 = 1", nil, argIdx, true, nil
|
|
}
|
|
return "1 = 0", nil, argIdx, true, nil
|
|
case "is_not":
|
|
if isEpisode {
|
|
return "1 = 0", nil, argIdx, true, nil
|
|
}
|
|
return "1 = 1", nil, argIdx, true, nil
|
|
default:
|
|
return "", nil, argIdx, true, fmt.Errorf("unsupported type operator %q", rule.Op)
|
|
}
|
|
}
|
|
|
|
func buildEpisodeCatalogArrayClause(column string, rule QueryRule, argIdx int) (string, []any, int, bool, error) {
|
|
if rule.Op != "is" && rule.Op != "is_not" && rule.Op != "contains" {
|
|
return "", nil, argIdx, true, fmt.Errorf("unsupported array operator %q", rule.Op)
|
|
}
|
|
clause := fmt.Sprintf("%s @> ARRAY[$%d]::text[]", column, argIdx)
|
|
if rule.Op == "is_not" {
|
|
clause = "NOT (" + clause + ")"
|
|
}
|
|
return clause, []any{rule.Value}, argIdx + 1, true, nil
|
|
}
|
|
|
|
func buildEpisodeCatalogLanguageArrayClause(column string, rule QueryRule, argIdx int) (string, []any, int, bool, error) {
|
|
value, ok := rule.Value.(string)
|
|
if !ok {
|
|
return "", nil, argIdx, true, fmt.Errorf("%s requires a string value", rule.Field)
|
|
}
|
|
rule.Value = strings.ToLower(strings.TrimSpace(value))
|
|
return buildEpisodeCatalogArrayClause(column, rule, argIdx)
|
|
}
|
|
|
|
func buildEpisodeCatalogResolutionClause(rule QueryRule, argIdx int) (string, []any, int, bool, error) {
|
|
value, ok := rule.Value.(string)
|
|
if !ok {
|
|
return "", nil, argIdx, true, fmt.Errorf("resolution requires a string value")
|
|
}
|
|
rule.Value = normalizeResolutionValue(value)
|
|
return buildEpisodeCatalogArrayClause("ece.resolution_codes", rule, argIdx)
|
|
}
|
|
|
|
func buildEpisodeCatalogEqualityClause(column string, rule QueryRule, argIdx int) (string, []any, int, bool, error) {
|
|
if rule.Op != "is" && rule.Op != "is_not" {
|
|
return "", nil, argIdx, true, fmt.Errorf("unsupported equality operator %q", rule.Op)
|
|
}
|
|
clause := fmt.Sprintf("%s = $%d", column, argIdx)
|
|
if rule.Op == "is_not" {
|
|
clause = "NOT (" + clause + ")"
|
|
}
|
|
return clause, []any{rule.Value}, argIdx + 1, true, nil
|
|
}
|
|
|
|
func buildEpisodeCatalogComparisonClause(column string, rule QueryRule, argIdx int, cast string) (string, []any, int, bool, error) {
|
|
placeholder := func(idx int) string {
|
|
if cast == "" {
|
|
return "$" + strconv.Itoa(idx)
|
|
}
|
|
return "$" + strconv.Itoa(idx) + "::" + cast
|
|
}
|
|
|
|
switch rule.Op {
|
|
case "gt", "gte", "lt", "lte":
|
|
operator := map[string]string{"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[rule.Op]
|
|
return fmt.Sprintf("%s %s %s", column, operator, placeholder(argIdx)), []any{rule.Value}, argIdx + 1, true, nil
|
|
case "between":
|
|
values, err := toBetweenValues(rule.Value)
|
|
if err != nil {
|
|
return "", nil, argIdx, true, fmt.Errorf("between requires [min, max] array: %w", err)
|
|
}
|
|
return fmt.Sprintf("%s >= %s AND %s <= %s", column, placeholder(argIdx), column, placeholder(argIdx+1)),
|
|
[]any{values[0], values[1]}, argIdx + 2, true, nil
|
|
case "is":
|
|
return fmt.Sprintf("%s = %s", column, placeholder(argIdx)), []any{rule.Value}, argIdx + 1, true, nil
|
|
case "is_not":
|
|
return fmt.Sprintf("NOT (%s = %s)", column, placeholder(argIdx)), []any{rule.Value}, argIdx + 1, true, nil
|
|
case "in_last":
|
|
duration, ok := rule.Value.(string)
|
|
if !ok {
|
|
return "", nil, argIdx, true, fmt.Errorf("in_last requires a duration string like '30d'")
|
|
}
|
|
interval, err := parseDuration(duration)
|
|
if err != nil {
|
|
return "", nil, argIdx, true, err
|
|
}
|
|
cutoff := fmt.Sprintf("NOW() - INTERVAL '%s'", interval)
|
|
if cast == "date" {
|
|
cutoff = fmt.Sprintf("(CURRENT_DATE - INTERVAL '%s')::date", interval)
|
|
}
|
|
return fmt.Sprintf("%s >= %s", column, cutoff), nil, argIdx, true, nil
|
|
default:
|
|
return "", nil, argIdx, true, fmt.Errorf("unsupported comparison operator %q", rule.Op)
|
|
}
|
|
}
|
|
|
|
func buildEpisodeCatalogBoolPresenceClause(trueColumn, falseColumn string, rule QueryRule, argIdx int) (string, []any, int, bool, error) {
|
|
if rule.Op != "is" {
|
|
return "", nil, argIdx, true, fmt.Errorf("unsupported boolean operator %q", rule.Op)
|
|
}
|
|
value, ok := rule.Value.(bool)
|
|
if !ok {
|
|
return "", nil, argIdx, true, fmt.Errorf("%s requires a boolean value", rule.Field)
|
|
}
|
|
if value {
|
|
return trueColumn, nil, argIdx, true, nil
|
|
}
|
|
return falseColumn, nil, argIdx, true, nil
|
|
}
|
|
|
|
func buildEpisodeCatalogBitrateClause(rule QueryRule, argIdx int) (string, []any, int, bool, error) {
|
|
switch rule.Op {
|
|
case "gt", "gte":
|
|
operator := map[string]string{"gt": ">", "gte": ">="}[rule.Op]
|
|
return fmt.Sprintf("ece.max_bitrate %s $%d", operator, argIdx), []any{rule.Value}, argIdx + 1, true, nil
|
|
case "lt", "lte":
|
|
operator := map[string]string{"lt": "<", "lte": "<="}[rule.Op]
|
|
return fmt.Sprintf("ece.min_bitrate %s $%d", operator, argIdx), []any{rule.Value}, argIdx + 1, true, nil
|
|
case "between":
|
|
return "", nil, argIdx, false, nil
|
|
default:
|
|
return "", nil, argIdx, true, fmt.Errorf("unsupported bitrate operator %q", rule.Op)
|
|
}
|
|
}
|
|
|
|
func episodeCatalogEntryOrderBy(sortConfig QuerySort) (string, bool) {
|
|
sortConfig = NormalizeQuerySort(sortConfig)
|
|
dir := "DESC"
|
|
if sortConfig.Order == "asc" {
|
|
dir = "ASC"
|
|
}
|
|
|
|
switch sortConfig.Field {
|
|
case "title":
|
|
return fmt.Sprintf("ORDER BY ece.sort_key %s, ece.episode_id ASC", dir), true
|
|
case "added_at":
|
|
return fmt.Sprintf("ORDER BY ece.added_at %s, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "release_date", "last_air_date":
|
|
return fmt.Sprintf("ORDER BY ece.episode_air_date %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "year":
|
|
return fmt.Sprintf("ORDER BY ece.year %s, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "content_rating":
|
|
return fmt.Sprintf("ORDER BY ece.content_rating_rank %s, ece.content_rating_label %s, ece.sort_key ASC, ece.episode_id ASC", dir, dir), true
|
|
case "runtime":
|
|
return fmt.Sprintf("ORDER BY ece.runtime %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "rating_imdb":
|
|
return fmt.Sprintf("ORDER BY ece.rating_imdb %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "rating_tmdb":
|
|
return fmt.Sprintf("ORDER BY ece.rating_tmdb %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "rating_rt_critic", "rating_rt_audience":
|
|
return "ORDER BY ece.sort_key ASC, ece.episode_id ASC", true
|
|
case "resolution":
|
|
return fmt.Sprintf("ORDER BY ece.max_resolution_rank %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
case "bitrate":
|
|
return fmt.Sprintf("ORDER BY ece.max_bitrate %s NULLS LAST, ece.sort_key ASC, ece.episode_id ASC", dir), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func countSameFileTechnicalRules(rules []QueryRule) int {
|
|
count := 0
|
|
for _, rule := range rules {
|
|
if isSameFileTechnicalRule(rule) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func episodeCatalogEntriesUnavailable(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) {
|
|
return false
|
|
}
|
|
return pgErr.Code == "42P01" || pgErr.Code == "42883"
|
|
}
|