* 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>
283 lines
10 KiB
Go
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] == '"'
|
|
}
|