Skip to content

Search

The Search feature enables users to find Grateful Dead shows using a powerful local FTS5 (Full-Text Search) implementation. Users can search by songs, venues, dates, cities, band members, or any text found in show data.

Overview

Purpose: Provide fast, relevant search results across the entire Grateful Dead show catalog (~2,500 shows) stored locally in the database.

Why It Exists: With thousands of shows available, browsing alone isn't practical. Search is the primary discovery mechanism for users looking for specific songs, venues, dates, or tour periods.

Key Characteristics: - Local-only search - No Archive.org API calls, all data is in local database - FTS5 powered - SQLite Full-Text Search with BM25 ranking - Debounced input - 800ms delay to avoid excessive queries while typing - Reactive state - Real-time updates via Kotlin Flows - Multi-field matching - Searches across songs, venues, dates, members, locations

Capabilities

  • Free-text query - Type anything, get ranked results
  • Multi-field matching - Automatically searches songs, venues, dates, cities, states, band members
  • Ranked results - BM25 relevance scoring via FTS5
  • Instant results - Typical search completes in < 100ms

Query Processing

  • Debounced input - 800ms delay before executing search (configurable)
  • Distinct queries - Only triggers search when query actually changes
  • Empty query handling - Clears results immediately when query is empty
  • Min query length - Queries under 3 characters don't get saved to history

Search Results

  • Relevance score - Each result has calculated relevance (FTS5 rank + position)
  • Match type - Indicates what matched (venue, year, location, song, general)
  • Full show data - Each result includes complete show information
  • Preserved ranking - FTS5 BM25 ranking maintained through result pipeline

Search History

  • Recent searches - Track previously executed queries
  • Quick re-execution - Tap recent search to run again (no debounce)
  • Clear history - Remove all recent searches

Search Suggestions (Planned)

  • Dynamic suggestions - Based on partial query
  • Quick selection - Tap suggestion to execute immediately
  • No debounce - Deliberate selections execute instantly

Discovery UI (SearchScreen)

The main search screen offers three browsing sections before the user types a query:

Decade Cards — Fixed 2x2 grid of decade buttons (1960s–1990s). Each fires a wildcard FTS query like 197*.

Discover Section — Three cards drawn from the SearchShortcut catalog, rotated on a time-based schedule. Uses a deterministic shuffle seeded by System.currentTimeMillis() / (4 hours), so the same three cards appear across recompositions, navigation, and app restarts within the same 4-hour window. After 4 hours the seed changes and the user sees a fresh set. Cards render with a vertical gradient and title/subtitle (image support is backlogged as DEAD-50).

Browse All Section — 8 rotating items drawn from shortcuts with priority >= 5 in the SearchShortcut catalog (filters, venues, cities, songs), using the same time-based seed mechanism as Discover (offset by 1 to rotate independently). Displayed in a 2-column grid (4 rows). Each card fires its searchQuery through the same FTS pipeline as typed queries.

Pull-to-Refresh — Both Discover and Browse All support pull-to-refresh via Material3 PullToRefreshBox. Pulling down increments a refreshCounter in the ViewModel, which invalidates the remember keys in both sections and recomputes the shuffle with a fresh seed (timeBasedSeed + counter). Each pull produces a different selection of cards.

SearchShortcut Catalog

Defined in SearchShortcuts.kt, the catalog is a flat list of SearchShortcut entries:

data class SearchShortcut(
    val title: String,          // Display name
    val subtitle: String,       // Short description
    val searchQuery: String,    // FTS query to execute
    val priority: Int = 0,      // Controls visibility (>= 5 for Browse All)
    val discoverImageRes: Int?, // Optional tall image (Discover cards)
    val browseImageRes: Int?,   // Optional short image (Browse All cards)
)

Priority levels:

Priority Category Examples
10 Filters Top Rated, Popular, Soundboard, Audience
5 Venues, Cities, Songs Fillmore, Red Rocks, Dark Star, San Francisco
3 Eras Brent Era, Pigpen Era, Keith Era

Both Discover and Browse All draw from the same catalog. Discover picks 3 random items (any priority); Browse All shows all items with priority >= 5.

Search Filters (Planned)

  • Venue, Year, Location
  • Has Downloads, Recent, Popular
  • Soundboard vs Audience recordings

Implementation

Modules

API Module: v2:core:api:search - SearchService.kt - Service interface defining search contract - Defines all data models (SearchResultShow, SearchStatus, SearchMatchType, etc.) - Exposes reactive Flows for UI observation

Core Module: v2:core:search - SearchServiceImpl.kt - Real FTS5 implementation - SearchServiceStub.kt - Test data stub for UI development - SearchModule.kt - Hilt dependency injection configuration

Feature Module: v2:feature:search - SearchViewModel.kt - Presentation logic and state coordination - SearchScreen.kt - Main search UI - SearchResultsScreen.kt - Results display - SearchNavigation.kt - Navigation integration

State Management

Service Layer (SearchServiceImpl)

Exposes 6 reactive Flows:

val currentQuery: Flow<String>               // Currently active query
val searchResults: Flow<List<SearchResultShow>>  // Ranked results
val searchStatus: Flow<SearchStatus>         // IDLE, SEARCHING, SUCCESS, NO_RESULTS, ERROR
val recentSearches: Flow<List<RecentSearch>> // Recent queries
val suggestedSearches: Flow<List<SuggestedSearch>>  // Query suggestions
val searchStats: Flow<SearchStats>           // Result count, search duration

ViewModel Layer (SearchViewModel)

Coordinates between UI and service:

data class SearchUiState(
    val searchQuery: String = "",
    val searchResults: List<SearchResultShow> = emptyList(),
    val searchStatus: SearchStatus = SearchStatus.IDLE,
    val isLoading: Boolean = false,
    val error: String? = null,
    val recentSearches: List<RecentSearch> = emptyList(),
    val suggestedSearches: List<SuggestedSearch> = emptyList(),
    val searchStats: SearchStats = SearchStats(0, 0)
)

Responsibilities: - Debounce user input (800ms configurable delay) - Collect service Flows and transform to UI state - Handle user actions (query changes, clear, recent selection) - Manage lifecycle (cancel jobs on clear)

Data Sources

Primary: show_search_v2 FTS5 Table

Search uses the FTS5 virtual table which indexes searchable text:

CREATE VIRTUAL TABLE show_search_v2 USING fts5(
    show_id UNINDEXED,
    search_text,          -- Concatenated searchable fields
    tokenize='unicode61'
)

What's indexed (from shows_v2 table): - Song names (from setlist) - Venue name - City, state, country - Band member names (from lineup) - Date variations (YYYY-MM-DD, YYYY-MM, YYYY) - Source type tags: soundboard/sbd, audience/aud, matrix - Quality tags: top-rated (avg rating >= 4.0 with >= 10 reviews), popular (>= 50 total reviews)

The source type and quality tags are synthetic — they don't appear in the raw show text but are appended to searchText during import based on ShowImportData metadata fields (sourceTypes, avgRating, totalHighRatings, totalLowRatings). This allows Browse All categories like "Soundboard" and "Popular" to return results via normal FTS queries.

Search flow: 1. ShowSearchDao.searchShows(query) queries FTS5 table 2. Returns list of show_id strings in BM25 rank order 3. ShowRepository.getShowById() fetches full show entities 4. Mapper converts entities → domain models 5. Results returned to UI with ranking preserved

Secondary: Database Cache

Recent searches planned to be persisted to database (not yet implemented).

Integration Points

  • Entry Points: Bottom nav bar, home screen quick search
  • Exit Points: Tap search result → Show Detail screen
  • Deep Links: Can navigate directly to search with query parameter

Show Detail

Search results link to Show Detail screen: - Passes show_id via navigation arguments - Show Detail loads full show data + recordings

Player

From search results, users can: - Tap show → view details → select recording → start playback - Quick actions planned: Play best recording directly from search result

Home

Home screen can display: - Recent searches (quick access) - Search suggestions (if user has search history)

Code References

API Contract

  • Interface: androidApp/v2/core/api/search/src/main/java/com/deadly/v2/core/api/search/SearchService.kt
  • Models: Lines 113-124 (SearchFilter enum)

Core Implementation

  • Service: androidApp/v2/core/search/src/main/java/com/deadly/v2/core/search/SearchServiceImpl.kt
  • FTS5 search: Lines 66-68
  • Domain conversion: Lines 71-73
  • Result mapping: Lines 78-88
  • Match type detection: Lines 143-151

UI Layer

  • ViewModel: androidApp/v2/feature/search/src/main/java/com/deadly/v2/feature/search/screens/main/models/SearchViewModel.kt
  • Debounced search: Lines 89-109
  • Query change handling: Lines 79-84
  • Service flow observation: Lines 212-252

Database

  • FTS5 DAO: androidApp/v2/core/database/src/main/java/com/deadly/v2/core/database/dao/ShowSearchDao.kt
  • Entity: androidApp/v2/core/database/src/main/java/com/deadly/v2/core/database/entity/ShowSearchEntity.kt

Search Algorithm

Query Processing

  1. User types in search field
  2. Debounce waits 800ms for typing to stop
  3. ViewModel calls searchService.updateSearchQuery(query)
  4. Service updates currentQuery Flow (UI shows query immediately)
  5. Service sets status to SEARCHING (UI shows loading)

FTS5 Execution

  1. FTS5 query executes: SELECT show_id FROM show_search_v2 WHERE search_text MATCH ?
  2. BM25 ranking automatically applied by FTS5
  3. Show IDs returned in relevance order

Result Enrichment

  1. Batch lookup: For each show_id, fetch full ShowEntity from database
  2. Domain conversion: Entity → Domain Show model via repository
  3. Match type detection: Determine what matched (venue/year/location/general)
  4. Relevance scoring: Calculate score from FTS5 position (1.0 for #1, decreasing)

State Update

  1. Results Flow updated with List<SearchResultShow>
  2. Status Flow updated to SUCCESS or NO_RESULTS
  3. Stats Flow updated with count and duration
  4. UI automatically updates via Flow collection

Empty Query

Special case: If query is blank: - Clear results immediately (no FTS5 query) - Set status to IDLE - Clear stats

Performance

Search Speed

  • Typical: 50-100ms for queries returning 10-50 results
  • Fast path: FTS5 index scan is O(log n)
  • Slow path: Batch entity lookup is O(k) where k = result count

Optimizations

  • Debouncing: Reduces queries while user types (800ms delay)
  • Distinct queries: Skips duplicate searches
  • FTS5 indexing: Pre-computed index for instant matching
  • Preserved ranking: No re-sorting after FTS5 (ranking is correct)
  • Lazy loading: Only fetches show entities for matched IDs

Known Limitations

  • No pagination: All results returned at once (typically < 100 results)
  • No result limit: FTS5 can return thousands of matches (rare)
  • No caching: Each query executes fresh FTS5 search

Testing

Unit Tests

ViewModel Tests:

@Test
fun `debounced search executes after delay`() {
    // Given: ViewModel with mock service
    // When: User types query
    viewModel.onSearchQueryChanged("Cornell")
    // Then: Service not called immediately
    verify(searchService, never()).updateSearchQuery(any())
    // When: 800ms elapses
    advanceTimeBy(800)
    // Then: Service called with query
    verify(searchService).updateSearchQuery("Cornell")
}

Service Tests:

@Test
fun `search returns results ranked by relevance`() {
    // Given: Database with shows
    // When: Search for "Scarlet Begonias"
    val results = searchService.updateSearchQuery("Scarlet Begonias")
    // Then: Results ordered by FTS5 rank
    assert(results[0].relevanceScore > results[1].relevanceScore)
}

Integration Tests

FTS5 + Repository:

@Test
fun `search flow returns complete domain models`() {
    // Given: Database with indexed shows
    // When: Execute search
    val results = searchService.updateSearchQuery("Cornell 1977")
    // Then: Results contain full Show domain models
    assert(results[0].show.venue.name.contains("Cornell"))
    assert(results[0].show.date == "1977-05-08")
}

UI Tests

Search Screen:

@Test
fun `typing query shows debounced results`() {
    // Given: Search screen
    // When: Type query
    onNode(hasTestTag("searchField")).performTextInput("Dark Star")
    // Then: Results appear after delay
    advanceTimeBy(800)
    onNode(hasText("Dark Star")).assertExists()
}

Future Enhancements

Planned Features

  1. Search filters - Venue, year, location, recording type
  2. Search suggestions - Autocomplete based on partial query
  3. Recent search persistence - Save history to database
  4. Search analytics - Track popular queries
  5. Voice search - Speech-to-text query input

Performance Improvements

  1. Result pagination - Load results in chunks
  2. Query caching - Cache recent query results
  3. Prefetch - Load show details for top results

UX Enhancements

  1. Search tips - Show example queries on empty state
  2. No results suggestions - "Did you mean..." for typos
  3. Quick actions - Play/favorite directly from results
  4. Advanced syntax - Boolean operators, field-specific queries

Platform Notes

Android (Current)

  • FTS5 via Room database
  • Kotlin Coroutines for async
  • Hilt for dependency injection
  • Jetpack Compose UI

iOS (To Be Implemented)

  • FTS5 via native SQLite
  • Swift Concurrency (async/await)
  • Property injection or factory for DI
  • SwiftUI

Search functionality should be identical on both platforms. FTS5 is available on iOS via SQLite3 library.