From 3d88f0b960bdc5f2ec8f2eb4a0b3f65cef545ab6 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 27 Sep 2026 19:55:58 -0400 Subject: [PATCH] feat(search): publisher_filter for the media search endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- internal/database/queries.sql.go | 115 ++++++++++++++------------ internal/database/queries/queries.sql | 3 + internal/handlers/media.go | 30 +++---- internal/services/search.go | 52 ++++++------ 4 files changed, 106 insertions(+), 94 deletions(-) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 479f1b7..45c312a 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -11285,46 +11285,48 @@ WHERE COALESCE(lv.is_visible, true) = true AND ($4 = '' OR word_similarity($4, COALESCE(mi.series, '')) > 0.3) -- Fuzzy genre filter 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 - 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) - AND ($7 = '' OR EXISTS ( + AND ($8 = '' OR EXISTS ( 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 AND ( - $8 = 0 OR - EXTRACT(YEAR FROM mi.date_published) >= $8 OR - (mi.date_published IS NULL AND mi.copyright_year >= $8) + $9 = 0 OR + EXTRACT(YEAR FROM mi.date_published) >= $9 OR + (mi.date_published IS NULL AND mi.copyright_year >= $9) ) AND ( - $9 = 0 OR - EXTRACT(YEAR FROM mi.date_published) <= $9 OR - (mi.date_published IS NULL AND mi.copyright_year <= $9) + $10 = 0 OR + EXTRACT(YEAR FROM mi.date_published) <= $10 OR + (mi.date_published IS NULL AND mi.copyright_year <= $10) ) -- Boolean (exact) AND ( - $10::bool IS NULL OR -- Not specified = show all - ($10::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 NULL OR -- Not specified = show all + ($11::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR + ($11::bool IS FALSE AND mi.cover_image_path IS NULL) ) -- Search query (fuzzy or exact based on quotes) AND ( - $11 = '' OR + $12 = '' OR -- Fuzzy search (default) - $12 = false AND ( - word_similarity($11, mi.title) > 0.3 OR - word_similarity($11, COALESCE(mi.author, '')) > 0.3 OR - word_similarity($11, COALESCE(mi.series, '')) > 0.3 OR + $13 = false AND ( + word_similarity($12, mi.title) > 0.3 OR + word_similarity($12, COALESCE(mi.author, '')) > 0.3 OR + word_similarity($12, COALESCE(mi.series, '')) > 0.3 OR EXISTS ( 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 ) OR EXISTS ( 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 ) ) OR @@ -11338,86 +11340,88 @@ WHERE COALESCE(lv.is_visible, true) = true -- sqlc.narg('search_pattern') = ANY(mi.contributors_search) -- ) -- Exact search (with quotes) - true exact match, not substring - $12 = true AND ( - mi.title = $11 OR - COALESCE(mi.author, '') = $11 OR - COALESCE(mi.series, '') = $11 OR - $11 = ANY(mi.tags_search) OR - $11 = ANY(mi.contributors_search) + $13 = true AND ( + mi.title = $12 OR + COALESCE(mi.author, '') = $12 OR + COALESCE(mi.series, '') = $12 OR + $12 = ANY(mi.tags_search) OR + $12 = ANY(mi.contributors_search) ) ) ORDER BY -- Primary sort: relevance score when searching CASE - WHEN $11 != '' THEN + WHEN $12 != '' THEN GREATEST( - CASE WHEN $12 = false THEN - word_similarity($11, mi.title) + CASE WHEN $13 = false THEN + word_similarity($12, mi.title) ELSE 0 END, - CASE WHEN $12 = false THEN - word_similarity($11, COALESCE(mi.author, '')) + CASE WHEN $13 = false THEN + word_similarity($12, COALESCE(mi.author, '')) ELSE 0 END, word_similarity($3, COALESCE(mi.author, '')), 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) ) ELSE 0 END DESC, -- Secondary sort: user-specified sort parameter CASE - WHEN $13 = 'title ASC' THEN mi.title + WHEN $14 = 'title ASC' THEN mi.title ELSE '' END ASC, CASE - WHEN $13 = 'title DESC' THEN mi.title + WHEN $14 = 'title DESC' THEN mi.title ELSE '' END DESC, CASE - WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '') + WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '') ELSE '' END ASC, CASE - WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '') + WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '') ELSE '' END DESC, CASE - WHEN $13 = 'created_at ASC' THEN mi.created_at + WHEN $14 = 'created_at ASC' THEN mi.created_at ELSE '1970-01-01'::timestamp END ASC, CASE - WHEN $13 = 'created_at DESC' THEN mi.created_at + WHEN $14 = 'created_at DESC' THEN mi.created_at ELSE '1970-01-01'::timestamp END DESC, 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 '' END ASC, 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 '' END DESC, -- Tertiary sort: title (default fallback) mi.title ASC -LIMIT $15 OFFSET $14 +LIMIT $16 OFFSET $15 ` type SearchMediaItemsUnifiedParams struct { - UserID pgtype.UUID `db:"user_id" json:"user_id"` - LibraryID pgtype.UUID `db:"library_id" json:"library_id"` - AuthorFilter interface{} `db:"author_filter" json:"author_filter"` - SeriesFilter interface{} `db:"series_filter" json:"series_filter"` - GenreFilter interface{} `db:"genre_filter" json:"genre_filter"` - LanguageFilter interface{} `db:"language_filter" json:"language_filter"` - TagsFilter interface{} `db:"tags_filter" json:"tags_filter"` - YearMin interface{} `db:"year_min" json:"year_min"` - YearMax interface{} `db:"year_max" json:"year_max"` - HasCover pgtype.Bool `db:"has_cover" json:"has_cover"` - SearchQuery interface{} `db:"search_query" json:"search_query"` - IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"` - Sort interface{} `db:"sort" json:"sort"` - Offset pgtype.Int4 `db:"offset" json:"offset"` - Limit pgtype.Int4 `db:"limit" json:"limit"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + AuthorFilter interface{} `db:"author_filter" json:"author_filter"` + SeriesFilter interface{} `db:"series_filter" json:"series_filter"` + GenreFilter interface{} `db:"genre_filter" json:"genre_filter"` + PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"` + LanguageFilter interface{} `db:"language_filter" json:"language_filter"` + TagsFilter interface{} `db:"tags_filter" json:"tags_filter"` + YearMin interface{} `db:"year_min" json:"year_min"` + YearMax interface{} `db:"year_max" json:"year_max"` + HasCover pgtype.Bool `db:"has_cover" json:"has_cover"` + SearchQuery interface{} `db:"search_query" json:"search_query"` + IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"` + Sort interface{} `db:"sort" json:"sort"` + Offset pgtype.Int4 `db:"offset" json:"offset"` + Limit pgtype.Int4 `db:"limit" json:"limit"` } type SearchMediaItemsUnifiedRow struct { @@ -11476,6 +11480,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt arg.AuthorFilter, arg.SeriesFilter, arg.GenreFilter, + arg.PublisherFilter, arg.LanguageFilter, arg.TagsFilter, arg.YearMin, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 99e664f..e2529cf 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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) -- Fuzzy genre filter 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 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) @@ -702,6 +704,7 @@ ORDER BY ELSE 0 END, word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')), 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)) FROM unnest(mi.tags_search) AS tag) ) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 04b0607..e2aa501 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -2133,6 +2133,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { authorFilter := c.QueryParam("author_filter") seriesFilter := c.QueryParam("series_filter") genreFilter := c.QueryParam("genre_filter") + publisherFilter := c.QueryParam("publisher_filter") tagsFilter := c.QueryParam("tags_filter") languageFilter := c.QueryParam("language_filter") yearMin, _ := strconv.Atoi(c.QueryParam("year_min")) @@ -2152,20 +2153,21 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { // Build search params params := services.SearchParams{ - UserID: userID.ID, - LibraryID: libUUID, - SearchQuery: query, - AuthorFilter: authorFilter, - SeriesFilter: seriesFilter, - GenreFilter: genreFilter, - TagsFilter: tagsFilter, - LanguageFilter: languageFilter, - YearMin: yearMin, - YearMax: yearMax, - HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid}, - Sort: sortParam, - Limit: limit, - Offset: offset, + UserID: userID.ID, + LibraryID: libUUID, + SearchQuery: query, + AuthorFilter: authorFilter, + SeriesFilter: seriesFilter, + GenreFilter: genreFilter, + PublisherFilter: publisherFilter, + TagsFilter: tagsFilter, + LanguageFilter: languageFilter, + YearMin: yearMin, + YearMax: yearMax, + HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid}, + Sort: sortParam, + Limit: limit, + Offset: offset, } c.Logger().Info("SearchMediaItems called", diff --git a/internal/services/search.go b/internal/services/search.go index 4bc5810..edf3ad4 100644 --- a/internal/services/search.go +++ b/internal/services/search.go @@ -22,20 +22,21 @@ func NewSearchService(db *database.Queries) *SearchService { // SearchParams contains parameters for unified search type SearchParams struct { - UserID pgtype.UUID - LibraryID pgtype.UUID - AuthorFilter string - SeriesFilter string - GenreFilter string - TagsFilter string - LanguageFilter string - YearMin int - YearMax int - HasCover pgtype.Bool - SearchQuery string - Sort string - Limit int - Offset int + UserID pgtype.UUID + LibraryID pgtype.UUID + AuthorFilter string + SeriesFilter string + GenreFilter string + PublisherFilter string + TagsFilter string + LanguageFilter string + YearMin int + YearMax int + HasCover pgtype.Bool + SearchQuery string + Sort string + Limit int + Offset int } // 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 dbParams := database.SearchMediaItemsUnifiedParams{ - UserID: params.UserID, - AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true}, - SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true}, - GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true}, - TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true}, - LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true}, - YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true}, - YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true}, - HasCover: params.HasCover, - SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, - IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true}, + UserID: params.UserID, + AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true}, + SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true}, + GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true}, + PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true}, + TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true}, + LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true}, + YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true}, + YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true}, + HasCover: params.HasCover, + SearchQuery: pgtype.Text{String: searchQuery, Valid: true}, + IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true}, // SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact}, Sort: pgtype.Text{String: params.Sort, Valid: true}, Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},