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 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-08 00:04:36 -07:00
parent 9ba375f7b1
commit fdc7493eb9

View file

@ -2833,16 +2833,15 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
// warm-filesystem rescan of a large library). // warm-filesystem rescan of a large library).
let db = Database::open(&db_path) let db = Database::open(&db_path)
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed")); .unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
let mut library = library::scan_channels_with_cache(&channels_root, Some(&db)); // Defer the initial library scan until *after* the listener binds (see
if let Ok(map) = db.get_all_channel_options() { // the spawn_blocking below). A cold-cache scan of a large library on a
library::apply_channel_options(&mut library, &map); // slow disk can take minutes, and running it here blocks the bind —
} // making the server look dead on the network (e.g. over Tailscale)
if let Ok(folder_map) = db.get_all_channel_assignments() { // until it finishes. Start empty; `/api/library` returns an empty set
library::apply_channel_folders(&mut library, &folder_map); // until the first scan lands and bumps the library version.
} let library: Vec<library::Channel> = Vec::new();
let watched = db.get_watched().unwrap_or_default(); let watched = db.get_watched().unwrap_or_default();
let positions = db.get_positions().unwrap_or_default(); let positions = db.get_positions().unwrap_or_default();
refresh_search_index(&db, &library);
let mut downloader = Downloader::new( let mut downloader = Downloader::new(
channels_root.clone(), channels_root.clone(),
@ -3041,7 +3040,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
// skips already-compressed content types automatically. // skips already-compressed content types automatically.
.layer(CompressionLayer::new().gzip(true)) .layer(CompressionLayer::new().gzip(true))
.layer(middleware::from_fn(security_headers)) .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 { let listener = match tokio::net::TcpListener::bind(format!("{bind_addr}:{port}")).await {
Ok(l) => l, 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}"); 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 // `into_make_service_with_connect_info` so handlers can extract the
// client's `SocketAddr` (used for per-IP rate limiting on /api/login). // client's `SocketAddr` (used for per-IP rate limiting on /api/login).
let server = axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()); let server = axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>());