From fe8a65af84bd5c00dc860f8030c5f332ff3345e7 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 24 Mar 2026 16:47:23 -0400 Subject: [PATCH] feat: enable cross-library search in unified search query Updates SearchMediaItemsUnified query to support searching across all libraries when library_id parameter is not provided. Changes SQL from requiring library_id to checking for NULL: AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) The explicit ::uuid cast ensures PostgreSQL handles type inference correctly when comparing UUID columns with nullable parameters. Regenerates Go database code including queries.sql.go and querier.go to reflect the updated SQL schema. This enables the /api/media-items/search endpoint to search all libraries by omitting the library_id query parameter, matching the behavior of the OPDS search endpoint. --- internal/database/querier.go | 1 - internal/database/queries.sql.go | 186 +------------------------- internal/database/queries/queries.sql | 47 +------ 3 files changed, 10 insertions(+), 224 deletions(-) diff --git a/internal/database/querier.go b/internal/database/querier.go index 643df7b..f02c81e 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -288,7 +288,6 @@ type Querier interface { SearchLanguageValues(ctx context.Context, arg SearchLanguageValuesParams) ([]SearchLanguageValuesRow, error) // Search Media Items queries SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error) - SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error) // Library Visibility queries diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 939d698..a715c2e 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -6878,7 +6878,7 @@ LIMIT $5 OFFSET $4 ` type SearchAuthorValuesParams struct { - SearchQuery interface{} `db:"search_query" json:"search_query"` + SearchQuery pgtype.Text `db:"search_query" json:"search_query"` UserID pgtype.UUID `db:"user_id" json:"user_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` Offset pgtype.Int4 `db:"offset" json:"offset"` @@ -6936,7 +6936,7 @@ LIMIT $5 OFFSET $4 ` type SearchGenreValuesParams struct { - SearchQuery interface{} `db:"search_query" json:"search_query"` + SearchQuery pgtype.Text `db:"search_query" json:"search_query"` UserID pgtype.UUID `db:"user_id" json:"user_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` Offset pgtype.Int4 `db:"offset" json:"offset"` @@ -6994,7 +6994,7 @@ LIMIT $5 OFFSET $4 ` type SearchLanguageValuesParams struct { - SearchQuery interface{} `db:"search_query" json:"search_query"` + SearchQuery pgtype.Text `db:"search_query" json:"search_query"` UserID pgtype.UUID `db:"user_id" json:"user_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` Offset pgtype.Int4 `db:"offset" json:"offset"` @@ -7191,187 +7191,15 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara return items, nil } -const SearchMediaItemsFuzzy = `-- name: SearchMediaItemsFuzzy :many -SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name -FROM media_items mi -JOIN libraries l ON mi.library_id = l.id -JOIN library_types lt ON l.library_type_id = lt.id -LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 -WHERE COALESCE(lv.is_visible, true) = true - AND ($2 IS NULL OR mi.library_id = $2) - AND ( - word_similarity($3, mi.title) > 0.3 OR - word_similarity($3, COALESCE(mi.author, '')) > 0.3 OR - word_similarity($3, COALESCE(mi.series, '')) > 0.3 OR - EXISTS ( - SELECT 1 FROM unnest(mi.tags_search) AS tag - WHERE word_similarity($3, tag) > 0.3 - LIMIT 1 - ) OR - EXISTS ( - SELECT 1 FROM unnest(mi.contributors_search) AS contributor - WHERE word_similarity($3, contributor) > 0.3 - LIMIT 1 - ) - ) - ORDER BY - GREATEST( - word_similarity($3, mi.title), - word_similarity($3, COALESCE(mi.author, '')), - word_similarity($3, COALESCE(mi.series, '')), - COALESCE( - (SELECT MAX(word_similarity($3, tag)) - FROM unnest(mi.tags_search) AS tag), - 0 - ), - COALESCE( - (SELECT MAX(word_similarity($3, contributor)) - FROM unnest(mi.contributors_search) AS contributor), - 0 - ) - ) DESC, - mi.title ASC - LIMIT $5 OFFSET $4 -` - -type SearchMediaItemsFuzzyParams struct { - UserID pgtype.UUID `db:"user_id" json:"user_id"` - LibraryID interface{} `db:"library_id" json:"library_id"` - SearchQuery interface{} `db:"search_query" json:"search_query"` - Offset pgtype.Int4 `db:"offset" json:"offset"` - Limit pgtype.Int4 `db:"limit" json:"limit"` -} - -type SearchMediaItemsFuzzyRow struct { - ID pgtype.UUID `db:"id" json:"id"` - LibraryID pgtype.UUID `db:"library_id" json:"library_id"` - Title string `db:"title" json:"title"` - Author pgtype.Text `db:"author" json:"author"` - Isbn pgtype.Text `db:"isbn" json:"isbn"` - Description pgtype.Text `db:"description" json:"description"` - FilePath string `db:"file_path" json:"file_path"` - FileSize pgtype.Int8 `db:"file_size" json:"file_size"` - MimeType pgtype.Text `db:"mime_type" json:"mime_type"` - CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` - Series pgtype.Text `db:"series" json:"series"` - SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"` - Tags []string `db:"tags" json:"tags"` - Asin pgtype.Text `db:"asin" json:"asin"` - DatePublished pgtype.Date `db:"date_published" json:"date_published"` - Publisher pgtype.Text `db:"publisher" json:"publisher"` - Contributors []string `db:"contributors" json:"contributors"` - Language pgtype.Text `db:"language" json:"language"` - Edition pgtype.Text `db:"edition" json:"edition"` - PageCount pgtype.Int4 `db:"page_count" json:"page_count"` - Genre pgtype.Text `db:"genre" json:"genre"` - CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"` - GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"` - OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"` - GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"` - AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` - FormatGroup string `db:"format_group" json:"format_group"` - FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"` - IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"` - HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"` - TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"` - ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"` - EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"` - RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` - KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` - KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` - TagsSearch []string `db:"tags_search" json:"tags_search"` - ContributorsSearch []string `db:"contributors_search" json:"contributors_search"` - FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` - OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` - OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` - HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` - LibraryName string `db:"library_name" json:"library_name"` - LibraryTypeName string `db:"library_type_name" json:"library_type_name"` -} - -func (q *Queries) SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error) { - rows, err := q.db.Query(ctx, SearchMediaItemsFuzzy, - arg.UserID, - arg.LibraryID, - arg.SearchQuery, - arg.Offset, - arg.Limit, - ) - if err != nil { - return nil, err - } - defer rows.Close() - items := []SearchMediaItemsFuzzyRow{} - for rows.Next() { - var i SearchMediaItemsFuzzyRow - if err := rows.Scan( - &i.ID, - &i.LibraryID, - &i.Title, - &i.Author, - &i.Isbn, - &i.Description, - &i.FilePath, - &i.FileSize, - &i.MimeType, - &i.CoverImagePath, - &i.Series, - &i.SeriesNumber, - &i.Tags, - &i.Asin, - &i.DatePublished, - &i.Publisher, - &i.Contributors, - &i.Language, - &i.Edition, - &i.PageCount, - &i.Genre, - &i.CopyrightYear, - &i.GoodreadsID, - &i.OpenlibraryID, - &i.GoogleBooksID, - &i.AddedByAdminID, - &i.CreatedAt, - &i.UpdatedAt, - &i.FormatGroup, - &i.FormatMimetype, - &i.IsReflowable, - &i.HasFixedLayout, - &i.TotalCharacters, - &i.ChapterCount, - &i.EntitlementID, - &i.RevisionNumber, - &i.KoboContentID, - &i.KoboMetadata, - &i.TagsSearch, - &i.ContributorsSearch, - &i.FileSha256, - &i.OpfIdentifier, - &i.OpfUuid, - &i.HashConfidence, - &i.LibraryName, - &i.LibraryTypeName, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const SearchMediaItemsUnified = `-- name: SearchMediaItemsUnified :many SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1 -WHERE COALESCE(lv.is_visible, true) = true - AND mi.library_id = $2 + WHERE COALESCE(lv.is_visible, true) = true + AND ($2::uuid IS NULL + OR mi.library_id = $2::uuid) -- Fuzzy author filter AND ($3 = '' OR word_similarity($3, COALESCE(mi.author, '')) > 0.3) -- Fuzzy series filter @@ -7636,7 +7464,7 @@ LIMIT $5 OFFSET $4 ` type SearchSeriesValuesParams struct { - SearchQuery interface{} `db:"search_query" json:"search_query"` + SearchQuery pgtype.Text `db:"search_query" json:"search_query"` UserID pgtype.UUID `db:"user_id" json:"user_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` Offset pgtype.Int4 `db:"offset" json:"offset"` diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index f0a228e..08ad311 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -372,56 +372,15 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.na mi.title ASC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); --- name: SearchMediaItemsFuzzy :many -SELECT mi.*, l.name as library_name, lt.name as library_type_name -FROM media_items mi -JOIN libraries l ON mi.library_id = l.id -JOIN library_types lt ON l.library_type_id = lt.id -LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') -WHERE COALESCE(lv.is_visible, true) = true - AND (sqlc.narg('library_id') IS NULL OR mi.library_id = sqlc.narg('library_id')) - AND ( - word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR - word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR - word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR - EXISTS ( - SELECT 1 FROM unnest(mi.tags_search) AS tag - WHERE word_similarity(sqlc.narg('search_query'), tag) > 0.3 - LIMIT 1 - ) OR - EXISTS ( - SELECT 1 FROM unnest(mi.contributors_search) AS contributor - WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3 - LIMIT 1 - ) - ) - ORDER BY - GREATEST( - word_similarity(sqlc.narg('search_query'), mi.title), - word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')), - word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')), - COALESCE( - (SELECT MAX(word_similarity(sqlc.narg('search_query'), tag)) - FROM unnest(mi.tags_search) AS tag), - 0 - ), - COALESCE( - (SELECT MAX(word_similarity(sqlc.narg('search_query'), contributor)) - FROM unnest(mi.contributors_search) AS contributor), - 0 - ) - ) DESC, - mi.title ASC - LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); - -- name: SearchMediaItemsUnified :many SELECT mi.*, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') -WHERE COALESCE(lv.is_visible, true) = true - AND mi.library_id = sqlc.narg('library_id') + WHERE COALESCE(lv.is_visible, true) = true + AND (sqlc.narg('library_id')::uuid IS NULL + OR mi.library_id = sqlc.narg('library_id')::uuid) -- Fuzzy author filter AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 0.3) -- Fuzzy series filter