* feat(sections): fix broken home section templates and add six new ones Fixes templates that silently produced nothing: - award_winners: hide from gallery (resolver is a stub until award data exists); saved sections keep resolving - seasonal_themed: christmas/st_patricks/thanksgiving get an interim title-keyword resolver, and multi-theme selection skips themes without an executable query so a data-less theme can no longer black out the section during its own window (previously killed the section all of December) - taste_match: empty genre now auto-picks the profile's strongest taste cluster (fallback: server top genre); the default preset was permanently empty - because_you_watched: honor the recipe's anchor_item_id key (fetcher only read legacy source_item_id, so pinning an anchor did nothing) - editorial_spotlight: reject subject_type=franchise (validated but could never resolve); fix drawer misrepresenting pinned presets as auto-rotate - admin_curated_list: add a catalog-search item picker so Editor's Picks is actually addable; block saving an empty list; hide admin_only recipes from profile-facing galleries - discovery fetchers (hidden_gems, forgotten_favorites, critically_acclaimed): honor single/multi library scope, intersected with viewer access; implement hidden_gems max_play_count New templates: returning_shows (new season of shows you've watched), genre_roulette (rotating top-genre spotlight with title override), anniversaries (milestone release anniversaries this month), short_watches (well-rated movies under a runtime cap), family_movie_night seasonal theme (Fri/Sat evenings), and a "New in 4K" format_showcase preset via a new sort=recent param. Adds a blanket test asserting every visible gallery preset's defaults pass its own recipe validation — the gap that let taste_match and Editor's Picks ship broken. New SQL shapes validated with EXPLAIN against the dev database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sections): address PR #332 review findings Codex review: - returning_shows: the new-season file check now applies the effective library scope (section scope ∩ viewer-allowed, minus disabled) to media_files.media_folder_id, so an episode file that only exists in an out-of-scope folder can no longer surface the series - buildLibraryScope: replaced the media_item_libraries row join with EXISTS / NOT EXISTS semi-joins. An item in several in-scope libraries now yields exactly one row in the non-GROUP BY rails (short_watches, anniversaries, seasonal keyword, format_showcase, new_to_library, ...), and the disabled-library check is item-level, closing the join-row leak where membership in an allowed library masked membership in a disabled one. Deny-only mode keeps the positive-membership guard, mirroring catalog's appendDiscoveryLibraryScope. CodeRabbit review: - recommendations reader: a taste cluster whose cached items are entirely filtered out now falls through to the next cluster / global fallback instead of returning an empty row - genre_roulette: multi-library scopes get distinct rotation seeds - returning_shows: reject negative lookback_days at validation - shared oneOf() enum validator replaces per-recipe switch duplication - SeasonalTitleOverride usable-filter contract covered by a direct test - web NumberParamField: integer-only guard + step=1 (backend fields are Go ints; fractional values failed unmarshalling at save) - curated list picker: search failures show an error instead of a misleading "No matches."; pre-existing item_ids hydrate display titles via the watch-detail endpoint instead of rendering raw ids Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
166 lines
5.9 KiB
Go
166 lines
5.9 KiB
Go
package recipes
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
type forYouRecipe struct{}
|
|
|
|
func (forYouRecipe) Type() string { return "recommended_for_you" }
|
|
func (forYouRecipe) NewParams() any { return &struct{}{} }
|
|
func (forYouRecipe) Validate(_ json.RawMessage) error { return nil }
|
|
func (forYouRecipe) DefaultCacheTTL() time.Duration { return time.Hour }
|
|
func (forYouRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("recommended_for_you", rc)
|
|
}
|
|
func (forYouRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "recommended_for_you",
|
|
Category: CategoryPersonalized,
|
|
AvoidDuplicates: true,
|
|
Presets: []GalleryPreset{
|
|
{Key: "for_you", DisplayName: "Recommended For You", Icon: "⭐", DescriptionShort: "Per-profile picks from the recommendation engine.", DefaultParams: json.RawMessage(`{}`)},
|
|
},
|
|
}
|
|
}
|
|
|
|
// BecauseYouWatchedParams is the typed param shape for because_you_watched.
|
|
type BecauseYouWatchedParams struct {
|
|
// AnchorItemID is the media item this row is anchored to. Empty = auto-pick the latest watched.
|
|
AnchorItemID string `json:"anchor_item_id"`
|
|
}
|
|
|
|
type becauseRecipe struct{}
|
|
|
|
func (becauseRecipe) Type() string { return "because_you_watched" }
|
|
func (becauseRecipe) NewParams() any { return &BecauseYouWatchedParams{} }
|
|
func (becauseRecipe) DefaultCacheTTL() time.Duration { return time.Hour }
|
|
func (becauseRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("because_you_watched", rc)
|
|
}
|
|
func (becauseRecipe) Validate(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var p BecauseYouWatchedParams
|
|
return json.Unmarshal(raw, &p)
|
|
}
|
|
func (becauseRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "because_you_watched",
|
|
Category: CategoryPersonalized,
|
|
AvoidDuplicates: true,
|
|
SupportsRotation: true, // auto-pick anchor rotates as profile completes new items
|
|
Presets: []GalleryPreset{
|
|
{Key: "bcw_auto", DisplayName: "Because You Watched", Icon: "📺", DescriptionShort: "Picks based on your most recent watch.", DefaultParams: json.RawMessage(`{"anchor_item_id":""}`)},
|
|
},
|
|
}
|
|
}
|
|
|
|
type similarUsersRecipe struct{}
|
|
|
|
func (similarUsersRecipe) Type() string { return "similar_users_liked" }
|
|
func (similarUsersRecipe) NewParams() any { return &struct{}{} }
|
|
func (similarUsersRecipe) Validate(_ json.RawMessage) error { return nil }
|
|
func (similarUsersRecipe) DefaultCacheTTL() time.Duration { return time.Hour }
|
|
func (similarUsersRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("similar_users_liked", rc)
|
|
}
|
|
func (similarUsersRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "similar_users_liked",
|
|
Category: CategoryPersonalized,
|
|
AvoidDuplicates: true,
|
|
Presets: []GalleryPreset{
|
|
{Key: "similar", DisplayName: "Profiles Like You Enjoyed", Icon: "👥", DescriptionShort: "What similar profiles are loving.", DefaultParams: json.RawMessage(`{}`)},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TasteMatchParams optionally narrows by genre. When Genre is empty the
|
|
// recommendation reader auto-picks the profile's strongest taste cluster
|
|
// (falling back to the server-wide top genre).
|
|
type TasteMatchParams struct {
|
|
Genre string `json:"genre"`
|
|
}
|
|
|
|
type tasteMatchRecipe struct{}
|
|
|
|
func (tasteMatchRecipe) Type() string { return "taste_match" }
|
|
func (tasteMatchRecipe) NewParams() any { return &TasteMatchParams{} }
|
|
func (tasteMatchRecipe) DefaultCacheTTL() time.Duration { return time.Hour }
|
|
func (tasteMatchRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("taste_match", rc)
|
|
}
|
|
func (tasteMatchRecipe) Validate(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var p TasteMatchParams
|
|
return json.Unmarshal(raw, &p)
|
|
}
|
|
func (tasteMatchRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "taste_match",
|
|
Category: CategoryPersonalized,
|
|
AvoidDuplicates: true,
|
|
Presets: []GalleryPreset{
|
|
{Key: "taste_top", DisplayName: "Top Picks Today", Icon: "🎯", DescriptionShort: "Best matches for your strongest taste — set a genre to narrow it.", DefaultParams: json.RawMessage(`{}`)},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ReturningShowsParams configures returning_shows: series the profile has
|
|
// watched that received a brand-new season within the lookback window.
|
|
type ReturningShowsParams struct {
|
|
LookbackDays int `json:"lookback_days,omitempty"` // default 30
|
|
}
|
|
|
|
type returningShowsRecipe struct{}
|
|
|
|
func (returningShowsRecipe) Type() string { return "returning_shows" }
|
|
func (returningShowsRecipe) NewParams() any { return &ReturningShowsParams{} }
|
|
func (returningShowsRecipe) DefaultCacheTTL() time.Duration { return time.Hour }
|
|
func (returningShowsRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("returning_shows", rc)
|
|
}
|
|
func (returningShowsRecipe) Validate(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var p ReturningShowsParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return err
|
|
}
|
|
if p.LookbackDays < 0 {
|
|
return errors.New("returning_shows: lookback_days must be >= 0")
|
|
}
|
|
return nil
|
|
}
|
|
func (returningShowsRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "returning_shows",
|
|
Category: CategoryPersonalized,
|
|
AvoidDuplicates: true,
|
|
Presets: []GalleryPreset{
|
|
{
|
|
Key: "returning_shows_default",
|
|
DisplayName: "Returning Shows",
|
|
Icon: "🔁",
|
|
DescriptionShort: "Shows you've watched with a brand-new season in your library.",
|
|
DefaultParams: json.RawMessage(`{"lookback_days":30}`),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
Register(forYouRecipe{})
|
|
Register(becauseRecipe{})
|
|
Register(similarUsersRecipe{})
|
|
Register(tasteMatchRecipe{})
|
|
Register(returningShowsRecipe{})
|
|
}
|