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 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-05 16:07:49 -04:00
co-authored by Claude Fable 5
parent b8b708cbe0
commit 8fa40e928b
4 changed files with 169 additions and 13 deletions
+17 -10
View File
@@ -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,
+77 -3
View File
@@ -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
@@ -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)
}
}
}
@@ -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 <ContinueTypeParamField params={params} onChange={onChange} />;
}
if (def.type === "watchlist" || def.type === "favorites") {
return <PersonalListFilterFields params={params} onChange={onChange} />;
}
if (def.type === "seasonal_themed") {
return <SeasonalParamField params={params} onChange={onChange} />;
}
@@ -112,6 +117,49 @@ interface ParamFieldProps {
onChange: (next: Record<string, unknown>) => 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 (
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-white/70">Media type</span>
<select
value={filterType || "all"}
onChange={(e) =>
onChange({
...params,
filter_type: e.target.value === "all" ? undefined : e.target.value,
})
}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
>
<option value="all">All Media</option>
<option value="movie">Movies</option>
<option value="series">TV Shows</option>
<option value="audiobook">Audiobooks</option>
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs text-white/70">Libraries</span>
<LibraryMultiSelect
libraries={libraries ?? []}
value={libraryIds}
onChange={(next) =>
onChange({ ...params, filter_library_ids: next.length > 0 ? next : undefined })
}
/>
</label>
</div>
);
}
function ContinueTypeParamField({ params, onChange }: ParamFieldProps) {
const continueType = params.continue_type === "listening" ? "listening" : "watching";