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

@ -158,6 +158,11 @@ pub struct App {
// Bulk selection
bulk_mode: bool,
bulk_selected: HashSet<String>,
// Full-text search (floating results window)
search_query: String,
search_results: Vec<crate::database::SearchHit>,
show_search: bool,
search_focus: bool,
// Scheduler
last_scheduled_check: Option<Instant>,
// Cards cache — recomputed only when inputs change
@ -349,6 +354,9 @@ impl App {
if let Ok(folder_map) = db.get_all_channel_assignments() {
library::apply_channel_folders(&mut library, &folder_map);
}
if let Err(e) = db.sync_search_index(&library::build_search_entries(&library)) {
eprintln!("search index sync failed: {e}");
}
let folders = db.list_folders().unwrap_or_default();
let watched = db.get_watched().unwrap_or_default();
let flags = db.get_video_flags().unwrap_or_default();
@ -475,6 +483,10 @@ impl App {
mpv_rx: None,
bulk_mode: false,
bulk_selected: HashSet::new(),
search_query: String::new(),
search_results: Vec::new(),
show_search: false,
search_focus: false,
last_scheduled_check: None,
cards_cache: Vec::new(),
cards_cache_key: None,
@ -588,6 +600,9 @@ impl App {
if let Ok(folder_map) = self.db.get_all_channel_assignments() {
library::apply_channel_folders(&mut new_lib, &folder_map);
}
if let Err(e) = self.db.sync_search_index(&library::build_search_entries(&new_lib)) {
eprintln!("search index sync failed: {e}");
}
self.folders = self.db.list_folders().unwrap_or_default();
self.library = new_lib;
self.music_library = library::scan_music(&self.music_root);
@ -847,6 +862,78 @@ impl App {
}
}
/// Find a video by id anywhere in the library and play it. Used by the
/// full-text search results, where we only have the id.
fn play_by_id(&mut self, id: &str) {
let path = self.library.iter()
.flat_map(|c| c.videos.iter().chain(c.playlists.iter().flat_map(|p| p.videos.iter())))
.find(|v| v.id == id)
.and_then(|v| v.video_path.clone());
match path {
Some(p) => { self.selected_video = Some(id.to_string()); self.play_with_tracking(&p, id.to_string()); }
None => self.status = format!("'{id}' has no playable file on disk"),
}
}
/// Floating full-text search window. Mirrors the web UI's 🔍 search —
/// queries the same FTS index (`db.search_videos`) and plays a result on
/// click. Distinct from the top-bar `self.search` filter, which only
/// narrows the already-loaded grid by title/id.
fn search_window(&mut self, ctx: &egui::Context) {
if !self.show_search { return; }
let mut open = true;
let mut to_play: Option<String> = None;
egui::Window::new("🔎 Search library")
.open(&mut open)
.default_width(560.0)
.show(ctx, |ui| {
let resp = ui.add(
egui::TextEdit::singleline(&mut self.search_query)
.hint_text("titles, channels, descriptions…")
.desired_width(f32::INFINITY),
);
if std::mem::take(&mut self.search_focus) {
resp.request_focus();
}
if resp.changed() {
let q = self.search_query.trim();
self.search_results =
self.db.search_videos(q, 100).unwrap_or_default();
}
ui.separator();
if self.search_query.trim().is_empty() {
ui.weak("Type to search every title, channel, and description in the library.");
} else if self.search_results.is_empty() {
ui.weak("No matches.");
} else {
ui.weak(format!("{} result(s)", self.search_results.len()));
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
for hit in &self.search_results {
ui.add_space(4.0);
let title = ui.add(
egui::Label::new(egui::RichText::new(&hit.title).strong())
.sense(egui::Sense::click()),
);
if title.clicked() { to_play = Some(hit.video_id.clone()); }
if title.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
ui.weak(format!("{} · {}", hit.channel, hit.platform));
if !hit.snippet.is_empty() {
// Strip the STX/ETX match markers egui can't style.
let clean: String = hit.snippet
.chars().filter(|c| *c != '\u{2}' && *c != '\u{3}').collect();
ui.label(egui::RichText::new(clean).small().weak());
}
ui.separator();
}
});
}
});
if !open { self.show_search = false; }
if let Some(id) = to_play { self.play_by_id(&id); }
}
fn play_with_tracking(&mut self, path: &Path, video_id: String) {
let cmd = self.config.player.command.clone();
// Only enable IPC for genuine mpv invocations — substring matching
@ -1092,6 +1179,13 @@ impl App {
.step_by(0.1),
);
ui.separator();
if ui.button("🔎 Search")
.on_hover_text("Full-text search titles + descriptions across the whole library")
.clicked()
{
self.show_search = true;
self.search_focus = true;
}
if ui.button("⟳ Rescan").clicked() {
self.rescan();
}
@ -3846,6 +3940,7 @@ impl eframe::App for App {
self.channel_options_window(ctx);
self.folder_manager_window(ctx);
self.move_to_folder_window(ctx);
self.search_window(ctx);
match self.current_screen {
Screen::Library => {