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:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user