* 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>
148 lines
4.8 KiB
Go
148 lines
4.8 KiB
Go
package recipes
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// moodInfo describes a single mood preset.
|
|
type moodInfo struct {
|
|
Key string
|
|
Label string
|
|
Icon string
|
|
GenresAny []string
|
|
MinRating float64
|
|
}
|
|
|
|
// Moods is the canonical ordered list. The order here drives the gallery preset order.
|
|
var Moods = []moodInfo{
|
|
{Key: "feel_good", Label: "Feel-Good Comedies", Icon: "😄", GenresAny: []string{"Comedy", "Family"}, MinRating: 6.5},
|
|
{Key: "mind_bending", Label: "Mind-Bending Sci-Fi", Icon: "🌀", GenresAny: []string{"Science Fiction", "Mystery"}, MinRating: 7.0},
|
|
{Key: "comfort", Label: "Comfort Rewatches", Icon: "🛋️", GenresAny: []string{"Comedy", "Romance", "Family"}, MinRating: 6.0},
|
|
{Key: "edge_of_seat", Label: "Edge of Your Seat", Icon: "😬", GenresAny: []string{"Thriller", "Action"}, MinRating: 6.5},
|
|
{Key: "tearjerker", Label: "Tearjerkers", Icon: "😢", GenresAny: []string{"Drama", "Romance"}, MinRating: 7.0},
|
|
{Key: "quiet_sunday", Label: "Quiet Sunday Cinema", Icon: "☕", GenresAny: []string{"Drama", "Documentary"}, MinRating: 7.0},
|
|
{Key: "date_night", Label: "Date Night", Icon: "💕", GenresAny: []string{"Romance", "Comedy"}, MinRating: 6.0},
|
|
{Key: "after_midnight", Label: "After Midnight", Icon: "🌙", GenresAny: []string{"Horror", "Thriller"}, MinRating: 6.0},
|
|
}
|
|
|
|
// MoodByKey looks up a mood by its key. Returns the mood and whether it exists.
|
|
func MoodByKey(key string) (moodInfo, bool) {
|
|
for _, m := range Moods {
|
|
if m.Key == key {
|
|
return m, true
|
|
}
|
|
}
|
|
return moodInfo{}, false
|
|
}
|
|
|
|
// MoodCollectionParams configures the mood_collection resolver.
|
|
//
|
|
// Intensity is reserved for future use (low | med | high). It will eventually
|
|
// adjust MinRating or genre weighting, but is currently ignored — persisting
|
|
// it in the section config is safe and forward-compatible.
|
|
type MoodCollectionParams struct {
|
|
Mood string `json:"mood"`
|
|
Intensity string `json:"intensity,omitempty"` // low | med | high
|
|
}
|
|
|
|
type moodRecipe struct{}
|
|
|
|
func (moodRecipe) Type() string { return "mood_collection" }
|
|
func (moodRecipe) NewParams() any { return &MoodCollectionParams{} }
|
|
func (moodRecipe) DefaultCacheTTL() time.Duration { return 12 * time.Hour }
|
|
|
|
func (moodRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("mood_collection", rc)
|
|
}
|
|
|
|
func (moodRecipe) Validate(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return errors.New("mood_collection: mood is required")
|
|
}
|
|
var p MoodCollectionParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return err
|
|
}
|
|
if p.Mood == "" {
|
|
return errors.New("mood_collection: mood is required")
|
|
}
|
|
if _, ok := MoodByKey(p.Mood); !ok {
|
|
return fmt.Errorf("mood_collection: unknown mood %q", p.Mood)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (moodRecipe) Definition() RecipeDefinition {
|
|
presets := make([]GalleryPreset, 0, len(Moods))
|
|
for _, m := range Moods {
|
|
params, _ := json.Marshal(MoodCollectionParams{Mood: m.Key})
|
|
presets = append(presets, GalleryPreset{
|
|
Key: "mood_" + m.Key,
|
|
DisplayName: m.Label,
|
|
Icon: m.Icon,
|
|
DescriptionShort: m.Label,
|
|
DefaultParams: json.RawMessage(params),
|
|
})
|
|
}
|
|
return RecipeDefinition{
|
|
Type: "mood_collection",
|
|
Category: CategoryMood,
|
|
SupportsRotation: false,
|
|
AvoidDuplicates: true,
|
|
Presets: presets,
|
|
}
|
|
}
|
|
|
|
// ShortWatchesParams configures short_watches: movies that fit in a short
|
|
// evening slot, capped by runtime.
|
|
type ShortWatchesParams struct {
|
|
MaxMinutes int `json:"max_minutes,omitempty"` // default 95
|
|
MinRating float64 `json:"min_rating,omitempty"` // default 6.0
|
|
}
|
|
|
|
type shortWatchesRecipe struct{}
|
|
|
|
func (shortWatchesRecipe) Type() string { return "short_watches" }
|
|
func (shortWatchesRecipe) NewParams() any { return &ShortWatchesParams{} }
|
|
func (shortWatchesRecipe) DefaultCacheTTL() time.Duration { return 6 * time.Hour }
|
|
func (shortWatchesRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
|
return delegateResolve("short_watches", rc)
|
|
}
|
|
func (shortWatchesRecipe) Validate(raw json.RawMessage) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var p ShortWatchesParams
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return err
|
|
}
|
|
if p.MaxMinutes < 0 {
|
|
return errors.New("short_watches: max_minutes must be >= 0")
|
|
}
|
|
return nil
|
|
}
|
|
func (shortWatchesRecipe) Definition() RecipeDefinition {
|
|
return RecipeDefinition{
|
|
Type: "short_watches",
|
|
Category: CategoryMood,
|
|
AvoidDuplicates: true,
|
|
Presets: []GalleryPreset{
|
|
{
|
|
Key: "short_watches_default",
|
|
DisplayName: "Short & Sweet",
|
|
Icon: "⏱️",
|
|
DescriptionShort: "Well-rated movies under 95 minutes.",
|
|
DefaultParams: json.RawMessage(`{"max_minutes":95}`),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
Register(moodRecipe{})
|
|
Register(shortWatchesRecipe{})
|
|
}
|