feat(catalog): audiobook-native filter fields (author / narrator / series)

Extends the catalog filter pipeline with three new dimensions
populated from existing tables:

- author   — item_people.kind = PersonKindAuthor (7)
- narrator — item_people.kind = PersonKindNarrator (8)
- series   — audiobook_series.series_name

Backend changes:

* CatalogFiltersResult gains Authors, Narrators, Series.
* facetFetcher learns PeopleByKind + AudiobookSeries; pgxFacetFetcher
  routes them to two new helpers in browse.go that inherit the existing
  filterWhereClauseForSource scope (so an audiobook library or
  audiobook media_scope filters video items out of the facet automatically).
* /api/v1/catalog/filters JSON emits authors, narrators, series for every
  request — empty arrays on video-only scopes.
* queryFieldDefs gains author/narrator/series with is/is_not ops.
* query_builder reuses buildPersonClause for author/narrator; new
  buildAudiobookSeriesClause does an EXISTS join on audiobook_series
  with LOWER(BTRIM(...)) on both sides so scanner-written values match
  user input regardless of incidental whitespace or casing.
* Registered in catalogQueryRuleFields + catalogPersonalRuleFields and
  in requiresAdvancedQueryExecution so the resolver picks the
  query-builder path for these rules.

Tests:

* stubFacetFetcher implements the two new interface methods.
* New query_builder cases verify kind = 7 / 8 are emitted and that the
  series clause joins audiobook_series with the case+whitespace fold;
  is_not wraps in NOT(...).

No schema changes; PersonKindAuthor/Narrator and audiobook_series have
been in place for some time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-27 18:58:31 +02:00
co-authored by Claude Opus 4.7
parent 451077f00a
commit 85a543b919
7 changed files with 235 additions and 2 deletions
+6
View File
@@ -42,6 +42,9 @@ type catalogFiltersResponse struct {
Countries []string `json:"countries"`
OriginalLanguages []string `json:"original_languages"`
ContentRatings []string `json:"content_ratings"`
Authors []string `json:"authors"`
Narrators []string `json:"narrators"`
Series []string `json:"series"`
Resolutions *[]string `json:"resolutions,omitempty"`
AudioLanguages *[]string `json:"audio_languages,omitempty"`
SubtitleLanguages *[]string `json:"subtitle_languages,omitempty"`
@@ -151,6 +154,9 @@ func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.
Countries: filters.Countries,
OriginalLanguages: filters.OriginalLanguages,
ContentRatings: filters.ContentRatings,
Authors: filters.Authors,
Narrators: filters.Narrators,
Series: filters.Series,
Resolutions: resolutions,
AudioLanguages: audioLanguages,
SubtitleLanguages: subtitleLanguages,
+61
View File
@@ -727,6 +727,67 @@ func (r *BrowseRepository) listDistinctJSONBLanguageWithFilters(ctx context.Cont
return listDistinctJSONBLanguageWithSource(ctx, r.pool, column, filters, "media_items mi", "")
}
// listDistinctPeopleByKindWithSource returns distinct people.name values for a
// PersonKind across the scoped result set. Powers the Authors and Narrators
// facets — both share item_people, just keyed by `kind`. The from/where
// clauses from filterWhereClauseForSource gate by library / access / scope,
// so an audiobook-only library or audiobook media_scope drops video-only
// content automatically.
func listDistinctPeopleByKindWithSource(
ctx context.Context,
pool *pgxpool.Pool,
kind models.PersonKind,
filters BrowseFilters,
baseRelation string,
mediaScope string,
) ([]string, error) {
fromClause, whereClause, args, earlyEmpty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
if earlyEmpty {
return []string{}, nil
}
args = append(args, int(kind))
kindIdx := 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) <> ''
ORDER BY p.name ASC
`, fromClause, kindIdx, browseFilterPrefix(whereClause))
return queryDistinctStrings(ctx, pool, query, args)
}
// listDistinctAudiobookSeriesWithSource returns distinct series_name values
// from audiobook_series joined onto the scoped result set. Names are trimmed
// and case-folded in the ORDER BY so the picker doesn't show duplicates that
// differ only by whitespace or casing — the literal series_name is still
// returned so existing rules continue to match.
func listDistinctAudiobookSeriesWithSource(
ctx context.Context,
pool *pgxpool.Pool,
filters BrowseFilters,
baseRelation string,
mediaScope string,
) ([]string, error) {
fromClause, whereClause, args, earlyEmpty := filterWhereClauseForSource(filters, baseRelation, mediaScope)
if earlyEmpty {
return []string{}, nil
}
query := fmt.Sprintf(`
SELECT DISTINCT BTRIM(s.series_name) AS series_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) <> ''
ORDER BY LOWER(BTRIM(s.series_name)) ASC
`, fromClause, browseFilterPrefix(whereClause))
return queryDistinctStrings(ctx, pool, query, args)
}
func listDistinctJSONBLanguageWithSource(
ctx context.Context,
pool *pgxpool.Pool,
+58 -1
View File
@@ -39,6 +39,13 @@ type CatalogFiltersResult struct {
Resolutions []string
AudioLanguages []string
SubtitleLanguages []string
// Authors, Narrators and Series are audiobook-native facets. Populated
// whenever the scope contains audiobook items; empty for video-only
// scopes. They share the item_people / audiobook_series tables so
// callers don't need to switch on media type.
Authors []string
Narrators []string
Series []string
}
type CatalogFilterOptions struct {
@@ -54,6 +61,12 @@ type facetFetcher interface {
Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// PeopleByKind returns distinct people names for the given PersonKind
// across the scoped result set. Used for Authors / Narrators facets.
PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// 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)
}
// pgxFacetFetcher is the production facetFetcher. It dispatches each method to
@@ -82,6 +95,14 @@ func (f *pgxFacetFetcher) SubtitleLanguages(ctx context.Context, filters BrowseF
return listSubtitleLanguagesWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctPeopleByKindWithSource(ctx, f.pool, kind, filters, baseRelation, mediaScope)
}
func (f *pgxFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return listDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope)
}
// 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.
@@ -691,6 +712,9 @@ func (r *CatalogResolver) listFiltersForSource(
resolutions []string
audioLanguages []string
subtitleLanguages []string
authors []string
narrators []string
series []string
)
eg, gctx := errgroup.WithContext(ctx)
@@ -755,6 +779,30 @@ func (r *CatalogResolver) listFiltersForSource(
contentRatings = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindAuthor, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog authors: %w", err)
}
authors = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog narrators: %w", err)
}
narrators = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.AudiobookSeries(gctx, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog audiobook series: %w", err)
}
series = out
return nil
}))
if options.IncludeTechnical {
eg.Go(withLimit(func() error {
@@ -794,6 +842,9 @@ func (r *CatalogResolver) listFiltersForSource(
Countries: countries,
OriginalLanguages: originalLanguages,
ContentRatings: contentRatings,
Authors: authors,
Narrators: narrators,
Series: series,
}
if options.IncludeTechnical {
result.Resolutions = resolutions
@@ -933,6 +984,9 @@ var catalogQueryRuleFields = map[string]bool{
"director": true,
"writer": true,
"producer": true,
"author": true,
"narrator": true,
"series": true,
"watched": true,
"favorited": true,
"in_watchlist": true,
@@ -962,6 +1016,9 @@ var catalogPersonalRuleFields = map[string]bool{
"director": true,
"writer": true,
"producer": true,
"author": true,
"narrator": true,
"series": true,
"watched": true,
"favorited": true,
"in_watchlist": true,
@@ -990,7 +1047,7 @@ func requiresAdvancedQueryExecution(def QueryDefinition) bool {
for _, group := range def.Groups {
for _, rule := range group.Rules {
switch rule.Field {
case "actor", "director", "writer", "producer", "watched", "favorited", "in_watchlist", "in_progress", "last_watched", "resolution", "hdr", "dolby_vision", "bitrate", "audio_language", "subtitle_language":
case "actor", "director", "writer", "producer", "author", "narrator", "series", "watched", "favorited", "in_watchlist", "in_progress", "last_watched", "resolution", "hdr", "dolby_vision", "bitrate", "audio_language", "subtitle_language":
return true
}
}
+11 -1
View File
@@ -170,6 +170,16 @@ func (s *stubFacetFetcher) SubtitleLanguages(ctx context.Context, filters Browse
return nil, nil
}
func (s *stubFacetFetcher) PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
s.hit()
return nil, nil
}
func (s *stubFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
s.hit()
return nil, 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
@@ -229,7 +239,7 @@ func TestPreviewQuerySource_NamePrefix_DoesNotFetchAllRows(t *testing.T) {
}
// TestListFiltersWithOptions_RunsFacetQueriesConcurrently asserts that the
// resolver runs the 9 facet lookups in parallel rather than serializing them.
// resolver runs the facet lookups in parallel rather than serializing them.
// We expect at least 6 concurrent in-flight callers (the configured cap).
func TestListFiltersWithOptions_RunsFacetQueriesConcurrently(t *testing.T) {
const expectedConcurrent = 6
+21
View File
@@ -355,6 +355,12 @@ func (qb *QueryBuilder) buildRule(rule QueryRule) (string, error) {
return qb.buildPersonClause(models.PersonKindWriter, rule)
case "producer":
return qb.buildPersonClause(models.PersonKindProducer, rule)
case "author":
return qb.buildPersonClause(models.PersonKindAuthor, rule)
case "narrator":
return qb.buildPersonClause(models.PersonKindNarrator, rule)
case "series":
return qb.buildAudiobookSeriesClause(rule)
case "watched":
return qb.buildWatchedClause(rule)
case "favorited":
@@ -446,6 +452,21 @@ func (qb *QueryBuilder) buildPersonClause(kind models.PersonKind, rule QueryRule
return clause, nil
}
func (qb *QueryBuilder) buildAudiobookSeriesClause(rule QueryRule) (string, error) {
qb.args = append(qb.args, rule.Value)
clause := fmt.Sprintf(`EXISTS (
SELECT 1
FROM audiobook_series s
WHERE s.content_id = %s.content_id
AND LOWER(BTRIM(s.series_name)) = LOWER(BTRIM($%d))
)`, qb.alias, qb.argIdx)
qb.argIdx++
if rule.Op == "is_not" {
return "NOT (" + clause + ")", nil
}
return clause, nil
}
func (qb *QueryBuilder) buildResolutionClause(rule QueryRule) (string, error) {
value, ok := rule.Value.(string)
if !ok {
+75
View File
@@ -368,6 +368,81 @@ func TestBuild_GroupAllCombinesHDRAndDolbyVisionOnSameFile(t *testing.T) {
}
}
func TestBuild_AuthorClauseUsesPersonKindAuthor(t *testing.T) {
clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{
Match: "all",
Groups: []QueryGroup{
{Match: "all", Rules: []QueryRule{{Field: "author", Op: "is", Value: "Brandon Sanderson"}}},
},
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if len(args) != 1 || args[0] != "Brandon Sanderson" {
t.Fatalf("expected author arg, got %v", args)
}
// PersonKindAuthor == 7. We compare against the integer constant so an
// accidental kind drift breaks the test rather than silently hitting
// the wrong table partition.
if !strings.Contains(clause, "ip.kind = 7") {
t.Fatalf("expected ip.kind = 7 (PersonKindAuthor) in clause, got %q", clause)
}
}
func TestBuild_NarratorClauseUsesPersonKindNarrator(t *testing.T) {
clause, _, err := NewQueryBuilder("mi").Build(QueryDefinition{
Match: "all",
Groups: []QueryGroup{
{Match: "all", Rules: []QueryRule{{Field: "narrator", Op: "is", Value: "Michael Kramer"}}},
},
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if !strings.Contains(clause, "ip.kind = 8") {
t.Fatalf("expected ip.kind = 8 (PersonKindNarrator) in clause, got %q", clause)
}
}
func TestBuild_SeriesClauseJoinsAudiobookSeries(t *testing.T) {
clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{
Match: "all",
Groups: []QueryGroup{
{Match: "all", Rules: []QueryRule{{Field: "series", Op: "is", Value: "Mistborn"}}},
},
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if len(args) != 1 || args[0] != "Mistborn" {
t.Fatalf("expected series arg, got %v", args)
}
if !strings.Contains(clause, "FROM audiobook_series s") {
t.Fatalf("expected audiobook_series join in series clause, got %q", clause)
}
// Both sides trimmed + case-folded so user-typed input matches stored
// values written by the scanner regardless of incidental whitespace.
if !strings.Contains(clause, "LOWER(BTRIM(s.series_name))") || !strings.Contains(clause, "LOWER(BTRIM($1))") {
t.Fatalf("expected case+whitespace-insensitive series match, got %q", clause)
}
}
func TestBuild_SeriesClauseNegationWrapsInNOT(t *testing.T) {
clause, _, err := NewQueryBuilder("mi").Build(QueryDefinition{
Match: "all",
Groups: []QueryGroup{
{Match: "all", Rules: []QueryRule{{Field: "series", Op: "is_not", Value: "Mistborn"}}},
},
})
if err != nil {
t.Fatalf("Build returned error: %v", err)
}
if !strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(clause), "WHERE")), "NOT (") &&
!strings.Contains(clause, "NOT (") {
t.Fatalf("expected is_not to wrap the EXISTS in NOT(...), got %q", clause)
}
}
func TestBuild_PersonClauseMatchesByResolvedPersonID(t *testing.T) {
clause, args, err := NewQueryBuilder("mi").Build(QueryDefinition{
Match: "all",
+3
View File
@@ -52,6 +52,9 @@ var queryFieldDefs = map[string]queryFieldDef{
"director": {executable: true, validOps: map[string]bool{"is": true, "is_not": true}},
"writer": {executable: true, validOps: map[string]bool{"is": true, "is_not": true}},
"producer": {executable: true, validOps: map[string]bool{"is": true, "is_not": true}},
"author": {executable: true, validOps: map[string]bool{"is": true, "is_not": true}},
"narrator": {executable: true, validOps: map[string]bool{"is": true, "is_not": true}},
"series": {executable: true, validOps: map[string]bool{"is": true, "is_not": true}},
"watched": {executable: true, personalized: true, validOps: map[string]bool{"is": true}},
"favorited": {executable: true, personalized: true, validOps: map[string]bool{"is": true}},
"in_watchlist": {executable: true, personalized: true, validOps: map[string]bool{"is": true}},