* 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>
225 lines
7.8 KiB
Go
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{})
|
|
}
|