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:
co-authored by
Claude Opus 4.7
parent
451077f00a
commit
85a543b919
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}},
|
||||
|
||||
Reference in New Issue
Block a user