Files
silo-server/internal/sections/recipes/discovery.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

225 lines
7.8 KiB
Go

package recipes
import (
"encoding/json"
"time"
)
// HiddenGemsParams configures the hidden_gems resolver.
type HiddenGemsParams struct {
MinRating float64 `json:"min_rating,omitempty"`
MaxPlayCount int `json:"max_play_count,omitempty"`
}
type hiddenGemsRecipe struct{}
func (hiddenGemsRecipe) Type() string { return "hidden_gems" }
func (hiddenGemsRecipe) NewParams() any { return &HiddenGemsParams{} }
func (hiddenGemsRecipe) DefaultCacheTTL() time.Duration { return 6 * time.Hour }
func (hiddenGemsRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
return delegateResolve("hidden_gems", rc)
}
func (hiddenGemsRecipe) Validate(raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
var p HiddenGemsParams
return json.Unmarshal(raw, &p)
}
func (hiddenGemsRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "hidden_gems",
Category: CategoryDiscovery,
AvoidDuplicates: true,
Presets: []GalleryPreset{
{
Key: "hidden_gems_default",
DisplayName: "Hidden Gems",
Icon: "💎",
DescriptionShort: "Highly rated titles in your library that no one's watched.",
DefaultParams: json.RawMessage(`{"min_rating":7.5,"max_play_count":2}`),
},
},
}
}
// CriticallyAcclaimedParams configures the critically_acclaimed resolver.
type CriticallyAcclaimedParams struct {
MinScore float64 `json:"min_score,omitempty"`
Source string `json:"source,omitempty"`
}
type criticallyAcclaimedRecipe struct{}
func (criticallyAcclaimedRecipe) Type() string { return "critically_acclaimed" }
func (criticallyAcclaimedRecipe) NewParams() any { return &CriticallyAcclaimedParams{} }
func (criticallyAcclaimedRecipe) DefaultCacheTTL() time.Duration { return 6 * time.Hour }
func (criticallyAcclaimedRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
return delegateResolve("critically_acclaimed", rc)
}
func (criticallyAcclaimedRecipe) Validate(raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
var p CriticallyAcclaimedParams
return json.Unmarshal(raw, &p)
}
func (criticallyAcclaimedRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "critically_acclaimed",
Category: CategoryDiscovery,
AvoidDuplicates: true,
Presets: []GalleryPreset{
{
Key: "ca_imdb",
DisplayName: "Critically Acclaimed",
Icon: "🏆",
DescriptionShort: "8.0+ rated by IMDb.",
DefaultParams: json.RawMessage(`{"min_score":8.0,"source":"imdb"}`),
},
},
}
}
// AwardWinnersParams configures the award_winners resolver.
type AwardWinnersParams struct {
AwardType string `json:"award_type,omitempty"`
Limit int `json:"limit,omitempty"`
}
type awardWinnersRecipe struct{}
func (awardWinnersRecipe) Type() string { return "award_winners" }
func (awardWinnersRecipe) NewParams() any { return &AwardWinnersParams{} }
func (awardWinnersRecipe) DefaultCacheTTL() time.Duration { return 6 * time.Hour }
func (awardWinnersRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
return delegateResolve("award_winners", rc)
}
func (awardWinnersRecipe) Validate(raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
var p AwardWinnersParams
return json.Unmarshal(raw, &p)
}
func (awardWinnersRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "award_winners",
Category: CategoryDiscovery,
AvoidDuplicates: true,
// The resolver is a stub until award metadata exists (see
// fetchAwardWinners). Hidden keeps existing saved sections resolvable
// (they render empty) without advertising presets that can't work.
Hidden: true,
Presets: []GalleryPreset{
{
Key: "aw_oscar",
DisplayName: "Oscar Winners",
Icon: "🏅",
DescriptionShort: "Academy Award winners in your library.",
DefaultParams: json.RawMessage(`{"award_type":"oscar"}`),
},
{
Key: "aw_emmy",
DisplayName: "Emmy Winners",
Icon: "📺",
DescriptionShort: "Emmy-winning shows.",
DefaultParams: json.RawMessage(`{"award_type":"emmy"}`),
},
{
Key: "aw_cannes",
DisplayName: "Cannes Selections",
Icon: "🎞️",
DescriptionShort: "Cannes Festival selections.",
DefaultParams: json.RawMessage(`{"award_type":"cannes"}`),
},
},
}
}
// ForgottenFavoritesParams configures the forgotten_favorites resolver.
type ForgottenFavoritesParams struct {
LookbackDays int `json:"lookback_days,omitempty"`
}
type forgottenFavoritesRecipe struct{}
func (forgottenFavoritesRecipe) Type() string { return "forgotten_favorites" }
func (forgottenFavoritesRecipe) NewParams() any { return &ForgottenFavoritesParams{} }
func (forgottenFavoritesRecipe) DefaultCacheTTL() time.Duration { return 6 * time.Hour }
func (forgottenFavoritesRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
return delegateResolve("forgotten_favorites", rc)
}
func (forgottenFavoritesRecipe) Validate(raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
var p ForgottenFavoritesParams
return json.Unmarshal(raw, &p)
}
func (forgottenFavoritesRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "forgotten_favorites",
Category: CategoryDiscovery,
AvoidDuplicates: true,
Presets: []GalleryPreset{
{
Key: "ff_default",
DisplayName: "Forgotten Favorites",
Icon: "🕰️",
DescriptionShort: "In your library, haven't been watched in a year.",
DefaultParams: json.RawMessage(`{"lookback_days":365}`),
},
},
}
}
// FormatShowcaseParams configures the format_showcase resolver.
type FormatShowcaseParams struct {
Format string `json:"format"` // 4k | dolby_vision | hdr
Sort string `json:"sort,omitempty"` // rating (default) | recent
}
type formatShowcaseRecipe struct{}
func (formatShowcaseRecipe) Type() string { return "format_showcase" }
func (formatShowcaseRecipe) NewParams() any { return &FormatShowcaseParams{} }
func (formatShowcaseRecipe) DefaultCacheTTL() time.Duration { return 6 * time.Hour }
func (formatShowcaseRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
return delegateResolve("format_showcase", rc)
}
func (formatShowcaseRecipe) Validate(raw json.RawMessage) error {
if len(raw) == 0 {
return nil
}
var p FormatShowcaseParams
if err := json.Unmarshal(raw, &p); err != nil {
return err
}
if err := oneOf("format_showcase: format", p.Format, "", "4k", "dolby_vision", "hdr"); err != nil {
return err
}
return oneOf("format_showcase: sort", p.Sort, "", "rating", "recent")
}
func (formatShowcaseRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "format_showcase",
Category: CategoryDiscovery,
AvoidDuplicates: false,
Presets: []GalleryPreset{
{Key: "fs_4k", DisplayName: "4K Showcase", Icon: "🎥", DescriptionShort: "Titles available in 4K UHD.", DefaultParams: json.RawMessage(`{"format":"4k"}`)},
{Key: "fs_4k_recent", DisplayName: "New in 4K", Icon: "🎥", DescriptionShort: "Recently added 4K UHD titles.", DefaultParams: json.RawMessage(`{"format":"4k","sort":"recent"}`)},
{Key: "fs_dv", DisplayName: "Dolby Vision Picks", Icon: "🌈", DescriptionShort: "Dolby Vision titles.", DefaultParams: json.RawMessage(`{"format":"dolby_vision"}`)},
{Key: "fs_hdr", DisplayName: "HDR Highlights", Icon: "✨", DescriptionShort: "HDR-mastered titles.", DefaultParams: json.RawMessage(`{"format":"hdr"}`)},
},
}
}
func init() {
Register(hiddenGemsRecipe{})
Register(criticallyAcclaimedRecipe{})
Register(awardWinnersRecipe{})
Register(forgottenFavoritesRecipe{})
Register(formatShowcaseRecipe{})
}