feat(library): bootstrap derived catalog

Build the catalog side by side from read-only local, Plex, Jellyfin, and Subsonic cache snapshots after the first useful Qt frame.

Persist bounded batch checkpoints, preflight disk capacity, validate materializations, and activate generations atomically while preserving the existing reader as fallback.

cx
This commit is contained in:
vicrodh committed 2026-08-23 07:23:23 -06:00
1 parent a0d8a7fc94
commit d39edf5850
13 files changed
+2164 -2

No files matched your search

+3
View File
@@ -75,6 +75,9 @@ qbz-models = { path = "../qbz-models" }
qbz-cache = { path = "../qbz-cache" }
qbz-offline-cache = { path = "../qbz-offline-cache" }
qbz-library = { path = "../qbz-library" }
# Derived, rebuildable Local Library read catalog. The Qt app only schedules
# its background bootstrap here; no Qt type crosses into the catalog crate.
qbz-local-catalog = { path = "../qbz-local-catalog" }
# MyQBZ: Mixtapes & Collections backend (schema + migrations, repo, shuffle,
# enqueue). Headless and frontend-agnostic (ADR-006) — the same crate the
# Slint app uses (crates/qbz/Cargo.toml:96), so the two frontends share
+104
View File
@@ -0,0 +1,104 @@
//! Background bootstrap for the derived Local Library catalog.
//!
//! C is intentionally invisible: current Local Library readers remain the
//! session fallback. This module only schedules the frontend-agnostic worker
//! after boot has had time to paint, coalesces progress into logs, and signals
//! bounded cancellation when the Qt event loop exits.
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use qbz_local_catalog::{BootstrapOutcome, CatalogError};
static STARTED: AtomicBool = AtomicBool::new(false);
static CANCELLED: AtomicBool = AtomicBool::new(false);
pub(crate) fn start() {
if STARTED.swap(true, Ordering::AcqRel) {
return;
}
CANCELLED.store(false, Ordering::Release);
crate::spawn(async {
// Component.onCompleted triggers `on_boot` before the first paint.
// This delay keeps source probes and SQLite open off that critical path.
tokio::time::sleep(Duration::from_secs(1)).await;
if CANCELLED.load(Ordering::Acquire) {
return;
}
let Some(data_dir) = dirs::data_dir().map(|root| root.join("qbz")) else {
log::warn!("[local-catalog] fallback=missing-data-directory");
return;
};
let result = tokio::task::spawn_blocking(move || {
let mut last_publish = std::time::Instant::now();
let mut last_source = None;
qbz_local_catalog::bootstrap_legacy_caches_with_progress(
&data_dir,
&CANCELLED,
|progress| {
let source_changed = last_source.as_ref() != Some(&progress.source);
if source_changed
|| progress.source_complete
|| last_publish.elapsed() >= Duration::from_secs(2)
{
log::info!(
"[local-catalog] phase=bootstrap generation={} source={:?} rows={} checkpoint={} complete={}",
progress.generation,
progress.source,
progress.committed_rows,
progress.checkpoint_cursor,
progress.source_complete
);
last_publish = std::time::Instant::now();
last_source = Some(progress.source.clone());
}
},
)
})
.await;
match result {
Ok(Ok(BootstrapOutcome::Activated {
generation,
track_count,
resumed_rows,
})) => log::info!(
"[local-catalog] phase=active generation={generation} tracks={track_count} resumed_rows={resumed_rows}"
),
Ok(Ok(BootstrapOutcome::Paused {
generation,
source,
committed_rows,
})) => log::info!(
"[local-catalog] phase=paused generation={generation} source={source:?} committed_rows={committed_rows}"
),
Ok(Ok(BootstrapOutcome::Fallback(reason))) => {
log::warn!("[local-catalog] fallback={reason:?}")
}
Ok(Err(CatalogError::InsufficientSpace {
required_bytes,
available_bytes,
})) => log::warn!(
"[local-catalog] phase=paused reason=low-disk required_bytes={required_bytes} available_bytes={available_bytes}"
),
Ok(Err(error)) => log::warn!("[local-catalog] fallback=bootstrap-error error={error}"),
Err(error) => log::warn!("[local-catalog] fallback=worker-join error={error}"),
}
});
}
pub(crate) fn cancel() {
CANCELLED.store(true, Ordering::Release);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cancellation_latch_is_visible_to_the_worker() {
CANCELLED.store(false, Ordering::Release);
cancel();
assert!(CANCELLED.load(Ordering::Acquire));
CANCELLED.store(false, Ordering::Release);
}
}
+10
View File
@@ -180,6 +180,7 @@ mod local_library_qt;
mod local_artist_match;
mod local_rows;
mod local_state;
mod local_catalog_qt;
mod local_plex;
// Plex PIN sign-in (the "Authorize" half of the Plex settings) + the
// Check-connection ping. Over `qbz_plex`'s existing pin/start, pin/check and
@@ -551,6 +552,12 @@ pub(crate) fn on_boot() {
let _ = tokio::task::spawn_blocking(artwork_qt::housekeeping).await;
});
// The derived Local Library catalog is deliberately delayed past boot's
// useful frame and runs entirely on the blocking pool. It reads the three
// authoritative caches read-only, yields between 250-row transactions and
// leaves every current view on its existing reader until E opts Tracks in.
local_catalog_qt::start();
// Splash -> silent session restore -> shell | login.
let runtime = app();
spawn(async move {
@@ -3173,6 +3180,9 @@ fn main() {
// user cannot do anything but wait, so NOTHING on this path may block
// without a bound.
log::info!("[qbz-qt] event loop exited; shutting down");
// The catalog worker commits at most its current bounded batch, whose
// checkpoint is part of that same transaction. A later launch resumes.
local_catalog_qt::cancel();
// The backstop for everything below AND for Qt's own teardown (engine
// + QGuiApplication destructors, atexit chain): if any of it wedges,
// the watchdog `_exit(0)`s the process. Idempotent — the quit paths