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

@ -196,6 +196,7 @@
<option value="size-desc">Largest</option>
</select>
<span id="hdr-stats"></span>
<button onclick="openSearch()" title="Search titles + descriptions (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>
@ -1420,6 +1421,54 @@ function renderComments(){
}
}
/* ── Full-text library search ───────────────────────────────────── */
let ftsSeq=0;
function openSearch(){
if(document.getElementById('fts-modal'))return;
const bg=document.createElement('div');bg.className='modal-bg';bg.id='fts-modal';
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"
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>
</div>`;
document.body.appendChild(bg);
const input=document.getElementById('fts-input');
input.focus();
let timer=null;
input.oninput=()=>{clearTimeout(timer);timer=setTimeout(()=>runFtsSearch(input.value.trim()),180)};
}
async function runFtsSearch(q){
const my=++ftsSeq;
const status=document.getElementById('fts-status');
const box=document.getElementById('fts-results');
if(!box)return;
if(!q){box.innerHTML='';if(status)status.textContent='';return}
if(status)status.textContent='Searching…';
try{
const r=await(await api('/api/search?limit=100&q='+encodeURIComponent(q))).json();
if(my!==ftsSeq)return; // a newer keystroke superseded this response
const res=r.results||[];
if(!res.length){box.innerHTML='<div style="color:var(--muted);padding:12px">No matches.</div>';if(status)status.textContent='0 results';return}
box.innerHTML=res.map(h=>`<div onclick="ftsOpen('${esc(h.video_id)}')" style="padding:8px 10px;border-bottom:1px solid var(--border);cursor:pointer">
<div style="font-size:13px">${esc(h.title)}</div>
<div style="font-size:11px;color:var(--muted)">${esc(h.channel)} · ${esc(h.platform)}</div>
${h.snippet?`<div style="font-size:12px;color:var(--muted);margin-top:2px">${ftsSnippet(h.snippet)}</div>`:''}
</div>`).join('');
if(status)status.textContent=res.length+' result'+(res.length===1?'':'s');
}catch(e){box.innerHTML=`<div style="color:#f87171;padding:12px">Search failed: ${esc(e.message)}</div>`}
}
// FTS5 wraps matched terms in STX/ETX control chars; escape first, then mark.
function ftsSnippet(s){return esc(s).replace(/\x02/g,'<mark>').replace(/\x03/g,'</mark>')}
function ftsOpen(id){
document.getElementById('fts-modal')?.remove();
if(!findVideo(id)){setStatus('Video not in the loaded library — try Rescan');return}
selectVideo(id);
document.getElementById('details')?.scrollIntoView({behavior:'smooth',block:'nearest'});
}
/* ── Select / Details ───────────────────────────────────────────── */
function selectVideo(id){selectedId=selectedId===id?null:id;renderGrid();renderDetails()}
function closeDetails(){selectedId=null;renderGrid();renderDetails()}
@ -2253,6 +2302,10 @@ document.addEventListener('keydown',(e)=>{
document.getElementById('search')?.focus();
e.preventDefault();
break;
case 'f':
openSearch();
e.preventDefault();
break;
case 'r':
rescan();
e.preventDefault();
@ -2275,7 +2328,8 @@ function showShortcutsHelp(){
bg.innerHTML=`<div class="modal" style="max-width:380px">
<div class="modal-hdr"><h2>Keyboard shortcuts</h2><button onclick="this.closest('.modal-bg').remove()"></button></div>
<table><tbody>
${row('/','Focus search')}
${row('/','Focus filter box')}
${row('f','Search titles + descriptions')}
${row('r','Rescan library')}
${row('d','Open downloads')}
${row('Esc','Close modal / cancel')}