From a119b60ca697c08b8c7000a9f3097ada4efee251 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 13:49:34 -0400 Subject: [PATCH] feat(calendar): generalize personal filter to an id-set restriction Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/catalog/calendar_repo.go | 73 +++++++++++++------------- internal/catalog/calendar_repo_test.go | 44 ++++++++++++++++ 2 files changed, 80 insertions(+), 37 deletions(-) diff --git a/internal/catalog/calendar_repo.go b/internal/catalog/calendar_repo.go index 46dd7069..7469eca2 100644 --- a/internal/catalog/calendar_repo.go +++ b/internal/catalog/calendar_repo.go @@ -32,13 +32,17 @@ type CalendarEvent struct { type CalendarFilter struct { Start time.Time End time.Time - Filter string // "all" | "favorites" | "watchlist" LibraryID *int AllowedLibraryIDs []int DisabledLibraryIDs []int MaxContentRating string - UserID int - ProfileID string + + // RestrictByIDs limits results to items whose movie content_id (movies) or + // series_id (episodes / season premieres) is in RestrictToIDs. When + // RestrictByIDs is true and RestrictToIDs is empty, no rows match. Callers + // resolve the id-set (Following / Popular / Trending) before querying. + RestrictByIDs bool + RestrictToIDs []string } // CalendarRepository provides calendar queries across movies, episodes, and seasons. @@ -102,9 +106,18 @@ func (r *CalendarRepository) buildListEventsQuery(f CalendarFilter) (string, []a args = append(args, f.End) argIdx++ - movieBranch := r.buildMovieBranch(startArg, endArg, f, &args, &argIdx) - filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, f, &args, &argIdx) - filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, f, &args, &argIdx) + // Optional id-set restriction, appended once and shared by all branches. + // restrictArg stays 0 when the set is empty so branches short-circuit. + restrictArg := 0 + if f.RestrictByIDs && len(f.RestrictToIDs) > 0 { + restrictArg = argIdx + args = append(args, f.RestrictToIDs) + argIdx++ + } + + movieBranch := r.buildMovieBranch(startArg, endArg, restrictArg, f, &args, &argIdx) + filteredEpisodes := r.buildFilteredEpisodesCTE(startArg, endArg, restrictArg, f, &args, &argIdx) + filteredSeasons := r.buildFilteredSeasonsCTE(startArg, endArg, restrictArg, f, &args, &argIdx) episodeBranch := r.buildEpisodeBranch() seasonBranch := r.buildSeasonBranch() @@ -139,14 +152,14 @@ FROM ( return query, args } -func (r *CalendarRepository) buildMovieBranch(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string { +func (r *CalendarRepository) buildMovieBranch(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string { conditions := []string{ "mi.type = 'movie'", fmt.Sprintf("mi.release_date BETWEEN $%d::date AND $%d::date", startArg, endArg), } r.appendLibraryExistsClauses("mi.content_id", f, &conditions, args, argIdx) r.appendContentRatingClause("mi", f, &conditions, args, argIdx) - r.appendPersonalFilterClause("mi.content_id", f, &conditions, args, argIdx) + r.appendRestrictClause("mi.content_id", restrictArg, f, &conditions) return fmt.Sprintf(`SELECT mi.content_id, 'movie'::text AS type, mi.title, NULL::text AS episode_title, NULL::text AS series_id, @@ -158,14 +171,14 @@ FROM media_items mi WHERE %s`, strings.Join(conditions, " AND ")) } -func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string { +func (r *CalendarRepository) buildFilteredEpisodesCTE(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string { conditions := []string{ fmt.Sprintf("e.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg), "e.season_number > 0", // exclude specials } r.appendLibraryExistsClauses("e.series_id", f, &conditions, args, argIdx) r.appendContentRatingClause("mi", f, &conditions, args, argIdx) - r.appendPersonalFilterClause("e.series_id", f, &conditions, args, argIdx) + r.appendRestrictClause("e.series_id", restrictArg, f, &conditions) return fmt.Sprintf(`SELECT e.content_id, e.series_id, e.season_number, e.episode_number, e.title AS episode_title, e.air_date, @@ -188,14 +201,14 @@ FROM filtered_episodes fe LEFT JOIN season_finales sf ON sf.series_id = fe.series_id AND sf.season_number = fe.season_number` } -func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg int, f CalendarFilter, args *[]any, argIdx *int) string { +func (r *CalendarRepository) buildFilteredSeasonsCTE(startArg, endArg, restrictArg int, f CalendarFilter, args *[]any, argIdx *int) string { conditions := []string{ fmt.Sprintf("s.air_date BETWEEN $%d::date AND $%d::date", startArg, endArg), "s.season_number > 0", // exclude specials } r.appendLibraryExistsClauses("s.series_id", f, &conditions, args, argIdx) r.appendContentRatingClause("mi", f, &conditions, args, argIdx) - r.appendPersonalFilterClause("s.series_id", f, &conditions, args, argIdx) + r.appendRestrictClause("s.series_id", restrictArg, f, &conditions) return fmt.Sprintf(`SELECT s.content_id, s.series_id, s.season_number, s.title AS episode_title, s.air_date, mi.title AS title, mi.air_time, mi.air_timezone, @@ -282,30 +295,16 @@ func (r *CalendarRepository) appendLibraryExistsClauses(contentIDExpr string, f *argIdx++ } -// appendPersonalFilterClause adds favorites/watchlist EXISTS subqueries. -func (r *CalendarRepository) appendPersonalFilterClause(itemIDExpr string, f CalendarFilter, conditions *[]string, args *[]any, argIdx *int) { - switch f.Filter { - case "favorites": - userArg := *argIdx - *args = append(*args, f.UserID) - *argIdx++ - profileArg := *argIdx - *args = append(*args, f.ProfileID) - *argIdx++ - *conditions = append(*conditions, fmt.Sprintf( - "EXISTS (SELECT 1 FROM user_favorites uf WHERE uf.user_id = $%d AND uf.profile_id = $%d AND uf.media_item_id = %s)", - userArg, profileArg, itemIDExpr, - )) - case "watchlist": - userArg := *argIdx - *args = append(*args, f.UserID) - *argIdx++ - profileArg := *argIdx - *args = append(*args, f.ProfileID) - *argIdx++ - *conditions = append(*conditions, fmt.Sprintf( - "EXISTS (SELECT 1 FROM user_watchlist uw WHERE uw.user_id = $%d AND uw.profile_id = $%d AND uw.media_item_id = %s)", - userArg, profileArg, itemIDExpr, - )) +// appendRestrictClause limits a branch to the id-set in CalendarFilter. restrictArg +// is the positional parameter holding the id array (0 when the set is empty). An +// empty restriction matches nothing. +func (r *CalendarRepository) appendRestrictClause(itemIDExpr string, restrictArg int, f CalendarFilter, conditions *[]string) { + if !f.RestrictByIDs { + return } + if restrictArg == 0 { + *conditions = append(*conditions, "1 = 0") + return + } + *conditions = append(*conditions, fmt.Sprintf("%s = ANY($%d)", itemIDExpr, restrictArg)) } diff --git a/internal/catalog/calendar_repo_test.go b/internal/catalog/calendar_repo_test.go index f846dbb0..fc6ca066 100644 --- a/internal/catalog/calendar_repo_test.go +++ b/internal/catalog/calendar_repo_test.go @@ -102,3 +102,47 @@ func TestBuildListEventsQuery_RejectsExplicitLibraryOutsideAllowedScope(t *testi t.Fatalf("expected season branch to short-circuit inaccessible library selection, got:\n%s", query) } } + +func TestBuildListEventsQuery_AppliesIDRestriction(t *testing.T) { + t.Parallel() + + repo := &CalendarRepository{} + query, args := repo.buildListEventsQuery(CalendarFilter{ + Start: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, time.April, 12, 0, 0, 0, 0, time.UTC), + RestrictByIDs: true, + RestrictToIDs: []string{"series-1", "movie-2"}, + }) + + for _, fragment := range []string{ + "mi.content_id = ANY($3)", + "e.series_id = ANY($3)", + "s.series_id = ANY($3)", + } { + if !strings.Contains(query, fragment) { + t.Fatalf("expected query to contain %q, got:\n%s", fragment, query) + } + } + if len(args) != 3 { + t.Fatalf("expected start/end/ids args, got %d", len(args)) + } +} + +func TestBuildListEventsQuery_EmptyRestrictionMatchesNothing(t *testing.T) { + t.Parallel() + + repo := &CalendarRepository{} + query, args := repo.buildListEventsQuery(CalendarFilter{ + Start: time.Date(2026, time.April, 6, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, time.April, 12, 0, 0, 0, 0, time.UTC), + RestrictByIDs: true, + RestrictToIDs: nil, + }) + + if strings.Count(query, "1 = 0") != 3 { + t.Fatalf("expected each branch to short-circuit with 1 = 0, got:\n%s", query) + } + if len(args) != 2 { + t.Fatalf("expected only start/end args, got %d", len(args)) + } +}