Authors are searchable (internal/search, migrations/0009) - The FTS index covered only title and path, so typing an artist or author found nothing even though people/item_people already held the credits. - Rather than a second index, the names are denormalised into a real media_items.authors column, so the existing external-content FTS and its triggers keep doing the work; item_people/people triggers recompute it, and the media_items UPDATE trigger re-indexes automatically. - Verified: searching "daft punk" now returns Random Access Memories, and so does the partial "punk". - The agent found and worked around a real FTS5 external-content bug: on an UPGRADE the backfill UPDATE fires the delete trigger for rowids not yet in the fresh index, raising "database disk image is malformed". A rebuild before the backfill fixes it. This would have broken existing databases while a fresh install looked fine. gzip for the SPA (internal/httpapi/static.go) - 456 KB of JS and 31 KB of CSS were served uncompressed on every cold load. - Scoped to the embedded SPA assets only, never API or media: compressing a Range response would break seeking, and wrapping every response would undo Wave 2's Flusher/ReaderFrom forwarding. - Compresses only when the client accepts gzip, the response is a compressible type, there is no Range header, and nothing already set Content-Encoding. Sets Vary: Accept-Encoding and drops the now-wrong Content-Length. - Verified by sha256 round-trip (identity vs gzip-decompressed are identical), and by confirming a Range request still returns 206 with no Content-Encoding. JS 351,835 -> 108,794 bytes (69% smaller); CSS 80% smaller. epub.js is code-split (Reader.svelte) - It was a static import, so ~350 KB was downloaded by everyone on first load even though most never open an ebook. Now loaded on demand. - Main bundle 458 KB -> 112 KB (gzipped 149 KB -> 39.5 KB). - Verified at runtime, which pnpm check cannot: only the main bundle loads initially, the epubjs chunk is fetched when a book is opened, and the reader still renders. Home screen tests (internal/media) - Coverage 23.4% -> 50.2%. (The 17.9% quoted in the brief was a stale reading; the agent measured the real baseline by moving its own files aside.) - Covers the five shelves, counts, Explore's invariants (visibility, no duplicates, every kind represented, varies across calls, wrap-around), the list offset/limit/total contract, MostViewed ordering, and — most importantly — grant scoping, which is the security chokepoint. Fixed a bug the tests found: Suggestions seeded from revoked libraries - topEngagement selected the user's most recent progress row with no library filter, so progress from a library whose grant had been revoked still steered the home screen — a visible item was labelled "More books" based on content the user could no longer read. Returned items were always correctly scoped, so this was an information-flow gap rather than a content leak. - Now scoped to visible libraries, and the test that pinned the old behaviour flipped to assert the correct reason. gofmt/vet clean, 14 packages pass, media tests run 3x for flakiness.
91 lines
2.7 KiB
Go
91 lines
2.7 KiB
Go
// Package search queries the FTS5 index over media item titles, paths, and
|
|
// credited people (authors/artists).
|
|
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"`
|
|
// Authors is the space-joined credit text denormalised onto media_items by
|
|
// migration 0009. It is exposed so a caller can show why a result matched.
|
|
Authors string `json:"authors"`
|
|
}
|
|
|
|
// 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)
|
|
|
|
// The MATCH predicate is unchanged: media_search now indexes `authors` too
|
|
// (migration 0009), so author terms match with no query-side change. The
|
|
// column is only selected so callers can display it.
|
|
query := `
|
|
SELECT i.id, i.library_id, i.kind, i.path, i.title, i.authors
|
|
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, &r.Authors); 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
|
|
}
|