Library-wide full-text search (SQLite FTS5), in both UIs (3.x)

A new FTS5 index (`video_search`) over every video's title, channel, and
description — searchable across the whole library, not just the loaded
grid. `search_meta` tracks each video's mtime so a routine rescan only
re-reads a description sidecar when the video actually changed; vanished
videos are evicted. The index is refreshed after every scan in both
front-ends via the shared `library::build_search_entries`.

- database.rs: schema + `sync_search_index` (mtime-gated, one txn) +
  `search_videos` (ranked, with a highlighted snippet) + a safe prefix
  MATCH builder. Unit-tested (index/search/prefix/AND/evict/garbage).
- web.rs: `GET /api/search?q=&limit=`; index synced after the initial
  scan, rescan, and maintenance-remove.
- Web UI: a 🔍 header button + `f` shortcut open a debounced search modal
  with ranked results and highlighted description snippets; clicking a
  result jumps to it.
- Desktop (egui): a 🔎 Search button opens a floating results window
  querying the same index; clicking a result plays the video. Closes the
  desktop/web parity gap for search.

Integration test seeds a real video + description, rescans, and asserts
title / prefix / description-only matches hit and unrelated misses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-07 05:21:07 -07:00
parent 3031b5b0e5
commit a47c7991b4
6 changed files with 447 additions and 1 deletions

View file

@ -318,3 +318,36 @@ fn backup_db_returns_sqlite() {
assert_eq!(code, 200);
assert!(body.starts_with("SQLite format 3"), "backup is a SQLite file");
}
#[test]
fn full_text_search_indexes_titles_and_descriptions() {
if !have_curl() { eprintln!("skip: no curl"); return; }
let s = Server::start();
// Seed a real video on disk: <dir>/ch/channels/TestChan/<Title> [id].mkv
// plus a .description sidecar (the description word isn't in the title).
let chan = s.dir.join("ch/channels/TestChan");
std::fs::create_dir_all(&chan).unwrap();
std::fs::write(chan.join("Cooking Sourdough [vid123].mkv"), b"").unwrap();
std::fs::write(chan.join("Cooking Sourdough [vid123].description"),
b"an artisan bread tutorial").unwrap();
// Rescan so the server picks up the new file and reindexes it.
let (code, _) = s.post("/api/rescan", "");
assert_eq!(code, 200);
// Title term hits.
let (code, body) = s.get("/api/search?q=sourdough");
assert_eq!(code, 200, "{body}");
assert!(body.contains("vid123"), "title search should hit: {body}");
// Prefix / type-ahead hits.
assert!(s.get("/api/search?q=sourd").1.contains("vid123"), "prefix search should hit");
// A word only in the description hits (proves the sidecar got indexed).
assert!(s.get("/api/search?q=artisan").1.contains("vid123"), "description search should hit");
// An unrelated query does not.
assert!(!s.get("/api/search?q=quantumchromodynamics").1.contains("vid123"),
"unrelated query must not hit");
}