Files
silo-server/internal/sections/types.go
T
CoffeeKnyteandGitHub 652b5d1e9f perf(jellycompat): fast-paths for slow Jellyfin-compat queries (#247)
* perf(jellycompat): fast-paths for slow Jellyfin-compat queries

Rebuilds the jellycompat slow-query fast-paths cleanly on main, without
the hub-sections virtual-library feature (which is intentionally absent
on this branch). Each path is bounded so behavior stays predictable
under load.

Fast-paths:
- Episode catalog: add the load-bearing `si.type = 'series'` hydration
  guard plus a matching entry-scan series-parent guard, so the page and
  count scans stay aligned with hydration (no under-filled pages, no
  inflated TotalRecordCount from podcast/audiobook episode rows).
- /Shows/{id}/Episodes AdjacentTo: serve a bounded prev/self/next
  neighbor window with series-membership validation and a cross-series
  id guard, falling back to the full listing.
- Resume: serve Continue Watching through the capped native
  continue-watching fetcher (next-up suppressed, in-progress only)
  instead of loadProgressPage's unbounded scan. Wires only the
  sections read-time fetcher (StoreProvider) into ItemsHandler; no
  hub-section/virtual-library exposure.
- Played-history: push the type + library filter down into SQL.
- recently_added: fan a no-parentId browse out across libraries with an
  app-side dedup merge, clamped to BrowseFilters.MaxLimit, and exclude
  compat-hidden (audiobook/podcast) libraries from the fan-out.
- Detail fields: batch per-item GetItemDetail in the Latest/browse
  detail-field paths via GetItemDetailsByIDs, with per-item fallback.
- Search: term-length and result-count guards for aux search.

* perf(jellycompat): clamp Resume fast-path fetch to maxResumeItems

A large client-supplied StartIndex on /UserItems/Resume inflated
loadResumeViaSections' fetchLimit (pageSize + StartIndex) and fed it
straight into ResolvedSection.ItemLimit, letting the continue-watching
fetcher scan up to its continueProgressMaxScanned hard cap (1000 rows)
instead of stopping near maxResumeItems. Clamp fetchLimit to
maxResumeItems and short-circuit to an empty page when StartIndex is at
or beyond the cap (no visible row can land there), keeping the capped
fetcher's scan bounded and predictable under load.

Addresses CodeRabbit review finding on PR #247.
2026-07-01 09:06:24 -04:00

274 lines
9.8 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"
)
// 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,
}
// 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] == '"'
}