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

@ -684,3 +684,26 @@ fn track_from_path(path: &Path, folder_artist: &str) -> Option<Track> {
file_size: std::fs::metadata(path).ok().map(|m| m.len()),
})
}
/// Flatten a scanned library into [`crate::database::SearchEntry`] rows for
/// the full-text index — every video in every channel and playlist, tagged
/// with its platform + channel name and a pointer to its description sidecar.
/// Shared by both front-ends so the index is built identically.
pub fn build_search_entries(lib: &[Channel]) -> Vec<crate::database::SearchEntry> {
let mut out = Vec::new();
for ch in lib {
let platform = ch.platform.dir_name();
let playlist_videos = ch.playlists.iter().flat_map(|p| p.videos.iter());
for v in ch.videos.iter().chain(playlist_videos) {
out.push(crate::database::SearchEntry {
video_id: v.id.clone(),
mtime_unix: v.mtime_unix.map(|m| m as i64).unwrap_or(0),
platform: platform.to_string(),
channel: ch.name.clone(),
title: v.title.clone(),
description_path: v.description_path.clone(),
});
}
}
out
}