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

@ -34,6 +34,46 @@ pub struct FolderRecord {
pub parent_id: Option<i64>,
}
/// One video's searchable fields, fed to [`Database::sync_search_index`].
/// `description_path` is read lazily — only when the video is new or its
/// `mtime_unix` changed since the last index — so a routine rescan doesn't
/// re-read every description sidecar.
#[derive(Clone, Debug)]
pub struct SearchEntry {
pub video_id: String,
pub mtime_unix: i64,
pub platform: String,
pub channel: String,
pub title: String,
pub description_path: Option<std::path::PathBuf>,
}
/// A full-text search result row from [`Database::search_videos`].
#[derive(Debug, Clone, serde::Serialize)]
pub struct SearchHit {
pub video_id: String,
pub platform: String,
pub channel: String,
pub title: String,
/// Description excerpt with the matched terms wrapped in `[`…`]`.
pub snippet: String,
}
/// Build a safe FTS5 MATCH expression from free-form user input: each
/// whitespace token becomes a quoted prefix term, AND-ed together. Quoting
/// neutralizes FTS5 operators in the input; the trailing `*` gives
/// type-ahead prefix matching. Returns "" when nothing is searchable.
fn fts_match_expr(query: &str) -> String {
query
.split_whitespace()
.map(|t| t.replace('"', " "))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.map(|t| format!("\"{t}\"*"))
.collect::<Vec<_>>()
.join(" ")
}
/// In-memory representation of the `video_flags` table. Each set holds the
/// video IDs that have the named flag enabled — kept small (a few hundred
/// to a few thousand entries in practice).
@ -185,6 +225,24 @@ impl Database {
body TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (target_kind, target_id)
);
-- Full-text search over the library. `video_search` is a standalone
-- FTS5 index (available in rusqlite's bundled SQLite); `search_meta`
-- tracks each indexed video's mtime so [`Database::sync_search_index`]
-- only re-reads a description sidecar when the video actually
-- changed. video_id/platform are UNINDEXED stored for retrieval,
-- not matched.
CREATE VIRTUAL TABLE IF NOT EXISTS video_search USING fts5(
video_id UNINDEXED,
platform UNINDEXED,
channel,
title,
description,
tokenize = 'porter unicode61'
);
CREATE TABLE IF NOT EXISTS search_meta (
video_id TEXT PRIMARY KEY,
mtime_unix INTEGER NOT NULL
);",
)?;
@ -887,6 +945,94 @@ impl Database {
let _ = conn.execute("DETACH DATABASE bk", []);
result
}
/// Refresh the full-text search index against the current library.
///
/// `entries` is the full set of videos currently on disk. A video whose
/// `mtime_unix` already matches the index is skipped; new/changed videos
/// get their description sidecar re-read and reindexed; videos that
/// vanished from disk are dropped. Returns how many rows were
/// (re)indexed (0 means the index was already current). Runs in one
/// transaction so a crash mid-sync can't leave the index half-written.
pub fn sync_search_index(&self, entries: &[SearchEntry]) -> Result<usize> {
let mut conn = self.conn();
let tx = conn.transaction()?;
// What's already indexed: video_id -> mtime.
let mut existing: HashMap<String, i64> = HashMap::new();
{
let mut stmt = tx.prepare("SELECT video_id, mtime_unix FROM search_meta")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows { let (id, m) = row?; existing.insert(id, m); }
}
let mut seen: HashSet<&str> = HashSet::with_capacity(entries.len());
let mut changed = 0usize;
for e in entries {
seen.insert(e.video_id.as_str());
if existing.get(&e.video_id) == Some(&e.mtime_unix) {
continue; // unchanged — leave the indexed row in place
}
// Only new/changed videos pay the description-read cost.
let description = e.description_path.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.unwrap_or_default();
tx.execute("DELETE FROM video_search WHERE video_id = ?1", [&e.video_id])?;
tx.execute(
"INSERT INTO video_search (video_id, platform, channel, title, description)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![e.video_id, e.platform, e.channel, e.title, description],
)?;
tx.execute(
"INSERT OR REPLACE INTO search_meta (video_id, mtime_unix) VALUES (?1, ?2)",
rusqlite::params![e.video_id, e.mtime_unix],
)?;
changed += 1;
}
// Evict videos that no longer exist on disk.
let stale: Vec<String> = existing.keys()
.filter(|id| !seen.contains(id.as_str()))
.cloned()
.collect();
for id in &stale {
tx.execute("DELETE FROM video_search WHERE video_id = ?1", [id])?;
tx.execute("DELETE FROM search_meta WHERE video_id = ?1", [id])?;
}
tx.commit()?;
Ok(changed)
}
/// Full-text search the library, newest-relevance first. Returns up to
/// `limit` hits, each with a highlighted description snippet. An empty or
/// punctuation-only query yields no results rather than an error.
pub fn search_videos(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
let match_expr = fts_match_expr(query);
if match_expr.is_empty() { return Ok(Vec::new()); }
let conn = self.conn();
let mut stmt = conn.prepare(
// STX/ETX (\u{2}/\u{3}) delimit matched terms — control chars
// that won't collide with literal '[' / ']' in a description.
// The UI turns them into highlight markup.
"SELECT video_id, platform, channel, title,
snippet(video_search, 4, char(2), char(3), '…', 12)
FROM video_search
WHERE video_search MATCH ?1
ORDER BY rank
LIMIT ?2",
)?;
let rows = stmt.query_map(rusqlite::params![match_expr, limit as i64], |r| {
Ok(SearchHit {
video_id: r.get(0)?,
platform: r.get(1)?,
channel: r.get(2)?,
title: r.get(3)?,
snippet: r.get(4)?,
})
})?;
rows.collect()
}
}
/// Per-table row counts that landed in the live DB during a restore.
@ -903,6 +1049,65 @@ pub struct RestoreSummary {
pub notes_added: u64,
}
#[cfg(test)]
mod search_tests {
use super::*;
fn entry(id: &str, mtime: i64, channel: &str, title: &str) -> SearchEntry {
SearchEntry {
video_id: id.into(), mtime_unix: mtime,
platform: "channels".into(), channel: channel.into(),
title: title.into(), description_path: None,
}
}
#[test]
fn indexes_searches_and_evicts() {
let db = Database::open_in_memory().unwrap();
let entries = vec![
entry("a", 1, "Rustaceans", "Async Rust deep dive"),
entry("b", 1, "Cooking", "Sourdough bread from scratch"),
];
assert_eq!(db.sync_search_index(&entries).unwrap(), 2);
// Title match.
let hits = db.search_videos("rust", 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].video_id, "a");
// Prefix (type-ahead) match.
assert_eq!(db.search_videos("sourd", 10).unwrap().len(), 1);
// The channel field is searched too.
let hits = db.search_videos("cooking", 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].video_id, "b");
// Multi-token is AND-ed.
assert_eq!(db.search_videos("async dive", 10).unwrap().len(), 1);
assert_eq!(db.search_videos("async sourdough", 10).unwrap().len(), 0);
// Re-syncing unchanged entries is a no-op.
assert_eq!(db.sync_search_index(&entries).unwrap(), 0);
// A changed mtime forces a reindex of just that row.
let changed = vec![
entry("a", 2, "Rustaceans", "Async Rust deep dive — updated"),
entry("b", 1, "Cooking", "Sourdough bread from scratch"),
];
assert_eq!(db.sync_search_index(&changed).unwrap(), 1);
assert_eq!(db.search_videos("updated", 10).unwrap().len(), 1);
// Dropping "b" from disk evicts it from the index.
assert_eq!(db.sync_search_index(&changed[..1]).unwrap(), 0);
assert_eq!(db.search_videos("sourdough", 10).unwrap().len(), 0);
// Garbage / empty queries return nothing, not an error.
assert!(db.search_videos("", 10).unwrap().is_empty());
assert!(db.search_videos(" \" ", 10).unwrap().is_empty());
}
}
#[cfg(test)]
mod restore_tests {
use super::*;