Files
yellowjacket/backend/database/search.go
T
yonlu cb5155b890 feat(15-01): migrate FTS5 search_index to contentless_delete=1
- Add contentless_delete=1 to search_index.sql schema file
- Update ClearSearchIndex CREATE statement to match schema
- Replace DeleteSearchIndex no-op with real DELETE WHERE rowid
- Add migration 8: drop/recreate/repopulate FTS5 table
- Update library.go comment about FTS entry lifecycle
2026-03-16 18:10:57 -04:00

467 lines
10 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)
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
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, " ")
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
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 {
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values are parameterized.
_, 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 single row from the FTS5 search_index
// by rowid. This is supported because the table uses
// contentless_delete=1. Phase 16 uses this for inline tag edits
// (delete old entry, reinsert with updated metadata).
func (d *DB) DeleteSearchIndex(rowid int64) error {
_, err := d.db.ExecContext(d.Ctx,
`DELETE FROM search_index WHERE rowid = ?`, rowid,
)
if err != nil {
return fmt.Errorf("could not delete search index entry: %w", err)
}
return nil
}
// ClearSearchIndex removes all rows from the FTS5 search_index.
// We drop and recreate it to ensure a clean slate for full rebuilds.
func (d *DB) ClearSearchIndex() error {
// SAFETY: Drop + recreate for full rebuild. No parameters.
if _, err := d.db.ExecContext(d.Ctx,
`DROP TABLE IF EXISTS search_index`,
); err != nil {
return fmt.Errorf("could not drop search_index: %w", err)
}
if _, err := d.db.ExecContext(d.Ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
file_path,
title,
artist,
album,
content='',
contentless_delete=1,
tokenize='unicode61 remove_diacritics 2'
)
`); err != nil {
return fmt.Errorf("could not recreate search_index: %w", err)
}
return nil
}
// 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,
)
}
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values sourced from track_metadata VIEW; no user input.
_, 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)
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
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
}
// SearchFTSTracksByLibrary performs a full-text search scoped to a
// specific library and returns full track metadata for each match.
func (d *DB) SearchFTSTracksByLibrary(
query string, limit int, libraryID int64,
) ([]SearchTrackRow, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
ftsQuery := buildFTSQuery(query)
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
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 ? AND tm.library_id = ?
ORDER BY rank
LIMIT ?
`, ftsQuery, libraryID, limit)
if err != nil {
return nil, fmt.Errorf(
"FTS library 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 library search track row: %w",
err,
)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"library 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
}