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";