From 8fa40e928b06ae0fd7b8d6431f3145d231e28e32 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:07:49 -0400 Subject: [PATCH] feat(sections): wire watchlist/favorites rails with type and library filters The dedicated watchlist/favorites section types were never dispatched by the section fetcher, so their home rails always resolved empty. Resolve them from the profile's user store, preserving stored order, and honor the section config's filter_type / filter_library_ids so admins can build e.g. separate "Movies Watchlist" and "TV Shows Watchlist" rails. Apply the same filters on the catalog "see all" path, and expose Media Type + Libraries pickers in the section editor for these types. Co-Authored-By: Claude Fable 5 --- internal/catalog/catalog_resolver.go | 27 ++++--- internal/sections/fetcher.go | 80 ++++++++++++++++++- .../sections/fetcher_personal_list_test.go | 27 +++++++ .../RecipeGallery/RecipeParamFields.tsx | 48 +++++++++++ 4 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 internal/sections/fetcher_personal_list_test.go diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index 61fce497..a659af5a 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -372,17 +372,24 @@ func (r *CatalogResolver) resolveSectionSource(ctx context.Context, req CatalogR SkipTotal: req.SkipTotal, UseSourceOrder: true, }, access) - case "favorites": + case "favorites", "watchlist": + source := CatalogSourceFavorites + if section.SectionType == "watchlist" { + source = CatalogSourceWatchlist + } + // Personal list sections may carry optional type/library filters; + // apply them as an overlay query so the "see all" page matches the rail. + sectionFilters := parseCatalogSectionFilters(section.Config) + query := QueryDefinition{ + MediaScope: sectionFilters.FilterType, + LibraryIDs: append([]int(nil), sectionFilters.LibraryIDs...), + }.Normalize() + if section.Scope == "library" && section.LibraryID != nil { + query.LibraryIDs = []int{*section.LibraryID} + } return r.resolvePersonalSource(ctx, CatalogRequest{ - Source: CatalogSourceFavorites, - Limit: req.Limit, - Offset: req.Offset, - SkipTotal: req.SkipTotal, - UseSourceOrder: true, - }, access) - case "watchlist": - return r.resolvePersonalSource(ctx, CatalogRequest{ - Source: CatalogSourceWatchlist, + Source: source, + Query: query, Limit: req.Limit, Offset: req.Offset, SkipTotal: req.SkipTotal, diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index bceb3cd1..7ff63d6b 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -1273,9 +1273,9 @@ func (f *Fetcher) fetchSection(ctx context.Context, s ResolvedSection, libraryID return f.fetchEditorialSpotlight(ctx, s, libraryID, libraryIDs, filter) case SectionProfileActivityFeed: return f.fetchProfileActivityFeed(ctx, s, libraryID, libraryIDs, profileID, filter) + case SectionWatchlist, SectionFavorites: + return f.fetchPersonalListSection(ctx, s, libraryID, libraryIDs, userID, profileID, filter) default: - // Profile-scoped types (continue_watching, watchlist, favorites) - // will be wired later when user store integration is added. return nil, 0, fmt.Errorf("unsupported section type: %s", s.SectionType) } } @@ -1467,6 +1467,71 @@ func (f *Fetcher) fetchUserCollection(ctx context.Context, s ResolvedSection, li return orderedItems, total, nil } +// personalListFetchLimit bounds how many watchlist/favorites entries are +// pulled from the user store when resolving a section; it mirrors the cap the +// catalog resolver uses for personal sources. +const personalListFetchLimit = 10000 + +// fetchPersonalListSection resolves watchlist and favorites sections from the +// profile's user store, preserving the stored list order and honoring the +// section's optional filter_type / library filters. +func (f *Fetcher) fetchPersonalListSection(ctx context.Context, s ResolvedSection, libraryID *int, libraryIDs []int, userID int, profileID string, filter catalog.AccessFilter) ([]*models.MediaItem, int, error) { + if f.StoreProvider == nil || userID <= 0 || profileID == "" { + return []*models.MediaItem{}, 0, nil + } + + store, err := f.StoreProvider.ForUser(ctx, userID) + if err != nil { + return nil, 0, fmt.Errorf("getting user store for %s section: %w", s.SectionType, err) + } + + var contentIDs []string + switch s.SectionType { + case SectionWatchlist: + entries, err := store.ListWatchlist(ctx, profileID, personalListFetchLimit, 0) + if err != nil { + return nil, 0, fmt.Errorf("listing watchlist: %w", err) + } + contentIDs = make([]string, 0, len(entries)) + for _, entry := range entries { + contentIDs = append(contentIDs, entry.MediaItemID) + } + case SectionFavorites: + entries, err := store.ListFavorites(ctx, profileID, personalListFetchLimit, 0) + if err != nil { + return nil, 0, fmt.Errorf("listing favorites: %w", err) + } + contentIDs = make([]string, 0, len(entries)) + for _, entry := range entries { + contentIDs = append(contentIDs, entry.MediaItemID) + } + default: + return nil, 0, fmt.Errorf("unsupported personal list section type: %s", s.SectionType) + } + if len(contentIDs) == 0 { + return []*models.MediaItem{}, 0, nil + } + + items, err := f.fetchItemsByContentIDsFiltered(ctx, contentIDs, libraryID, libraryIDs, ParseConfigFilters(s.Config), filter) + if err != nil { + return nil, 0, err + } + + itemByID := make(map[string]*models.MediaItem, len(items)) + for _, item := range items { + itemByID[item.ContentID] = item + } + ordered := make([]*models.MediaItem, 0, len(items)) + for _, contentID := range contentIDs { + if item, ok := itemByID[contentID]; ok { + ordered = append(ordered, item) + } + } + + ordered, total := limitUserCollectionSectionItems(ordered, s.ItemLimit) + return ordered, total, nil +} + func limitUserCollectionSectionItems(items []*models.MediaItem, limit int) ([]*models.MediaItem, int) { total := len(items) if limit > 0 && limit < len(items) { @@ -2358,6 +2423,13 @@ func (f *Fetcher) fetchRandom(ctx context.Context, s ResolvedSection, libraryID } func (f *Fetcher) fetchItemsByContentIDs(ctx context.Context, contentIDs []string, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) ([]*models.MediaItem, error) { + return f.fetchItemsByContentIDsFiltered(ctx, contentIDs, libraryID, libraryIDs, SectionConfigFilters{}, filter) +} + +// fetchItemsByContentIDsFiltered is fetchItemsByContentIDs with the section +// config's optional type and library filters applied on top of the access +// scope. +func (f *Fetcher) fetchItemsByContentIDsFiltered(ctx context.Context, contentIDs []string, libraryID *int, libraryIDs []int, cfgFilters SectionConfigFilters, filter catalog.AccessFilter) ([]*models.MediaItem, error) { if len(contentIDs) == 0 { return []*models.MediaItem{}, nil } @@ -2374,8 +2446,10 @@ func (f *Fetcher) fetchItemsByContentIDs(ctx context.Context, contentIDs []strin } conditions = append(conditions, fmt.Sprintf("mi.content_id IN (%s)", strings.Join(placeholders, ", "))) + applyConfigTypeFilter("mi", cfgFilters.FilterType, &conditions, &args, &argIdx) + effectiveLibraryIDs := effectiveFetchLibraryIDs(libraryIDs, filter) - fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, effectiveLibraryIDs, nil, filter.DisabledLibraryIDs, argIdx) + fromClause, libConditions, libArgs, newArgIdx := buildLibraryScope(libraryID, effectiveLibraryIDs, cfgFilters.LibraryIDs(), filter.DisabledLibraryIDs, argIdx) conditions = append(conditions, libConditions...) args = append(args, libArgs...) argIdx = newArgIdx diff --git a/internal/sections/fetcher_personal_list_test.go b/internal/sections/fetcher_personal_list_test.go new file mode 100644 index 00000000..ce9df865 --- /dev/null +++ b/internal/sections/fetcher_personal_list_test.go @@ -0,0 +1,27 @@ +package sections + +import ( + "context" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// Watchlist and favorites sections are profile-scoped: with no user store (or +// no authenticated profile) they must degrade to an empty rail instead of the +// "unsupported section type" error they returned before being wired up. +func TestFetchSectionPersonalListWithoutStoreReturnsEmpty(t *testing.T) { + t.Parallel() + + f := &Fetcher{} + for _, sectionType := range []SectionType{SectionWatchlist, SectionFavorites} { + s := ResolvedSection{ID: "s1", SectionType: sectionType, ItemLimit: 20} + items, total, err := f.fetchSection(context.Background(), s, nil, nil, 0, "", catalog.AccessFilter{}) + if err != nil { + t.Fatalf("fetchSection(%s) error = %v, want nil", sectionType, err) + } + if len(items) != 0 || total != 0 { + t.Fatalf("fetchSection(%s) = %d items, total %d; want empty", sectionType, len(items), total) + } + } +} diff --git a/web/src/components/RecipeGallery/RecipeParamFields.tsx b/web/src/components/RecipeGallery/RecipeParamFields.tsx index c492b653..9037839d 100644 --- a/web/src/components/RecipeGallery/RecipeParamFields.tsx +++ b/web/src/components/RecipeGallery/RecipeParamFields.tsx @@ -1,5 +1,7 @@ import { CollectionSearchableSelect } from "@/components/CollectionSearchableSelect"; +import LibraryMultiSelect from "@/components/LibraryMultiSelect"; import { useAllUserCollections } from "@/hooks/queries/useAllUserCollections"; +import { useAvailableUserLibraries } from "@/hooks/queries/libraries"; import type { RecipeDefinition } from "@/lib/recipes"; export interface RecipeParamFieldsProps { @@ -15,6 +17,9 @@ export default function RecipeParamFields({ def, params, onChange }: RecipeParam if (def.type === "continue_watching") { return ; } + if (def.type === "watchlist" || def.type === "favorites") { + return ; + } if (def.type === "seasonal_themed") { return ; } @@ -112,6 +117,49 @@ interface ParamFieldProps { onChange: (next: Record) => void; } +// PersonalListFilterFields edits the optional filter_type / filter_library_ids +// filters for watchlist and favorites sections, e.g. a "Movies watchlist" rail. +function PersonalListFilterFields({ params, onChange }: ParamFieldProps) { + const { data: libraries } = useAvailableUserLibraries(); + const filterType = typeof params.filter_type === "string" ? params.filter_type : ""; + const libraryIds = Array.isArray(params.filter_library_ids) + ? params.filter_library_ids.filter((id): id is number => typeof id === "number") + : []; + + return ( +
+ + +
+ ); +} + function ContinueTypeParamField({ params, onChange }: ParamFieldProps) { const continueType = params.continue_type === "listening" ? "listening" : "watching";