Files
yellowjacket/backend/database/search.go
T
yonlu 9159b409dc refactor(06-01): consolidate search queries to use track_metadata VIEW
- SearchFTS uses JOIN track_metadata instead of 5-table inline JOIN
- SearchFTSByFilename uses JOIN track_metadata instead of 5-table inline JOIN
- SearchFTSTracks uses JOIN track_metadata instead of 6-table inline JOIN
- RebuildSearchIndex selects from track_metadata instead of inline JOIN
- All 15 database tests pass with -race
2026-03-04 19:23:11 -05:00

351 lines
7.4 KiB
Go

// Package database provides SQLite database access.
package database
import (
"database/sql"
"fmt"
"strings"
)
// SearchRow holds a single result from an FTS5 or basename search.
type SearchRow struct {
FilePath string
LengthMilliseconds int64
Title string
Artist string
Album string
}
// SearchFTS performs a full-text search across title, artist, album,
// and file_path using the FTS5 search_index. The query string is
// tokenised by FTS5's unicode61 tokeniser.
func (d *DB) SearchFTS(
query string, limit int,
) ([]SearchRow, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
// Escape double quotes and wrap each token in quotes so
// special characters are treated as literals.
ftsQuery := buildFTSQuery(query)
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.album
FROM search_index si
JOIN track_metadata tm ON tm.id = si.rowid
WHERE search_index MATCH ?
ORDER BY rank
LIMIT ?
`, ftsQuery, limit)
if err != nil {
return nil, fmt.Errorf(
"FTS search failed: %w", err,
)
}
defer func() { _ = rows.Close() }()
return scanSearchRows(rows)
}
// SearchFTSByFilename searches the file_path column of the FTS5
// index for tokens extracted from the given basename.
func (d *DB) SearchFTSByFilename(
basename string, limit int,
) ([]SearchRow, error) {
basename = strings.TrimSpace(basename)
if basename == "" {
return nil, nil
}
// Strip extension and build an FTS query scoped to
// the file_path column.
stem := stripExtForSearch(basename)
tokens := tokeniseForFTS(stem)
if len(tokens) == 0 {
return nil, nil
}
ftsQuery := "file_path : " +
strings.Join(tokens, " ")
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.album
FROM search_index si
JOIN track_metadata tm ON tm.id = si.rowid
WHERE search_index MATCH ?
ORDER BY rank
LIMIT ?
`, ftsQuery, limit)
if err != nil {
return nil, fmt.Errorf(
"FTS filename search failed: %w", err,
)
}
defer func() { _ = rows.Close() }()
return scanSearchRows(rows)
}
// InsertSearchIndex adds a row to the FTS5 search_index.
func (d *DB) InsertSearchIndex(
rowid int64,
filePath, title, artist, album string,
) error {
_, err := d.db.ExecContext(d.Ctx, `
INSERT INTO search_index(rowid, file_path, title, artist, album)
VALUES (?, ?, ?, ?, ?)
`, rowid, filePath, title, artist, album)
return err
}
// DeleteSearchIndex removes a row from the FTS5 search_index.
func (d *DB) DeleteSearchIndex(rowid int64) error {
_, err := d.db.ExecContext(d.Ctx, `
DELETE FROM search_index WHERE rowid = ?
`, rowid)
return err
}
// ClearSearchIndex removes all rows from the FTS5 search_index.
func (d *DB) ClearSearchIndex() error {
_, err := d.db.ExecContext(d.Ctx, `
DELETE FROM search_index
`)
return err
}
// RebuildSearchIndex repopulates the FTS5 search_index from
// scratch using current audio_files + recordings data.
func (d *DB) RebuildSearchIndex() error {
if err := d.ClearSearchIndex(); err != nil {
return fmt.Errorf(
"could not clear search index: %w", err,
)
}
_, err := d.db.ExecContext(d.Ctx, `
INSERT INTO search_index(rowid, file_path, title, artist, album)
SELECT id, file_path, title, artist_name, album
FROM track_metadata
`)
if err != nil {
return fmt.Errorf(
"could not rebuild search index: %w", err,
)
}
return nil
}
// SearchTrackRow holds a full track result from an FTS5 search,
// matching all 16 columns returned by GetAllTracksWithFullMetadata.
type SearchTrackRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
}
// SearchFTSTracks performs a full-text search and returns full track
// metadata for each match. Unlike SearchFTS (which returns only 5
// columns), this includes all 16 fields needed for library.Track.
func (d *DB) SearchFTSTracks(
query string, limit int,
) ([]SearchTrackRow, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
ftsQuery := buildFTSQuery(query)
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.track_number,
tm.disc_number,
tm.album,
tm.genre,
tm.year,
tm.composer,
tm.file_type,
tm.sample_rate,
tm.bit_depth,
tm.channels,
tm.bitrate,
tm.file_size
FROM search_index si
JOIN track_metadata tm ON tm.id = si.rowid
WHERE search_index MATCH ?
ORDER BY rank
LIMIT ?
`, ftsQuery, limit)
if err != nil {
return nil, fmt.Errorf(
"FTS track search failed: %w", err,
)
}
defer func() { _ = rows.Close() }()
var results []SearchTrackRow
for rows.Next() {
var r SearchTrackRow
if err := rows.Scan(
&r.FilePath,
&r.LengthMilliseconds,
&r.Title,
&r.ArtistName,
&r.TrackNumber,
&r.DiscNumber,
&r.Album,
&r.Genre,
&r.Year,
&r.Composer,
&r.FileType,
&r.SampleRate,
&r.BitDepth,
&r.Channels,
&r.Bitrate,
&r.FileSize,
); err != nil {
return nil, fmt.Errorf(
"could not scan search track row: %w",
err,
)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"search track row iteration error: %w",
err,
)
}
return results, nil
}
// scanSearchRows reads all rows from a query result into a slice.
func scanSearchRows(
rows interface {
Next() bool
Scan(dest ...any) error
Err() error
},
) ([]SearchRow, error) {
var results []SearchRow
for rows.Next() {
var r SearchRow
if err := rows.Scan(
&r.FilePath,
&r.LengthMilliseconds,
&r.Title,
&r.Artist,
&r.Album,
); err != nil {
return nil, fmt.Errorf(
"could not scan search row: %w", err,
)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"search row iteration error: %w", err,
)
}
return results, nil
}
// buildFTSQuery converts a user query string into an FTS5 query.
// Each word is quoted to escape special characters and combined
// with implicit AND.
func buildFTSQuery(query string) string {
tokens := tokeniseForFTS(query)
if len(tokens) == 0 {
return query
}
return strings.Join(tokens, " ")
}
// tokeniseForFTS splits a string on whitespace and common
// separators, returning quoted FTS5 tokens.
func tokeniseForFTS(s string) []string {
// Split on whitespace, hyphens, underscores, dots.
fields := strings.FieldsFunc(
s, func(r rune) bool {
return r == ' ' || r == '-' ||
r == '_' || r == '.' ||
r == '/' || r == '\\'
},
)
tokens := make([]string, 0, len(fields))
for _, f := range fields {
f = strings.TrimSpace(f)
if f == "" {
continue
}
// Escape any double quotes inside the token.
f = strings.ReplaceAll(f, `"`, `""`)
tokens = append(tokens, `"`+f+`"`)
}
return tokens
}
// stripExtForSearch removes the file extension from a string.
func stripExtForSearch(s string) string {
if idx := strings.LastIndexByte(s, '.'); idx > 0 {
return s[:idx]
}
return s
}