fix(catalog): support relative date filters

This commit is contained in:
Quick
2026-05-27 13:59:29 -04:00
parent 3c92ded9df
commit d19b6c48e9
6 changed files with 406 additions and 15 deletions
+106 -1
View File
@@ -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:
+44
View File
@@ -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:
@@ -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)
}
@@ -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",
+170 -13
View File
@@ -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
}
}
+55
View File
@@ -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",