//! Advanced analytics strategy — batch-select library tracks that still need //! waveform / loudness / enrichment work (spec: Settings → Library). use psysonic_core::ports::TrackAnalysisNeedsWorkQuery; use tauri::{AppHandle, Manager}; use crate::repos::TrackRepository; use crate::runtime::LibraryRuntime; const SCAN_CHUNK: usize = 500; const MAX_SCAN_IDS_PER_CALL: usize = 10_000; const DEFAULT_BATCH: u32 = 20; const MAX_BATCH: u32 = 50; const PROGRESS_SCAN_CHUNK: usize = 1000; #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct LibraryAnalysisBackfillBatchDto { pub track_ids: Vec, pub next_cursor: Option, pub exhausted: bool, } #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct LibraryAnalysisProgressDto { pub total_tracks: i64, pub pending_tracks: i64, pub done_tracks: i64, } /// Persisted across native coordinator ticks (see `library_analysis_backfill` worker). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub enum AnalysisBackfillScanPhase { #[default] Candidates, /// Tracks with hash + BPM that may still need waveform/LUFS/enrichment gaps. HashBpmGaps, } enum ScanMode { Candidates, HashBpmGaps, } impl From for ScanMode { fn from(p: AnalysisBackfillScanPhase) -> Self { match p { AnalysisBackfillScanPhase::Candidates => ScanMode::Candidates, AnalysisBackfillScanPhase::HashBpmGaps => ScanMode::HashBpmGaps, } } } impl From for AnalysisBackfillScanPhase { fn from(m: ScanMode) -> Self { match m { ScanMode::Candidates => AnalysisBackfillScanPhase::Candidates, ScanMode::HashBpmGaps => AnalysisBackfillScanPhase::HashBpmGaps, } } } /// End of the cheap candidate SQL pass — start hash/BPM gap scan from the first id. fn begin_hash_bpm_gap_scan_from_start() -> (ScanMode, Option) { (ScanMode::HashBpmGaps, None) } /// Candidate page empty only because `id > cursor`; continue the id walk in hash/BPM phase. fn begin_hash_bpm_gap_scan_from_cursor(cursor: String) -> (ScanMode, Option) { (ScanMode::HashBpmGaps, Some(cursor)) } fn advance_after_empty_candidate_page(after: Option) -> (ScanMode, Option) { match after { None => begin_hash_bpm_gap_scan_from_start(), Some(cursor) => begin_hash_bpm_gap_scan_from_cursor(cursor), } } pub fn collect_analysis_backfill_batch( app: &AppHandle, runtime: &LibraryRuntime, server_id: &str, phase: AnalysisBackfillScanPhase, cursor: Option<&str>, limit: Option, ) -> Result<(LibraryAnalysisBackfillBatchDto, AnalysisBackfillScanPhase), String> { let want = limit.unwrap_or(DEFAULT_BATCH).min(MAX_BATCH) as usize; let needs_work = app .try_state::() .ok_or_else(|| "TrackAnalysisNeedsWorkQuery not registered".to_string())?; let repo = TrackRepository::new(&runtime.store); let mut found = Vec::with_capacity(want); let mut after = cursor.map(str::to_string); let mut mode = ScanMode::from(phase); let mut scanned = 0usize; while found.len() < want && scanned < MAX_SCAN_IDS_PER_CALL { let page = match mode { ScanMode::Candidates => { repo.list_analysis_candidate_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) = advance_after_empty_candidate_page(after); continue; } ScanMode::HashBpmGaps => { let dto = LibraryAnalysisBackfillBatchDto { track_ids: found, next_cursor: after, exhausted: true, }; return Ok((dto, AnalysisBackfillScanPhase::HashBpmGaps)); } } } let page_len = page.len(); for id in page { scanned += 1; after = Some(id.clone()); if needs_work.needs_work(server_id, &id)? { found.push(id); if found.len() >= want { break; } } if scanned >= MAX_SCAN_IDS_PER_CALL { break; } } if found.len() >= want || scanned >= MAX_SCAN_IDS_PER_CALL { break; } if page_len < SCAN_CHUNK { match mode { ScanMode::Candidates => { (mode, after) = begin_hash_bpm_gap_scan_from_start(); } ScanMode::HashBpmGaps => { let dto = LibraryAnalysisBackfillBatchDto { track_ids: found, next_cursor: after, exhausted: true, }; return Ok((dto, AnalysisBackfillScanPhase::HashBpmGaps)); } } } } let dto = LibraryAnalysisBackfillBatchDto { track_ids: found, next_cursor: after, exhausted: false, }; Ok((dto, ScanMode::into(mode))) } pub fn collect_analysis_progress( app: &AppHandle, runtime: &LibraryRuntime, server_id: &str, ) -> Result { let needs_work = app .try_state::() .ok_or_else(|| "TrackAnalysisNeedsWorkQuery not registered".to_string())?; let repo = TrackRepository::new(&runtime.store); let total = repo.count_live_tracks(server_id)?; if total <= 0 { return Ok(LibraryAnalysisProgressDto { total_tracks: 0, pending_tracks: 0, done_tracks: 0, }); } let mut pending: i64 = 0; let mut after: Option = None; loop { let page = repo.list_track_ids_after( server_id, after.as_deref(), PROGRESS_SCAN_CHUNK, )?; if page.is_empty() { break; } for id in page { after = Some(id.clone()); if needs_work.needs_work(server_id, &id)? { pending += 1; } } } let done = total.saturating_sub(pending); Ok(LibraryAnalysisProgressDto { total_tracks: total, pending_tracks: pending, done_tracks: done, }) } #[cfg(test)] mod tests { use super::*; #[test] fn empty_candidate_page_with_cursor_keeps_id_walk() { let (mode, after) = advance_after_empty_candidate_page(Some("track-z".to_string())); assert!(matches!(mode, ScanMode::HashBpmGaps)); assert_eq!(after.as_deref(), Some("track-z")); } #[test] fn empty_candidate_page_without_cursor_starts_gap_scan_at_beginning() { let (mode, after) = advance_after_empty_candidate_page(None); assert!(matches!(mode, ScanMode::HashBpmGaps)); assert!(after.is_none()); } #[test] fn finished_candidate_phase_resets_gap_scan_cursor() { let (mode, after) = begin_hash_bpm_gap_scan_from_start(); assert!(matches!(mode, ScanMode::HashBpmGaps)); assert!(after.is_none()); } }