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:
co-authored by
Claude Opus 4.7
parent
b997183894
commit
093154d92c
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user