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
+5
View File
@@ -3,6 +3,7 @@
pub mod cli;
mod cover_cache;
mod library_analysis_backfill;
mod lib_commands;
pub use psysonic_integration::discord;
@@ -123,6 +124,9 @@ pub fn run() {
cover_cache::init_cover_cache(app.handle())
.map_err(|e| format!("cover cache init failed: {e}"))?;
library_analysis_backfill::init_library_analysis_backfill(app.handle())
.map_err(|e| format!("library analysis backfill init failed: {e}"))?;
// ── Library track store (psysonic-library, PR-5a + PR-5b) ─────
// PR-5a brought up the read-only Tauri surface + LibraryRuntime.
// PR-5b adds the mutating commands, sync session map, current-job
@@ -734,6 +738,7 @@ pub fn run() {
psysonic_library::commands::library_migrate_server_index_keys,
psysonic_library::commands::library_delete_server_data,
psysonic_library::commands::library_analysis_backfill_batch,
library_analysis_backfill::library_analysis_backfill_configure,
psysonic_library::commands::library_resolve_cover_entry,
cover_cache::cover_cache_peek_batch,
cover_cache::cover_cache_ensure,
@@ -0,0 +1,59 @@
//! Library analysis backfill — native coordinator (advanced analytics strategy).
mod worker;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use worker::{
spawn_coordinator, setup_library_sync_idle_listener, LibraryAnalysisBackfillSession,
LibraryAnalysisBackfillWorker,
};
pub fn init_library_analysis_backfill(app: &AppHandle) -> Result<(), String> {
let worker = Arc::new(LibraryAnalysisBackfillWorker::new());
app.manage(worker.clone());
setup_library_sync_idle_listener(app);
spawn_coordinator(app, worker);
Ok(())
}
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri command surface — args map 1:1 to the JS call (like cover configure + workers).
pub async fn library_analysis_backfill_configure(
app: AppHandle,
enabled: bool,
server_index_key: String,
library_server_id: String,
server_url: String,
username: String,
password: String,
workers: u32,
) -> Result<(), String> {
let worker = app
.try_state::<Arc<LibraryAnalysisBackfillWorker>>()
.ok_or_else(|| "library analysis backfill worker not initialized".to_string())?;
let session = if enabled
&& !server_index_key.is_empty()
&& !library_server_id.is_empty()
&& !server_url.is_empty()
{
Some(LibraryAnalysisBackfillSession {
server_index_key,
library_server_id,
server_url,
username,
password,
workers: workers.max(1),
})
} else {
None
};
worker
.set_session(enabled && session.is_some(), session)
.await;
Ok(())
}
@@ -0,0 +1,343 @@
//! Native library analysis backfill coordinator (advanced strategy).
//!
//! Replaces the webview `while` loop: top-up HTTP/CPU seed backlog without
//! blocking the UI on `library_analysis_backfill_batch` IPC.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use psysonic_analysis::analysis_runtime::{
analysis_pipeline_queue_stats, analysis_set_pipeline_parallelism, enqueue_seed_from_url,
AnalysisBackfillPriority,
};
use psysonic_integration::subsonic::build_stream_view_url;
use psysonic_library::analysis_backfill::{
collect_analysis_backfill_batch, collect_analysis_progress, LibraryAnalysisBackfillBatchDto,
};
use psysonic_library::analysis_backfill_policy::{
library_backfill_needs_top_up, library_backfill_top_up_limit, PipelineBacklogCounts,
};
use psysonic_library::library_readiness::library_server_is_ready;
use psysonic_library::payload::LibrarySyncProgressPayload;
use psysonic_library::repos::TrackRepository;
use psysonic_library::LibraryRuntime;
use serde::Deserialize;
use tauri::{AppHandle, Listener, Manager};
use tokio::sync::Mutex;
const TOP_UP_POLL_MS: u64 = 500;
const STEADY_POLL_MS: u64 = 2000;
const READY_POLL_MS: u64 = 5000;
const EXHAUSTED_PAUSE_MS: u64 = 15_000;
const EXHAUSTED_PENDING_RESCAN_MS: u64 = 2000;
const COMPLETED_RECHECK_MS: u64 = 300_000;
const EXHAUSTED_DONE_STREAK: u32 = 2;
const SYNC_WAIT_MS: u64 = 5000;
#[derive(Clone)]
pub struct LibraryAnalysisBackfillSession {
pub server_index_key: String,
pub library_server_id: String,
pub server_url: String,
pub username: String,
pub password: String,
pub workers: u32,
}
pub struct LibraryAnalysisBackfillWorker {
pub enabled: AtomicBool,
session: Mutex<Option<LibraryAnalysisBackfillSession>>,
cursor: Mutex<Option<String>>,
completed_total: Mutex<Option<i64>>,
exhausted_streak: Mutex<u32>,
}
impl LibraryAnalysisBackfillWorker {
pub fn new() -> Self {
Self {
enabled: AtomicBool::new(false),
session: Mutex::new(None),
cursor: Mutex::new(None),
completed_total: Mutex::new(None),
exhausted_streak: Mutex::new(0),
}
}
pub async fn set_session(&self, enabled: bool, session: Option<LibraryAnalysisBackfillSession>) {
self.enabled.store(enabled, Ordering::Relaxed);
*self.session.lock().await = session;
if !enabled {
*self.cursor.lock().await = None;
*self.completed_total.lock().await = None;
*self.exhausted_streak.lock().await = 0;
}
}
}
async fn session_still_focused(
worker: &LibraryAnalysisBackfillWorker,
expected: &LibraryAnalysisBackfillSession,
) -> 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)
}
fn sync_blocks_backfill(store: &psysonic_library::store::LibraryStore, server_id: &str) -> bool {
use psysonic_library::repos::sync_state::SyncStateRepository;
let repo = SyncStateRepository::new(store);
match repo.get_sync_phase(server_id, "") {
Ok(Some(phase)) => phase == "initial_sync" || phase == "probing",
_ => false,
}
}
fn session_matches_server(session: &LibraryAnalysisBackfillSession, server_id: &str) -> bool {
server_id == session.server_index_key || server_id == session.library_server_id
}
struct CoordinatorTick {
sleep_ms: u64,
}
async fn run_coordinator_forever(app: AppHandle, worker: Arc<LibraryAnalysisBackfillWorker>) {
loop {
let tick = coordinator_tick(&app, worker.as_ref()).await;
tokio::time::sleep(Duration::from_millis(tick.sleep_ms)).await;
}
}
async fn coordinator_tick(
app: &AppHandle,
worker: &LibraryAnalysisBackfillWorker,
) -> CoordinatorTick {
if !worker.enabled.load(Ordering::Relaxed) {
return CoordinatorTick { sleep_ms: STEADY_POLL_MS };
}
let session = worker.session.lock().await.clone();
let Some(session) = session else {
return CoordinatorTick { sleep_ms: STEADY_POLL_MS };
};
if !session_still_focused(worker, &session).await {
return CoordinatorTick { sleep_ms: STEADY_POLL_MS };
}
analysis_set_pipeline_parallelism(session.workers as usize);
let runtime = match app.try_state::<LibraryRuntime>() {
Some(r) => r,
None => return CoordinatorTick { sleep_ms: READY_POLL_MS },
};
if let Some(done_total) = *worker.completed_total.lock().await {
let store = runtime.store.clone();
let lib_id = session.library_server_id.clone();
let still_same = tauri::async_runtime::spawn_blocking(move || {
TrackRepository::new(&store)
.count_live_tracks(&lib_id)
.map(|n| n == done_total)
})
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(false);
if still_same {
return CoordinatorTick {
sleep_ms: COMPLETED_RECHECK_MS,
};
}
*worker.completed_total.lock().await = None;
*worker.cursor.lock().await = None;
*worker.exhausted_streak.lock().await = 0;
}
while sync_blocks_backfill(&runtime.store, &session.library_server_id) {
if !worker.enabled.load(Ordering::Relaxed) {
return CoordinatorTick { sleep_ms: SYNC_WAIT_MS };
}
tokio::time::sleep(Duration::from_millis(SYNC_WAIT_MS)).await;
}
let store = runtime.store.clone();
let lib_id = session.library_server_id.clone();
let ready = tauri::async_runtime::spawn_blocking(move || library_server_is_ready(&store, &lib_id))
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(false);
if !ready {
return CoordinatorTick { sleep_ms: READY_POLL_MS };
}
let stats = analysis_pipeline_queue_stats();
let counts = PipelineBacklogCounts {
http_queued: stats.http_queued as u32,
http_download_active: stats.http_download_active as u32,
cpu_queued: stats.cpu_queued as u32,
cpu_decode_active: stats.cpu_decode_active as u32,
};
if !library_backfill_needs_top_up(counts, session.workers) {
return CoordinatorTick { sleep_ms: STEADY_POLL_MS };
}
let fetch_limit = library_backfill_top_up_limit(counts, session.workers);
if fetch_limit == 0 {
return CoordinatorTick { sleep_ms: TOP_UP_POLL_MS };
}
let cursor = worker.cursor.lock().await.clone();
let app_for_batch = app.clone();
let lib_id = session.library_server_id.clone();
let batch: Option<LibraryAnalysisBackfillBatchDto> = tauri::async_runtime::spawn_blocking(
move || {
let runtime = app_for_batch
.try_state::<LibraryRuntime>()
.ok_or_else(|| "LibraryRuntime not available".to_string())?;
collect_analysis_backfill_batch(
&app_for_batch,
&runtime,
lib_id.trim(),
cursor.as_deref().filter(|s| !s.is_empty()),
Some(fetch_limit),
)
},
)
.await
.ok()
.and_then(|r| r.ok());
let Some(batch) = batch else {
return CoordinatorTick { sleep_ms: TOP_UP_POLL_MS };
};
*worker.cursor.lock().await = batch.next_cursor.clone();
let enqueued_count = batch.track_ids.len();
let track_ids = batch.track_ids.clone();
let index_key = session.server_index_key.clone();
let server_url = session.server_url.clone();
let username = session.username.clone();
let password = session.password.clone();
let app_for_enqueue = app.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
for track_id in &track_ids {
let url = build_stream_view_url(&server_url, &username, &password, track_id);
let _ = enqueue_seed_from_url(
&app_for_enqueue,
track_id,
&url,
Some(index_key.as_str()),
Some(AnalysisBackfillPriority::Low),
false,
);
}
})
.await;
if enqueued_count > 0 {
*worker.exhausted_streak.lock().await = 0;
}
if batch.exhausted {
let app_for_progress = app.clone();
let lib_id = session.library_server_id.clone();
let progress = tauri::async_runtime::spawn_blocking(move || {
let runtime = app_for_progress
.try_state::<LibraryRuntime>()
.ok_or_else(|| "LibraryRuntime not available".to_string())?;
collect_analysis_progress(&app_for_progress, &runtime, lib_id.trim())
})
.await
.ok()
.and_then(|r| r.ok());
let pending = progress.as_ref().map(|p| p.pending_tracks).unwrap_or(-1);
if pending <= 0 && enqueued_count == 0 {
let mut streak = worker.exhausted_streak.lock().await;
*streak += 1;
if *streak >= EXHAUSTED_DONE_STREAK {
if let Some(p) = progress {
*worker.completed_total.lock().await = Some(p.total_tracks);
}
*worker.cursor.lock().await = None;
return CoordinatorTick {
sleep_ms: COMPLETED_RECHECK_MS,
};
}
} else {
*worker.exhausted_streak.lock().await = 0;
}
if pending > 0 {
*worker.cursor.lock().await = None;
return CoordinatorTick {
sleep_ms: EXHAUSTED_PENDING_RESCAN_MS,
};
}
*worker.cursor.lock().await = None;
return CoordinatorTick {
sleep_ms: EXHAUSTED_PAUSE_MS,
};
}
CoordinatorTick { sleep_ms: TOP_UP_POLL_MS }
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncIdlePayload {
server_id: String,
ok: bool,
}
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<LibraryAnalysisBackfillWorker>>() {
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;
}
*worker.cursor.lock().await = None;
*worker.exhausted_streak.lock().await = 0;
});
}
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);
});
}
pub fn spawn_coordinator(app: &AppHandle, worker: Arc<LibraryAnalysisBackfillWorker>) {
let app = app.clone();
tauri::async_runtime::spawn(async move {
run_coordinator_forever(app, worker).await;
});
}