Files
psysonic/src-tauri/src/cover_cache/backfill_worker.rs
T
cucadmuh 9925771a86 feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890)
* fix(cover): per-server cache stats and cover pipeline perf probe

Stop count_cached_cover_ids from borrowing sibling bucket counts so
Settings progress no longer attributes one server's disk cache to another.

Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP
semaphores) to Performance Probe overlay, with clearer ui/lib labels.

* fix(browse): stabilize in-page infinite scroll and cap cover memory caches

Extract useInpageScrollSentinel for album grids and song lists so sentinel
reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with
sync loading refs, tighter root margin, and hasMore termination when dedupe
adds nothing. Pause middle-priority cover work during SQL pagination and bound
diskSrc/resolve/ensure tail maps on long cold-cache sessions.

* refactor(browse): unify in-page infinite scroll hooks and sentinel UI

Extract shared transport (viewport ref, async pagination guards, client slice)
and InpageScrollSentinel so Albums, New Releases, Artists, and song lists
use one pagination pattern instead of duplicated IntersectionObserver wiring.

* fix(browse): prioritize album SQL pagination over cover ensures

Pause the entire webview ensure pump during grid page fetches, resume after
SQL settles, add cover-queue backpressure before load-more, and re-probe the
sentinel when pagination finishes so cold-cache scroll does not stall.

* fix(browse): unblock covers, SQL spawn_blocking, and pagination retry

Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump
after SQL, retry load-more when the cover backlog drains while the sentinel
stays visible, and run album browse SQL on spawn_blocking so Tokio stays
responsive during library_advanced_search.

* feat(browse): All Albums client-slice scroll on local index (Artists-style)

Load the filtered catalog once from SQLite when the library index is ready,
then grow the visible grid with useClientSliceInfiniteScroll instead of
offset SQL pagination per scroll. Network-only servers keep page mode.

* fix(browse): lazy local catalog chunks instead of full 50k SQL fetch

All Albums slice mode now loads 200 albums first, shows the grid immediately,
then appends catalog chunks in the background as the user scrolls. Avoids the
blocking library_advanced_search that hung the app on large libraries.

* fix(browse): keep album covers loading during active grid scroll

Pass high ensure priority and the in-page scroll root to AlbumCard on All
Albums, stop pausing cover traffic for background catalog chunks, and never
trim high-priority ensure jobs from the queue during scroll bursts.

* fix(cover): viewport priority tiers and unstick ensure invoke pump

All Albums uses IO-driven high/middle instead of blanket high; release
only on unmount so scroll-ahead jobs are not dropped on reprioritize.
Ensure queue shares one Rust flight per cover id, attaches duplicate
waiters without consuming invoke slots, and times out wedged calls.
Warm the first viewport slice on large grids; acquire CPU permits before
spawn_blocking in cover_cache to avoid blocking-thread deadlocks.

* fix(cover): wire in-page scroll root on New Releases and Lossless grids

AlbumCard IO uses the same viewport id as VirtualCardGrid so cover
ensure priority tracks visible in-page rows like All Albums.

* fix(browse): lazy local artist catalog in 200-row chunks

Replace runLocalBrowseAllArtists bulk fetch with paginated local-index
chunks so large libraries do not hang on open; preserve text search,
starred, letter filter, and client-slice scroll behavior.

* feat(perf): add RSS and thread CPU groups to Performance Probe

Extend performance_cpu_snapshot with process RSS (psysonic + WebKit
children) and in-process thread CPU breakdown. Classify tokio-rt-worker
and tokio-* workers separately from glib, audio/pipewire, reqwest, and
other misc threads (Linux /proc only).

* feat(perf): redesign Performance Probe with tabs, pins, and overlay layout

Split the probe into Monitor (live metric cards, per-metric overlay pins,
corner and opacity controls) and Toggles (diagnostic tree). Share live
polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD.

* feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes

Add 1-minute pinned-metric sparklines with right-aligned growth and a shared
poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite
loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar
rescale flicker.

* docs: CHANGELOG and credits for PR #890

* perf(probe): scoped CPU poll, adjustable interval, lazy thread groups

Read only psysonic + WebKit children instead of the full process table;
macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s
poll slider (default 2s). Collect /proc thread groups only when the
Monitor section is open or a thread metric is pinned.

* feat(perf): three-way overlay mode switch (off / FPS / pinned)

Add Monitor control for overlay visibility: hidden, FPS-only, or pinned
metrics from Monitor. Live CPU poll runs only in pinned mode with live pins.
2026-05-29 04:40:31 +03:00

421 lines
13 KiB
Rust

//! Library cover backfill — one background pass per wake (native, not webview timers).
use super::{state, CoverCacheEnsureArgs, CoverCacheState};
use psysonic_library::cover_backfill::{
clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress,
LibraryCoverBackfillBatchDto, LIBRARY_COVER_CANONICAL_TIER,
};
use psysonic_library::payload::LibrarySyncProgressPayload;
use psysonic_library::repos::sync_state::SyncStateRepository;
use psysonic_library::LibraryRuntime;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Listener, Manager};
use tokio::sync::{Mutex, Semaphore};
use super::{count_cached_cover_ids, dir_usage_for_server};
/// Concurrent library downloads + encodes (hard cap — avoids saturating all CPU cores).
const LIBRARY_BACKFILL_PARALLEL: usize = 2;
const BATCH_SIZE: u32 = 24;
const PENDING_RESTART_THRESHOLD: i64 = 32;
const SYNC_WAIT_MS: u64 = 5000;
const PROGRESS_EVERY_BATCHES: u32 = 8;
#[derive(Clone)]
pub struct CoverBackfillSession {
pub server_index_key: String,
pub library_server_id: String,
pub rest_base_url: String,
pub username: String,
pub password: String,
}
pub struct CoverBackfillWorker {
pub enabled: AtomicBool,
/// When true, the active pass yields so visible-route cover IPC is not starved.
pub ui_priority_hold: AtomicBool,
session: Mutex<Option<CoverBackfillSession>>,
cursor: Mutex<String>,
pass_running: AtomicBool,
backfill_http: Arc<Semaphore>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverBackfillPulseDto {
pub scheduled: u32,
pub exhausted: bool,
pub pending: i64,
pub done: i64,
pub total: i64,
pub status: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverBackfillRunDto {
pub started: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncIdlePayload {
server_id: String,
ok: bool,
}
impl CoverBackfillWorker {
pub fn new() -> Self {
Self {
enabled: AtomicBool::new(false),
ui_priority_hold: AtomicBool::new(false),
session: Mutex::new(None),
cursor: Mutex::new(String::new()),
pass_running: AtomicBool::new(false),
backfill_http: Arc::new(Semaphore::new(LIBRARY_BACKFILL_PARALLEL)),
}
}
pub fn set_ui_priority_hold(&self, hold: bool) {
self.ui_priority_hold.store(hold, Ordering::Relaxed);
}
pub async fn set_session(&self, enabled: bool, session: Option<CoverBackfillSession>) {
self.enabled.store(enabled, Ordering::Relaxed);
*self.session.lock().await = session;
if !enabled {
*self.cursor.lock().await = String::new();
}
}
pub async fn reset_cursor(&self) {
*self.cursor.lock().await = String::new();
}
/// Semaphore-backed library backfill HTTP slots (perf probe).
pub fn pipeline_http_stats(&self) -> (u32, u32, bool) {
let max = LIBRARY_BACKFILL_PARALLEL as u32;
let active = max.saturating_sub(self.backfill_http.available_permits() as u32);
let pass_running = self.pass_running.load(Ordering::Relaxed);
(max, active, pass_running)
}
}
fn sync_allows_cover_backfill(store: &psysonic_library::store::LibraryStore, server_id: &str) -> bool {
let repo = SyncStateRepository::new(store);
match repo.get_sync_phase(server_id, "") {
Ok(Some(phase)) => phase != "initial_sync" && phase != "probing",
_ => true,
}
}
fn session_matches_server(session: &CoverBackfillSession, server_id: &str) -> bool {
server_id == session.server_index_key || server_id == session.library_server_id
}
/// Backfill runs only while this session is still the configured focus (active server).
async fn session_still_focused(worker: &CoverBackfillWorker, expected: &CoverBackfillSession) -> bool {
if !worker.enabled.load(Ordering::Relaxed) {
return false;
}
worker
.session
.lock()
.await
.as_ref()
.is_some_and(|s| s.server_index_key == expected.server_index_key)
}
async fn progress_snapshot(
store: &psysonic_library::store::LibraryStore,
root: &std::path::Path,
library_server_id: &str,
server_index_key: &str,
) -> Result<(i64, i64, i64), String> {
let cached = count_cached_cover_ids(root, server_index_key);
let p = collect_cover_progress(store, library_server_id, root, server_index_key, cached)?;
Ok((p.done, p.total_distinct, p.pending))
}
async fn emit_library_progress(
app: &AppHandle,
session: &CoverBackfillSession,
done: i64,
total: i64,
pending: i64,
root: &std::path::Path,
) {
let (bytes, entry_count) = dir_usage_for_server(root, &session.server_index_key);
let _ = app.emit(
"cover:library-progress",
serde_json::json!({
"serverIndexKey": session.server_index_key,
"done": done,
"total": total,
"pending": pending,
"bytes": bytes,
"entryCount": entry_count,
}),
);
}
async fn ensure_one(
worker: &CoverBackfillWorker,
st: Arc<tokio::sync::Mutex<CoverCacheState>>,
http_sem: Arc<Semaphore>,
app: AppHandle,
session: CoverBackfillSession,
item: psysonic_library::cover_backfill::CoverBackfillItem,
) {
if worker.ui_priority_hold.load(Ordering::Relaxed) {
return;
}
let args = CoverCacheEnsureArgs {
server_index_key: session.server_index_key,
cache_kind: item.cache_kind,
cache_entity_id: item.cache_entity_id,
cover_art_id: item.fetch_cover_art_id,
tier: LIBRARY_COVER_CANONICAL_TIER,
rest_base_url: session.rest_base_url,
username: session.username,
password: session.password,
library_bulk: true,
};
let _ = CoverCacheState::ensure_inner(&st, &app, &args, Some(http_sem)).await;
}
async fn run_full_pass(app: AppHandle, worker: Arc<CoverBackfillWorker>) {
if !worker.enabled.load(Ordering::Relaxed) {
return;
}
let session = worker.session.lock().await.clone();
let Some(session) = session else {
return;
};
let runtime = match app.try_state::<LibraryRuntime>() {
Some(r) => r,
None => return,
};
while !sync_allows_cover_backfill(&runtime.store, &session.library_server_id) {
if !worker.enabled.load(Ordering::Relaxed) {
return;
}
tokio::time::sleep(Duration::from_millis(SYNC_WAIT_MS)).await;
}
let st = match state(&app) {
Ok(s) => s,
Err(_) => return,
};
let root = {
let guard = st.lock().await;
guard.root.clone()
};
let st_arc = st.clone();
worker.reset_cursor().await;
let http_sem = worker.backfill_http.clone();
let mut batch_count = 0u32;
loop {
if !session_still_focused(&worker, &session).await {
break;
}
if worker.ui_priority_hold.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
let cursor = worker.cursor.lock().await.clone();
let cursor_opt = if cursor.is_empty() {
None
} else {
Some(cursor)
};
let store = runtime.store.clone();
let lib_id = session.library_server_id.clone();
let index_key = session.server_index_key.clone();
let root_for_batch = root.clone();
let batch: Option<LibraryCoverBackfillBatchDto> =
match tauri::async_runtime::spawn_blocking(move || {
collect_cover_backfill_batch(
&store,
&lib_id,
&root_for_batch,
&index_key,
cursor_opt.as_deref(),
Some(BATCH_SIZE),
)
})
.await
{
Ok(Ok(b)) => Some(b),
_ => None,
};
let Some(batch) = batch else {
break;
};
batch_count += 1;
if !session_still_focused(&worker, &session).await {
break;
}
let items = batch.items.clone();
let mut paused_for_ui_priority = false;
let batch_slots = Arc::new(Semaphore::new(LIBRARY_BACKFILL_PARALLEL));
let mut set = tokio::task::JoinSet::new();
for item in items {
if worker.ui_priority_hold.load(Ordering::Relaxed) {
paused_for_ui_priority = true;
break;
}
let st = st_arc.clone();
let http_sem = http_sem.clone();
let app = app.clone();
let session = session.clone();
let worker_arc = worker.clone();
let batch_slots = batch_slots.clone();
set.spawn(async move {
let Ok(_slot) = batch_slots.acquire().await else {
return;
};
ensure_one(worker_arc.as_ref(), st, http_sem, app, session, item).await;
});
}
while set.join_next().await.is_some() {}
if paused_for_ui_priority || worker.ui_priority_hold.load(Ordering::Relaxed) {
continue;
}
if batch_count.is_multiple_of(PROGRESS_EVERY_BATCHES) {
if let Ok((done, total, pending)) = progress_snapshot(
&runtime.store,
&root,
&session.library_server_id,
&session.server_index_key,
)
.await
{
emit_library_progress(&app, &session, done, total, pending, &root).await;
}
}
if batch.exhausted {
worker.cursor.lock().await.clear();
if let Ok((done, total, pending)) = progress_snapshot(
&runtime.store,
&root,
&session.library_server_id,
&session.server_index_key,
)
.await
{
if pending > PENDING_RESTART_THRESHOLD {
let root3 = root.clone();
let index_key3 = session.server_index_key.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
clear_cover_fetch_failures(&root3, &index_key3)
})
.await;
}
emit_library_progress(&app, &session, done, total, pending, &root).await;
}
break;
}
if let Some(next) = batch.next_cursor {
*worker.cursor.lock().await = next;
}
}
}
/// Start one full-catalog pass on the Tokio runtime (survives inactive webview).
pub async fn try_schedule_full_pass(app: &AppHandle) -> bool {
let worker = match app.try_state::<Arc<CoverBackfillWorker>>() {
Some(w) => w.inner().clone(),
None => return false,
};
if !worker.enabled.load(Ordering::Relaxed) {
return false;
}
if worker
.pass_running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return false;
}
let app = app.clone();
tauri::async_runtime::spawn(async move {
run_full_pass(app, worker.clone()).await;
worker.pass_running.store(false, Ordering::SeqCst);
});
true
}
fn on_sync_idle(app: &AppHandle, payload: SyncIdlePayload) {
if !payload.ok {
return;
}
let app = app.clone();
tauri::async_runtime::spawn(async move {
let worker = match app.try_state::<Arc<CoverBackfillWorker>>() {
Some(w) => w.inner().clone(),
None => return,
};
if !worker.enabled.load(Ordering::Relaxed) {
return;
}
let session = worker.session.lock().await.clone();
let Some(session) = session else {
return;
};
if !session_matches_server(&session, &payload.server_id) {
return;
}
let _ = try_schedule_full_pass(&app).await;
});
}
/// Listen for library sync completion in native code (not throttled with the webview).
pub fn setup_library_sync_idle_listener(app: &AppHandle) {
let app_handle = app.clone();
let _ = app.listen(LibrarySyncProgressPayload::IDLE_EVENT_NAME, move |event| {
let Ok(payload) = serde_json::from_str::<SyncIdlePayload>(event.payload()) else {
return;
};
on_sync_idle(&app_handle, payload);
});
}
/// Legacy single-step API (optional diagnostics).
pub async fn pulse_backfill(app: &AppHandle, _worker: &Arc<CoverBackfillWorker>) -> CoverBackfillPulseDto {
if try_schedule_full_pass(app).await {
return CoverBackfillPulseDto {
scheduled: 0,
exhausted: false,
pending: 0,
done: 0,
total: 0,
status: "active".into(),
};
}
CoverBackfillPulseDto {
scheduled: 0,
exhausted: true,
pending: 0,
done: 0,
total: 0,
status: "disabled".into(),
}
}