17 KiB
Server Query Hardening And Optimization Audit
Last updated: 2026-04-19
Purpose
This document is a living reference for server-side query hardening and performance work. It captures what the current queries are trying to do, where the highest-leverage issues are, and what order to tackle them in.
This pass was a read-only code audit across the main server query surfaces. It was grounded in
the current code paths, but it did not include live EXPLAIN ANALYZE sampling, so treat the
items below as implementation-informed priorities rather than measured query plans.
A second read-only verification pass reviewed this document for accuracy, expected benefit, and implementation risk. The recommendations below incorporate that review, including places where the original plan was too broad, slightly stale, or needed behavior-preservation caveats.
Scope Covered
- Shared catalog/query engine
- API handlers and browse/query endpoints
- Auth, session, profile, and user-state storage
- Scanner, metadata, admin jobs, and catalog seed import/export
- Playback, Jelly compat, subtitles, plugins, sections, node pool, and webhook sync
Top Priorities
P1: Fix live catalog count and pagination correctness
The highest-priority issue is that some catalog and browse paths count joined rows instead of deduped items.
internal/catalog/browse.gointernal/api/handlers/catalog.go
Why it matters:
totalandhas_morecan be inflated- items can be effectively overcounted when they belong to multiple libraries or person joins
- fallback query loops can do extra pages based on a bad total
Recommended fix:
- Make the count query operate on the same deduped relation as the data query
- Prefer
COUNT(DISTINCT mi.content_id)or a grouped subquery over rawCOUNT(*)
P1: Stop materializing full candidate sets in query-source fallbacks
The main query-source fallback path currently does too much work when it cannot stay on the direct SQL path.
internal/catalog/catalog_resolver.gointernal/catalog/item_repo.go
Current pattern:
- fetch all search candidates
- count inside
Search - page through
Search - sometimes re-fetch all candidates again for
name_prefixor secondary sort handling
Why it matters:
- scales poorly with library size
- duplicates count work
- turns many searches into full-set materialization problems
Recommended fix:
- move resolver call sites onto the existing no-total page paths where only
has_moreis needed - push
name_prefixdown into SQL only with a semantics-preserving rewrite - avoid whole-candidate fetches for non-relevance sorts unless absolutely necessary
P1: Fix technical filter and sort scoping for disabled libraries
Technical media-file predicates and joins do not consistently honor disabled-library exclusions.
internal/catalog/query_executor.gointernal/catalog/query_builder.go
Why it matters:
- a disabled-library file can still satisfy
resolution,hdr,bitrate,audio_language, orsubtitle_language - this is both a correctness issue and a trust-boundary issue for filtered views
Recommended fix:
- pass disabled-library scope all the way into media-file
EXISTSpredicates and sort joins - make technical filter/sort scoping use the same effective library rules as item visibility
Cross-Cutting Themes
Deduped counts must match data semantics
Anywhere a query joins media_item_libraries, item_people, or similar fanout tables, the
count path must match the item-level dedupe semantics of the data query.
Avoid repeated full counts when has_more is enough
Several paths still pay for COUNT(*) on every page even when the caller only needs to know
whether another page exists. Add explicit no-total execution paths where possible.
Batch hydration instead of per-item lookups
The codebase has multiple season/detail/compat/profile surfaces that still do follow-up queries per item, per episode, or per installation. Those should move to batched list queries or per-request caching.
Wrap multi-step reconciliation in transactions
Some write paths are still read-check-write loops or multi-statement reconciliation sequences without a transaction. Those should be hardened before deeper tuning.
Preserve Existing Behavior While Optimizing
Several of the recommendations below are only safe if they preserve current semantics:
name_prefixmust continue matching the currenttitle OR sort_titlebehaviorrelease_datemust keep its cross-scope movie / series / episode contract- provider-chain batching must preserve fallback display names, priority defaults, and ordering
- representative-file batching must preserve current first-hit and fallback selection behavior
- compat startup changes must not regress clients that depend on current manifest readiness behavior
- subtitle dedupe must become conflict-aware before it is made more aggressive
Indexes should match real read shapes
A number of hot paths have indexes that are close, but not quite aligned with their current query predicates or sort order.
Detailed Findings By Area
Shared Catalog Query Layer
Files:
internal/catalog/browse.gointernal/catalog/query_definition.gointernal/catalog/query_builder.gointernal/catalog/query_executor.gointernal/catalog/catalog_resolver.gointernal/catalog/air_date_sql.go
Key findings:
- Browse counts do not match deduped item semantics when library or person joins are present.
release_dateis modeled as a text expression in the shared query definition even though the movie-side field is date-typed. That weakens type semantics and index use, but it is also part of a cross-scope contract for series and episode surfaces, so it cannot be split carelessly.name_prefixis often filtered in Go after fetching candidates instead of being pushed into SQL, but any pushdown must preserve currenttitle OR sort_titlematching semantics.fetchAllBrowseCandidates()currently pays for a full count on every page.- Browse
ASCsorts on nullable fields do not preserve the same null-handling semantics as the shared query-builder sort path. Fixing that will change visible ordering and should be treated as a behavior adjustment, not only as an optimization. effectiveLastAirDateExpr()is centralized and semantically strong, but it can still be reused more efficiently on the browse side when a sort join has already materialized the same aggregate.
What is already strong:
- explicit access scoping
- stable tie-break ordering
- centralized
last_air_datenormalization - same-file technical rule collapsing for positive technical predicates
Recommended next steps:
- Fix count/data parity in browse and legacy catalog query paths.
- Push
name_prefixinto SQL with an exact semantics-preserving rewrite and matching index plan. - Rework
release_datehandling carefully so planner gains do not break series/episode parity. - Move fallback resolver call sites onto the existing no-total browse/query paths.
API Handlers And Surface-Level Query Use
Files:
internal/api/handlers/catalog.gointernal/api/handlers/items.gointernal/api/handlers/user_state.gointernal/api/handlers/profiles.gointernal/api/handlers/admin.gointernal/api/handlers/nodes.go
Key findings:
- The legacy
POST /catalog/querypath has the same join-row count inflation risk as the shared browse layer. - Catalog item hydration still does extra follow-up passes for overlay summaries, user state, and episode metadata.
- Season detail still contains per-episode aggregate user-state work; episode detail is much less problematic and should not be lumped into the same recommendation.
- Profile creation currently does extra pre-read work and has race potential around
max_profilesand first-primary assignment. - Some profile summary uses only need names and IDs, but the backing repository eagerly loads additional profile-library state. This is mainly an admin and session-list summary issue, not a reason to change full profile payloads everywhere.
- Node force-reload loads all nodes and filters in Go instead of using the enabled-node query shape.
Recommended next steps:
- Collapse season aggregate hydration into batched file and progress reads.
- Make profile creation atomic with locking/transaction semantics that preserve bootstrap and allowed-library write behavior.
- Add lightweight profile summary queries for admin and session-list surfaces instead of changing the semantics of full profile list calls.
Auth, Sessions, Profiles, And User State
Files:
internal/auth/session.gointernal/userstore/pgstore/progress.gointernal/userstore/pgstore/section_overrides.gointernal/userstore/pgstore/collections.gointernal/api/handlers/api_keys.go
Key findings:
auth_sessionsalready supports user-scoped listing, revocation, and expiry cleanup at the repository level, but the schema does not appear to have ideal index support for the current list/revoke shapes.- Hidden-history suppression already has a PK on
(user_id, profile_id, media_item_id)and a profile/time index. A wider composite index may still help the suppression probes, but that should be validated withEXPLAINbefore treating it as an obvious win. - Profile progress listing sorts by
updated_at DESCwithout a matching index shape. - Postgres section overrides are stored as a single JSON blob in
user_settings, which causes read amplification and creates lost-update risk. Full normalization is a later structural option, but smaller compare-and-swap or transactional protections may be the better first step. - Collection listing still does per-collection profile hydration, but the ROI depends on how large those lists get in practice.
- The admin API-key create path is safe so long as it remains admin-gated, but it should stay clearly separated from self-service creation semantics.
Recommended next steps:
- Add session indexes aligned with the real list/revoke paths, and only add expiry-cleanup support if a cleanup job is actually wired.
- Add a progress-list index aligned with the real sort shape.
- Add transactional CAS/versioning protection to section overrides first; treat full normalization as a later cleanup if the surface keeps growing.
- Batch collection/profile membership loading where the surface area justifies it.
Scanner, Metadata, Admin Jobs, And Catalog Seed
Files:
internal/scanner/scanner.gointernal/metadata/chain.gointernal/metadata/refresh_debt_repo.gointernal/adminjob/item_refresh.gointernal/adminjob/repository.gointernal/catalogseed/service.go
Key findings:
syncPresentLibraryStateperforms a multi-step reconciliation without a transaction.- Metadata provider-chain resolution is still chatty and partially N+1, but any batching must preserve current fallback display-name, default-priority, and ordering semantics.
AppendProviderToAllChainsis a read-check-write loop without a transaction.- Representative-file resolution for series and seasons is still N+1, but any replacement needs to preserve the current first-hit and fallback selection behavior.
- Refresh-debt claiming performs broad cleanup work inline with claim flow. If that cleanup moves, an equivalent prune path must remain in place.
- Admin job list-by-type lacks an ideal supporting index.
- Catalog export still does avoidable read amplification, especially when loading totals, but this is lower-priority admin progress plumbing rather than a top-tier hot-path issue.
Recommended next steps:
- Wrap the truly non-transactional reconciliation and mutation paths in transactions.
- Batch provider-chain metadata and priority resolution only if fallback and ordering semantics are preserved explicitly.
- Replace representative-file N+1 lookups only with a query that preserves first-hit selection and fallback behavior.
- Move broad queue cleanup out of hot claim paths only if an equivalent prune mechanism remains.
- Treat export total collapsing as a lower-priority admin-path tuning item.
Playback, Jelly Compat, Subtitles, Plugins, Sections, Nodes, Webhook Sync
Files:
internal/jellycompat/streams.gointernal/jellycompat/playback_sessions.gointernal/jellycompat/handlers_items.gointernal/jellycompat/content_direct.gointernal/playback/session.gointernal/subtitles/pgrepo.gointernal/subtitles/manager.gointernal/plugins/task_registry.gointernal/plugins/user_config.gointernal/webhooksync/repo_events.gointernal/sections/fetcher.gointernal/nodepool/repository.gointernal/watchtogether/repository.go
Key findings:
- Compat playback route resolution still scans active sessions linearly.
- Downloaded subtitle rows are repeatedly reloaded on visible compat/playback paths.
- Compat browse and season surfaces still over-fetch and rehydrate too much state.
- Some stream paths re-read in-memory session state immediately after update.
- Manifest waiting uses tight polling instead of an event or gentler backoff, but that behavior exists partly for client compatibility and should not be changed casually.
- Playback session bookkeeping still does repeated full-map scans by user and media file.
- Subtitle dedupe is based on pre-check plus insert instead of an authoritative uniqueness rule, and any change here must become conflict-aware to avoid deleting another request's successful insert.
- Plugin task-registry building is still N+1 across installations and capabilities.
- Plugin user config scans all user settings for prefix filtering and does non-transactional replace.
- Webhook event retention trims with a broad self-correlated delete on every insert. The issue is the delete shape and churn cost, not a missing index in the current schema.
- Random sections use
ORDER BY RANDOM(). - Watch-together lookup may want a functional index on
lower(code)only if case-insensitive room codes are a real requirement; normalizing input to exact-match semantics may be cheaper.
Recommended next steps:
- Add reverse indexes for compat and playback session lookup.
- Cache or batch downloaded subtitle hydration.
- Add a uniqueness-backed, conflict-safe dedupe path for subtitle storage.
- Flatten plugin registry rebuild queries.
- Replace
ORDER BY RANDOM()only if random sections become a notable cost center.
Index Candidates
These are the most obvious index opportunities from this audit:
auth_sessionsindexes aligned withListByUser/RevokeAllByUser, plus anexpires_atindex only if expiry cleanup is actually scheduled- consider
user_history_hidden_items (user_id, profile_id, media_item_id, hidden_before DESC)only ifEXPLAINshows it materially improves suppression probes beyond the PK and current profile/time index - progress listing index aligned with
updated_at DESC downloaded_subtitles (media_file_id, created_at DESC)if subtitle history ordering matters- webhook event retention delete-shape tuning; the current composite index already exists
- functional index on
lower(code)for watch-together room lookup only if case-insensitive codes are a real product requirement - compat session support indexes on expiry and streamapp user keys
Suggested Rollout Order
Wave 1: Correctness And Hot Query Shape Fixes
- Fix deduped count semantics in browse and legacy catalog query paths
- Fix disabled-library scoping in technical filters and sorts
- Remove full-set query fallback behavior where possible
- Make scanner reconciliation transactional
Wave 2: Index Pack
- session indexes aligned with real list/revoke paths, plus optional expiry-cleanup support
- hidden-history index improvement only if supported by
EXPLAIN - progress-list index
- webhook retention delete-shape tuning
- watch-together lookup strategy, which may be normalization rather than a new functional index
Wave 3: Batch Hydration
- season aggregate detail and user-state hydration
- compat subtitle loading
- compat season/progress surfaces
- plugin registry loading
- collection/profile membership loading where ROI is clear
Wave 4: Structural Cleanups
- normalize section overrides away from JSON blob storage only if smaller CAS/versioning fixes are not sufficient
- improve provider-chain resolution and mutation paths without changing current fallback/order semantics
- replace broad queue cleanup in refresh-debt claiming only with an equivalent prune path
- revisit random-section strategy if still needed
How To Update This Document
When a query issue is fixed or re-scoped:
- Keep the section, but mark the item as addressed or reduced in scope.
- Add the file path or migration that changed the behavior.
- If a finding was disproven with measurement, note the evidence and why it is no longer a priority.
- Prefer moving solved items into a short "Resolved" subsection instead of deleting the history.
Notes
- This audit intentionally distinguishes existing good structure from true gaps. The catalog query layer is already a strong foundation; most of the leverage now is in tightening count semantics, avoiding full-set fallbacks, batching repeated hydration, and hardening multi-step mutations.
- Reliability can still beat small theoretical query savings on user-facing browse surfaces. Where exact totals are important, keep exact totals, but make sure they are counting the right thing.