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:
parent
3031b5b0e5
commit
a47c7991b4
6 changed files with 447 additions and 1 deletions
36
src/web.rs
36
src/web.rs
|
|
@ -894,6 +894,16 @@ fn bump_library_version(state: &WebState) {
|
|||
state.library_version.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Bring the full-text search index in line with the current library. Cheap
|
||||
/// after the first build (only new/changed videos re-read a description), so
|
||||
/// it's fine to call inline after every (re)scan. Errors are logged, not
|
||||
/// fatal — search degrading is better than a scan failing.
|
||||
fn refresh_search_index(db: &Database, lib: &[library::Channel]) {
|
||||
if let Err(e) = db.sync_search_index(&library::build_search_entries(lib)) {
|
||||
eprintln!("search index sync failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the password setting from the DB and update the cache. Called
|
||||
/// after any change that could affect whether a password exists.
|
||||
fn refresh_password_cache(state: &WebState) {
|
||||
|
|
@ -1861,6 +1871,28 @@ async fn get_comments(
|
|||
(StatusCode::OK, Json(serde_json::json!({"comments": comments}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearchQuery {
|
||||
#[serde(default)]
|
||||
q: String,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// `GET /api/search?q=…&limit=…` — full-text search over the library's
|
||||
/// titles, channel names, and descriptions. Returns ranked hits with a
|
||||
/// highlighted snippet. The index is kept current by [`refresh_search_index`]
|
||||
/// after each scan.
|
||||
async fn get_search(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(params): Query<SearchQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let limit = params.limit.unwrap_or(50).clamp(1, 200);
|
||||
match state.db.search_videos(¶ms.q, limit) {
|
||||
Ok(results) => Json(serde_json::json!({ "results": results })).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("search failed: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(video_id): Path<String>,
|
||||
|
|
@ -1908,6 +1940,7 @@ async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
|||
if let Ok(w) = state.db.get_watched() {
|
||||
*state.watched.lock_recover() = w;
|
||||
}
|
||||
refresh_search_index(&state.db, &new_lib);
|
||||
*state.library.lock_recover() = new_lib;
|
||||
bump_library_version(&state);
|
||||
(StatusCode::OK, "rescanned")
|
||||
|
|
@ -1958,6 +1991,7 @@ async fn post_maintenance_remove(
|
|||
if let Ok(folder_map) = state.db.get_all_channel_assignments() {
|
||||
library::apply_channel_folders(&mut new_lib, &folder_map);
|
||||
}
|
||||
refresh_search_index(&state.db, &new_lib);
|
||||
*state.library.lock_recover() = new_lib;
|
||||
bump_library_version(&state);
|
||||
Json(serde_json::json!({ "removed": removed, "errors": errors }))
|
||||
|
|
@ -2533,6 +2567,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
}
|
||||
let watched = db.get_watched().unwrap_or_default();
|
||||
let positions = db.get_positions().unwrap_or_default();
|
||||
refresh_search_index(&db, &library);
|
||||
|
||||
let mut downloader = Downloader::new(
|
||||
channels_root.clone(),
|
||||
|
|
@ -2678,6 +2713,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
.route("/api/description/:id", get(get_description))
|
||||
.route("/api/chapters/:id", get(get_chapters))
|
||||
.route("/api/comments/:id", get(get_comments))
|
||||
.route("/api/search", get(get_search))
|
||||
.route("/api/metadata/:id", get(get_metadata))
|
||||
.route("/api/settings", get(get_settings).post(post_settings))
|
||||
.route("/api/transcode/:id", get(get_transcode))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue