From fdc7493eb90b64d3bbf7b67308afdc94b773d006 Mon Sep 17 00:00:00 2001 From: Luna Date: Mon, 8 Jun 2026 00:04:36 -0700 Subject: [PATCH] web: bind the listener before the initial library scan serve() ran a full library scan + search-index rebuild before binding the HTTP listener. On a large library with a cold cache (e.g. on a slow mount) that scan is disk-bound for minutes, during which nothing listens on the port and the server looks dead on the network (notably over Tailscale). Build WebState with an empty library, bind + print the URL immediately, then run the scan in a spawn_blocking task that swaps the result in and bumps the library version (same pattern as post_rescan). The server is now reachable in ~1s; content appears once the first scan lands and clients pick it up via the bumped version/ETag. Co-Authored-By: Claude Opus 4.8 --- src/web.rs | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/web.rs b/src/web.rs index 6db906a..89f417f 100644 --- a/src/web.rs +++ b/src/web.rs @@ -2833,16 +2833,15 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { // warm-filesystem rescan of a large library). let db = Database::open(&db_path) .unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed")); - let mut library = library::scan_channels_with_cache(&channels_root, Some(&db)); - if let Ok(map) = db.get_all_channel_options() { - library::apply_channel_options(&mut library, &map); - } - if let Ok(folder_map) = db.get_all_channel_assignments() { - library::apply_channel_folders(&mut library, &folder_map); - } + // Defer the initial library scan until *after* the listener binds (see + // the spawn_blocking below). A cold-cache scan of a large library on a + // slow disk can take minutes, and running it here blocks the bind — + // making the server look dead on the network (e.g. over Tailscale) + // until it finishes. Start empty; `/api/library` returns an empty set + // until the first scan lands and bumps the library version. + let library: Vec = Vec::new(); 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(), @@ -3041,7 +3040,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { // skips already-compressed content types automatically. .layer(CompressionLayer::new().gzip(true)) .layer(middleware::from_fn(security_headers)) - .with_state(state); + .with_state(state.clone()); let listener = match tokio::net::TcpListener::bind(format!("{bind_addr}:{port}")).await { Ok(l) => l, @@ -3052,6 +3051,27 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { }; println!("yt-offline web UI: http://localhost:{port}"); + // Now that we're accepting connections, run the initial library scan in + // the background so a cold-cache scan of a large library doesn't delay + // the bind. Mirrors post_rescan(): scan → apply options/folders → + // refresh search index → swap into state + bump the library version so + // connected clients pick it up. spawn_blocking keeps the parallel + // (rayon) filesystem walk off the async worker threads. + let scan_state = state.clone(); + tokio::task::spawn_blocking(move || { + let mut new_lib = + library::scan_channels_with_cache(&scan_state.channels_root, Some(&scan_state.db)); + if let Ok(map) = scan_state.db.get_all_channel_options() { + library::apply_channel_options(&mut new_lib, &map); + } + if let Ok(folder_map) = scan_state.db.get_all_channel_assignments() { + library::apply_channel_folders(&mut new_lib, &folder_map); + } + refresh_search_index(&scan_state.db, &new_lib); + *scan_state.library.lock_recover() = new_lib; + bump_library_version(&scan_state); + }); + // `into_make_service_with_connect_info` so handlers can extract the // client's `SocketAddr` (used for per-IP rate limiting on /api/login). let server = axum::serve(listener, app.into_make_service_with_connect_info::());