Files
bookhoard/UNIFIED_SEARCH_IMPLEMENTATION.md
T
john-okeefe aa7776db5f docs: consolidate implementation plans into unified search document
- Remove GET_SAVED_FILTER_BY_ID_IMPLEMENTATION.md (superseded)
- Remove SAVED_FILTERS_IMPLEMENTATION.md (superseded)
- Add UNIFIED_SEARCH_IMPLEMENTATION.md with comprehensive plan for:
  - Consolidating /filtered and /search endpoints
  - All-fuzzy text filters (author, series, genre, language)
  - Exact match with quotes for Google-style search
  - Field-specific fuzzy search for autocomplete dropdowns
  - Combined search + filters functionality
  - Phase-by-phase implementation with SQL, service, handler, frontend, tests, docs
2026-03-22 00:20:00 -04:00

38 KiB

Unified Search and Filter Implementation Plan

Executive Summary

Goal: Consolidate /api/media-items/filtered and /api/media-items/search endpoints into a single unified /api/media-items/search endpoint that supports:

  • All-fuzzy filters (except years/booleans)
  • Google-style "exact match in quotes" for search queries
  • Field-specific fuzzy search for autocomplete dropdowns
  • Combined search + filters functionality
  • Backward compatibility with saved filters

Approach: Surgical, incremental changes that reuse existing code, following PROJECT_GUIDELINES.md strictly.


Current State Analysis

Existing Endpoints

1. /api/media-items/search (internal/router/search.go:11)

  • Purpose: Global fuzzy search across all libraries
  • Handler: SearchMediaItems (internal/handlers/media.go:1419-1493)
  • Logic:
    • Uses SearchMediaItems query (ILIKE pattern matching)
    • Falls back to SearchMediaItemsFuzzy query (pg_trgm word_similarity())
    • Threshold: 0.3 similarity
  • Parameters: q, library_id, limit, offset
  • Used by: Search box (incorrectly - currently calls /filtered)

2. /api/media-items/filtered (internal/router/media.go:17)

  • Purpose: Exact match filtering
  • Handler: ListMediaItemsFiltered (internal/handlers/media.go:705-766)
  • Logic:
    • ListMediaItemsFiltered query (queries.sql:227-268)
    • Author/series: ILIKE (case-insensitive, NO wildcards)
    • Genre/language: = (exact match)
    • Years: >=, <= (exact range)
    • Boolean: exact match
  • Parameters: author_filter, series_filter, genre_filter, language_filter, year_min, year_max, has_cover, sort, limit, offset
  • Used by: All filter fields, search box (incorrectly)

Current Frontend Implementation

Search box (templates/bookshelf.templ:73-83):

<input name="search"
       hx-get="/api/media-items/filtered"  <!-- WRONG: Should be /search -->
       hx-trigger="keyup changed delay:300ms"
       hx-include="#filter-form">

Filter fields (templates/bookshelf.templ:90-117):

<input name="author_filter"
       hx-get="/api/media-items/filtered"
       hx-include="#filter-form">

<input name="genre_filter"
       hx-get="/api/media-items/filtered"
       hx-include="#filter-form">

Database Schema

Indexes exist (database/schema/schema.sql):

  • B-tree indexes: author, genre, language, series, copyright_year (lines 365-373)
  • GIN indexes: tags_search, contributors_search (lines 145-146)

Missing: GIN indexes for pg_trgm fuzzy search on text fields

Saved Filters

Implementation: Frontend-only (web/src/bookshelf.ts:44-173)

  • Stores filter config as JSON in database
  • Loads filter config into form via loadFilter() method
  • Makes API call to /api/saved-filters/:id
  • Populates form fields with exact values

Impact: Should work seamlessly with new endpoint (only field names matter)


Implementation Phases

Phase 1: Database Schema - Add GIN Indexes

File: database/schema/schema.sql

Add after line 146:

-- Add GIN indexes for pg_trgm fuzzy search performance
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
  ON media_items USING GIN (author gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_media_items_title_trgm
  ON media_items USING GIN (title gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_media_items_series_trgm
  ON media_items USING GIN (series gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_media_items_genre_trgm
  ON media_items USING GIN (genre gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_media_items_language_trgm
  ON media_items USING GIN (language gin_trgm_ops);

Verification:

podman compose down -v
podman compose up -d
podman exec bookhoard_db psql -U postgres -d bookhoard -c "\d+ media_items"

Phase 2: SQL Queries - Add Unified Search Query

File: internal/database/queries/queries.sql

Add after SearchMediaItemsFuzzy (line 460):

-- 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')
  -- Fuzzy author filter
  AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 0.3)
  -- Fuzzy series filter
  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 language filter
  AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
  -- Year range (exact)
  AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min'))
  AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max'))
  -- Boolean (exact)
  AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL)
  -- Search query (fuzzy or exact based on quotes)
  AND (
    sqlc.narg('search_query') = '' OR
    -- Fuzzy search (default)
    sqlc.narg('is_exact_search') = false 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
      )
    ) OR
    -- Exact search (with quotes)
    sqlc.narg('is_exact_search') = true AND (
      mi.title ILIKE sqlc.narg('search_pattern') OR
      mi.author ILIKE sqlc.narg('search_pattern') OR
      mi.series ILIKE sqlc.narg('search_pattern') OR
      sqlc.narg('search_pattern') = ANY(mi.tags_search) OR
      sqlc.narg('search_pattern') = ANY(mi.contributors_search)
    )
  )
ORDER BY
  CASE
    WHEN sqlc.narg('search_query') != '' THEN
      GREATEST(
        CASE WHEN sqlc.narg('is_exact_search') = false THEN
          word_similarity(sqlc.narg('search_query'), mi.title)
        ELSE 0 END,
        CASE WHEN sqlc.narg('is_exact_search') = false THEN
          word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))
        ELSE 0 END,
        word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
        word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, ''))
      )
    ELSE 0
  END DESC,
  mi.title ASC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');

Add field value search query (for autocomplete dropdowns):

-- name: SearchFieldValues :many
SELECT DISTINCT
  CASE sqlc.narg('field_type')
    WHEN 'author' THEN mi.author
    WHEN 'genre' THEN mi.genre
    WHEN 'series' THEN mi.series
    WHEN 'language' THEN mi.language
  END as value,
  COUNT(*) as count,
  CASE sqlc.narg('field_type')
    WHEN 'author' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))
    WHEN 'genre' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))
    WHEN 'series' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))
    WHEN 'language' THEN word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))
  END as score
FROM media_items mi
WHERE mi.library_id = sqlc.narg('library_id')
  AND (
    (sqlc.narg('field_type') = 'author' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3) OR
    (sqlc.narg('field_type') = 'genre' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3) OR
    (sqlc.narg('field_type') = 'series' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3) OR
    (sqlc.narg('field_type') = 'language' AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3)
  )
GROUP BY value, score
HAVING value IS NOT NULL AND value != ''
ORDER BY score DESC, count DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');

Regenerate Go code:

go generate ./internal/database

Verify: Check internal/database/queries.sql.go for new functions


Phase 3: Create Search Service

File: internal/services/search.go (NEW)

package services

import (
  "bookhoard/internal/database"
  "context"
  "strconv"
  "strings"

  "github.com/google/uuid"
  "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
  LanguageFilter string
  YearMin        int
  YearMax        int
  HasCover       bool
  SearchQuery    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)
  searchPattern := ""
  if isExact {
    searchPattern = "%" + searchQuery + "%"
  }

  // Build database parameters
  dbParams := database.SearchMediaItemsUnifiedParams{
    UserID:         params.UserID,
    LibraryID:      params.LibraryID,
    AuthorFilter:   pgtype.Text{String: params.AuthorFilter, Valid: true},
    SeriesFilter:   pgtype.Text{String: params.SeriesFilter, Valid: true},
    GenreFilter:    pgtype.Text{String: params.GenreFilter, 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:       pgtype.Bool{Bool: params.HasCover, Valid: true},
    SearchQuery:    pgtype.Text{String: searchQuery, Valid: true},
    IsExactSearch:  pgtype.Bool{Bool: isExact, Valid: true},
    SearchPattern:  pgtype.Text{String: searchPattern, Valid: isExact},
    Limit:          pgtype.Int4{Int32: int32(params.Limit), Valid: true},
    Offset:         pgtype.Int4{Int32: int32(params.Offset), Valid: true},
  }

  // 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
  Count int64
  Score float64
}

// SearchFieldValues handles field-specific search for autocomplete dropdowns
// Returns distinct values with counts and similarity scores
func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearchParams) ([]FieldValue, error) {
  // Build database parameters
  dbParams := database.SearchFieldValuesParams{
    UserID:      params.UserID,
    LibraryID:   params.LibraryID,
    FieldType:   pgtype.Text{String: params.FieldType, Valid: true},
    SearchQuery: pgtype.Text{String: params.SearchQuery, Valid: true},
    Limit:       pgtype.Int4{Int32: params.Limit), Valid: true},
    Offset:      pgtype.Int4{Int32: params.Offset), Valid: true},
  }

  // Execute field values query
  results, err := s.db.SearchFieldValues(ctx, dbParams)
  if err != nil {
    return nil, err
  }

  // Convert to service-level response type
  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
}

Verification:

go build ./internal/handlers

Note: Router initialization will also need updating (see Phase 7)

4.1 Fix Search Box Endpoint

File: templates/bookshelf.templ

Change line 79:

<!-- BEFORE -->
<input ... hx-get="/api/media-items/filtered" ... >

<!-- AFTER -->
<input ... hx-get="/api/media-items/search" ... >

Also fix pagination buttons (lines 298, 311):

<!-- BEFORE -->
hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"

<!-- AFTER -->
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"

Change filter field names (lines 92, 109, 126, etc.):

<!-- BEFORE -->
<input name="author_filter" ... hx-get="/api/media-items/filtered" ... >
<input name="genre_filter" ... hx-get="/api/media-items/filtered" ... >

<!-- AFTER -->
<input name="author_filter" ... hx-get="/api/media-items/search" ... >
<input name="genre_filter" ... hx-get="/api/media-items/search" ... >

4.2 Add Autocomplete Dropdowns

File: templates/bookshelf.templ

Replace author filter (lines 85-101):

<!-- Author Filter with Autocomplete -->
<div class="flex-1 min-w-[150px]">
  <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
    Author
  </label>
  <div class="relative" x-data="{ authorValues: [] }">
    <input
      type="text"
      name="author_filter"
      placeholder="Filter by author"
      class="w-full px-3 py-2 border rounded-lg"
      style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
      hx-get="/api/media-items/search"
      hx-target="#books-grid"
      hx-trigger="change"
      hx-include="#filter-form"
      list="author-datalist"
      @focus="fetchAuthorValues($el)"
      @input.debounce.300ms="if($el.value === '') authorValues = []"
    />
    <datalist id="author-datalist">
      <template x-for="item in authorValues" :key="item.value">
        <option :value="item.value" x-text="`${item.value} (${item.count})`"></option>
      </template>
    </datalist>
  </div>
</div>

Similar changes for genre, series, language filters

4.3 Add Frontend Functions

File: web/src/bookshelf.ts

Add at end of file:

// Fetch field values for autocomplete dropdowns
async function fetchFieldValues(
  field: string,
  search: string,
  datalistId: string
): Promise<void> {
  const token = localStorage.getItem("token");
  if (!token) {
    console.error("Not authenticated");
    return;
  }

  if (search.length < 2) return; // Wait for at least 2 characters

  const currentLibraryId = (
    document.getElementById("library-select") as HTMLSelectElement
  )?.value;
  if (!currentLibraryId) {
    console.error("No library selected");
    return;
  }

  try {
    const response = await fetch(
      `/api/media-items/search?${field}=${encodeURIComponent(
        search
      )}&library_id=${currentLibraryId}&limit=50`,
      {
        headers: { Authorization: `Bearer ${token}` },
      }
    );

    if (!response.ok) {
      console.error("Failed to fetch field values");
      return;
    }

    const data = await response.json();

    // Update datalist
    const datalist = document.getElementById(datalistId);
    if (!datalist) {
      console.error(`Datalist ${datalistId} not found`);
      return;
    }

    // Clear existing options
    datalist.innerHTML = "";

    // Add new options
    data.results.forEach((item: { value: string; count: number }) => {
      const option = document.createElement("option");
      option.value = item.value;
      option.textContent = `${item.value} (${item.count})`;
      datalist.appendChild(option);
    });
  } catch (error) {
    console.error("Error fetching field values:", error);
  }
}

// Fetch author values for autocomplete
async function fetchAuthorValues(input: HTMLInputElement): Promise<void> {
  const search = input.value;
  await fetchFieldValues("authors", search, "author-datalist");
}

// Fetch genre values for autocomplete
async function fetchGenreValues(input: HTMLInputElement): Promise<void> {
  const search = input.value;
  await fetchFieldValues("genres", search, "genre-datalist");
}

// Fetch series values for autocomplete
async function fetchSeriesValues(input: HTMLInputElement): Promise<void> {
  const search = input.value;
  await fetchFieldValues("series", search, "series-datalist");
}

// Fetch language values for autocomplete
async function fetchLanguageValues(input: HTMLInputElement): Promise<void> {
  const search = input.value;
  await fetchFieldValues("languages", search, "language-datalist");
}

// Register functions globally
(window as any).fetchAuthorValues = fetchAuthorValues;
(window as any).fetchGenreValues = fetchGenreValues;
(window as any).fetchSeriesValues = fetchSeriesValues;
(window as any).fetchLanguageValues = fetchLanguageValues;

Verify TypeScript compilation:

cd web && npm run build

Phase 5: Tests

File: cmd/server/tests/search_unified_test.go (NEW)

package main

import (
  "bookhoard/internal/handlers"
  "encoding/json"
  "net/http"
  "net/http/httptest"
  "testing"

  "github.com/stretchr/testify/assert"
  "github.com/stretchr/testify/require"
)

func TestUnifiedSearch(t *testing.T) {
  setup := setupDeviceTest(t)
  defer setup.Server.Close()

  libraryID := setup.CreateLibrary(t, "Test Search Library", "ebooks")
  _ = setup.CreateDevice(t, "Test Search Device", "koreader", "search-test-123")

  t.Run("Fuzzy author filter", func(t *testing.T) {
    req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&author_filter=asimov", nil)
    req.Header.Set("Authorization", "Bearer "+setup.UserToken)
    rec := httptest.NewRecorder()
    setup.Server.Config.Handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code, "Should fuzzy match author")
  })

  t.Run("Fuzzy genre filter", func(t *testing.T) {
    req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&genre_filter=scifi", nil)
    req.Header.Set("Authorization", "Bearer "+setup.UserToken)
    rec := httptest.NewRecorder()
    setup.Server.Config.Handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code, "Should fuzzy match genre")
  })

  t.Run("Exact match with quotes", func(t *testing.T) {
    req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&q=%22Foundation%20and%20Empire%22", nil)
    req.Header.Set("Authorization", "Bearer "+setup.UserToken)
    rec := httptest.NewRecorder()
    setup.Server.Config.Handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code, "Should exact match quoted query")
  })

  t.Run("Combined search + filters", func(t *testing.T) {
    req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&q=foundation&author_filter=asimov", nil)
    req.Header.Set("Authorization", "Bearer "+setup.UserToken)
    rec := httptest.NewRecorder()
    setup.Server.Config.Handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code, "Should combine search and filters")
  })

  t.Run("Field-specific search for dropdown - authors", func(t *testing.T) {
    req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&authors=asimov", nil)
    req.Header.Set("Authorization", "Bearer "+setup.UserToken)
    rec := httptest.NewRecorder()
    setup.Server.Config.Handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code, "Should return author values")

    var response struct {
      Results []struct {
        Value string  `json:"value"`
        Count int64   `json:"count"`
        Score float64 `json:"score"`
      } `json:"results"`
      Total int `json:"total"`
    }
    err := json.Unmarshal(rec.Body.Bytes(), &response)
    require.NoError(t, err, "Should unmarshal field values response")
    assert.Greater(t, len(response.Results), 0, "Should have results")
  })

  t.Run("Year range filter (exact)", func(t *testing.T) {
    req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&year_min=2000&year_max=2020", nil)
    req.Header.Set("Authorization", "Bearer "+setup.UserToken)
    rec := httptest.NewRecorder()
    setup.Server.Config.Handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code, "Should filter by year range")
  })

  t.Run("Boolean filter (exact)", func(t *testing.T) {
    req := httptest.NewRequest("GET", "/api/media-items/search?library_id="+libraryID+"&has_cover=true", nil)
    req.Header.Set("Authorization", "Bearer "+setup.UserToken)
    rec := httptest.NewRecorder()
    setup.Server.Config.Handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover")
  })

  t.Run("Missing library_id", func(t *testing.T) {
    req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil)
    req.Header.Set("Authorization", "Bearer "+setup.UserToken)
    rec := httptest.NewRecorder()
    setup.Server.Config.Handler.ServeHTTP(rec, req)

    assert.Equal(t, http.StatusBadRequest, rec.Code, "Should require library_id")
  })
}

Verify tests:

go test ./cmd/server/tests -v -run TestUnifiedSearch

Phase 6: Documentation Updates

6.1 Update API Documentation

File: docs/developer/api/media-items/search_media_items.md

Replace entire content:

# Search Media Items (Unified)

Search and filter media items with fuzzy matching support.

**Note:** All text filters use fuzzy matching via PostgreSQL pg_trgm (threshold: 0.3 similarity). This handles typos and partial matches automatically. Use quotes for exact match.

**Endpoint**: `GET /api/media-items/search`
**Auth**: Required

## Query Parameters

### Search Parameters

| Parameter  | Type    | Required | Description                                          |
| ---------- | ------- | -------- | ---------------------------------------------------- |
| q          | string  | No       | Search query (fuzzy by default, exact in quotes)     |
| library_id | string  | Yes      | Filter to specific library (UUID)                    |
| limit      | integer | No       | Number of results (default 50, max 200)              |
| offset     | integer | No       | Number to skip for pagination                        |

### Filter Parameters (All Fuzzy Except Years/Booleans)

| Parameter      | Type    | Description                                         |
| -------------- | ------- | --------------------------------------------------- |
| author_filter  | string  | Fuzzy match author field                            |
| series_filter  | string  | Fuzzy match series field                            |
| genre_filter   | string  | Fuzzy match genre field                             |
| language_filter| string  | Fuzzy match language field                          |
| year_min       | integer | Minimum copyright year (exact range)                |
| year_max       | integer | Maximum copyright year (exact range)                |
| has_cover      | boolean | Filter by cover image presence (exact boolean)      |

### Autocomplete Parameters (Field-Specific Search)

| Parameter  | Type   | Description                                    |
| ---------- | ------ | ---------------------------------------------- |
| authors    | string | Search author values for autocomplete dropdown |
| genres     | string | Search genre values for autocomplete dropdown  |
| series     | string | Search series values for autocomplete dropdown |
| languages  | string | Search language values for autocomplete        |

## Request Headers

| Header        | Type   | Required | Description  |
| ------------- | ------ | -------- | ------------ |
| Authorization | string | Yes      | Bearer token |

## Search Behavior

### Fuzzy Search (Default)

Handles typos and partial matches automatically:

- `"asimov"` → matches "Asimov, Isaac", "Asimov, Foundation"
- `"scifi"` → matches "Sci-Fi", "Science Fiction"
- `"azimov"` → matches "Asimov, Isaac" (typo tolerance)

### Exact Search (With Quotes)

Use double quotes for exact phrase matching:

- `"\"Foundation and Empire\""` → only "Foundation and Empire"
- `"\"Asimov, Isaac\""` → only "Asimov, Isaac"

### Filter Behavior

**Text filters (fuzzy):**
- `author_filter=asimov` → fuzzy matches author field
- `genre_filter=scifi` → fuzzy matches genre field

**Exact filters:**
- `year_min=2000&year_max=2010` → exact year range
- `has_cover=true` → exact boolean match

## Example Requests

### 1. Global Fuzzy Search

Search all fields for "foundation":

```http
GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q=foundation
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

2. Fuzzy Author Filter

Find books by "asimov" (matches "Asimov, Isaac"):

GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&author_filter=asimov
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

3. Combined Search + Filters

Search "foundation" within books by "asimov":

GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q=foundation&author_filter=asimov
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

4. Exact Match with Quotes

Exact phrase search:

GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&q="Foundation%20and%20Empire"
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

5. Multiple Fuzzy Filters

Fiction books from 2000-2010:

GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&genre_filter=fiction&year_min=2000&year_max=2010
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

6. Field-Specific Search (Autocomplete)

Get author values for dropdown:

GET /api/media-items/search?library_id=123e4567-e89b-12d3-a456-426614174000&authors=asimov&limit=50
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response:

{
  "results": [
    {"value": "Asimov, Isaac", "count": 47, "score": 0.8},
    {"value": "Asimov, Isaac & Robert Silverberg", "count": 2, "score": 0.75}
  ],
  "total": 2
}

Response (200 OK)

Media items search:

[
  {
    "id": "uuid",
    "title": "Foundation",
    "author": "Asimov, Isaac",
    "library_id": "...",
    "library_name": "E-Books"
  }
]

Field values search (autocomplete):

{
  "results": [
    {"value": "Asimov, Isaac", "count": 47, "score": 0.8}
  ],
  "total": 1
}

Error Responses

Code Description
400 Invalid library_id
400 Missing library_id
401 Invalid or expired token
404 No results found

#### 6.2 Update Bruno Collection

**File:** `bruno/media-items/Search All Libraries.yml`

**Update params section:**

```yaml
params:
  - name: q
    value: "foundation"
    type: query
    disabled: false
  - name: library_id
    value: "{{library_id}}"
    type: query
    disabled: false
  - name: author_filter
    value: ""
    type: query
    disabled: true
  - name: genre_filter
    value: ""
    type: query
    disabled: true
  - name: year_min
    value: ""
    type: query
    disabled: true
  - name: year_max
    value: ""
    type: query
    disabled: true

Add new Bruno files:

bruno/media-items/Fuzzy Author Filter.yml bruno/media-items/Fuzzy Genre Filter.yml bruno/media-items/Combined Search and Filters.yml bruno/media-items/Exact Match With Quotes.yml bruno/media-items/Field Values Search - Authors.yml bruno/media-items/scenarios/Unified Search Scenarios.yml

6.3 Delete Deprecated Filtered Endpoint Documentation

Files to delete:

  • docs/developer/api/media-items/filtered_media_items.md (if exists)
  • Remove /api/media-items/filtered from docs/developer/api/api-reference.md
  • Remove /api/media-items/filtered from docs/developer/api-reference.md

Phase 7: Update Handler Initialization

Files to update:

  • cmd/server/main.go (line ~122-126)
  • cmd/server/tests/test_helpers_test.go (line ~468-472)

In both files, find the MediaHandler initialization:

// BEFORE
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)

// AFTER
searchService := services.NewSearchService(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, searchService, worker)

Note: This follows the same pattern as conversionService which is created in main.go (line 118) and passed to handlers.

Verify compilation:

go build ./cmd/server
go test ./cmd/server/tests -run TestNonExistent # Compile test only

Phase 8: Cleanup

7.1 Delete Deprecated Filtered Endpoint

File: internal/router/media.go

Delete line 17:

protected.GET("/media-items/filtered", cfg.MediaHandler.ListMediaItemsFiltered)

File: internal/handlers/media.go

Delete handler ListMediaItemsFiltered (lines 705-766):

// DELETE THIS FUNCTION
func (mh *MediaHandler) ListMediaItemsFiltered(c *echo.Context) error {
  ...
}

File: internal/database/queries/queries.sql

Delete query ListMediaItemsFiltered (lines 227-268):

-- DELETE THIS QUERY
-- name: ListMediaItemsFiltered :many
...

Regenerate Go code:

go generate ./internal/database

7.2 Delete Deprecated Tests

File: cmd/server/tests/filtering_test.go

Delete entire file (tests now covered by search_unified_test.go)

OR update tests to use /api/media-items/search endpoint if some test cases are still valuable

7.3 Delete Deprecated Bruno Files

Delete: bruno/media-items/scenarios/Filter Media Items.yml (if exists)


Phase 10: Verification

8.1 Compile Check

go build ./...
cd web && npm run build

8.2 Run Tests

go test ./cmd/server/tests -v -run TestUnifiedSearch
go test ./cmd/server/tests -v  # All tests should pass

8.3 Manual Testing Checklist

  • Search box uses /api/media-items/search
  • Fuzzy author filter works (asimov → Asimov, Isaac)
  • Fuzzy genre filter works (scifi → Sci-Fi)
  • Exact match with quotes works ("Foundation and Empire")
  • Combined search + filters works
  • Autocomplete dropdowns populate correctly
  • Year range filter works (exact)
  • Boolean filter works (exact)
  • Saved filters load correctly
  • Pagination works
  • No errors in browser console
  • No errors in server logs

8.4 Documentation Verification

# Start server
podman compose up -d

# Access docs at http://localhost:8080/docs
# Verify search endpoint documentation renders correctly
# Verify search finds new documentation

Commit 3: Add search service git add internal/services/search.go git commit -m "feat: add SearchService for unified search functionality

  • Create SearchService with SearchMediaItemsUnified method
  • Add SearchFieldValues method for autocomplete dropdowns
  • Add parseSearchQuery helper for quote detection
  • Move all business logic from handler to service layer
  • Follow established service pattern (FiltersService, CollectionService)"
# Commit 1: Database schema (GIN indexes)
git add database/schema/schema.sql
git commit -m "feat: add GIN indexes for pg_trgm fuzzy search performance

- Add gin_trgm_ops indexes on author, title, series, genre, language
- Improves fuzzy search performance on large libraries
- Required for unified search/filter endpoint"

# Commit 2: SQL queries
git add internal/database/queries/queries.sql
git commit -m "feat: add unified search SQL query with fuzzy filters

- Add SearchMediaItemsUnified query with all-fuzzy filters
- Add SearchFieldValues query for autocomplete dropdowns
- Support exact match with quotes detection
- Combine search + filters in single query"

# Commit 3: Add search service
git add internal/services/search.go
git commit -m "feat: add SearchService for unified search functionality

- Create SearchService with SearchMediaItemsUnified method
- Add SearchFieldValues method for autocomplete dropdowns
- Add parseSearchQuery helper for quote detection
- Move all business logic from handler to service layer
- Follow established service pattern (FiltersService, CollectionService)"

# Commit 4: Regenerate database code
git add internal/database/queries.sql.go internal/database/models.go
git commit -m "chore: regenerate database code from queries.sql"

# Commit 5: Update handler to use search service
git add internal/handlers/media.go
git commit -m "refactor: update SearchMediaItems handler to use SearchService

- Add searchService to MediaHandler struct
- Update NewMediaHandler constructor
- Refactor SearchMediaItems to delegate to service layer
- Add handleUnifiedSearch method (thin wrapper)
- Add handleFieldValuesSearch method (thin wrapper)
- Handler now only extracts params and calls service"

# Commit 6: Update handler initialization
git add cmd/server/main.go cmd/server/tests/test_helpers_test.go
git commit -m "refactor: add SearchService to handler initialization

- Instantiate SearchService in main.go and test_helpers_test.go
- Pass searchService to MediaHandler constructor
- Follow existing pattern (like conversionService)
- Update both production and test initialization"

# Commit 7: Frontend templates
git add templates/bookshelf.templ
git commit -m "feat: fix search box to use /search endpoint with autocomplete

- Change search box from /filtered to /search
- Change pagination buttons to use /search
- Add datalist elements for autocomplete
- Add Alpine.js event handlers for dropdown population"

# Commit 8: Frontend TypeScript
git add web/src/bookshelf.ts
git commit -m "feat: add autocomplete dropdown support for filter fields

- Add fetchFieldValues function for API calls
- Add fetchAuthorValues, fetchGenreValues, etc.
- Register functions globally for template access
- Populate datalist elements with fuzzy search results"

# Commit 9: Tests
git add cmd/server/tests/search_unified_test.go
git commit -m "test: add comprehensive tests for unified search endpoint

- Test fuzzy author/genre filters
- Test exact match with quotes
- Test combined search + filters
- Test field-specific search for dropdowns
- Test year range and boolean filters
- Use setupTestServer helper following PROJECT_GUIDELINES.md"

# Commit 10: Documentation
git add docs/developer/api/media-items/search_media_items.md
git add bruno/media-items/Search\ All\ Libraries.yml
git add bruno/media-items/Fuzzy\ Author\ Filter.yml
git add bruno/media-items/Fuzzy\ Genre\ Filter.yml
git add bruno/media-items/Combined\ Search\ and\ Filters.yml
git add bruno/media-items/Exact\ Match\ With\ Quotes.yml
git add bruno/media-items/Field\ Values\ Search\ -\ Authors.yml
git commit -m "docs: update search API documentation with fuzzy filters

- Document all-fuzzy filters (except years/booleans)
- Document exact match with quotes
- Document combined search + filters
- Document field-specific search for autocomplete
- Add comprehensive examples
- Update Bruno collection with new endpoints"

# Commit 11: Delete deprecated code
git add internal/router/media.go
git add internal/handlers/media.go
git add internal/database/queries/queries.sql
git add internal/database/queries.sql.go
git add internal/database/models.go
git add cmd/server/tests/filtering_test.go
git commit -m "refactor: remove deprecated /filtered endpoint

- Delete /media-items/filtered route registration
- Delete ListMediaItemsFiltered handler
- Delete ListMediaItemsFiltered SQL query
- Delete filtering_test.go (covered by search_unified_test.go)
- Regenerate database code after query deletion"

# Commit 12: Final verification
git add .
git commit -m "chore: final verification of unified search implementation

- All tests pass
- Documentation renders correctly
- Bruno collection updated
- No compilation errors
- Manual testing complete"

Risk Mitigation

Potential Issues

  1. Saved filters breaking: Frontend-only, should work seamlessly
  2. Performance degradation: GIN indexes should prevent this
  3. Breaking mobile apps: /filtered endpoint will be deleted
  4. Test coverage gaps: Comprehensive tests in Phase 5

Rollback Plan

If issues arise:

# Revert to previous commit
git revert HEAD

# Or restore specific files
git show HEAD~1:internal/handlers/media.go > internal/handlers/media.go
git show HEAD~1:templates/bookshelf.templ > templates/bookshelf.templ

Timeline Estimate

  • Phase 1 (Database): 30 minutes
  • Phase 2 (SQL Queries): 1 hour
  • Phase 3 (Search Service): 2 hours
  • Phase 4 (Handler Updates): 1 hour
  • Phase 5 (Router Updates): 15 minutes
  • Phase 6 (Frontend): 2 hours
  • Phase 7 (TypeScript): 1 hour
  • Phase 8 (Tests): 2 hours
  • Phase 9 (Documentation): 1 hour
  • Phase 10 (Cleanup): 30 minutes
  • Phase 11 (Verification): 1 hour

Total: ~12 hours


Success Criteria

All business logic in services/search.go (service layer pattern) Handler is thin - only extracts params and calls service All text filters use fuzzy matching (pg_trgm) Exact match with quotes works Combined search + filters work Autocomplete dropdowns populate correctly Years/booleans remain exact match Saved filters load correctly All tests pass using setupTestServer helper Documentation updated No breaking changes to saved filters Deprecated /filtered endpoint removed Bruno collection updated