Files
bookhoard/internal/services/search.go
T
John O'Keefe 86f0af632e feat(audiobooks): A2 — narrator metadata, audio-aware UX, player polish, bookmarks
A2a (metadata round): narrators TEXT[] + narrators_search (tags
pattern: accumulator + GIN pairs) + abridged bool on media_items;
scanner fills from the Libation/ABS sidecar (narrators verbatim,
abridged flag) with audio-payload promotion; editorial fields ride
the real columns while audio_metadata stays scan-derived (duration/
codec/chapters). Detail/edit/save paths carry the fields; the web
edit dialog grows an Audio section (Narrator(s) comma-input, Abridged
toggle, scanned duration read-only); search weights + narrator filter
(word_similarity over narrators_search) + narrator autocomplete added.

A2c (per-media-type wording): copy helpers keyed on format_group —
Listen Now, Mark Complete/Not Complete, Listening Progress, Your
Listening Stats, Time Listening, Time listened (pages tile swapped for
audio), Position H:MM:SS / total H:MM:SS on detail + progress pages
(duration parsed from audio_metadata). Dashboard: system collections
swap to Continue Listening / Recently Listened at DISPLAY time when
the audiobooks library is selected (stored names unchanged; restores
keep working); week card = Listening Time / Sessions / Books Touched;
cover-corner badges identify audiobook (headphones) / comic / manga
cards on mixed grids — books stay unbadged; hover action icon follows
the media type.

A2b (player): the scrubber binds value to the playhead with an explicit
scrub guard (the handle previously froze at its initial max); header
row = reader-style back arrow + Chapter n/total launcher; chapters
open in a floating top-down drawer (current chapter highlighted, page
never shifts); Skip/Speed rows share one label-left layout; honest
wall-clock session accounting (playing-only deltas, paused flush).

A2d (bookmarks): note-bearing time bookmarks ride the existing
media_bookmarks substrate (notes column + sync-aware service already
there) — audio locator convention audio:<seconds>, chapter_number
filled, auto-title '<chapter> — H:MM:SS', capture popover with optional
note, drawer with jump/edit-note/delete. Zero server changes needed.

Verified live on the Libation sample: narrators backfilled by rescan;
PUT edit persisted + overrides detected (narrators, abridged); force
rescan preserved the user edit; reset-to-scanned-of-defaults restored
truth; narrator filter/autocomplete scoped by library; dashboard
listsening wording + badges render; bookmark save with note deduped to
one row.
2026-10-15 18:03:35 -04:00

246 lines
8.4 KiB
Go

package services
import (
"bookhoard/internal/database"
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// SearchService handles all search and filter operations
type SearchService struct {
db *database.Queries
}
// NewSearchService creates a new search service instance
func NewSearchService(db *database.Queries) *SearchService {
return &SearchService{db: db}
}
// SearchParams contains parameters for unified search
type SearchParams struct {
UserID pgtype.UUID
LibraryID pgtype.UUID
AuthorFilter string
SeriesFilter string
GenreFilter string
PublisherFilter string
TagsFilter string
NarratorFilter 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
// Returns (isExact, processedQuery)
// Examples:
//
// "asimov" → (false, "asimov")
// "\"Asimov, Isaac\"" → (true, "Asimov, Isaac")
func (s *SearchService) parseSearchQuery(query string) (bool, string) {
query = strings.TrimSpace(query)
if strings.HasPrefix(query, "\"") && strings.HasSuffix(query, "\"") && len(query) >= 2 {
return true, strings.Trim(query, "\"")
}
return false, query
}
// SearchMediaItemsUnified handles combined search + filters
// Supports:
// - Fuzzy text filters (author, series, genre, language)
// - Exact filters (year range, has_cover boolean)
// - Fuzzy search query (or exact match with quotes)
// - Combined search + filters
func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) {
// Parse search query for exact match detection
isExact, searchQuery := s.parseSearchQuery(params.SearchQuery)
// For now implementing real exact search. If you want to reimplement wildcard patterns uncomment this and line 81 and check queries.sql line 475 and frontend.go line 211
// searchPattern := ""
// if isExact {
// searchPattern = "%" + searchQuery + "%"
// }
// 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},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
NarratorFilter: pgtype.Text{String: params.NarratorFilter, 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},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
}
if params.LibraryID.Valid {
dbParams.LibraryID = params.LibraryID
}
// Execute unified search query
results, err := s.db.SearchMediaItemsUnified(ctx, dbParams)
if err != nil {
return nil, err
}
return results, nil
}
// FieldSearchParams contains parameters for field-specific search (autocomplete)
type FieldSearchParams struct {
UserID pgtype.UUID
LibraryID pgtype.UUID
FieldType string // "author", "genre", "series", "language"
SearchQuery string
Limit int
Offset int
}
// FieldValue represents a single field value with metadata
type FieldValue struct {
Value string `json:"value"`
Count int64 `json:"count"`
Score float64 `json:"score"`
}
// SearchFieldValues handles field-specific search for autocomplete dropdowns
// Returns distinct values with counts and similarity scores
// Uses 4 separate queries (one per field type) for sqlc v1.30.0 compatibility
func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearchParams) ([]FieldValue, error) {
switch params.FieldType {
case "author":
results, err := s.db.SearchAuthorValues(ctx, database.SearchAuthorValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "genre":
results, err := s.db.SearchGenreValues(ctx, database.SearchGenreValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "tags":
results, err := s.db.SearchTagsValues(ctx, database.SearchTagsValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "narrator":
results, err := s.db.SearchNarratorValues(ctx, database.SearchNarratorValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "series":
results, err := s.db.SearchSeriesValues(ctx, database.SearchSeriesValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
case "language":
results, err := s.db.SearchLanguageValues(ctx, database.SearchLanguageValuesParams{
SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
UserID: params.UserID,
LibraryID: params.LibraryID,
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
Offset: pgtype.Int4{Int32: int32(params.Offset), Valid: true},
})
if err != nil {
return nil, err
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
default:
return []FieldValue{}, nil
}
}
// ExecuteSearch performs search and returns results with count
// Shared between JSON endpoint and HTML rendering
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
results, err := s.SearchMediaItemsUnified(ctx, params)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, err
}
count := len(results)
return results, count, nil
}