Files
silo-server/internal/sections/recipes/personalized.go
11704a1701 feat(sections): fix broken home section templates and add six new ones (#332)
* 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>
2026-07-07 10:47:20 -04:00

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{})
}