fix(catalog): cap each facet query at 1000 distinct values

/api/v1/catalog/filters returned an 11.7 MB payload on this server's
audiobook library — 88k authors, 92k narrators, 161k series — and that
landed in every client filter-sheet open. Above ~1000 entries a
dropdown is unusable anyway, so cap each facet helper at
catalogFacetMaxValues (1000) with a SQL LIMIT.

Applied uniformly to listDistinctArrayColumnWithSource (genres,
studios, networks, countries), listDistinctScalarColumnWithSource
(content_rating, original_language), listDistinctPeopleByKindWithSource
(authors, narrators), listDistinctAudiobookSeriesWithSource, and
listDistinctJSONBLanguageWithSource (audio + subtitle languages, both
array and JSONB paths). Genres, studios, etc. have fewer than 1000
distinct values today, so the cap is a no-op for the small facets and
the visible effect lands on authors / narrators / series.

Stopgap, not the final answer. A real fix is server-side typeahead
where the request includes a search prefix and the response is the top
N matches for that prefix; tracked in a follow-up plan rather than
shipped here so existing audiobookshelf clients and the silo UI keep
working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-27 19:49:18 +02:00
co-authored by Claude Opus 4.7
parent 33cbf6274a
commit 83d23462ba
2 changed files with 24 additions and 7 deletions
+14 -7
View File
@@ -523,7 +523,8 @@ func listDistinctArrayColumnWithSource(
) vals
WHERE val <> ''
ORDER BY val ASC
`, column, fromClause, whereClause)
LIMIT %d
`, column, fromClause, whereClause, catalogFacetMaxValues)
rows, err := pool.Query(ctx, query, args...)
if err != nil {
@@ -576,7 +577,8 @@ func listDistinctScalarColumnWithSource(
%s
AND mi.%s <> ''
ORDER BY mi.%s ASC
`, column, fromClause, whereClause, column, column)
LIMIT %d
`, column, fromClause, whereClause, column, column, catalogFacetMaxValues)
// When there are no WHERE conditions, the extra AND is invalid — prepend WHERE instead.
if whereClause == "" {
@@ -585,7 +587,8 @@ func listDistinctScalarColumnWithSource(
FROM %s
WHERE mi.%s <> ''
ORDER BY mi.%s ASC
`, column, fromClause, column, column)
LIMIT %d
`, column, fromClause, column, column, catalogFacetMaxValues)
}
rows, err := pool.Query(ctx, query, args...)
@@ -756,7 +759,8 @@ func listDistinctPeopleByKindWithSource(
AND p.name IS NOT NULL
AND BTRIM(p.name) <> ''
ORDER BY p.name ASC
`, fromClause, kindIdx, browseFilterPrefix(whereClause))
LIMIT %d
`, fromClause, kindIdx, browseFilterPrefix(whereClause), catalogFacetMaxValues)
return queryDistinctStrings(ctx, pool, query, args)
}
@@ -791,7 +795,8 @@ func listDistinctAudiobookSeriesWithSource(
AND BTRIM(s.series_name) <> ''
) names
ORDER BY LOWER(name) ASC
`, fromClause, browseFilterPrefix(whereClause))
LIMIT %d
`, fromClause, browseFilterPrefix(whereClause), catalogFacetMaxValues)
return queryDistinctStrings(ctx, pool, query, args)
}
@@ -825,7 +830,8 @@ func listDistinctJSONBLanguageWithSource(
AND lang IS NOT NULL
AND lang <> ''
ORDER BY value ASC
`, fromClause, mediaFileJoin, arrayColumn, browseFilterPrefix(whereClause))
LIMIT %d
`, fromClause, mediaFileJoin, arrayColumn, browseFilterPrefix(whereClause), catalogFacetMaxValues)
return queryDistinctStrings(ctx, pool, query, args)
}
@@ -838,7 +844,8 @@ func listDistinctJSONBLanguageWithSource(
AND mf.missing_since IS NULL
AND COALESCE(track->>'language', '') <> ''
ORDER BY value ASC
`, fromClause, mediaFileJoin, column, browseFilterPrefix(whereClause))
LIMIT %d
`, fromClause, mediaFileJoin, column, browseFilterPrefix(whereClause), catalogFacetMaxValues)
return queryDistinctStrings(ctx, pool, query, args)
}
+10
View File
@@ -690,6 +690,16 @@ func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req Catalo
// headroom for other concurrent work and avoids saturating the pool.
const catalogFacetConcurrency = 6
// catalogFacetMaxValues caps how many distinct values each facet query
// returns. Above ~1000 entries a typeahead UI is the only sensible way to
// present a dropdown — and the audiobook library on this server has
// >88k distinct authors / >92k narrators / >161k series, which produced
// an 11.7 MB /api/v1/catalog/filters response before this cap landed.
// The SearchableSelect dropdown stays responsive at 1000 client-side
// entries; beyond that, the filter response should switch to a server-
// side typeahead surface (out of scope for the cap-only fix).
const catalogFacetMaxValues = 1000
func (r *CatalogResolver) listFiltersForSource(
ctx context.Context,
filters BrowseFilters,