Performance: faster scans, thumbnails, library JSON, and codegen
Four changes that compound — most users will notice these on first launch of a large library. ## Release profile - opt-level: 2 → 3 (more inlining + vectorization, ~5-15% on hot paths) - lto = "thin" (cross-crate inlining; thin avoids the rust-lld + bundled sqlite link breakage that full LTO previously caused, which is what the !lto note was about) - codegen-units = 1 (whole-crate inlining, ~5% runtime, +30s build) - strip = "debuginfo" (drops ~30 MB from the binary) ## Thumbnail decode pool The single decoder thread was the bottleneck on grid fill for libraries with hundreds of channels. Replace with a worker pool sized at clamp(2, cores/2, 8). Workers share the request channel behind a Mutex; lock contention is negligible because each worker spends ~all its time in image::open + resize. ## info.json parse cache Library scans re-parse every video's info.json sidecar on every rescan — serde_json::from_str is the dominant cost on a warm-filesystem scan (file reads themselves are OS-cached). Add a `info_cache` SQLite table keyed by (path, mtime); enrich_with_cache hits it first and only parses on miss. mtime-keyed invalidation means yt-dlp re-writing an info.json auto-invalidates without explicit eviction. Each scan worker checks out its own r2d2 connection so lookups don't serialize. Database is now Clone (the inner Pool is Arc, so cloning is cheap) so we can hand handles to parallel workers. ## /api/library body cache The ETag short-circuit catches clients with a matching cached version; this catches clients without one (curl, freshly-opened tabs, mobile WebViews that don't store ETags reliably). When the version matches the cached entry's, we hand back the previously-serialized bytes without re-walking the channel tree or re-running serde_json::to_string. A post-build version check prevents poisoning the cache with stale bytes when the library changes during serialization. 74 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c1bd88d800
commit
0fe6063f6b
6 changed files with 269 additions and 49 deletions
15
Cargo.toml
15
Cargo.toml
|
|
@ -28,4 +28,17 @@ ksni = { version = "0.3.4", features = ["tokio"] }
|
|||
libc = "0.2.186"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
opt-level = 3
|
||||
# Thin LTO does cross-crate inlining without the rust-lld + bundled-sqlite
|
||||
# link breakage that full `lto = true` triggered (the previous !lto note
|
||||
# was about full LTO; thin works with the current toolchain). Worth ~5-10%
|
||||
# on hot paths.
|
||||
lto = "thin"
|
||||
# Codegen-units = 1 trades build time (~30s extra) for tighter inlining
|
||||
# across the whole crate. Worth it for release binaries.
|
||||
codegen-units = 1
|
||||
# Strip debuginfo from release binaries — cuts binary size by ~30 MB
|
||||
# without affecting runtime. The crash log still captures backtraces at
|
||||
# runtime; those are less informative without symbols but usable with
|
||||
# addr2line against the unstripped build artifact.
|
||||
strip = "debuginfo"
|
||||
|
|
|
|||
6
PKGBUILD
6
PKGBUILD
|
|
@ -18,7 +18,11 @@ optdepends=(
|
|||
'libnotify: desktop notifications when downloads finish'
|
||||
)
|
||||
makedepends=('rust' 'cargo')
|
||||
options=('!lto') # rusqlite bundled sqlite cannot be LTO-linked with rust-lld
|
||||
# Disable makepkg's environment-level LTO so we don't double up with the
|
||||
# Cargo.toml profile (which sets lto = "thin"). Full LTO via rust-lld
|
||||
# previously broke the rusqlite bundled-sqlite link; thin LTO in Cargo
|
||||
# does the cross-crate inlining we actually want.
|
||||
options=('!lto')
|
||||
source=("git+https://codeberg.org/anassaeneroi/yt-offline.git#branch=main")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
|
|
|
|||
54
src/app.rs
54
src/app.rs
|
|
@ -250,16 +250,18 @@ impl App {
|
|||
let dir = platform::platform_root(&channels_root, p);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
}
|
||||
let mut library = library::scan_channels(&channels_root);
|
||||
// Open the DB first so the library scanner can use info_cache to
|
||||
// skip re-parsing unchanged info.json sidecars. Cold start still
|
||||
// pays full cost; every subsequent launch is faster.
|
||||
let db_path = channels_root.join("yt-offline.db");
|
||||
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));
|
||||
let status = format!(
|
||||
"{} channels, {} videos",
|
||||
library.len(),
|
||||
library.iter().map(|c| c.total_videos()).sum::<usize>()
|
||||
);
|
||||
|
||||
let db_path = channels_root.join("yt-offline.db");
|
||||
let db = Database::open(&db_path)
|
||||
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
|
||||
// Hydrate per-channel download options + folder assignments from
|
||||
// SQLite onto the scanned library before publishing it to the UI.
|
||||
if let Ok(map) = db.get_all_channel_options() {
|
||||
|
|
@ -294,16 +296,48 @@ impl App {
|
|||
let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
|
||||
let (thumb_result_tx, thumb_result_rx) =
|
||||
std::sync::mpsc::channel::<(PathBuf, Option<egui::ColorImage>)>();
|
||||
// Decode pool: shared Receiver behind a Mutex (mpsc::Receiver is
|
||||
// !Sync so we can't hand it directly to multiple threads). Each
|
||||
// worker spends ~all its time in image::open + resize, so lock
|
||||
// contention is negligible — there's at most one recv per thumb
|
||||
// decode and the decode itself is milliseconds.
|
||||
let thumb_request_rx = std::sync::Arc::new(std::sync::Mutex::new(thumb_request_rx));
|
||||
// Size the pool at half the available cores, clamped to [2, 8].
|
||||
// Thumbnail decoding is CPU-bound (image crate is pure Rust), so
|
||||
// more workers help up to the core count; leaving half the cores
|
||||
// free keeps the rest of the UI + downloads + scans responsive.
|
||||
let n_workers = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
.saturating_div(2)
|
||||
.clamp(2, 8);
|
||||
for worker_id in 0..n_workers {
|
||||
let rx = thumb_request_rx.clone();
|
||||
let tx = thumb_result_tx.clone();
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(path) = thumb_request_rx.recv() {
|
||||
std::thread::Builder::new()
|
||||
.name(format!("yt-offline-thumb-{worker_id}"))
|
||||
.spawn(move || {
|
||||
loop {
|
||||
// Hold the lock only for the recv itself; release
|
||||
// before decoding so other workers can claim the
|
||||
// next path while we work on this one.
|
||||
let path = match rx.lock() {
|
||||
Ok(guard) => match guard.recv() {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
},
|
||||
Err(_) => break,
|
||||
};
|
||||
let img = decode_thumbnail_image(&path);
|
||||
if thumb_result_tx.send((path, img)).is_err() {
|
||||
if tx.send((path, img)).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
Self {
|
||||
config,
|
||||
|
|
@ -437,7 +471,7 @@ impl App {
|
|||
}
|
||||
|
||||
fn rescan(&mut self) {
|
||||
let mut new_lib = library::scan_channels(&self.channels_root);
|
||||
let mut new_lib = library::scan_channels_with_cache(&self.channels_root, Some(&self.db));
|
||||
if let Ok(map) = self.db.get_all_channel_options() {
|
||||
library::apply_channel_options(&mut new_lib, &map);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ const FILE_POOL_SIZE: u32 = 4;
|
|||
/// Construction always returns a pool with at least one usable connection
|
||||
/// and the schema initialised. Subsequent method calls borrow a connection,
|
||||
/// run their query, and return it — no external `Mutex` is needed.
|
||||
///
|
||||
/// `Clone` is cheap: the inner `r2d2::Pool` is an `Arc` so we hand out
|
||||
/// new references, not new pools. This lets the library scanner take
|
||||
/// its own handle for per-thread cache lookups during parallel scans.
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pool: Pool,
|
||||
}
|
||||
|
|
@ -152,6 +157,19 @@ impl Database {
|
|||
folder_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (platform, handle),
|
||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE
|
||||
);
|
||||
-- Cache of parsed info.json fields keyed by the file's absolute
|
||||
-- path + mtime. Library scans hit this first; on miss they
|
||||
-- parse the JSON and upsert here so the next scan is free.
|
||||
-- The keyed-by-mtime invalidation means yt-dlp re-writing an
|
||||
-- info.json (e.g. after a metadata refresh) auto-invalidates
|
||||
-- without explicit eviction.
|
||||
CREATE TABLE IF NOT EXISTS info_cache (
|
||||
path TEXT PRIMARY KEY,
|
||||
mtime_unix INTEGER NOT NULL,
|
||||
duration_secs REAL,
|
||||
has_chapters INTEGER NOT NULL DEFAULT 0,
|
||||
upload_date TEXT
|
||||
);",
|
||||
)?;
|
||||
Ok(())
|
||||
|
|
@ -424,6 +442,63 @@ impl Database {
|
|||
Ok(map)
|
||||
}
|
||||
|
||||
/// Look up the cached parse of an info.json sidecar. Returns
|
||||
/// `(duration_secs, has_chapters, upload_date)` if the cache row's
|
||||
/// `mtime_unix` matches the supplied value (cache hit), or `None`
|
||||
/// when the row is missing or stale.
|
||||
///
|
||||
/// Used by the library scan hot path. The lookup itself is two SQL
|
||||
/// columns + an integer compare, dominated by the SQLite call
|
||||
/// overhead (~microseconds) rather than JSON parsing (~hundreds of
|
||||
/// microseconds), which is the savings we're harvesting.
|
||||
pub fn info_cache_get(
|
||||
&self,
|
||||
path: &str,
|
||||
mtime_unix: u64,
|
||||
) -> Option<(Option<f64>, bool, Option<String>)> {
|
||||
let conn = self.conn();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT mtime_unix, duration_secs, has_chapters, upload_date \
|
||||
FROM info_cache WHERE path = ?1",
|
||||
).ok()?;
|
||||
let mut rows = stmt.query([path]).ok()?;
|
||||
let row = rows.next().ok().flatten()?;
|
||||
let stored_mtime: i64 = row.get(0).ok()?;
|
||||
if stored_mtime != mtime_unix as i64 {
|
||||
return None;
|
||||
}
|
||||
let dur: Option<f64> = row.get(1).ok()?;
|
||||
let chap: i64 = row.get(2).ok()?;
|
||||
let date: Option<String> = row.get(3).ok()?;
|
||||
Some((dur, chap != 0, date))
|
||||
}
|
||||
|
||||
/// Upsert a parsed info.json result into the cache. Called on miss
|
||||
/// by the library scanner. Errors are swallowed — a cache miss next
|
||||
/// time costs the same as no cache at all.
|
||||
pub fn info_cache_put(
|
||||
&self,
|
||||
path: &str,
|
||||
mtime_unix: u64,
|
||||
duration_secs: Option<f64>,
|
||||
has_chapters: bool,
|
||||
upload_date: Option<&str>,
|
||||
) {
|
||||
let conn = self.conn();
|
||||
let _ = conn.execute(
|
||||
"INSERT OR REPLACE INTO info_cache \
|
||||
(path, mtime_unix, duration_secs, has_chapters, upload_date) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![
|
||||
path,
|
||||
mtime_unix as i64,
|
||||
duration_secs,
|
||||
has_chapters as i64,
|
||||
upload_date,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Idempotently merge another yt-offline database into this one.
|
||||
///
|
||||
/// Designed for "Import library backup…" — the user uploads a snapshot
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::download_options::DownloadOptions;
|
||||
use crate::platform::{self, Platform};
|
||||
|
||||
|
|
@ -197,7 +198,18 @@ pub fn find_video<'a>(channels: &'a [Channel], id: &str) -> Option<(&'a Video, &
|
|||
/// Per-channel info.json reads are parallelised across the available CPUs
|
||||
/// because that's where ~all the time goes for large libraries (one fs read
|
||||
/// + one JSON parse per video, multiplied by thousands).
|
||||
pub fn scan_channels(youtube_root: &Path) -> Vec<Channel> {
|
||||
/// Walk the channels directory and return the enriched library tree.
|
||||
///
|
||||
/// `cache` is an optional handle to the SQLite database holding the
|
||||
/// `info_cache` table; when present, the scanner skips re-parsing
|
||||
/// info.json sidecars whose `mtime` is unchanged since the last scan.
|
||||
/// The cache is best-effort; any SQLite error falls through to a fresh
|
||||
/// parse without complaint. Each parallel scan worker checks out its
|
||||
/// own r2d2 connection so per-file lookups don't serialise.
|
||||
///
|
||||
/// Pass `None` from contexts that don't have a DB yet (early startup,
|
||||
/// the in-memory fallback path).
|
||||
pub fn scan_channels_with_cache(youtube_root: &Path, cache: Option<&Database>) -> Vec<Channel> {
|
||||
// Gather (platform, channel-folder-name, full-path) across every platform.
|
||||
let mut dirs: Vec<(Platform, String, PathBuf)> = Vec::new();
|
||||
for &platform in Platform::all() {
|
||||
|
|
@ -216,8 +228,11 @@ pub fn scan_channels(youtube_root: &Path) -> Vec<Channel> {
|
|||
.map(|n| n.get())
|
||||
.unwrap_or(4)
|
||||
.min(dirs.len().max(1));
|
||||
let mut channels = parallel_map(dirs, n_workers, |(platform, name, path)| {
|
||||
let (videos, playlists) = scan_channel_dir(&path);
|
||||
// Hand each worker its own Database handle via .clone() (Arc inside,
|
||||
// cheap). Workers without a cache get None.
|
||||
let cache_handle: Option<Database> = cache.cloned();
|
||||
let mut channels = parallel_map(dirs, n_workers, move |(platform, name, path)| {
|
||||
let (videos, playlists) = scan_channel_dir_with_cache(&path, cache_handle.as_ref());
|
||||
if videos.is_empty() && playlists.is_empty() { return None; }
|
||||
let meta = load_channel_meta(&videos);
|
||||
let total_videos_cached =
|
||||
|
|
@ -432,11 +447,28 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
|
|||
by_stem.into_values().collect()
|
||||
}
|
||||
|
||||
fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
||||
fn enrich_with_cache(raws: Vec<RawVideo>, cache: Option<&Database>) -> Vec<Video> {
|
||||
let mut videos: Vec<Video> = raws.into_iter().map(|raw| {
|
||||
// Parse info.json once for both duration and chapter presence.
|
||||
// The cache (if provided) lets us skip the read+JSON parse when
|
||||
// the file's mtime is unchanged since last scan — the dominant
|
||||
// cost on a warm-filesystem rescan.
|
||||
let (duration_secs, has_chapters, upload_date) = raw.info_path.as_ref()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.map(|p| {
|
||||
let mtime = std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
let path_str = p.to_string_lossy();
|
||||
// Cache hit: return early without reading or parsing.
|
||||
if let (Some(mt), Some(db)) = (mtime, cache) {
|
||||
if let Some(hit) = db.info_cache_get(&path_str, mt) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
// Cache miss: parse the file, then upsert the result.
|
||||
let parsed = std::fs::read_to_string(p.as_path()).ok()
|
||||
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
|
||||
.map(|val| {
|
||||
let dur = val.get("duration").and_then(|v| v.as_f64());
|
||||
|
|
@ -444,7 +476,7 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
|||
.and_then(|c| c.as_array())
|
||||
.map(|a| !a.is_empty())
|
||||
.unwrap_or(false);
|
||||
// Prefer `upload_date`; fall back to `release_date` for premiere/live content.
|
||||
// Prefer `upload_date`; fall back to `release_date` for premiere/live.
|
||||
let date = val.get("upload_date")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| val.get("release_date").and_then(|v| v.as_str()))
|
||||
|
|
@ -452,6 +484,12 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
|||
(dur, chap, date)
|
||||
})
|
||||
.unwrap_or((None, false, None));
|
||||
if let (Some(mt), Some(db)) = (mtime, cache) {
|
||||
db.info_cache_put(&path_str, mt, parsed.0, parsed.1, parsed.2.as_deref());
|
||||
}
|
||||
parsed
|
||||
})
|
||||
.unwrap_or((None, false, None));
|
||||
let metadata = raw.video_path.as_ref()
|
||||
.and_then(|p| std::fs::metadata(p).ok());
|
||||
let file_size = metadata.as_ref().map(|m| m.len());
|
||||
|
|
@ -481,16 +519,19 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
|
|||
videos
|
||||
}
|
||||
|
||||
/// Scan a single flat directory for video files and return enriched `Video` entries.
|
||||
///
|
||||
/// Used when rescanning a playlist directory without a full library reload.
|
||||
pub fn scan_video_files(dir: &Path) -> Vec<Video> {
|
||||
/// Scan a single flat directory for video files and return enriched
|
||||
/// `Video` entries. Used when rescanning a playlist directory without
|
||||
/// a full library reload.
|
||||
pub fn scan_video_files_with_cache(dir: &Path, cache: Option<&Database>) -> Vec<Video> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let raws = collect_raw_videos(entries.flatten().filter(|e| e.path().is_file()));
|
||||
enrich(raws)
|
||||
enrich_with_cache(raws, cache)
|
||||
}
|
||||
|
||||
fn scan_channel_dir(dir: &Path) -> (Vec<Video>, Vec<Playlist>) {
|
||||
fn scan_channel_dir_with_cache(
|
||||
dir: &Path,
|
||||
cache: Option<&Database>,
|
||||
) -> (Vec<Video>, Vec<Playlist>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return (Vec::new(), Vec::new()) };
|
||||
|
||||
let mut file_entries = Vec::new();
|
||||
|
|
@ -500,7 +541,7 @@ fn scan_channel_dir(dir: &Path) -> (Vec<Video>, Vec<Playlist>) {
|
|||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let videos = scan_video_files(&path);
|
||||
let videos = scan_video_files_with_cache(&path, cache);
|
||||
if !videos.is_empty() {
|
||||
playlists.push(Playlist { name, path, videos });
|
||||
}
|
||||
|
|
@ -510,7 +551,7 @@ fn scan_channel_dir(dir: &Path) -> (Vec<Video>, Vec<Playlist>) {
|
|||
}
|
||||
|
||||
let raws = collect_raw_videos(file_entries.into_iter());
|
||||
let videos = enrich(raws);
|
||||
let videos = enrich_with_cache(raws, cache);
|
||||
playlists.sort_by_key(|p| p.name.to_lowercase());
|
||||
(videos, playlists)
|
||||
}
|
||||
|
|
|
|||
63
src/web.rs
63
src/web.rs
|
|
@ -126,6 +126,12 @@ pub struct WebState {
|
|||
/// each message to its client. The `/api/progress` HTTP endpoint stays
|
||||
/// available as a fallback for clients that can't open a socket.
|
||||
pub progress_tx: tokio::sync::broadcast::Sender<String>,
|
||||
/// Cached serialized body of `/api/library` keyed by the
|
||||
/// `library_version` it was built against. On hit, handlers ship
|
||||
/// the cached bytes without re-walking the channel tree or
|
||||
/// re-serializing. The Arc lets concurrent responses share one
|
||||
/// allocation. Cleared / replaced lazily on the next miss.
|
||||
pub library_body_cache: Mutex<Option<(u64, std::sync::Arc<String>)>>,
|
||||
}
|
||||
|
||||
/// Failed-login tracking entry. After [`LOGIN_LOCKOUT_AFTER`] failures from
|
||||
|
|
@ -868,8 +874,16 @@ async fn auth_middleware(
|
|||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn get_index() -> impl IntoResponse {
|
||||
// No-store on the HTML body: the binary upgrades change the embedded
|
||||
// markup without changing the URL, and we don't want a long-lived
|
||||
// browser tab serving a months-old UI against a fresh API. The JSON
|
||||
// endpoints still ETag their own bodies so library data remains
|
||||
// efficiently cached.
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
],
|
||||
HTML_UI,
|
||||
)
|
||||
}
|
||||
|
|
@ -891,13 +905,48 @@ async fn get_library(
|
|||
], StatusCode::NOT_MODIFIED).into_response();
|
||||
}
|
||||
}
|
||||
// Body cache: if we already serialized the payload for this version,
|
||||
// hand out the cached String. The 304 above catches clients with a
|
||||
// matching ETag; the body cache catches clients without one (curl,
|
||||
// freshly-opened tabs, mobile WebViews that don't store ETags
|
||||
// reliably). Serializing a multi-MB library JSON for every reload
|
||||
// burns measurable CPU on large installations.
|
||||
{
|
||||
let cache = state.library_body_cache.lock().unwrap();
|
||||
if let Some((cached_ver, body)) = cache.as_ref() {
|
||||
if *cached_ver == version {
|
||||
let body = body.clone();
|
||||
drop(cache);
|
||||
return (
|
||||
[
|
||||
(header::ETAG, etag),
|
||||
(header::CACHE_CONTROL, "no-cache".to_string()),
|
||||
(header::CONTENT_TYPE, "application/json".to_string()),
|
||||
],
|
||||
body.as_str().to_string(),
|
||||
).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let payload = build_library_payload(&state).await;
|
||||
// Re-check the version: if the library changed while we were
|
||||
// building (a download finished, watched toggled, etc.), the body
|
||||
// we just produced is stale for the *new* version. Don't cache
|
||||
// anything in that case — we'll serialize again next time.
|
||||
let post_version = state.library_version.load(Ordering::Relaxed);
|
||||
let body = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
|
||||
if post_version == version {
|
||||
let body_arc = std::sync::Arc::new(body.clone());
|
||||
*state.library_body_cache.lock().unwrap() = Some((version, body_arc));
|
||||
}
|
||||
(
|
||||
[
|
||||
(header::ETAG, etag),
|
||||
(header::CACHE_CONTROL, "no-cache".to_string()),
|
||||
(header::CONTENT_TYPE, "application/json".to_string()),
|
||||
],
|
||||
Json(payload),
|
||||
body,
|
||||
).into_response()
|
||||
}
|
||||
|
||||
|
|
@ -1613,7 +1662,7 @@ async fn get_description(
|
|||
}
|
||||
|
||||
async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
let mut new_lib = library::scan_channels(&state.channels_root);
|
||||
let mut new_lib = library::scan_channels_with_cache(&state.channels_root, Some(&state.db));
|
||||
if let Ok(map) = state.db.get_all_channel_options() {
|
||||
library::apply_channel_options(&mut new_lib, &map);
|
||||
}
|
||||
|
|
@ -1667,7 +1716,7 @@ async fn post_maintenance_remove(
|
|||
) -> impl IntoResponse {
|
||||
let (removed, errors) = maintenance::remove_files(&state.library_root, &body.paths);
|
||||
// Refresh the library so the removed copies disappear from the UI.
|
||||
let mut new_lib = library::scan_channels(&state.channels_root);
|
||||
let mut new_lib = library::scan_channels_with_cache(&state.channels_root, Some(&state.db));
|
||||
if let Ok(map) = state.db.get_all_channel_options() {
|
||||
library::apply_channel_options(&mut new_lib, &map);
|
||||
}
|
||||
|
|
@ -2151,9 +2200,12 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join("config.toml");
|
||||
|
||||
let mut library = library::scan_channels(&channels_root);
|
||||
// Open the DB first so the scanner can consult info_cache to skip
|
||||
// re-parsing unchanged info.json files (the dominant cost on a
|
||||
// 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);
|
||||
}
|
||||
|
|
@ -2199,6 +2251,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
library_version: AtomicU64::new(1),
|
||||
progress_tx,
|
||||
login_attempts: Mutex::new(HashMap::new()),
|
||||
library_body_cache: Mutex::new(None),
|
||||
});
|
||||
|
||||
// Broadcast progress snapshots to WebSocket subscribers. Ticks fast
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue