Files
silo-server/internal/sections/recipes/editorial.go
T

150 lines
4.9 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"`
}
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{})
}