fix(analysis): native library backfill coordinator for advanced strategy (#881)

* feat(analysis): native library backfill coordinator for advanced strategy

Move advanced analytics scheduling from the webview loop into a Rust
background worker (configure + spawn_blocking batch/enqueue), matching
cover backfill. Scan hash+BPM gap tracks instead of the full library;
suppress low-priority analysis UI events; limit loudness refresh IPC to
the playback window.

* fix(analysis): flat backfill configure IPC and restore probe track-perf

Use flattened Tauri args like cover backfill so release/prod invoke works;
keep emitting analysis:track-perf for library low-priority work so Performance
Probe tpm/last-track stats update while waveform/enrichment UI events stay suppressed.

* chore(analysis): fix clippy and drop duplicate TS backfill policy

Allow too_many_arguments on library_analysis_backfill_configure for CI;
remove unused frontend batch API and TS watermark helpers now owned in Rust;
clarify analysis_emits_ui_events comment for low-priority track-perf.

* docs: CHANGELOG and credits for PR #881 native analysis backfill
This commit is contained in:
cucadmuh
2026-05-28 10:49:31 +03:00
committed by GitHub
parent df3533bb5a
commit b24a7fc5cb
21 changed files with 888 additions and 387 deletions
@@ -31,14 +31,12 @@ pub struct LibraryAnalysisProgressDto {
enum ScanMode {
Candidates,
Full,
/// Tracks with hash + BPM that may still need waveform/LUFS/enrichment gaps.
HashBpmGaps,
}
/// Candidate SQL skips tracks that already have `content_hash` and an analysis BPM
/// fact. Those rows can still need waveform/LUFS — the full-table pass must start
/// from the first id, not from the last candidate cursor.
fn begin_full_library_scan() -> (ScanMode, Option<String>) {
(ScanMode::Full, None)
fn begin_hash_bpm_gap_scan() -> (ScanMode, Option<String>) {
(ScanMode::HashBpmGaps, None)
}
pub fn collect_analysis_backfill_batch(
@@ -64,16 +62,18 @@ pub fn collect_analysis_backfill_batch(
ScanMode::Candidates => {
repo.list_analysis_candidate_ids_after(server_id, after.as_deref(), SCAN_CHUNK)?
}
ScanMode::Full => repo.list_track_ids_after(server_id, after.as_deref(), SCAN_CHUNK)?,
ScanMode::HashBpmGaps => {
repo.list_analysis_hash_bpm_ids_after(server_id, after.as_deref(), SCAN_CHUNK)?
}
};
if page.is_empty() {
match mode {
ScanMode::Candidates => {
(mode, after) = begin_full_library_scan();
(mode, after) = begin_hash_bpm_gap_scan();
continue;
}
ScanMode::Full => {
ScanMode::HashBpmGaps => {
return Ok(LibraryAnalysisBackfillBatchDto {
track_ids: found,
next_cursor: after,
@@ -105,9 +105,9 @@ pub fn collect_analysis_backfill_batch(
if page_len < SCAN_CHUNK {
match mode {
ScanMode::Candidates => {
(mode, after) = begin_full_library_scan();
(mode, after) = begin_hash_bpm_gap_scan();
}
ScanMode::Full => {
ScanMode::HashBpmGaps => {
return Ok(LibraryAnalysisBackfillBatchDto {
track_ids: found,
next_cursor: after,
@@ -0,0 +1,64 @@
//! Library analysis backfill queue watermark — native coordinator (spec: Settings → Library).
pub const LIBRARY_BACKLOG_DEPTH_MULTIPLIER: u32 = 3;
pub const LIBRARY_BACKLOG_MIN: u32 = 8;
pub const LIBRARY_BACKLOG_MAX: u32 = 240;
pub const LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE: u32 = 20;
#[derive(Debug, Clone, Copy, Default)]
pub struct PipelineBacklogCounts {
pub http_queued: u32,
pub http_download_active: u32,
pub cpu_queued: u32,
pub cpu_decode_active: u32,
}
impl PipelineBacklogCounts {
pub fn total(self) -> u32 {
self.http_queued
.saturating_add(self.http_download_active)
.saturating_add(self.cpu_queued)
.saturating_add(self.cpu_decode_active)
}
}
pub fn compute_library_backfill_target_depth(workers: u32) -> u32 {
let w = workers.max(1);
(w * LIBRARY_BACKLOG_DEPTH_MULTIPLIER).clamp(LIBRARY_BACKLOG_MIN, LIBRARY_BACKLOG_MAX)
}
pub fn library_backfill_needs_top_up(counts: PipelineBacklogCounts, workers: u32) -> bool {
counts.total() < compute_library_backfill_target_depth(workers)
}
pub fn library_backfill_top_up_limit(counts: PipelineBacklogCounts, workers: u32) -> u32 {
let target = compute_library_backfill_target_depth(workers);
let deficit = target.saturating_sub(counts.total());
if deficit == 0 {
return 0;
}
deficit.min(LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_depth_uses_floor_and_cap() {
assert_eq!(compute_library_backfill_target_depth(1), 8);
assert_eq!(compute_library_backfill_target_depth(4), 12);
assert_eq!(compute_library_backfill_target_depth(100), 240);
}
#[test]
fn top_up_when_backlog_below_target() {
let counts = PipelineBacklogCounts {
http_queued: 2,
http_download_active: 1,
..Default::default()
};
assert!(library_backfill_needs_top_up(counts, 8));
assert_eq!(library_backfill_top_up_limit(counts, 8), 20);
}
}
@@ -13,6 +13,8 @@ pub mod album_compilation_filter;
pub mod browse_support;
mod advanced_search_mood;
pub mod analysis_backfill;
pub mod analysis_backfill_policy;
pub mod library_readiness;
pub mod artist_lossless_browse;
pub mod cover_backfill;
pub mod cover_resolve;
@@ -0,0 +1,64 @@
//! When the local library index is safe for background analysis backfill.
use crate::dto::local_tracks_max_updated_ms;
use crate::repos::{sync_state::SyncStateRepository, TrackRepository};
use crate::store::LibraryStore;
/// Mirrors frontend `libraryStatusIsReady` / `libraryIsReady` (spec §9.3).
pub fn library_server_is_ready(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
let repo = SyncStateRepository::new(store);
let phase = repo
.get_sync_phase(server_id, "")
.map_err(|e| e.to_string())?;
let Some(phase) = phase else {
return Ok(has_any_local_tracks(store, server_id));
};
if phase == "ready" {
return Ok(true);
}
if phase == "initial_sync" {
let local = repo
.get_local_track_count(server_id, "")
.map_err(|e| e.to_string())?
.unwrap_or(0);
let server = repo
.get_server_track_count(server_id, "")
.map_err(|e| e.to_string())?
.unwrap_or(0);
if server > 0 && (local as f64 / server as f64) >= 0.95 {
return Ok(true);
}
return Ok(false);
}
if phase == "idle" {
if has_any_local_tracks(store, server_id) {
return Ok(true);
}
if repo
.has_last_full_sync_at(server_id, "")
.map_err(|e| e.to_string())?
{
return Ok(true);
}
if local_tracks_max_updated_ms(store, server_id)?
.is_some_and(|ms| ms > 0)
{
return Ok(true);
}
let local = repo
.get_local_track_count(server_id, "")
.map_err(|e| e.to_string())?
.unwrap_or(0);
return Ok(local > 0);
}
Ok(false)
}
fn has_any_local_tracks(store: &LibraryStore, server_id: &str) -> bool {
TrackRepository::new(store)
.count_live_tracks(server_id)
.map(|n| n > 0)
.unwrap_or(false)
}
@@ -292,6 +292,37 @@ impl<'a> TrackRepository<'a> {
})
}
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
pub fn list_analysis_hash_bpm_ids_after(
&self,
server_id: &str,
after_id: Option<&str>,
limit: usize,
) -> Result<Vec<String>, String> {
if limit == 0 {
return Ok(vec![]);
}
let limit = i64::try_from(limit).map_err(|e| e.to_string())?;
self.store.with_read_conn(|conn| {
let sql = "SELECT t.id FROM track t \
WHERE t.server_id = ?1 AND t.deleted = 0 \
AND (?2 IS NULL OR t.id > ?2) \
AND t.content_hash IS NOT NULL \
AND EXISTS ( \
SELECT 1 FROM track_fact f \
WHERE f.server_id = t.server_id \
AND f.track_id = t.id \
AND f.fact_kind = 'bpm' \
AND f.source_kind = 'analysis' \
) \
ORDER BY t.id ASC LIMIT ?3";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![server_id, after_id, limit], |row| row.get(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()
})
}
/// Cheap SQL prefilter: tracks that never received a playback hash and/or
/// lack an oximedia BPM fact. Full analysis gaps are confirmed per id via
/// [`TrackAnalysisNeedsWorkQuery`] in the shell crate.