// Package search queries the FTS5 index over media item titles and paths. package search import ( "context" "database/sql" "fmt" "strings" ) // Result is one matching item. type Result struct { ID int64 `json:"id"` LibraryID int64 `json:"library_id"` Kind string `json:"kind"` Path string `json:"path"` Title string `json:"title"` } // Query searches visible libraries for a free-text string. // // Each whitespace-separated term becomes a quoted FTS5 token joined by // implicit AND. Quoting is not cosmetic: raw user input containing a quote, a // hyphen, or the word OR would otherwise be parsed as FTS5 query syntax and // raise an error, or silently change the meaning of the search. func Query(ctx context.Context, db *sql.DB, q string, libraryIDs []int64, limit int) ([]Result, error) { terms := ftsTerms(q) if len(terms) == 0 { return []Result{}, nil } if len(libraryIDs) == 0 { return []Result{}, nil } if limit <= 0 || limit > 200 { limit = 50 } placeholders := make([]string, len(libraryIDs)) args := make([]any, 0, len(libraryIDs)+2) args = append(args, strings.Join(terms, " ")) for i, id := range libraryIDs { placeholders[i] = "?" args = append(args, id) } args = append(args, limit) query := ` SELECT i.id, i.library_id, i.kind, i.path, i.title FROM media_search s JOIN media_items i ON i.id = s.rowid WHERE media_search MATCH ? AND i.missing = 0 AND i.library_id IN (` + strings.Join(placeholders, ",") + `) ORDER BY bm25(media_search) LIMIT ?` rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("search: query: %w", err) } defer rows.Close() out := []Result{} for rows.Next() { var r Result if err := rows.Scan(&r.ID, &r.LibraryID, &r.Kind, &r.Path, &r.Title); err != nil { return nil, err } out = append(out, r) } return out, rows.Err() } // ftsTerms turns free text into a safe FTS5 expression: every term quoted, so // the result is always valid FTS5 and always means "all these words". func ftsTerms(q string) []string { var terms []string for _, field := range strings.Fields(q) { // A double quote inside a quoted FTS5 string is escaped by doubling it. escaped := strings.ReplaceAll(field, `"`, `""`) terms = append(terms, `"`+escaped+`"`) } return terms }