Index transcripts into library search (FTS), both UIs
The 🔍 library search now matches spoken words, not just titles /
channels / descriptions.
- database.rs: add a `transcript` column to the `video_search` FTS5 index.
Fresh DBs get it directly; existing ones are migrated (FTS5 has no ADD
COLUMN, so the table is recreated and search_meta cleared to force a
one-time reindex). `sync_search_index` reads each video's first subtitle
(mtime-gated like the description), flattens it via the shared `vtt`
parser (`transcript_text`, capped at 256 KB), and indexes it. Search
snippets now use column -1 so the excerpt comes from whichever column
matched (description or transcript).
- library.rs: `build_search_entries` passes the first subtitle path.
- Both UIs: search copy updated to mention transcripts.
Tests: a unit test (a word only in the .vtt is found), a migration test
(seed an old 5-column index + stale meta, open, confirm the recreated
index indexes transcript text), and the search integration test now seeds
a subtitle and asserts a spoken-only word hits. Also bumped the test
server's startup budget so the ffmpeg-heavy dedup test can't starve a
sibling server into a flaky timeout. 120 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4136a9468b
commit
e840d55332
5 changed files with 132 additions and 18 deletions
|
|
@ -994,7 +994,7 @@ impl App {
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
let resp = ui.add(
|
let resp = ui.add(
|
||||||
egui::TextEdit::singleline(&mut self.search_query)
|
egui::TextEdit::singleline(&mut self.search_query)
|
||||||
.hint_text("titles, channels, descriptions…")
|
.hint_text("titles, channels, descriptions, transcripts…")
|
||||||
.desired_width(f32::INFINITY),
|
.desired_width(f32::INFINITY),
|
||||||
);
|
);
|
||||||
if std::mem::take(&mut self.search_focus) {
|
if std::mem::take(&mut self.search_focus) {
|
||||||
|
|
@ -1007,7 +1007,7 @@ impl App {
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if self.search_query.trim().is_empty() {
|
if self.search_query.trim().is_empty() {
|
||||||
ui.weak("Type to search every title, channel, and description in the library.");
|
ui.weak("Type to search every title, channel, description, and transcript in the library.");
|
||||||
} else if self.search_results.is_empty() {
|
} else if self.search_results.is_empty() {
|
||||||
ui.weak("No matches.");
|
ui.weak("No matches.");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1377,7 +1377,7 @@ impl App {
|
||||||
);
|
);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui.button("🔎 Search")
|
if ui.button("🔎 Search")
|
||||||
.on_hover_text("Full-text search titles + descriptions across the whole library")
|
.on_hover_text("Full-text search titles, descriptions + transcripts across the whole library")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
self.show_search = true;
|
self.show_search = true;
|
||||||
|
|
|
||||||
127
src/database.rs
127
src/database.rs
|
|
@ -46,6 +46,9 @@ pub struct SearchEntry {
|
||||||
pub channel: String,
|
pub channel: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description_path: Option<std::path::PathBuf>,
|
pub description_path: Option<std::path::PathBuf>,
|
||||||
|
/// First subtitle file, if any. Its parsed text is indexed so search
|
||||||
|
/// matches spoken words. Read lazily, only for new/changed videos.
|
||||||
|
pub subtitle_path: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A full-text search result row from [`Database::search_videos`].
|
/// A full-text search result row from [`Database::search_videos`].
|
||||||
|
|
@ -68,6 +71,20 @@ pub struct StoredFingerprint {
|
||||||
pub hashes: Vec<u64>,
|
pub hashes: Vec<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Flatten a subtitle file's cues into one searchable text blob, capped so a
|
||||||
|
/// very long video can't bloat the index unboundedly. Uses the shared
|
||||||
|
/// VTT/SRT parser, so styling tags and duplicate rolling lines are stripped.
|
||||||
|
fn transcript_text(vtt: &str) -> String {
|
||||||
|
const CAP: usize = 256 * 1024;
|
||||||
|
let mut out = String::new();
|
||||||
|
for cue in crate::vtt::parse(vtt) {
|
||||||
|
if out.len() + cue.text.len() + 1 > CAP { break; }
|
||||||
|
if !out.is_empty() { out.push(' '); }
|
||||||
|
out.push_str(&cue.text);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a safe FTS5 MATCH expression from free-form user input: each
|
/// Build a safe FTS5 MATCH expression from free-form user input: each
|
||||||
/// whitespace token becomes a quoted prefix term, AND-ed together. Quoting
|
/// whitespace token becomes a quoted prefix term, AND-ed together. Quoting
|
||||||
/// neutralizes FTS5 operators in the input; the trailing `*` gives
|
/// neutralizes FTS5 operators in the input; the trailing `*` gives
|
||||||
|
|
@ -238,15 +255,17 @@ impl Database {
|
||||||
-- Full-text search over the library. `video_search` is a standalone
|
-- Full-text search over the library. `video_search` is a standalone
|
||||||
-- FTS5 index (available in rusqlite's bundled SQLite); `search_meta`
|
-- FTS5 index (available in rusqlite's bundled SQLite); `search_meta`
|
||||||
-- tracks each indexed video's mtime so [`Database::sync_search_index`]
|
-- tracks each indexed video's mtime so [`Database::sync_search_index`]
|
||||||
-- only re-reads a description sidecar when the video actually
|
-- only re-reads a video's sidecars when it actually changed.
|
||||||
-- changed. video_id/platform are UNINDEXED — stored for retrieval,
|
-- video_id/platform are UNINDEXED — stored for retrieval, not
|
||||||
-- not matched.
|
-- matched. `transcript` holds the video's subtitle text so search
|
||||||
|
-- matches spoken words too.
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS video_search USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS video_search USING fts5(
|
||||||
video_id UNINDEXED,
|
video_id UNINDEXED,
|
||||||
platform UNINDEXED,
|
platform UNINDEXED,
|
||||||
channel,
|
channel,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
transcript,
|
||||||
tokenize = 'porter unicode61'
|
tokenize = 'porter unicode61'
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS search_meta (
|
CREATE TABLE IF NOT EXISTS search_meta (
|
||||||
|
|
@ -278,6 +297,28 @@ impl Database {
|
||||||
if msg.contains("duplicate column") => {}
|
if msg.contains("duplicate column") => {}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Migration: video_search.transcript column ────────────────────
|
||||||
|
// FTS5 has no ADD COLUMN, so when the index predates the transcript
|
||||||
|
// column we recreate the table and clear search_meta to force a
|
||||||
|
// one-time reindex (which pulls in subtitle text). The index is a
|
||||||
|
// derived cache, so dropping it loses nothing permanent.
|
||||||
|
let has_transcript = {
|
||||||
|
let mut stmt = conn.prepare("PRAGMA table_info(video_search)")?;
|
||||||
|
let cols = stmt.query_map([], |r| r.get::<_, String>(1))?;
|
||||||
|
let mut found = false;
|
||||||
|
for c in cols { if c? == "transcript" { found = true; } }
|
||||||
|
found
|
||||||
|
};
|
||||||
|
if !has_transcript {
|
||||||
|
conn.execute_batch(
|
||||||
|
"DROP TABLE IF EXISTS video_search;
|
||||||
|
CREATE VIRTUAL TABLE video_search USING fts5(
|
||||||
|
video_id UNINDEXED, platform UNINDEXED, channel, title,
|
||||||
|
description, transcript, tokenize = 'porter unicode61');
|
||||||
|
DELETE FROM search_meta;",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -993,15 +1034,19 @@ impl Database {
|
||||||
if existing.get(&e.video_id) == Some(&e.mtime_unix) {
|
if existing.get(&e.video_id) == Some(&e.mtime_unix) {
|
||||||
continue; // unchanged — leave the indexed row in place
|
continue; // unchanged — leave the indexed row in place
|
||||||
}
|
}
|
||||||
// Only new/changed videos pay the description-read cost.
|
// Only new/changed videos pay the file-read cost.
|
||||||
let description = e.description_path.as_ref()
|
let description = e.description_path.as_ref()
|
||||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let transcript = e.subtitle_path.as_ref()
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.map(|vtt| transcript_text(&vtt))
|
||||||
|
.unwrap_or_default();
|
||||||
tx.execute("DELETE FROM video_search WHERE video_id = ?1", [&e.video_id])?;
|
tx.execute("DELETE FROM video_search WHERE video_id = ?1", [&e.video_id])?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO video_search (video_id, platform, channel, title, description)
|
"INSERT INTO video_search (video_id, platform, channel, title, description, transcript)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
rusqlite::params![e.video_id, e.platform, e.channel, e.title, description],
|
rusqlite::params![e.video_id, e.platform, e.channel, e.title, description, transcript],
|
||||||
)?;
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT OR REPLACE INTO search_meta (video_id, mtime_unix) VALUES (?1, ?2)",
|
"INSERT OR REPLACE INTO search_meta (video_id, mtime_unix) VALUES (?1, ?2)",
|
||||||
|
|
@ -1033,10 +1078,12 @@ impl Database {
|
||||||
let conn = self.conn();
|
let conn = self.conn();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
// STX/ETX (\u{2}/\u{3}) delimit matched terms — control chars
|
// STX/ETX (\u{2}/\u{3}) delimit matched terms — control chars
|
||||||
// that won't collide with literal '[' / ']' in a description.
|
// that won't collide with literal '[' / ']' in the text. Column
|
||||||
// The UI turns them into highlight markup.
|
// -1 lets FTS5 pull the snippet from whichever column matched
|
||||||
|
// (description or transcript). The UI turns the markers into
|
||||||
|
// highlight markup.
|
||||||
"SELECT video_id, platform, channel, title,
|
"SELECT video_id, platform, channel, title,
|
||||||
snippet(video_search, 4, char(2), char(3), '…', 12)
|
snippet(video_search, -1, char(2), char(3), '…', 12)
|
||||||
FROM video_search
|
FROM video_search
|
||||||
WHERE video_search MATCH ?1
|
WHERE video_search MATCH ?1
|
||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
|
|
@ -1145,7 +1192,7 @@ mod search_tests {
|
||||||
SearchEntry {
|
SearchEntry {
|
||||||
video_id: id.into(), mtime_unix: mtime,
|
video_id: id.into(), mtime_unix: mtime,
|
||||||
platform: "channels".into(), channel: channel.into(),
|
platform: "channels".into(), channel: channel.into(),
|
||||||
title: title.into(), description_path: None,
|
title: title.into(), description_path: None, subtitle_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1194,6 +1241,64 @@ mod search_tests {
|
||||||
assert!(db.search_videos("", 10).unwrap().is_empty());
|
assert!(db.search_videos("", 10).unwrap().is_empty());
|
||||||
assert!(db.search_videos(" \" ", 10).unwrap().is_empty());
|
assert!(db.search_videos(" \" ", 10).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrates_pre_transcript_index() {
|
||||||
|
// Seed a file DB with the OLD 5-column FTS table (no transcript) and a
|
||||||
|
// stale search_meta row, then open it — init_schema must recreate the
|
||||||
|
// table with a transcript column and clear search_meta.
|
||||||
|
let path = std::env::temp_dir().join(format!("ytoff-mig-{}.db", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE VIRTUAL TABLE video_search USING fts5(
|
||||||
|
video_id UNINDEXED, platform UNINDEXED, channel, title, description,
|
||||||
|
tokenize='porter unicode61');
|
||||||
|
CREATE TABLE search_meta(video_id TEXT PRIMARY KEY, mtime_unix INTEGER NOT NULL);
|
||||||
|
INSERT INTO search_meta VALUES('stale', 1);",
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
let db = Database::open(&path).unwrap(); // runs the migration
|
||||||
|
|
||||||
|
// If the table were still 5-column, the 6-column INSERT in
|
||||||
|
// sync_search_index would error — so a transcript hit proves migration.
|
||||||
|
let dir = std::env::temp_dir().join(format!("ytoff-mig-vtt-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let vtt = dir.join("s.vtt");
|
||||||
|
std::fs::write(&vtt, "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nmagnificent aardvark\n").unwrap();
|
||||||
|
let e = SearchEntry {
|
||||||
|
video_id: "v".into(), mtime_unix: 5, platform: "channels".into(),
|
||||||
|
channel: "c".into(), title: "t".into(), description_path: None,
|
||||||
|
subtitle_path: Some(vtt),
|
||||||
|
};
|
||||||
|
db.sync_search_index(&[e]).unwrap();
|
||||||
|
assert_eq!(db.search_videos("aardvark", 10).unwrap().len(), 1);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indexes_transcript_text() {
|
||||||
|
let db = Database::open_in_memory().unwrap();
|
||||||
|
let dir = std::env::temp_dir().join(format!("ytoff-tr-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let vtt = dir.join("a.en.vtt");
|
||||||
|
std::fs::write(&vtt, "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nthe quick brown fox\n").unwrap();
|
||||||
|
let e = SearchEntry {
|
||||||
|
video_id: "a".into(), mtime_unix: 1, platform: "channels".into(),
|
||||||
|
channel: "Chan".into(), title: "Unrelated Title".into(),
|
||||||
|
description_path: None, subtitle_path: Some(vtt),
|
||||||
|
};
|
||||||
|
assert_eq!(db.sync_search_index(&[e]).unwrap(), 1);
|
||||||
|
// A word only spoken in the transcript still matches.
|
||||||
|
assert_eq!(db.search_videos("brown", 10).unwrap().len(), 1);
|
||||||
|
assert_eq!(db.search_videos("fox", 10).unwrap()[0].video_id, "a");
|
||||||
|
// A word in nothing misses.
|
||||||
|
assert!(db.search_videos("zebra", 10).unwrap().is_empty());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -702,6 +702,7 @@ pub fn build_search_entries(lib: &[Channel]) -> Vec<crate::database::SearchEntry
|
||||||
channel: ch.name.clone(),
|
channel: ch.name.clone(),
|
||||||
title: v.title.clone(),
|
title: v.title.clone(),
|
||||||
description_path: v.description_path.clone(),
|
description_path: v.description_path.clone(),
|
||||||
|
subtitle_path: v.subtitles.first().map(|s| s.path.clone()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@
|
||||||
<option value="size-desc">Largest</option>
|
<option value="size-desc">Largest</option>
|
||||||
</select>
|
</select>
|
||||||
<span id="hdr-stats"></span>
|
<span id="hdr-stats"></span>
|
||||||
<button onclick="openSearch()" title="Search titles + descriptions (f)">🔍</button>
|
<button onclick="openSearch()" title="Search titles, descriptions & transcripts (f)">🔍</button>
|
||||||
<button onclick="shufflePlay()" title="Play a random unwatched video">🎲</button>
|
<button onclick="shufflePlay()" title="Play a random unwatched video">🎲</button>
|
||||||
<button onclick="rescan()" title="Rescan library">⟳</button>
|
<button onclick="rescan()" title="Rescan library">⟳</button>
|
||||||
<button id="dl-btn" onclick="openDownloads()" title="Downloads">⬇<span id="dl-badge">0</span></button>
|
<button id="dl-btn" onclick="openDownloads()" title="Downloads">⬇<span id="dl-badge">0</span></button>
|
||||||
|
|
@ -1516,7 +1516,7 @@ function openSearch(){
|
||||||
bg.onclick=e=>{if(e.target===bg)bg.remove()};
|
bg.onclick=e=>{if(e.target===bg)bg.remove()};
|
||||||
bg.innerHTML=`<div class="modal" style="max-width:720px;width:100%">
|
bg.innerHTML=`<div class="modal" style="max-width:720px;width:100%">
|
||||||
<div class="modal-hdr"><h2>🔍 Search library</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
<div class="modal-hdr"><h2>🔍 Search library</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
||||||
<input id="fts-input" type="search" placeholder="Search titles, channels, descriptions…" autocomplete="off"
|
<input id="fts-input" type="search" placeholder="Search titles, channels, descriptions, transcripts…" autocomplete="off"
|
||||||
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:14px">
|
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:14px">
|
||||||
<div id="fts-status" style="font-size:11px;color:var(--muted);padding:6px 2px"></div>
|
<div id="fts-status" style="font-size:11px;color:var(--muted);padding:6px 2px"></div>
|
||||||
<div id="fts-results" style="overflow-y:auto;max-height:64vh"></div>
|
<div id="fts-results" style="overflow-y:auto;max-height:64vh"></div>
|
||||||
|
|
@ -2495,7 +2495,7 @@ function showShortcutsHelp(){
|
||||||
<div class="modal-hdr"><h2>Keyboard shortcuts</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
<div class="modal-hdr"><h2>Keyboard shortcuts</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
||||||
<table><tbody>
|
<table><tbody>
|
||||||
${row('/','Focus filter box')}
|
${row('/','Focus filter box')}
|
||||||
${row('f','Search titles + descriptions')}
|
${row('f','Search titles, descriptions & transcripts')}
|
||||||
${row('r','Rescan library')}
|
${row('r','Rescan library')}
|
||||||
${row('d','Open downloads')}
|
${row('d','Open downloads')}
|
||||||
${row('Esc','Close modal / cancel')}
|
${row('Esc','Close modal / cancel')}
|
||||||
|
|
|
||||||
10
tests/api.rs
10
tests/api.rs
|
|
@ -66,7 +66,9 @@ impl Server {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wait_ready(&self) {
|
fn wait_ready(&self) {
|
||||||
for _ in 0..150 {
|
// Generous budget: the ffmpeg-heavy dedup test can spike CPU and delay
|
||||||
|
// a sibling server's startup, so don't flake under parallel load.
|
||||||
|
for _ in 0..400 {
|
||||||
if let Some((code, _)) = curl(&self.req_args("/", "GET"), None) {
|
if let Some((code, _)) = curl(&self.req_args("/", "GET"), None) {
|
||||||
if code != 0 { return; } // 0 = connection refused (not up yet)
|
if code != 0 { return; } // 0 = connection refused (not up yet)
|
||||||
}
|
}
|
||||||
|
|
@ -338,6 +340,9 @@ fn full_text_search_indexes_titles_and_descriptions() {
|
||||||
std::fs::write(chan.join("Cooking Sourdough [vid123].mkv"), b"").unwrap();
|
std::fs::write(chan.join("Cooking Sourdough [vid123].mkv"), b"").unwrap();
|
||||||
std::fs::write(chan.join("Cooking Sourdough [vid123].description"),
|
std::fs::write(chan.join("Cooking Sourdough [vid123].description"),
|
||||||
b"an artisan bread tutorial").unwrap();
|
b"an artisan bread tutorial").unwrap();
|
||||||
|
// A subtitle sidecar whose spoken words appear nowhere in the title/desc.
|
||||||
|
std::fs::write(chan.join("Cooking Sourdough [vid123].en.vtt"),
|
||||||
|
b"WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nwhisk the poolish gently\n").unwrap();
|
||||||
|
|
||||||
// Rescan so the server picks up the new file and reindexes it.
|
// Rescan so the server picks up the new file and reindexes it.
|
||||||
let (code, _) = s.post("/api/rescan", "");
|
let (code, _) = s.post("/api/rescan", "");
|
||||||
|
|
@ -354,6 +359,9 @@ fn full_text_search_indexes_titles_and_descriptions() {
|
||||||
// A word only in the description hits (proves the sidecar got indexed).
|
// 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");
|
assert!(s.get("/api/search?q=artisan").1.contains("vid123"), "description search should hit");
|
||||||
|
|
||||||
|
// A word only spoken in the transcript hits (proves the .vtt got indexed).
|
||||||
|
assert!(s.get("/api/search?q=poolish").1.contains("vid123"), "transcript search should hit");
|
||||||
|
|
||||||
// An unrelated query does not.
|
// An unrelated query does not.
|
||||||
assert!(!s.get("/api/search?q=quantumchromodynamics").1.contains("vid123"),
|
assert!(!s.get("/api/search?q=quantumchromodynamics").1.contains("vid123"),
|
||||||
"unrelated query must not hit");
|
"unrelated query must not hit");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue