* 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>
238 lines
8.3 KiB
Go
238 lines
8.3 KiB
Go
package recipes
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"time"
|
|
)
|
|
|
|
// RotationCadence controls how often the editorial spotlight rotates.
|
|
type RotationCadence string
|
|
|
|
const (
|
|
CadenceDaily RotationCadence = "daily"
|
|
CadenceWeekly RotationCadence = "weekly"
|
|
CadenceMonthly RotationCadence = "monthly"
|
|
)
|
|
|
|
// bucketTime converts a timestamp to a stable bucket key based on the cadence.
|
|
// Daily cadence (cadenceDays <= 1) uses the unix-day index. Monthly cadence
|
|
// (cadenceDays >= 28) uses calendar year*100+month. All other cadences use
|
|
// ISO year*100+week so spotlights advance cleanly on ISO-week boundaries.
|
|
func bucketTime(now time.Time, cadenceDays int) int64 {
|
|
if cadenceDays <= 1 {
|
|
return now.Unix() / 86400
|
|
}
|
|
if cadenceDays >= 28 {
|
|
return int64(now.Year())*100 + int64(now.Month())
|
|
}
|
|
year, week := now.ISOWeek()
|
|
return int64(year)*100 + int64(week)
|
|
}
|
|
|
|
// RotationIndex returns a deterministic index in [0, count) for the given
|
|
// timestamp, subject key, candidate count, and bucket size in days.
|
|
// When days is 0, weekly (7-day) bucketing is used.
|
|
func RotationIndex(t time.Time, key string, count int, days int) int {
|
|
if count <= 0 {
|
|
return 0
|
|
}
|
|
bucket := bucketTime(t, days)
|
|
h := fnv.New64a()
|
|
_, _ = fmt.Fprintf(h, "%s|%d", key, bucket)
|
|
return int(h.Sum64() % uint64(count))
|
|
}
|
|
|
|
// EditorialSpotlightParams configures the editorial_spotlight resolver.
|
|
//
|
|
// LibraryID is reserved for future use: the section-level libraryID passed
|
|
// through fetchSection currently takes precedence and config-level pinning
|
|
// is not yet honored. Persisting LibraryID in the section config is safe —
|
|
// it will be picked up when the wiring lands.
|
|
type EditorialSpotlightParams struct {
|
|
SubjectType string `json:"subject_type"`
|
|
Subject string `json:"subject,omitempty"`
|
|
AutoRotate bool `json:"auto_rotate,omitempty"`
|
|
RotationCadence RotationCadence `json:"rotation_cadence,omitempty"`
|
|
LibraryID *int `json:"library_id,omitempty"`
|
|
}
|
|
|
|
// Note: "franchise" is intentionally absent — the fetcher has no franchise
|
|
// data source yet, so accepting it would validate configs that can never
|
|
// resolve. Re-add once franchise/collection grouping data lands.
|
|
var validSubjectTypes = map[string]bool{
|
|
"director": true,
|
|
"studio": true,
|
|
"actor": true,
|
|
"era": true,
|
|
}
|
|
|
|
type editorialSpotlightRecipe struct{}
|
|
|
|
func (editorialSpotlightRecipe) Type() string { return "editorial_spotlight" }
|
|
func (editorialSpotlightRecipe) NewParams() any { return &EditorialSpotlightParams{} }
|
|
func (editorialSpotlightRecipe) DefaultCacheTTL() time.Duration { return 24 * time.Hour }
|
|
|
|
func (editorialSpotlightRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("editorial_spotlight", rc)
|
|
}
|
|
|
|
func (editorialSpotlightRecipe) Validate(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return errors.New("editorial_spotlight: subject_type is required")
|
|
}
|
|
var p EditorialSpotlightParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return err
|
|
}
|
|
if p.SubjectType == "" {
|
|
return errors.New("editorial_spotlight: subject_type is required")
|
|
}
|
|
if !validSubjectTypes[p.SubjectType] {
|
|
return fmt.Errorf("editorial_spotlight: unknown subject_type %q", p.SubjectType)
|
|
}
|
|
|
|
if err := oneOf("editorial_spotlight: rotation_cadence", string(p.RotationCadence),
|
|
"", string(CadenceDaily), string(CadenceWeekly), string(CadenceMonthly)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !p.AutoRotate && p.Subject == "" {
|
|
return errors.New("editorial_spotlight: subject is required when auto_rotate is false")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (editorialSpotlightRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "editorial_spotlight",
|
|
Category: CategoryEditorial,
|
|
SupportsRotation: true,
|
|
AvoidDuplicates: false,
|
|
Presets: []GalleryPreset{
|
|
{
|
|
Key: "es_director_auto",
|
|
DisplayName: "Director Spotlight",
|
|
Icon: "🎬",
|
|
DescriptionShort: "Weekly spotlight on a director in your library.",
|
|
DefaultParams: json.RawMessage(`{"subject_type":"director","auto_rotate":true,"rotation_cadence":"weekly"}`),
|
|
},
|
|
{
|
|
Key: "es_actor",
|
|
DisplayName: "Actor Spotlight",
|
|
Icon: "🌟",
|
|
DescriptionShort: "Weekly spotlight on an actor in your library.",
|
|
DefaultParams: json.RawMessage(`{"subject_type":"actor","auto_rotate":true,"rotation_cadence":"weekly"}`),
|
|
},
|
|
{
|
|
Key: "es_studio",
|
|
DisplayName: "Studio Spotlight",
|
|
Icon: "🏛️",
|
|
DescriptionShort: "Weekly spotlight on a studio in your library.",
|
|
DefaultParams: json.RawMessage(`{"subject_type":"studio","auto_rotate":true,"rotation_cadence":"weekly"}`),
|
|
},
|
|
{Key: "es_era_80s", DisplayName: "The 80s", Icon: "📼", DescriptionShort: "Films from the 1980s.", DefaultParams: json.RawMessage(`{"subject_type":"era","subject":"1980s"}`)},
|
|
},
|
|
}
|
|
}
|
|
|
|
// GenreRouletteParams configures genre_roulette: a deterministic rotating
|
|
// spotlight on one of the library's top genres, using the same bucket hashing
|
|
// as editorial_spotlight so every client sees the same genre for the whole
|
|
// rotation window.
|
|
type GenreRouletteParams struct {
|
|
RotationCadence RotationCadence `json:"rotation_cadence,omitempty"` // daily | weekly (default) | monthly
|
|
MinRating float64 `json:"min_rating,omitempty"` // default 6.0
|
|
}
|
|
|
|
type genreRouletteRecipe struct{}
|
|
|
|
func (genreRouletteRecipe) Type() string { return "genre_roulette" }
|
|
func (genreRouletteRecipe) NewParams() any { return &GenreRouletteParams{} }
|
|
func (genreRouletteRecipe) DefaultCacheTTL() time.Duration { return 6 * time.Hour }
|
|
func (genreRouletteRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("genre_roulette", rc)
|
|
}
|
|
func (genreRouletteRecipe) Validate(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var p GenreRouletteParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return err
|
|
}
|
|
return oneOf("genre_roulette: rotation_cadence", string(p.RotationCadence),
|
|
"", string(CadenceDaily), string(CadenceWeekly), string(CadenceMonthly))
|
|
}
|
|
func (genreRouletteRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "genre_roulette",
|
|
Category: CategoryEditorial,
|
|
SupportsRotation: true,
|
|
Presets: []GalleryPreset{
|
|
{
|
|
Key: "genre_roulette_weekly",
|
|
DisplayName: "Genre Roulette",
|
|
Icon: "🎰",
|
|
DescriptionShort: "A different genre from your library every week.",
|
|
DefaultParams: json.RawMessage(`{"rotation_cadence":"weekly"}`),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// AnniversariesParams configures anniversaries: titles celebrating a round
|
|
// release anniversary this month (e.g. released exactly 10/20/25 years ago).
|
|
type AnniversariesParams struct {
|
|
// MilestoneYears keeps only anniversaries that are a multiple of this many
|
|
// years (default 5, so 5th/10th/15th/... anniversaries qualify). Set to 1
|
|
// to include every anniversary.
|
|
MilestoneYears int `json:"milestone_years,omitempty"`
|
|
}
|
|
|
|
type anniversariesRecipe struct{}
|
|
|
|
func (anniversariesRecipe) Type() string { return "anniversaries" }
|
|
func (anniversariesRecipe) NewParams() any { return &AnniversariesParams{} }
|
|
func (anniversariesRecipe) DefaultCacheTTL() time.Duration { return 24 * time.Hour }
|
|
func (anniversariesRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("anniversaries", rc)
|
|
}
|
|
func (anniversariesRecipe) Validate(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var p AnniversariesParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return err
|
|
}
|
|
if p.MilestoneYears < 0 {
|
|
return errors.New("anniversaries: milestone_years must be >= 0")
|
|
}
|
|
return nil
|
|
}
|
|
func (anniversariesRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "anniversaries",
|
|
Category: CategoryEditorial,
|
|
Presets: []GalleryPreset{
|
|
{
|
|
Key: "anniversaries_default",
|
|
DisplayName: "Anniversaries",
|
|
Icon: "🎂",
|
|
DescriptionShort: "Titles celebrating a milestone release anniversary this month.",
|
|
DefaultParams: json.RawMessage(`{"milestone_years":5}`),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
Register(editorialSpotlightRecipe{})
|
|
Register(genreRouletteRecipe{})
|
|
Register(anniversariesRecipe{})
|
|
}
|