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| {
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.search_query)
|
||||
.hint_text("titles, channels, descriptions…")
|
||||
.hint_text("titles, channels, descriptions, transcripts…")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
if std::mem::take(&mut self.search_focus) {
|
||||
|
|
@ -1007,7 +1007,7 @@ impl App {
|
|||
}
|
||||
ui.separator();
|
||||
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() {
|
||||
ui.weak("No matches.");
|
||||
} else {
|
||||
|
|
@ -1377,7 +1377,7 @@ impl App {
|
|||
);
|
||||
ui.separator();
|
||||
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()
|
||||
{
|
||||
self.show_search = true;
|
||||
|
|
|
|||
127
src/database.rs
127
src/database.rs
|
|
@ -46,6 +46,9 @@ pub struct SearchEntry {
|
|||
pub channel: String,
|
||||
pub title: String,
|
||||
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`].
|
||||
|
|
@ -68,6 +71,20 @@ pub struct StoredFingerprint {
|
|||
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
|
||||
/// whitespace token becomes a quoted prefix term, AND-ed together. Quoting
|
||||
/// 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
|
||||
-- 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.
|
||||
-- only re-reads a video's sidecars when it actually changed.
|
||||
-- video_id/platform are UNINDEXED — stored for retrieval, not
|
||||
-- matched. `transcript` holds the video's subtitle text so search
|
||||
-- matches spoken words too.
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS video_search USING fts5(
|
||||
video_id UNINDEXED,
|
||||
platform UNINDEXED,
|
||||
channel,
|
||||
title,
|
||||
description,
|
||||
transcript,
|
||||
tokenize = 'porter unicode61'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS search_meta (
|
||||
|
|
@ -278,6 +297,28 @@ impl Database {
|
|||
if msg.contains("duplicate column") => {}
|
||||
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(())
|
||||
}
|
||||
|
||||
|
|
@ -993,15 +1034,19 @@ impl Database {
|
|||
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.
|
||||
// Only new/changed videos pay the file-read cost.
|
||||
let description = e.description_path.as_ref()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.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(
|
||||
"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],
|
||||
"INSERT INTO video_search (video_id, platform, channel, title, description, transcript)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![e.video_id, e.platform, e.channel, e.title, description, transcript],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO search_meta (video_id, mtime_unix) VALUES (?1, ?2)",
|
||||
|
|
@ -1033,10 +1078,12 @@ impl Database {
|
|||
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.
|
||||
// that won't collide with literal '[' / ']' in the text. Column
|
||||
// -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,
|
||||
snippet(video_search, 4, char(2), char(3), '…', 12)
|
||||
snippet(video_search, -1, char(2), char(3), '…', 12)
|
||||
FROM video_search
|
||||
WHERE video_search MATCH ?1
|
||||
ORDER BY rank
|
||||
|
|
@ -1145,7 +1192,7 @@ mod search_tests {
|
|||
SearchEntry {
|
||||
video_id: id.into(), mtime_unix: mtime,
|
||||
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());
|
||||
}
|
||||
|
||||
#[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)]
|
||||
|
|
|
|||
|
|
@ -702,6 +702,7 @@ pub fn build_search_entries(lib: &[Channel]) -> Vec<crate::database::SearchEntry
|
|||
channel: ch.name.clone(),
|
||||
title: v.title.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>
|
||||
</select>
|
||||
<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="rescan()" title="Rescan library">⟳</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.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>
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -2495,7 +2495,7 @@ function showShortcutsHelp(){
|
|||
<div class="modal-hdr"><h2>Keyboard shortcuts</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
||||
<table><tbody>
|
||||
${row('/','Focus filter box')}
|
||||
${row('f','Search titles + descriptions')}
|
||||
${row('f','Search titles, descriptions & transcripts')}
|
||||
${row('r','Rescan library')}
|
||||
${row('d','Open downloads')}
|
||||
${row('Esc','Close modal / cancel')}
|
||||
|
|
|
|||
10
tests/api.rs
10
tests/api.rs
|
|
@ -66,7 +66,9 @@ impl Server {
|
|||
}
|
||||
|
||||
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 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].description"),
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
assert!(!s.get("/api/search?q=quantumchromodynamics").1.contains("vid123"),
|
||||
"unrelated query must not hit");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue