feat(catalog): typeahead search endpoint for filter facets

Adds GET /api/v1/catalog/filters/search — a prefix-search endpoint for
the high-cardinality filter facets. The bulk /api/v1/catalog/filters
endpoint stays as-is (top 1000 per facet capped in 83d2346) for the
initial dropdown render; this endpoint takes over once the user starts
typing.

Surface:

  GET /api/v1/catalog/filters/search?source=query&library_id=9
      &facet=author&q=sand&limit=20

  → { "matches": ["Brandon Sanderson", ...], "has_more": false }

Supported facets: genre, studio, network, country, original_language,
content_rating, author, narrator, series. Unknown facet → 400.

Plumbing:

* facetFetcher gains Search{DistinctArrayColumn, DistinctScalarColumn,
  PeopleByKind, AudiobookSeries} — typeahead variants of the existing
  list helpers. Each takes a prefix + limit, runs the scope SQL with an
  added LOWER(value) LIKE LOWER(prefix || '%'), and returns matches +
  hasMore (computed via LIMIT N+1).
* pgxFacetFetcher routes the four methods to four new SQL helpers in
  browse.go; stubFacetFetcher in the resolver tests matches.
* CatalogResolver.SearchFacet validates the request the same way
  ListFiltersWithOptions does (source, validation, access scoping),
  then dispatches to the right facet helper via dispatchFacetSearch.
* HandleGetCatalogFacetSearch wires it onto the existing
  /catalog/filters chi mount point.

Reply shape (catalogFacetSearchResponse) is deliberately small —
matches[] and has_more, nothing else — so future frontend variants
(grouped matches, ranking signals) can land additively.

This is the backend half of the catalog-facet-typeahead plan
(docs/superpowers/plans/2026-05-27-catalog-facet-typeahead.md). The
frontend half lands separately so the API can be exercised first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-27 20:10:21 +02:00
co-authored by Claude Opus 4.7
parent b997183894
commit 093154d92c
5 changed files with 441 additions and 0 deletions
+76
View File
@@ -163,6 +163,82 @@ func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.
})
}
// catalogFacetSearchResponse mirrors catalog.CatalogFacetSearchResult on
// the wire. matches[] is always present (empty when no hits); has_more
// is true when the underlying result set held more entries than the
// requested limit.
type catalogFacetSearchResponse struct {
Matches []string `json:"matches"`
HasMore bool `json:"has_more"`
}
// HandleGetCatalogFacetSearch — GET /api/v1/catalog/filters/search
//
// Prefix-typeahead for the high-cardinality filter facets (authors /
// narrators / series, plus genre / studio / network / country /
// original_language / content_rating for consistency). Query
// parameters: same as /api/v1/catalog/filters for scope (source,
// library_id, etc.), plus facet=<name>, q=<prefix>, limit=<N>.
//
// The bulk /api/v1/catalog/filters endpoint stays as the source for
// the initial dropdown render (top 1000 alphabetical); this endpoint
// takes over once the user starts typing.
func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *http.Request) {
if h == nil || h.resolver == nil || h.itemsH == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
return
}
req, err := catalog.ParseCatalogRequest(r.URL.Query())
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
facet := strings.TrimSpace(r.URL.Query().Get("facet"))
if facet == "" {
writeError(w, http.StatusBadRequest, "bad_request", "facet parameter is required")
return
}
prefix := r.URL.Query().Get("q")
limit := 20
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
n, parseErr := strconv.Atoi(raw)
if parseErr != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
limit = n
}
result, err := h.resolver.SearchFacet(
r.Context(),
req,
h.itemsH.accessFilter(r),
facet,
prefix,
limit,
)
if err != nil {
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search catalog facet")
return
}
matches := result.Matches
if matches == nil {
matches = []string{}
}
writeJSON(w, http.StatusOK, catalogFacetSearchResponse{
Matches: matches,
HasMore: result.HasMore,
})
}
func parseIncludeTechnical(raw string) bool {
if strings.TrimSpace(raw) == "" {
return true
+1
View File
@@ -1256,6 +1256,7 @@ func NewRouter(deps Dependencies) chi.Router {
if itemsHandler != nil {
r.Get("/catalog", catalogHandler.HandleGetCatalog)
r.Get("/catalog/filters", catalogHandler.HandleGetCatalogFilters)
r.Get("/catalog/filters/search", catalogHandler.HandleGetCatalogFacetSearch)
r.Post("/catalog/query", catalogHandler.HandlePostCatalogQuery)
if catalogResourceHandler != nil {
r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail)
+185
View File
@@ -800,6 +800,191 @@ func listDistinctAudiobookSeriesWithSource(
return queryDistinctStrings(ctx, pool, query, args)
}
// searchDistinctArrayColumnWithSource prefix-searches the distinct values
// of an array column (genres, studios, networks, countries) within the
// scoped result set. Returns up to limit matches in alphabetical order
// plus a hasMore flag (true when the underlying result set held more
// rows than limit).
func searchDistinctArrayColumnWithSource(
ctx context.Context,
pool *pgxpool.Pool,
column string,
filters BrowseFilters,
baseRelation string,
mediaScope string,
prefix string,
limit int,
) ([]string, bool, error) {
prefix = strings.TrimSpace(prefix)
if limit <= 0 {
return []string{}, false, nil
}
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
if empty {
return []string{}, false, nil
}
args = append(args, prefix+"%")
prefixIdx := len(args)
query := fmt.Sprintf(`
SELECT name FROM (
SELECT DISTINCT UNNEST(mi.%s) AS name
FROM %s
%s
) vals
WHERE name IS NOT NULL
AND BTRIM(name) <> ''
AND LOWER(name) LIKE LOWER($%d)
ORDER BY LOWER(name) ASC
LIMIT %d
`, column, fromClause, whereClause, prefixIdx, limit+1)
return queryFacetSearchResults(ctx, pool, query, args, limit)
}
// searchDistinctScalarColumnWithSource is the scalar (e.g. content_rating)
// variant of searchDistinctArrayColumnWithSource.
func searchDistinctScalarColumnWithSource(
ctx context.Context,
pool *pgxpool.Pool,
column string,
filters BrowseFilters,
baseRelation string,
mediaScope string,
prefix string,
limit int,
) ([]string, bool, error) {
prefix = strings.TrimSpace(prefix)
if limit <= 0 {
return []string{}, false, nil
}
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
if empty {
return []string{}, false, nil
}
args = append(args, prefix+"%")
prefixIdx := len(args)
query := fmt.Sprintf(`
SELECT DISTINCT mi.%s AS name
FROM %s
%s
AND mi.%s IS NOT NULL
AND BTRIM(mi.%s) <> ''
AND LOWER(mi.%s) LIKE LOWER($%d)
ORDER BY LOWER(mi.%s) ASC
LIMIT %d
`, column, fromClause, browseFilterPrefix(whereClause), column, column, column, prefixIdx, column, limit+1)
return queryFacetSearchResults(ctx, pool, query, args, limit)
}
// searchDistinctPeopleByKindWithSource is the typeahead equivalent of
// listDistinctPeopleByKindWithSource; powers /api/v1/catalog/filters/search
// for facet=author and facet=narrator.
func searchDistinctPeopleByKindWithSource(
ctx context.Context,
pool *pgxpool.Pool,
kind models.PersonKind,
filters BrowseFilters,
baseRelation string,
mediaScope string,
prefix string,
limit int,
) ([]string, bool, error) {
prefix = strings.TrimSpace(prefix)
if limit <= 0 {
return []string{}, false, nil
}
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
if empty {
return []string{}, false, nil
}
args = append(args, int(kind))
kindIdx := len(args)
args = append(args, prefix+"%")
prefixIdx := len(args)
query := fmt.Sprintf(`
SELECT DISTINCT p.name
FROM %s
JOIN item_people ip ON ip.content_id = mi.content_id AND ip.kind = $%d
JOIN people p ON p.id = ip.person_id
%s
AND p.name IS NOT NULL
AND BTRIM(p.name) <> ''
AND LOWER(p.name) LIKE LOWER($%d)
ORDER BY LOWER(p.name) ASC
LIMIT %d
`, fromClause, kindIdx, browseFilterPrefix(whereClause), prefixIdx, limit+1)
return queryFacetSearchResults(ctx, pool, query, args, limit)
}
// searchDistinctAudiobookSeriesWithSource is the typeahead equivalent of
// listDistinctAudiobookSeriesWithSource.
func searchDistinctAudiobookSeriesWithSource(
ctx context.Context,
pool *pgxpool.Pool,
filters BrowseFilters,
baseRelation string,
mediaScope string,
prefix string,
limit int,
) ([]string, bool, error) {
prefix = strings.TrimSpace(prefix)
if limit <= 0 {
return []string{}, false, nil
}
fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
if empty {
return []string{}, false, nil
}
args = append(args, prefix+"%")
prefixIdx := len(args)
query := fmt.Sprintf(`
SELECT name FROM (
SELECT DISTINCT BTRIM(s.series_name) AS name
FROM %s
JOIN audiobook_series s ON s.content_id = mi.content_id
%s
AND s.series_name IS NOT NULL
AND BTRIM(s.series_name) <> ''
AND LOWER(BTRIM(s.series_name)) LIKE LOWER($%d)
) names
ORDER BY LOWER(name) ASC
LIMIT %d
`, fromClause, browseFilterPrefix(whereClause), prefixIdx, limit+1)
return queryFacetSearchResults(ctx, pool, query, args, limit)
}
// queryFacetSearchResults executes a facet search query that was built
// with LIMIT N+1 and returns the first N rows plus a hasMore flag
// indicating whether an additional row was present (i.e. the result set
// exceeded the requested limit).
func queryFacetSearchResults(ctx context.Context, pool *pgxpool.Pool, query string, args []any, limit int) ([]string, bool, error) {
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return nil, false, err
}
defer rows.Close()
values := make([]string, 0, limit)
hasMore := false
for rows.Next() {
var value string
if err := rows.Scan(&value); err != nil {
return nil, false, err
}
value = strings.TrimSpace(value)
if value == "" {
continue
}
if len(values) >= limit {
hasMore = true
continue
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
return values, hasMore, nil
}
func listDistinctJSONBLanguageWithSource(
ctx context.Context,
pool *pgxpool.Pool,
+159
View File
@@ -67,6 +67,17 @@ type facetFetcher interface {
// AudiobookSeries returns distinct series_name values from
// audiobook_series joined onto the scoped result set.
AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// SearchDistinctArrayColumn prefix-searches an array-column facet
// (genres, studios, networks, countries). Returns up to limit
// alphabetical matches and hasMore=true when more would be available.
SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchDistinctScalarColumn is the scalar-column variant (e.g.
// content_rating, original_language).
SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchPeopleByKind is the typeahead equivalent of PeopleByKind.
SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
// SearchAudiobookSeries is the typeahead equivalent of AudiobookSeries.
SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error)
}
// pgxFacetFetcher is the production facetFetcher. It dispatches each method to
@@ -103,6 +114,22 @@ func (f *pgxFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFil
return listDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctArrayColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctScalarColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctPeopleByKindWithSource(ctx, f.pool, kind, filters, baseRelation, mediaScope, prefix, limit)
}
func (f *pgxFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return searchDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope, prefix, limit)
}
// previewExecutor is the seam consumed by previewQuerySource so tests can
// substitute a stub that records call counts without touching the database.
// The production implementation is *QueryExecutor.
@@ -684,6 +711,138 @@ func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req Catalo
return r.listFiltersForSource(ctx, filters, options, "media_items mi", "")
}
// CatalogFacetSearchResult is the typed return for SearchFacet. Matches
// is in alphabetical order; HasMore is true when the underlying result
// set held more rows than the supplied limit.
type CatalogFacetSearchResult struct {
Matches []string
HasMore bool
}
// catalogFacetSearchMaxLimit caps how many matches a single typeahead
// query can request. 50 is enough for a dropdown without overwhelming
// the page; clients that need more pages should narrow the prefix.
const catalogFacetSearchMaxLimit = 50
// SearchFacet powers /api/v1/catalog/filters/search. The request scope
// (libraries / access / media scope) mirrors ListFiltersWithOptions;
// the additional facet + prefix + limit arguments come from the URL.
// Empty or whitespace-only prefix returns no matches — the caller
// should fall back to the bulk /catalog/filters endpoint for the
// initial dropdown render.
func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, access AccessFilter, facet string, prefix string, limit int) (*CatalogFacetSearchResult, error) {
if r == nil || r.browseRepo == nil {
return nil, fmt.Errorf("catalog resolver requires a browse repository")
}
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return &CatalogFacetSearchResult{Matches: []string{}}, nil
}
if limit <= 0 {
limit = 20
}
if limit > catalogFacetSearchMaxLimit {
limit = catalogFacetSearchMaxLimit
}
var (
filters BrowseFilters
earlyEmpty bool
err error
)
switch req.Source {
case CatalogSourceQuery:
if err := validateCatalogQueryRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
if err := validateCatalogPersonalRequest(req); err != nil {
return nil, err
}
if access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" {
return nil, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal")
}
store, err := r.catalogStoreForAccess(ctx, access)
if err != nil {
return nil, err
}
contentIDs, err := r.loadPersonalSourceIDs(ctx, store, req, access.ProfileID)
if err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.ContentIDs = contentIDs
case CatalogSourcePerson:
if err := validateCatalogPersonRequest(req); err != nil {
return nil, err
}
filters, earlyEmpty, err = catalogBrowseFilters(req, access)
if err != nil {
return nil, err
}
filters.PersonID = req.PersonID
default:
return nil, fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source)
}
if earlyEmpty {
return &CatalogFacetSearchResult{Matches: []string{}}, nil
}
baseRelation := "media_items mi"
mediaScope := ""
if isEpisodeCatalogScope(req.Query.MediaScope) {
baseRelation = episodeCatalogBaseRelation
mediaScope = req.Query.MediaScope
}
facets := r.facets
if facets == nil {
facets = &pgxFacetFetcher{pool: r.browseRepo.pool}
}
matches, hasMore, err := dispatchFacetSearch(ctx, facets, facet, filters, baseRelation, mediaScope, prefix, limit)
if err != nil {
return nil, err
}
return &CatalogFacetSearchResult{Matches: matches, HasMore: hasMore}, nil
}
// dispatchFacetSearch routes a facet name to the right facetFetcher
// method. The set of supported facet names mirrors what
// /api/v1/catalog/filters returns; anything else is rejected as an
// invalid request so callers learn about typos at the API boundary.
func dispatchFacetSearch(ctx context.Context, facets facetFetcher, facet string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
switch facet {
case "genre":
return facets.SearchDistinctArrayColumn(ctx, "genres", filters, baseRelation, mediaScope, prefix, limit)
case "studio":
return facets.SearchDistinctArrayColumn(ctx, "studios", filters, baseRelation, mediaScope, prefix, limit)
case "network":
return facets.SearchDistinctArrayColumn(ctx, "networks", filters, baseRelation, mediaScope, prefix, limit)
case "country":
return facets.SearchDistinctArrayColumn(ctx, "countries", filters, baseRelation, mediaScope, prefix, limit)
case "original_language":
return facets.SearchDistinctScalarColumn(ctx, "original_language", filters, baseRelation, mediaScope, prefix, limit)
case "content_rating":
return facets.SearchDistinctScalarColumn(ctx, "content_rating", filters, baseRelation, mediaScope, prefix, limit)
case "author":
return facets.SearchPeopleByKind(ctx, models.PersonKindAuthor, filters, baseRelation, mediaScope, prefix, limit)
case "narrator":
return facets.SearchPeopleByKind(ctx, models.PersonKindNarrator, filters, baseRelation, mediaScope, prefix, limit)
case "series":
return facets.SearchAudiobookSeries(ctx, filters, baseRelation, mediaScope, prefix, limit)
default:
return nil, false, fmt.Errorf("%w: unknown facet %q", ErrInvalidCatalogRequest, facet)
}
}
// catalogFacetConcurrency caps how many facet queries run in parallel for a
// single ListFiltersWithOptions invocation. With nine independent facet
// lookups and a default pgx pool of ten connections, capping at six leaves
+20
View File
@@ -180,6 +180,26 @@ func (s *stubFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFi
return nil, nil
}
func (s *stubFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
s.hit()
return nil, false, nil
}
func (s *stubFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
s.hit()
return nil, false, nil
}
func (s *stubFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
s.hit()
return nil, false, nil
}
func (s *stubFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
s.hit()
return nil, false, nil
}
// countingExecutor is a previewExecutor stub that records the number of
// PreviewPage calls and the AccessFilter passed in (so tests can confirm
// NamePrefix made it through). It returns an empty result set so the resolver