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:
Luna 2026-06-07 06:55:00 -07:00
parent 4136a9468b
commit e840d55332
5 changed files with 132 additions and 18 deletions

View file

@ -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')}