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"` } var validSubjectTypes = map[string]bool{ "director": true, "studio": true, "actor": true, "era": true, "franchise": 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) } // Default empty cadence to weekly before validating. if p.RotationCadence == "" { p.RotationCadence = CadenceWeekly } switch p.RotationCadence { case CadenceDaily, CadenceWeekly, CadenceMonthly: // valid default: return fmt.Errorf("editorial_spotlight: invalid rotation_cadence %q (want daily|weekly|monthly)", p.RotationCadence) } 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"}`)}, }, } } func init() { Register(editorialSpotlightRecipe{}) }