Files
yellowjacket/PLAN-fts-search-and-genre-query.md
T

12 KiB

Plan: Track List FTS Search (#1) & Genre Details Query (#4)

Goal

When the user types in the track list search bar, delegate to the backend FTS5 index instead of filtering all tracks in-memory in JavaScript. Backend-only search with debounce. FTS5 index stays as-is (title, artist, album, file_path — no expansion).

Current flow

  1. All tracks fetched once via Library.GetAllTracks() → cached in libraryStore
  2. On each keystroke, computeFilteredTracks() in track-list.ts runs toLowerCase().includes(term) across every track's active columns
  3. Virtual scrolling renders only visible rows

Proposed flow

  1. All tracks still fetched and cached (needed for empty-search display, sorting, column rendering)
  2. When search term is non-empty, call new backend method Library.SearchTracks(query) which uses FTS5 internally
  3. Backend returns []library.Track (same 16-field type as GetAllTracks)
  4. Frontend uses these results directly instead of client-side filtering
  5. Frontend debounces the backend call (~200-250ms) to avoid excessive round-trips on fast typing

Backend changes

1. backend/database/search.go — New method SearchFTSTracks

Add SearchFTSTracks(query string, limit int) method on *DB.

  • Uses buildFTSQuery(query) to tokenise the user input
  • Runs FTS5 MATCH against search_index
  • JOINs to all the same tables as GetAllTracksWithFullMetadata: audio_files, recordings, artist_credit, release_group_recordings, release_groups, file_types
  • Includes the GROUP_CONCAT subquery for genres
  • Returns all 16 columns needed for library.Track
  • Returns a new SearchTrackRow struct (or reuse generated types if practical)

Query shape:

SELECT
    af.file_path,
    af.length_milliseconds,
    COALESCE(r.name, '')              AS title,
    COALESCE(ac.text, '')             AS artist_name,
    r.track_number,
    r.disc_number,
    COALESCE(rg.name, '')             AS album,
    CAST(COALESCE(
        (SELECT GROUP_CONCAT(g.name, '||')
         FROM recording_genres rg_sub
         JOIN genres g ON rg_sub.genre_id = g.id
         WHERE rg_sub.recording_id = r.id),
        ''
    ) AS TEXT)                        AS genre,
    COALESCE(r.year, 0)               AS year,
    COALESCE(r.composer, '')           AS composer,
    COALESCE(ft.extension, '')         AS file_type,
    af.sample_rate,
    af.bit_depth,
    af.channels,
    af.bitrate,
    af.file_size
FROM search_index si
JOIN audio_files af        ON af.id = si.rowid
JOIN recordings r          ON af.recording_id = r.id
JOIN artist_credit ac      ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg            ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft                ON af.file_type_id = ft.id
WHERE search_index MATCH ?
ORDER BY rank
LIMIT ?

Define a SearchTrackRow struct with all 16 fields (using sql.NullInt64 for track_number, disc_number, year; sql.NullString for composer).

2. backend/library/query.go — New Wails-bound method SearchTracks

func (l *Library) SearchTracks(query string) ([]Track, error)
  • Calls l.db.SearchFTSTracks(query, 200) (cap at 200 results)
  • Maps each SearchTrackRow to library.Track using the same logic as GetAllTracks (splitGenres, NullInt64 unwrap, etc.)
  • Reuse or extract common row-mapping into a shared helper to avoid duplication with GetAllTracks

Frontend changes

3. frontend/src/store/library-store.ts — Add search method + state

Add to LibraryStore:

  • async searchTracks(query: string): Promise<library.Track[]> — calls the Wails-bound Library.SearchTracks(query) and returns results
  • Clear any cached search results on invalidate() (library scan)

Changes to the search flow:

  • Remove computeFilteredTracks() (the in-memory filter)
  • Add @state() private searchResults: library.Track[] | null = null
  • Add @state() private searchLoading = false
  • Add a debounced method debouncedSearch(term: string) (~200ms) that:
    • If term is empty → sets searchResults = null (show all tracks)
    • Otherwise → calls libraryStore.searchTracks(term), stores results in searchResults
  • In recomputeTrackCaches() (or willUpdate): if searchResults is non-null, use it as the filtered track set; otherwise use this.tracks
  • Trigger debouncedSearch from the SearchController when the term changes
  • The sort step (computeSortedTracks) still runs on the filtered set

5. Wails bindings — Auto-regenerated

After adding the Go method, run wails generate (or make dev / build) to regenerate frontend/wailsjs/go/library/Library.js and .d.ts.


Feature #4: Genre Details Query

Goal

Replace the fetch-all-then-filter pattern in genre-details.ts with a dedicated SQL query. Also add a GetAllGenresWithCounts query to eliminate the other fetch-all-tracks dependency in genres-view.ts.

Current flow (genre details)

  1. genre-details.ts calls libraryCtrl.getTracks() → fetches ALL tracks
  2. Filters in JS: tracks.filter(t => t.Genre.includes(genreName))

Proposed flow (genre details)

  1. genre-details.ts calls new Library.GetTracksByGenre(genreName)
  2. Backend runs a JOIN query filtered by genre name
  3. Returns []library.Track — same 16-field type

Current flow (genre list)

  1. genres-view.ts calls libraryCtrl.getTracks() → fetches ALL tracks
  2. extractGenres() iterates every track, counts genre occurrences, returns sorted Genre[]

Proposed flow (genre list)

  1. genres-view.ts calls new Library.GetAllGenresWithCounts()
  2. Backend runs a simple GROUP BY query
  3. Returns []GenreWithCount (name + track count)

Backend changes

6. backend/database/sql/queries/genres.sql — Two new sqlc queries

Query 1: GetTracksByGenre

-- name: GetTracksByGenre :many
SELECT
    af.file_path,
    af.length_milliseconds,
    COALESCE(r.name, '')              AS title,
    COALESCE(ac.text, '')             AS artist_name,
    r.track_number,
    r.disc_number,
    COALESCE(rg.name, '')             AS album,
    CAST(COALESCE(
        (SELECT GROUP_CONCAT(g2.name, '||')
         FROM recording_genres rg2
         JOIN genres g2 ON rg2.genre_id = g2.id
         WHERE rg2.recording_id = r.id),
        ''
    ) AS TEXT)                        AS genre,
    COALESCE(r.year, 0)               AS year,
    COALESCE(r.composer, '')           AS composer,
    COALESCE(ft.extension, '')         AS file_type,
    af.sample_rate,
    af.bit_depth,
    af.channels,
    af.bitrate,
    af.file_size
FROM genres g
JOIN recording_genres rg       ON g.id = rg.genre_id
JOIN recordings r              ON rg.recording_id = r.id
JOIN audio_files af            ON af.recording_id = r.id
JOIN artist_credit ac          ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg           ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft                ON af.file_type_id = ft.id
WHERE g.name = ?
ORDER BY r.name;

Uses idx_recording_genres_genre_id for the initial genre lookup.

Query 2: GetAllGenresWithCounts

-- name: GetAllGenresWithCounts :many
SELECT g.name, COUNT(rg.recording_id) AS track_count
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
GROUP BY g.id, g.name
ORDER BY g.name;

7. backend/library/query.go — Two new Wails-bound methods

Method 1: GetTracksByGenre

func (l *Library) GetTracksByGenre(genreName string) ([]Track, error)
  • Calls the sqlc-generated l.db.Queries.GetTracksByGenre(ctx, genreName)
  • Maps rows to []Track using the same row-mapping helper as GetAllTracks and SearchTracks

Method 2: GetAllGenresWithCounts

type GenreWithCount struct {
    Name       string `json:"Name"`
    TrackCount int64  `json:"TrackCount"`
}

func (l *Library) GetAllGenresWithCounts() ([]GenreWithCount, error)
  • Calls the sqlc-generated l.db.Queries.GetAllGenresWithCounts(ctx)
  • Maps rows to []GenreWithCount

8. Run make generate to regenerate sqlc output

After adding the queries to genres.sql, run make generate to produce the Go types and query methods in backend/database/sql/sqlcgen/.

Frontend changes

9. frontend/src/components/genre-details/genre-details.ts — Use new endpoint

Replace loadTracks():

private async loadTracks() {
    if (!this.genreName) return;
    try {
        this.tracks = await GetTracksByGenre(this.genreName);
    } catch (error) {
        console.error('Error loading genre tracks:', error);
        this.tracks = [];
    } finally {
        this.loading = false;
    }
}
  • Import GetTracksByGenre from @go/library/Library
  • Remove libraryCtrl.getTracks() call and in-memory filter
  • Remove the lastTracksRef cache-invalidation pattern (no longer needed — each call fetches fresh data for the specific genre)
  • Still listen for LibraryScanComplete to re-trigger loadTracks() if the genre details view is open during a rescan

10. frontend/src/components/genres-view/genres-view.ts — Use new endpoint

Replace loadGenres():

  • Call Library.GetAllGenresWithCounts() instead of fetching all tracks
  • Map results directly to the local Genre[] array (name + trackCount)
  • Remove extractGenres() method
  • Remove this.allTracks state (no longer needed for genre extraction)
  • Note: allTracks may still be needed for other purposes in the component — check if it's used elsewhere (e.g. for passing to genre-details). If genre-details fetches its own tracks, this dependency chain can be fully removed.

11. Wails bindings — Auto-regenerated

Run wails generate to produce the new TypeScript bindings for GetTracksByGenre, GetAllGenresWithCounts, and SearchTracks.


Shared refactoring: Row-mapping helper

GetAllTracks, SearchTracks, and GetTracksByGenre all map database rows with the same 16 columns into library.Track. Currently this logic lives inline in GetAllTracks. Extract it into a shared helper:

func mapTrackRow(
    filePath string,
    lengthMs int64,
    title, artistName string,
    trackNumber, discNumber sql.NullInt64,
    album, genre string,
    year sql.NullInt64,
    composer, fileType string,
    sampleRate, bitDepth, channels, bitrate, fileSize int64,
) Track

This avoids tripling the row-mapping code across three methods.


Implementation order

  1. Backend: extract row-mapping helper in query.go
  2. Backend: add SearchFTSTracks to search.go + SearchTracks to query.go
  3. Backend: add sqlc queries to genres.sql + make generate
  4. Backend: add GetTracksByGenre + GetAllGenresWithCounts to query.go
  5. Verify: make lint && make test
  6. Frontend: update genre-details.ts to use GetTracksByGenre
  7. Frontend: update genres-view.ts to use GetAllGenresWithCounts
  8. Frontend: update library-store.ts with searchTracks method
  9. Frontend: update track-list.ts with debounced backend search
  10. Verify: pnpm exec tsc --noEmit
  11. Full verify: make lint && make test

Files touched (summary)

File Action
backend/database/search.go Add SearchFTSTracks, SearchTrackRow
backend/library/query.go Add SearchTracks, GetTracksByGenre, GetAllGenresWithCounts, GenreWithCount, extract mapTrackRow helper
backend/database/sql/queries/genres.sql Add GetTracksByGenre, GetAllGenresWithCounts
backend/database/sql/sqlcgen/* Regenerated via make generate
frontend/src/store/library-store.ts Add searchTracks method
frontend/src/components/track-list/track-list.ts Replace in-memory filter with debounced backend FTS search
frontend/src/components/genre-details/genre-details.ts Replace fetch-all-then-filter with GetTracksByGenre
frontend/src/components/genres-view/genres-view.ts Replace extractGenres with GetAllGenresWithCounts
frontend/wailsjs/go/library/Library.js + .d.ts Auto-regenerated