From d19b6c48e95fad6d3e315a3b24e8eecc24dc5dc5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 27 May 2026 13:59:29 -0400 Subject: [PATCH] fix(catalog): support relative date filters --- internal/catalog/catalog_resolver.go | 107 +++++++++- internal/catalog/catalog_resolver_test.go | 44 +++++ .../episode_catalog_entries_executor.go | 6 +- .../episode_catalog_entries_executor_test.go | 26 +++ internal/catalog/query_builder.go | 183 ++++++++++++++++-- internal/catalog/query_builder_test.go | 55 ++++++ 6 files changed, 406 insertions(+), 15 deletions(-) diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index e6f48ecd..fe953b28 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -927,6 +927,7 @@ var catalogQueryRuleFields = map[string]bool{ "country": true, "original_language": true, "content_rating": true, + "added_at": true, "release_date": true, "status": true, "actor": true, @@ -956,6 +957,7 @@ var catalogPersonalRuleFields = map[string]bool{ "country": true, "original_language": true, "content_rating": true, + "added_at": true, "release_date": true, "status": true, "actor": true, @@ -1713,6 +1715,12 @@ func catalogRuleMatchesItem(item *models.MediaItem, rule QueryRule) bool { 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 == "" { @@ -1793,11 +1801,24 @@ func compareCatalogStringDate(actual, op string, value any) bool { } switch op { - case "gt", "gte", "lt", "lte", "between", "is", "is_not": + 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 { @@ -1838,6 +1859,90 @@ func compareCatalogStringDate(actual, op string, value any) bool { } } +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: diff --git a/internal/catalog/catalog_resolver_test.go b/internal/catalog/catalog_resolver_test.go index 21d8356c..f2c3ae33 100644 --- a/internal/catalog/catalog_resolver_test.go +++ b/internal/catalog/catalog_resolver_test.go @@ -39,6 +39,24 @@ func TestValidateCatalogQueryRequest_AllowsEpisodeMediaScope(t *testing.T) { } } +func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) { + req := CatalogRequest{ + Source: CatalogSourceQuery, + Query: QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{{Field: "added_at", Op: "in_last", Value: "1y"}}, + }}, + Sort: QuerySort{Field: "title", Order: "asc"}, + }, + } + + if err := validateCatalogQueryRequest(req, true); err != nil { + t.Fatalf("expected added_at filter to be accepted, got %v", err) + } +} + func TestValidateCatalogQueryRequest_RejectsPersonalizedSortWithoutProfileScopedSurface(t *testing.T) { req := CatalogRequest{ Source: CatalogSourceQuery, @@ -67,6 +85,32 @@ func TestValidateCatalogQueryRequest_AllowsPersonalizedSortWithProfileScopedSurf } } +func TestCatalogRuleMatchesItem_InLastDateFields(t *testing.T) { + recentRelease := time.Now().UTC().AddDate(0, 0, -10).Format("2006-01-02") + oldRelease := time.Now().UTC().AddDate(-2, 0, 0).Format("2006-01-02") + recentItem := &models.MediaItem{ + CreatedAt: time.Now().AddDate(0, 0, -10), + ReleaseDate: &recentRelease, + } + oldItem := &models.MediaItem{ + CreatedAt: time.Now().AddDate(-2, 0, 0), + ReleaseDate: &oldRelease, + } + + if !catalogRuleMatchesItem(recentItem, QueryRule{Field: "added_at", Op: "in_last", Value: "1y"}) { + t.Fatal("expected recent added_at to match 1y in_last") + } + if catalogRuleMatchesItem(oldItem, QueryRule{Field: "added_at", Op: "in_last", Value: "1y"}) { + t.Fatal("expected old added_at not to match 1y in_last") + } + if !catalogRuleMatchesItem(recentItem, QueryRule{Field: "release_date", Op: "in_last", Value: "1y"}) { + t.Fatal("expected recent release_date to match 1y in_last") + } + if catalogRuleMatchesItem(oldItem, QueryRule{Field: "release_date", Op: "in_last", Value: "1y"}) { + t.Fatal("expected old release_date not to match 1y in_last") + } +} + // concurrentCallBarrier records the maximum number of callers observed // in-flight at the same time. Each call into the stubbed facet fetcher invokes // hit, which: diff --git a/internal/catalog/episode_catalog_entries_executor.go b/internal/catalog/episode_catalog_entries_executor.go index 930f2b56..ede10727 100644 --- a/internal/catalog/episode_catalog_entries_executor.go +++ b/internal/catalog/episode_catalog_entries_executor.go @@ -935,7 +935,11 @@ func buildEpisodeCatalogComparisonClause(column string, rule QueryRule, argIdx i if err != nil { return "", nil, argIdx, true, err } - return fmt.Sprintf("%s >= NOW() - INTERVAL '%s'", column, interval), nil, argIdx, true, nil + 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) } diff --git a/internal/catalog/episode_catalog_entries_executor_test.go b/internal/catalog/episode_catalog_entries_executor_test.go index f255f7ce..b49a500e 100644 --- a/internal/catalog/episode_catalog_entries_executor_test.go +++ b/internal/catalog/episode_catalog_entries_executor_test.go @@ -106,6 +106,32 @@ func TestEpisodeCatalogAddedAtFilterUsesEpisodeCreatedAt(t *testing.T) { } } +func TestEpisodeCatalogReleaseDateInLastUsesDateCutoff(t *testing.T) { + def := QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{ + {Field: "release_date", Op: "in_last", Value: "1y"}, + }, + }}, + }.Normalize() + + where, _, _, ok, err := buildEpisodeCatalogEntryQueryWhere(def, 2) + if err != nil { + t.Fatalf("buildEpisodeCatalogEntryQueryWhere returned error: %v", err) + } + if !ok { + t.Fatal("buildEpisodeCatalogEntryQueryWhere unexpectedly fell back") + } + if !strings.Contains(where, "ece.episode_air_date >= (CURRENT_DATE - INTERVAL '1 years')::date") { + t.Fatalf("expected release_date filter to use date cutoff, got %q", where) + } + if strings.Contains(where, "NOW() - INTERVAL") { + t.Fatalf("release_date filter must not compare a date to NOW(), got %q", where) + } +} + func TestEpisodeCatalogEntryQueryFallsBackForSameFileTechnicalAnd(t *testing.T) { group := QueryGroup{ Match: "all", diff --git a/internal/catalog/query_builder.go b/internal/catalog/query_builder.go index f56a222f..8678d056 100644 --- a/internal/catalog/query_builder.go +++ b/internal/catalog/query_builder.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/models" @@ -365,6 +366,10 @@ func (qb *QueryBuilder) buildRule(rule QueryRule) (string, error) { return qb.buildInProgressClause(rule) case "last_watched": return qb.buildLastWatchedClause(rule) + case "added_at": + return qb.buildAddedAtClause(rule) + case "release_date": + return qb.buildReleaseDateClause(rule) case "resolution": return qb.buildResolutionClause(rule) case "hdr": @@ -940,6 +945,64 @@ func (qb *QueryBuilder) buildLastWatchedClause(rule QueryRule) (string, error) { } } +func (qb *QueryBuilder) buildAddedAtClause(rule QueryRule) (string, error) { + column := qb.addedAtFilterExpr() + + switch rule.Op { + case "gt": + return qb.buildTypedComparisonClause(column, ">", rule.Value, "timestamptz"), nil + case "gte": + return qb.buildTypedComparisonClause(column, ">=", rule.Value, "timestamptz"), nil + case "lt": + return qb.buildTypedComparisonClause(column, "<", rule.Value, "timestamptz"), nil + case "lte": + return qb.buildTypedComparisonClause(column, "<=", rule.Value, "timestamptz"), nil + case "between": + return qb.buildTypedBetweenClause(column, rule.Value, "timestamptz") + case "in_last": + duration, ok := rule.Value.(string) + if !ok { + return "", fmt.Errorf("in_last requires a duration string like '30d'") + } + interval, err := parseDuration(duration) + if err != nil { + return "", err + } + return fmt.Sprintf("%s >= NOW() - INTERVAL '%s'", column, interval), nil + default: + return "", fmt.Errorf("unsupported operator %q for added_at", rule.Op) + } +} + +func (qb *QueryBuilder) buildReleaseDateClause(rule QueryRule) (string, error) { + column := qb.releaseDateFilterExpr() + + switch rule.Op { + case "gt": + return qb.buildTypedComparisonClause(column, ">", rule.Value, "date"), nil + case "gte": + return qb.buildTypedComparisonClause(column, ">=", rule.Value, "date"), nil + case "lt": + return qb.buildTypedComparisonClause(column, "<", rule.Value, "date"), nil + case "lte": + return qb.buildTypedComparisonClause(column, "<=", rule.Value, "date"), nil + case "between": + return qb.buildTypedBetweenClause(column, rule.Value, "date") + case "in_last": + duration, ok := rule.Value.(string) + if !ok { + return "", fmt.Errorf("in_last requires a duration string like '30d'") + } + interval, err := parseDuration(duration) + if err != nil { + return "", err + } + return fmt.Sprintf("%s >= (CURRENT_DATE - INTERVAL '%s')::date", column, interval), nil + default: + return "", fmt.Errorf("unsupported operator %q for release_date", rule.Op) + } +} + func (qb *QueryBuilder) userStateCompletionClause() string { qb.args = append(qb.args, qb.userID, qb.profileID) clause := fmt.Sprintf(`( @@ -1015,6 +1078,34 @@ func (qb *QueryBuilder) buildTimestampComparisonClause(column, op string, value return clause } +func (qb *QueryBuilder) buildTypedComparisonClause(column, op string, value any, cast string) string { + qb.args = append(qb.args, value) + placeholder := fmt.Sprintf("$%d", qb.argIdx) + if cast != "" { + placeholder += "::" + cast + } + clause := fmt.Sprintf("%s %s %s", column, op, placeholder) + qb.argIdx++ + return clause +} + +func (qb *QueryBuilder) buildTypedBetweenClause(column string, value any, cast string) (string, error) { + values, err := toBetweenValues(value) + if err != nil { + return "", fmt.Errorf("between requires [min, max] array: %w", err) + } + qb.args = append(qb.args, values[0], values[1]) + left := fmt.Sprintf("$%d", qb.argIdx) + right := fmt.Sprintf("$%d", qb.argIdx+1) + if cast != "" { + left += "::" + cast + right += "::" + cast + } + clause := fmt.Sprintf("%s >= %s AND %s <= %s", column, left, column, right) + qb.argIdx += 2 + return clause, nil +} + func (qb *QueryBuilder) normalizedTitleExpr() string { if isEpisodeCatalogScope(qb.mediaScope) { return fmt.Sprintf("%s.sort_key", qb.alias) @@ -1033,6 +1124,26 @@ func (qb *QueryBuilder) releaseDateSortExpr() string { return queryColumnSQL(qb.alias, querySortDefs["release_date"].columnSQL) } +func (qb *QueryBuilder) releaseDateFilterExpr() string { + if isEpisodeCatalogScope(qb.mediaScope) { + return fmt.Sprintf("%s.episode_air_date", qb.alias) + } + firstAirDate := fmt.Sprintf("NULLIF(BTRIM(%s.first_air_date), '')", qb.alias) + return fmt.Sprintf( + `COALESCE(%s.release_date, CASE WHEN %s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN %s::date ELSE NULL END)`, + qb.alias, + firstAirDate, + firstAirDate, + ) +} + +func (qb *QueryBuilder) addedAtFilterExpr() string { + if isEpisodeCatalogScope(qb.mediaScope) { + return fmt.Sprintf("%s.episode_created_at", qb.alias) + } + return fmt.Sprintf("%s.created_at", qb.alias) +} + func (qb *QueryBuilder) orderByExpr(expr, dir string, nullsLast bool, titleExpr string) string { clause := fmt.Sprintf("ORDER BY %s %s", expr, dir) if nullsLast { @@ -1349,24 +1460,70 @@ func toBetweenValues(v any) ([2]any, error) { } } +type relativeDuration struct { + amount int + unit byte +} + func parseDuration(s string) (string, error) { - if len(s) < 2 { - return "", fmt.Errorf("invalid duration: %q", s) + spec, err := parseDurationSpec(s) + if err != nil { + return "", err + } + return spec.sqlInterval(), nil +} + +func parseDurationSpec(s string) (relativeDuration, error) { + normalized := strings.ToLower(strings.TrimSpace(s)) + if len(normalized) < 2 { + return relativeDuration{}, fmt.Errorf("invalid duration: %q", s) } - value := s[:len(s)-1] - unit := s[len(s)-1] + value := strings.TrimSpace(normalized[:len(normalized)-1]) + unit := normalized[len(normalized)-1] + amount, err := strconv.Atoi(value) + if err != nil || amount <= 0 { + return relativeDuration{}, fmt.Errorf("invalid duration: %q", s) + } switch unit { - case 'h': - return value + " hours", nil - case 'd': - return value + " days", nil - case 'w': - return value + " weeks", nil - case 'm': - return value + " months", nil + case 'h', 'd', 'w', 'm', 'y': + return relativeDuration{amount: amount, unit: unit}, nil default: - return "", fmt.Errorf("unsupported duration unit %q", unit) + return relativeDuration{}, fmt.Errorf("unsupported duration unit %q", unit) + } +} + +func (d relativeDuration) sqlInterval() string { + switch d.unit { + case 'h': + return fmt.Sprintf("%d hours", d.amount) + case 'd': + return fmt.Sprintf("%d days", d.amount) + case 'w': + return fmt.Sprintf("%d weeks", d.amount) + case 'm': + return fmt.Sprintf("%d months", d.amount) + case 'y': + return fmt.Sprintf("%d years", d.amount) + default: + return "" + } +} + +func (d relativeDuration) cutoffTime(now time.Time) time.Time { + switch d.unit { + case 'h': + return now.Add(-time.Duration(d.amount) * time.Hour) + case 'd': + return now.AddDate(0, 0, -d.amount) + case 'w': + return now.AddDate(0, 0, -7*d.amount) + case 'm': + return now.AddDate(0, -d.amount, 0) + case 'y': + return now.AddDate(-d.amount, 0, 0) + default: + return now } } diff --git a/internal/catalog/query_builder_test.go b/internal/catalog/query_builder_test.go index 41400c14..f6409db4 100644 --- a/internal/catalog/query_builder_test.go +++ b/internal/catalog/query_builder_test.go @@ -135,6 +135,61 @@ func TestBuildSortClause_AddedAtFallsBackToCreatedAtWithoutLibraryScope(t *testi } } +func TestBuild_AddedAtInLastAcceptsYears(t *testing.T) { + clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{{Field: "added_at", Op: "in_last", Value: "1y"}}, + }}, + }) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + if len(args) != 0 { + t.Fatalf("expected no args, got %v", args) + } + if !strings.Contains(clause, "mi.created_at >= NOW() - INTERVAL '1 years'") { + t.Fatalf("expected added_at in_last to use created_at and years interval, got %q", clause) + } +} + +func TestBuild_ReleaseDateInLastComparesDateExpression(t *testing.T) { + clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{ + Match: "all", + Groups: []QueryGroup{{ + Match: "all", + Rules: []QueryRule{{Field: "release_date", Op: "in_last", Value: "1y"}}, + }}, + }) + if err != nil { + t.Fatalf("Build returned error: %v", err) + } + if len(args) != 0 { + t.Fatalf("expected no args, got %v", args) + } + for _, want := range []string{ + "COALESCE(mi.release_date, CASE WHEN NULLIF(BTRIM(mi.first_air_date), '') ~", + ">= (CURRENT_DATE - INTERVAL '1 years')::date", + } { + if !strings.Contains(clause, want) { + t.Fatalf("expected release_date in_last clause to contain %q, got %q", want, clause) + } + } + if strings.Contains(clause, "release_date::text") || strings.Contains(clause, "NOW() - INTERVAL") { + t.Fatalf("release_date in_last must not compare text to timestamp, got %q", clause) + } +} + +func TestParseDurationRejectsMalformedInput(t *testing.T) { + if _, err := parseDuration("1'; SELECT 1; --d"); err == nil { + t.Fatal("expected malformed duration to be rejected") + } + if got, err := parseDuration("2Y"); err != nil || got != "2 years" { + t.Fatalf("parseDuration(2Y) = %q, %v; want 2 years", got, err) + } +} + func TestBuildSortClause_ContentRatingUsesRankedOrdering(t *testing.T) { clause, _, err := NewQueryBuilder("mi").BuildSortClause(QuerySort{ Field: "content_rating",