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:
Luna 2026-05-27 03:28:40 -07:00
parent c1bd88d800
commit 0fe6063f6b
6 changed files with 269 additions and 49 deletions

View file

@ -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