Files
silo-server/internal/sections/types.go
T
11704a1701 feat(sections): fix broken home section templates and add six new ones (#332)
* 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>
2026-07-07 10:47:20 -04:00

283 lines
10 KiB
Go

package sections
import (
"encoding/json"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
)
// SectionType enumerates the supported section types.
type SectionType string
const (
SectionContinueWatching SectionType = "continue_watching"
SectionRecentlyAdded SectionType = "recently_added"
SectionRecentlyReleased SectionType = "recently_released"
SectionWatchlist SectionType = "watchlist"
SectionFavorites SectionType = "favorites"
SectionGenre SectionType = "genre"
SectionCustomFilter SectionType = "custom_filter"
SectionRandom SectionType = "random"
SectionCollection SectionType = "collection"
SectionRecommendedForYou SectionType = "recommended_for_you"
SectionBecauseYouWatched SectionType = "because_you_watched"
SectionSimilarUsersLiked SectionType = "similar_users_liked"
SectionTasteMatch SectionType = "taste_match"
SectionNextUp SectionType = "next_up"
SectionNextInSeries SectionType = "next_in_series"
SectionHiddenGems SectionType = "hidden_gems"
SectionCriticallyAcclaimed SectionType = "critically_acclaimed"
SectionAwardWinners SectionType = "award_winners"
SectionForgottenFavorites SectionType = "forgotten_favorites"
SectionFormatShowcase SectionType = "format_showcase"
SectionEditorialSpotlight SectionType = "editorial_spotlight"
SectionSeasonalThemed SectionType = "seasonal_themed"
SectionMoodCollection SectionType = "mood_collection"
SectionTrendingOnServer SectionType = "trending_on_server"
SectionProfileActivityFeed SectionType = "profile_activity_feed"
SectionNewToLibrary SectionType = "new_to_library"
SectionMostWatched SectionType = "most_watched"
SectionTrendingDiscover SectionType = "trending_discover"
SectionAdminCuratedList SectionType = "admin_curated_list"
SectionReturningShows SectionType = "returning_shows"
SectionGenreRoulette SectionType = "genre_roulette"
SectionAnniversaries SectionType = "anniversaries"
SectionShortWatches SectionType = "short_watches"
)
// ValidSectionTypes is the set of all valid section type values.
var ValidSectionTypes = map[SectionType]bool{
SectionContinueWatching: true,
SectionRecentlyAdded: true,
SectionRecentlyReleased: true,
SectionWatchlist: true,
SectionFavorites: true,
SectionGenre: true,
SectionCustomFilter: true,
SectionRandom: true,
SectionCollection: true,
SectionRecommendedForYou: true,
SectionBecauseYouWatched: true,
SectionSimilarUsersLiked: true,
SectionTasteMatch: true,
SectionNextUp: true,
SectionNextInSeries: true,
SectionHiddenGems: true,
SectionCriticallyAcclaimed: true,
SectionAwardWinners: true,
SectionForgottenFavorites: true,
SectionFormatShowcase: true,
SectionEditorialSpotlight: true,
SectionSeasonalThemed: true,
SectionMoodCollection: true,
SectionTrendingOnServer: true,
SectionProfileActivityFeed: true,
SectionNewToLibrary: true,
SectionMostWatched: true,
SectionTrendingDiscover: true,
SectionAdminCuratedList: true,
SectionReturningShows: true,
SectionGenreRoulette: true,
SectionAnniversaries: true,
SectionShortWatches: true,
}
// PageSection is an admin-defined section stored in PostgreSQL.
type PageSection struct {
ID string `json:"id"`
Scope string `json:"scope"`
LibraryID *int `json:"library_id"`
Position int `json:"position"`
SectionType SectionType `json:"section_type"`
Title string `json:"title"`
Featured bool `json:"featured"`
ItemLimit int `json:"item_limit"`
Config json.RawMessage `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProfileSectionOverride is a per-profile customization stored in the user store.
type ProfileSectionOverride struct {
ID string `json:"id"`
ProfileID string `json:"profile_id"`
Scope string `json:"scope"`
LibraryID string `json:"library_id,omitempty"`
SectionID string `json:"section_id,omitempty"`
Position *int `json:"position,omitempty"`
Hidden bool `json:"hidden"`
Removed bool `json:"removed"`
SectionType SectionType `json:"section_type,omitempty"`
Title string `json:"title,omitempty"`
Featured *bool `json:"featured,omitempty"`
ItemLimit *int `json:"item_limit,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// IsUserAdded marks this override as a profile-built recipe instance
// rather than a customization of an admin section. When true, SectionID
// is empty and UserSectionType / UserConfig / UserTitle take precedence
// over the legacy SectionType / Config / Title fields.
IsUserAdded bool `json:"is_user_added,omitempty"`
UserSectionType SectionType `json:"user_section_type,omitempty"`
UserConfig json.RawMessage `json:"user_config,omitempty"`
UserTitle string `json:"user_title,omitempty"`
}
// ResolvedSection is the merged result of admin section + profile override.
type ResolvedSection struct {
ID string `json:"id"`
SectionType SectionType `json:"section_type"`
Title string `json:"title"`
Featured bool `json:"featured"`
ItemLimit int `json:"item_limit"`
Config json.RawMessage `json:"config"`
Position int `json:"position"`
IsCustom bool `json:"is_custom"`
Customized bool `json:"customized"`
Hidden bool `json:"hidden,omitempty"`
// SuppressNextUp, when true on a continue-watching section, skips the
// next-up injection (and the combined series collapse/sort that pairs with
// it) so the section returns in-progress resume points only. Callers that
// need a strict "resume" view — e.g. the Jellyfin /UserItems/Resume
// compatibility endpoint, which must never surface not-yet-started items —
// set this; admin/home rendering leaves it false to keep next-up cards.
SuppressNextUp bool `json:"-"`
}
// FilterConfig represents the rule-group filter structure.
type FilterConfig struct {
Match string `json:"match"`
Groups []FilterGroup `json:"groups"`
Sort string `json:"sort,omitempty"`
Order string `json:"order,omitempty"`
}
// FilterGroup is a group of filter rules joined by AND or OR.
type FilterGroup struct {
Match string `json:"match"`
Rules []FilterRule `json:"rules"`
}
// FilterRule is a single filter condition.
type FilterRule struct {
Field string `json:"field"`
Op string `json:"op"`
Value any `json:"value"`
}
// SectionConfigFilters holds the optional type and library filters from section config.
type SectionConfigFilters struct {
FilterType string `json:"filter_type"`
FilterLibraryID *int `json:"filter_library_id"`
FilterLibraryIDs []int `json:"filter_library_ids"`
}
// SectionCollectionConfig holds the selected collection reference.
// A section may reference either a library collection (admin-managed) or a
// user collection (personal, profile-scoped). Only one field should be set.
type SectionCollectionConfig struct {
LibraryCollectionID string `json:"library_collection_id,omitempty"`
UserCollectionID string `json:"user_collection_id,omitempty"`
}
// ParseConfigFilters extracts filter_type and filter_library_id from config JSON.
func ParseConfigFilters(config json.RawMessage) SectionConfigFilters {
var f SectionConfigFilters
if len(config) > 0 {
_ = json.Unmarshal(config, &f)
}
if len(f.FilterLibraryIDs) == 0 && f.FilterLibraryID == nil {
def, err := ParseQueryDefinition(config)
if err == nil {
switch def.MediaScope {
case "movie", "series", "audiobook":
f.FilterType = def.MediaScope
}
if len(def.LibraryIDs) > 0 {
f.FilterLibraryIDs = append([]int(nil), def.LibraryIDs...)
}
}
}
return f
}
// LibraryIDs returns the effective library filter IDs, supporting both the
// legacy single-library field and the newer multi-library field.
func (f SectionConfigFilters) LibraryIDs() []int {
ids := make([]int, 0, len(f.FilterLibraryIDs)+1)
seen := make(map[int]struct{}, len(f.FilterLibraryIDs)+1)
for _, id := range f.FilterLibraryIDs {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if f.FilterLibraryID != nil && *f.FilterLibraryID > 0 {
if _, ok := seen[*f.FilterLibraryID]; !ok {
ids = append(ids, *f.FilterLibraryID)
}
}
if len(ids) == 0 {
return nil
}
return ids
}
// ParseCollectionConfig extracts library_collection_id from config JSON.
func ParseCollectionConfig(config json.RawMessage) SectionCollectionConfig {
var c SectionCollectionConfig
if len(config) > 0 {
_ = json.Unmarshal(config, &c)
}
return c
}
func ParseQueryDefinition(config json.RawMessage) (catalog.QueryDefinition, error) {
if len(config) == 0 {
return catalog.QueryDefinition{}.Normalize(), nil
}
var legacy struct {
FilterType string `json:"filter_type"`
FilterLibraryID *int `json:"filter_library_id"`
FilterLibraryIDs []int `json:"filter_library_ids"`
Sort json.RawMessage `json:"sort"`
Order string `json:"order"`
}
if err := json.Unmarshal(config, &legacy); err != nil {
return catalog.QueryDefinition{}, err
}
if legacy.FilterType != "" || legacy.FilterLibraryID != nil || len(legacy.FilterLibraryIDs) > 0 || legacy.Order != "" || isLegacySortConfig(legacy.Sort) {
return catalog.NormalizeLegacySectionFilter(config)
}
var def catalog.QueryDefinition
if err := json.Unmarshal(config, &def); err != nil {
return catalog.QueryDefinition{}, err
}
def = def.Normalize()
return def, def.Validate()
}
func isLegacySortConfig(raw json.RawMessage) bool {
return len(raw) > 0 && raw[0] == '"'
}