feat(search): publisher_filter for the media search endpoint

Adds publisher_filter to SearchMediaItems/SearchMediaItemsUnified
(fuzzy word_similarity against mi.publisher, mirroring genre_filter),
plumbs it through services.SearchParams and the search handler, and
extends the GREATEST relevance ranking to include publisher matches.
Serves the app's author/publisher/genre/tag click-through browses
(UX pass item 5 — publisher was the only facet without a server
filter).
This commit is contained in:
John O'Keefe
2026-09-27 19:55:58 -04:00
parent 6aea6a4e2c
commit 3d88f0b960
4 changed files with 106 additions and 94 deletions
+60 -55
View File
@@ -11285,46 +11285,48 @@ WHERE COALESCE(lv.is_visible, true) = true
AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3) AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter -- Fuzzy genre filter
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3) AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND ($6 = '' OR word_similarity($6, COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy language filter -- Fuzzy language filter
AND ($6 = '' OR word_similarity($6, COALESCE(mi.language, '')) > 0.3) AND ($7 = '' OR word_similarity($7, COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array) -- Tags filter (NEW - fuzzy match against tags array)
AND ($7 = '' OR EXISTS ( AND ($8 = '' OR EXISTS (
SELECT 1 FROM unnest(mi.tags_search) AS tag SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($7, tag) > 0.3 WHERE word_similarity($8, tag) > 0.3
)) ))
-- Year range (exact) - prioritize date_published, fallback to copyright_year -- Year range (exact) - prioritize date_published, fallback to copyright_year
AND ( AND (
$8 = 0 OR $9 = 0 OR
EXTRACT(YEAR FROM mi.date_published) >= $8 OR EXTRACT(YEAR FROM mi.date_published) >= $9 OR
(mi.date_published IS NULL AND mi.copyright_year >= $8) (mi.date_published IS NULL AND mi.copyright_year >= $9)
) )
AND ( AND (
$9 = 0 OR $10 = 0 OR
EXTRACT(YEAR FROM mi.date_published) <= $9 OR EXTRACT(YEAR FROM mi.date_published) <= $10 OR
(mi.date_published IS NULL AND mi.copyright_year <= $9) (mi.date_published IS NULL AND mi.copyright_year <= $10)
) )
-- Boolean (exact) -- Boolean (exact)
AND ( AND (
$10::bool IS NULL OR -- Not specified = show all $11::bool IS NULL OR -- Not specified = show all
($10::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR ($11::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
($10::bool IS FALSE AND mi.cover_image_path IS NULL) ($11::bool IS FALSE AND mi.cover_image_path IS NULL)
) )
-- Search query (fuzzy or exact based on quotes) -- Search query (fuzzy or exact based on quotes)
AND ( AND (
$11 = '' OR $12 = '' OR
-- Fuzzy search (default) -- Fuzzy search (default)
$12 = false AND ( $13 = false AND (
word_similarity($11, mi.title) > 0.3 OR word_similarity($12, mi.title) > 0.3 OR
word_similarity($11, COALESCE(mi.author, '')) > 0.3 OR word_similarity($12, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($11, COALESCE(mi.series, '')) > 0.3 OR word_similarity($12, COALESCE(mi.series, '')) > 0.3 OR
EXISTS ( EXISTS (
SELECT 1 FROM unnest(mi.tags_search) AS tag SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($11, tag) > 0.3 WHERE word_similarity($12, tag) > 0.3
LIMIT 1 LIMIT 1
) OR ) OR
EXISTS ( EXISTS (
SELECT 1 FROM unnest(mi.contributors_search) AS contributor SELECT 1 FROM unnest(mi.contributors_search) AS contributor
WHERE word_similarity($11, contributor) > 0.3 WHERE word_similarity($12, contributor) > 0.3
LIMIT 1 LIMIT 1
) )
) OR ) OR
@@ -11338,86 +11340,88 @@ WHERE COALESCE(lv.is_visible, true) = true
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search) -- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
-- ) -- )
-- Exact search (with quotes) - true exact match, not substring -- Exact search (with quotes) - true exact match, not substring
$12 = true AND ( $13 = true AND (
mi.title = $11 OR mi.title = $12 OR
COALESCE(mi.author, '') = $11 OR COALESCE(mi.author, '') = $12 OR
COALESCE(mi.series, '') = $11 OR COALESCE(mi.series, '') = $12 OR
$11 = ANY(mi.tags_search) OR $12 = ANY(mi.tags_search) OR
$11 = ANY(mi.contributors_search) $12 = ANY(mi.contributors_search)
) )
) )
ORDER BY ORDER BY
-- Primary sort: relevance score when searching -- Primary sort: relevance score when searching
CASE CASE
WHEN $11 != '' THEN WHEN $12 != '' THEN
GREATEST( GREATEST(
CASE WHEN $12 = false THEN CASE WHEN $13 = false THEN
word_similarity($11, mi.title) word_similarity($12, mi.title)
ELSE 0 END, ELSE 0 END,
CASE WHEN $12 = false THEN CASE WHEN $13 = false THEN
word_similarity($11, COALESCE(mi.author, '')) word_similarity($12, COALESCE(mi.author, ''))
ELSE 0 END, ELSE 0 END,
word_similarity($3, COALESCE(mi.author, '')), word_similarity($3, COALESCE(mi.author, '')),
word_similarity($5, COALESCE(mi.genre, '')), word_similarity($5, COALESCE(mi.genre, '')),
(SELECT MAX(word_similarity($7, tag)) word_similarity($6, COALESCE(mi.publisher, '')),
(SELECT MAX(word_similarity($8, tag))
FROM unnest(mi.tags_search) AS tag) FROM unnest(mi.tags_search) AS tag)
) )
ELSE 0 ELSE 0
END DESC, END DESC,
-- Secondary sort: user-specified sort parameter -- Secondary sort: user-specified sort parameter
CASE CASE
WHEN $13 = 'title ASC' THEN mi.title WHEN $14 = 'title ASC' THEN mi.title
ELSE '' ELSE ''
END ASC, END ASC,
CASE CASE
WHEN $13 = 'title DESC' THEN mi.title WHEN $14 = 'title DESC' THEN mi.title
ELSE '' ELSE ''
END DESC, END DESC,
CASE CASE
WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '') WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '')
ELSE '' ELSE ''
END ASC, END ASC,
CASE CASE
WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '') WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '')
ELSE '' ELSE ''
END DESC, END DESC,
CASE CASE
WHEN $13 = 'created_at ASC' THEN mi.created_at WHEN $14 = 'created_at ASC' THEN mi.created_at
ELSE '1970-01-01'::timestamp ELSE '1970-01-01'::timestamp
END ASC, END ASC,
CASE CASE
WHEN $13 = 'created_at DESC' THEN mi.created_at WHEN $14 = 'created_at DESC' THEN mi.created_at
ELSE '1970-01-01'::timestamp ELSE '1970-01-01'::timestamp
END DESC, END DESC,
CASE CASE
WHEN $13 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') WHEN $14 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
ELSE '' ELSE ''
END ASC, END ASC,
CASE CASE
WHEN $13 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') WHEN $14 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
ELSE '' ELSE ''
END DESC, END DESC,
-- Tertiary sort: title (default fallback) -- Tertiary sort: title (default fallback)
mi.title ASC mi.title ASC
LIMIT $15 OFFSET $14 LIMIT $16 OFFSET $15
` `
type SearchMediaItemsUnifiedParams struct { type SearchMediaItemsUnifiedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"` UserID pgtype.UUID `db:"user_id" json:"user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
AuthorFilter interface{} `db:"author_filter" json:"author_filter"` AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
SeriesFilter interface{} `db:"series_filter" json:"series_filter"` SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"` GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
LanguageFilter interface{} `db:"language_filter" json:"language_filter"` PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"` LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
YearMin interface{} `db:"year_min" json:"year_min"` TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
YearMax interface{} `db:"year_max" json:"year_max"` YearMin interface{} `db:"year_min" json:"year_min"`
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"` YearMax interface{} `db:"year_max" json:"year_max"`
SearchQuery interface{} `db:"search_query" json:"search_query"` HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"` SearchQuery interface{} `db:"search_query" json:"search_query"`
Sort interface{} `db:"sort" json:"sort"` IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
Offset pgtype.Int4 `db:"offset" json:"offset"` Sort interface{} `db:"sort" json:"sort"`
Limit pgtype.Int4 `db:"limit" json:"limit"` Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
} }
type SearchMediaItemsUnifiedRow struct { type SearchMediaItemsUnifiedRow struct {
@@ -11476,6 +11480,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
arg.AuthorFilter, arg.AuthorFilter,
arg.SeriesFilter, arg.SeriesFilter,
arg.GenreFilter, arg.GenreFilter,
arg.PublisherFilter,
arg.LanguageFilter, arg.LanguageFilter,
arg.TagsFilter, arg.TagsFilter,
arg.YearMin, arg.YearMin,
+3
View File
@@ -628,6 +628,8 @@ WHERE COALESCE(lv.is_visible, true) = true
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3) AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
-- Fuzzy genre filter -- Fuzzy genre filter
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3) AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy publisher filter
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 0.3)
-- Fuzzy language filter -- Fuzzy language filter
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3) AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array) -- Tags filter (NEW - fuzzy match against tags array)
@@ -702,6 +704,7 @@ ORDER BY
ELSE 0 END, ELSE 0 END,
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')), word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')), word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')),
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag)) (SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
FROM unnest(mi.tags_search) AS tag) FROM unnest(mi.tags_search) AS tag)
) )
+16 -14
View File
@@ -2133,6 +2133,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
authorFilter := c.QueryParam("author_filter") authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter") seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter") genreFilter := c.QueryParam("genre_filter")
publisherFilter := c.QueryParam("publisher_filter")
tagsFilter := c.QueryParam("tags_filter") tagsFilter := c.QueryParam("tags_filter")
languageFilter := c.QueryParam("language_filter") languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min")) yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
@@ -2152,20 +2153,21 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
// Build search params // Build search params
params := services.SearchParams{ params := services.SearchParams{
UserID: userID.ID, UserID: userID.ID,
LibraryID: libUUID, LibraryID: libUUID,
SearchQuery: query, SearchQuery: query,
AuthorFilter: authorFilter, AuthorFilter: authorFilter,
SeriesFilter: seriesFilter, SeriesFilter: seriesFilter,
GenreFilter: genreFilter, GenreFilter: genreFilter,
TagsFilter: tagsFilter, PublisherFilter: publisherFilter,
LanguageFilter: languageFilter, TagsFilter: tagsFilter,
YearMin: yearMin, LanguageFilter: languageFilter,
YearMax: yearMax, YearMin: yearMin,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid}, YearMax: yearMax,
Sort: sortParam, HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Limit: limit, Sort: sortParam,
Offset: offset, Limit: limit,
Offset: offset,
} }
c.Logger().Info("SearchMediaItems called", c.Logger().Info("SearchMediaItems called",
+27 -25
View File
@@ -22,20 +22,21 @@ func NewSearchService(db *database.Queries) *SearchService {
// SearchParams contains parameters for unified search // SearchParams contains parameters for unified search
type SearchParams struct { type SearchParams struct {
UserID pgtype.UUID UserID pgtype.UUID
LibraryID pgtype.UUID LibraryID pgtype.UUID
AuthorFilter string AuthorFilter string
SeriesFilter string SeriesFilter string
GenreFilter string GenreFilter string
TagsFilter string PublisherFilter string
LanguageFilter string TagsFilter string
YearMin int LanguageFilter string
YearMax int YearMin int
HasCover pgtype.Bool YearMax int
SearchQuery string HasCover pgtype.Bool
Sort string SearchQuery string
Limit int Sort string
Offset int Limit int
Offset int
} }
// parseSearchQuery detects quoted strings for exact match search // parseSearchQuery detects quoted strings for exact match search
@@ -69,17 +70,18 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
// Build database parameters - only set valid true if filter // Build database parameters - only set valid true if filter
dbParams := database.SearchMediaItemsUnifiedParams{ dbParams := database.SearchMediaItemsUnifiedParams{
UserID: params.UserID, UserID: params.UserID,
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true}, AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true}, SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true}, GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true}, PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true}, TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true}, LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true}, YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
HasCover: params.HasCover, YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, HasCover: params.HasCover,
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true}, SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact}, // SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
Sort: pgtype.Text{String: params.Sort, Valid: true}, Sort: pgtype.Text{String: params.Sort, Valid: true},
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true}, Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},