//! Analysis workflow: feature extraction, backfill, and the tag-suggestion //! review flow. Drives the analysis worker and owns the review/backfill //! bookkeeping applied to its `BackendEvent`s in `poll_workers`. use super::{ AnalysisConfig, AnalysisResult, BrowserState, ImportMode, ReviewItem, SuggestionState, }; /// How many analysis results to accumulate before flushing them to the DB in one /// transaction. Bounds buffered memory while keeping per-result fsyncs rare. const ANALYSIS_SAVE_BATCH: usize = 64; impl BrowserState { /// Persist buffered analysis results in one transaction, then apply tag rules /// to the flushed hashes (also one transaction). Rules run after the save so /// they evaluate against the persisted analysis. No-op when the buffer empty. fn flush_analysis_saves(&mut self) { if self.import_wf.analysis_save_buffer.is_empty() { return; } let results = std::mem::take(&mut self.import_wf.analysis_save_buffer); let hashes: Vec = results.iter().map(|r| r.hash.clone()).collect(); let n = results.len(); if let Err(e) = self.backend.save_analysis_batch(&results) { // The transaction is atomic (no partial write), but the user should // know the analysis didn't persist rather than see silent loss. self.status = format!("Failed to save analysis for {n} samples: {e}"); return; } super::log_backend_err( "apply_rules_to_samples", self.backend.apply_rules_to_samples(&hashes), ); } /// Silently recompute feature vectors for samples that lack a current-version one /// (libraries imported before the `sample_features` store existed, or after a /// feature-extractor version bump). Reuses the analysis worker but suppresses the /// review/progress screens, and yields to user-initiated analysis (which cancels and /// replaces the worker). Resumable: leftover samples are picked up on the next call. pub fn start_backfill(&mut self) { if self.import_wf.backfill_in_progress || !matches!(self.import_wf.import_mode, ImportMode::None) { return; } let needed = self.backend.samples_needing_features().unwrap_or_default(); if needed.is_empty() { return; } let count = needed.len(); self.import_wf.backfill_in_progress = true; self.status = format!("Backfilling features for {count} samples…"); if self .backend .start_analysis(needed, AnalysisConfig::default()) .is_err() { self.import_wf.backfill_in_progress = false; self.status = "Feature backfill could not start".to_string(); } } /// Begin the analysis workflow by showing the configuration screen. pub fn start_analysis_flow(&mut self, sample_hashes: Vec<(String, String)>) { if sample_hashes.is_empty() { return; } self.import_wf.import_mode = ImportMode::ConfigureAnalysis { sample_hashes, config: AnalysisConfig::default(), }; } /// Spawn the background analysis worker and start processing samples. pub fn run_analysis(&mut self, sample_hashes: Vec<(String, String)>, config: AnalysisConfig) { // This is a foreground, user-initiated analysis (it drives the Analyzing // screen and populates pending_review_items). If a background Settings // "Backfill audio features" run was in flight, its flag must not survive // into this batch: the completion handler routes results by // `backfill_in_progress`, so a leaked flag would make this import's // Analyzing screen freeze at 0/total and silently skip tag review. The // foreground analysis supersedes the backfill. (Mirrors cancel_import's // quick_import reset, the flag-leak class from fuzz-2026-07-06 B1.) self.import_wf.backfill_in_progress = false; // Stash parameters so the retry button can restart analysis. self.import_wf .last_analysis_hashes .clone_from(&sample_hashes); self.import_wf.last_analysis_config = Some(config.clone()); let total = sample_hashes.len(); self.import_wf.pending_review_items.clear(); self.import_wf.analysis_errors.clear(); self.import_wf.operation_progress = Some(crate::state::OperationProgress::new()); self.import_wf.import_mode = ImportMode::Analyzing { completed: 0, total, current_name: String::new(), }; self.status = format!("Analyzing {total} samples..."); super::log_backend_err( "start_analysis (import)", self.backend.start_analysis(sample_hashes, config), ); } /// Cancel the running analysis batch and land in the acknowledgement /// screen (C-3) so the user sees what was analysed vs what was discarded. /// Falls through to `None` only when there's no meaningful progress to /// acknowledge (cancel before any work happened). pub fn cancel_analysis(&mut self) { let progress = match &self.import_wf.import_mode { ImportMode::Analyzing { completed, total, .. } => Some((*completed, *total)), _ => None, }; let _ = self.backend.cancel_analysis(); // Persist whatever finished before the cancel landed (matches the old // save-per-result behavior), then clear the in-flight flags so a later // import isn't silently treated as quick/backfill (they only reset on the // happy path otherwise, the flag-leak finding). self.flush_analysis_saves(); self.import_wf.quick_import = false; self.import_wf.backfill_in_progress = false; self.import_wf.pending_review_items.clear(); self.status = "Analysis cancelled".to_string(); self.import_wf.import_mode = match progress { Some((completed, total)) if total > 0 => ImportMode::OperationCancelled { kind: crate::state::CancelKind::Analysis, completed, total, destination: None, }, _ => ImportMode::None, }; } /// Cancel the current analysis and restart it with the same parameters. pub fn retry_analysis(&mut self) { self.cancel_analysis(); let hashes = std::mem::take(&mut self.import_wf.last_analysis_hashes); if let Some(config) = self.import_wf.last_analysis_config.take() && !hashes.is_empty() { self.run_analysis(hashes, config); } } /// Update the Analyzing screen (or a silent backfill status line) as samples /// are processed. `poll_workers` handler for `AnalysisProgress`. pub(super) fn on_analysis_progress( &mut self, completed: usize, total: usize, current_name: String, ) { if self.import_wf.backfill_in_progress { // Silent: keep the normal browser view, just a status line. self.status = format!("Backfilling features… {completed}/{total}"); } else { self.import_wf.import_mode = ImportMode::Analyzing { completed, total, current_name, }; } } /// Buffer a finished sample's analysis for batched persistence and, outside /// backfill, queue it for tag review. `poll_workers` handler for /// `AnalysisSampleDone`. pub(super) fn on_analysis_sample_done( &mut self, result: AnalysisResult, suggestions: Vec, ) { // Buffer the DB write; flushed in one transaction every // ANALYSIS_SAVE_BATCH results and on AnalysisBatchComplete. // (Rules are applied per flushed batch, after the save, so they // still see the persisted analysis.) self.import_wf.analysis_save_buffer.push(result.clone()); if self.import_wf.analysis_save_buffer.len() >= ANALYSIS_SAVE_BATCH { self.flush_analysis_saves(); } // Backfill: persist vectors + apply rules only, no review queue. if !self.import_wf.backfill_in_progress { let hash = audiofiles_core::SampleHash::from_trusted(result.hash.clone()); let name = self .backend .sample_original_name(&hash) .unwrap_or_else(|_| hash.to_string()); self.import_wf.pending_review_items.push(ReviewItem { hash, name, suggestions: suggestions .into_iter() .map(|s| SuggestionState { accepted: true, suggestion: s, }) .collect(), result, }); } } /// Record a per-sample analysis failure for the error-review screen. /// `poll_workers` handler for `AnalysisSampleError`. pub(super) fn on_analysis_sample_error(&mut self, hash: String, error: String) { let name = self .backend .sample_original_name(&hash) .unwrap_or_else(|_| hash.clone()); self.import_wf .analysis_errors .push(super::AnalysisFileError { hash, name, error }); } /// Flush buffered analysis, then route to the review, backfill-done, or /// error screen. Returns `true` when the caller (`poll_workers`) should stop /// draining this event batch and return immediately (backfill completion). /// `poll_workers` handler for `AnalysisBatchComplete`. pub(super) fn on_analysis_batch_complete(&mut self) -> bool { // Persist any analysis results still buffered, then apply rules, // before the review/backfill bookkeeping below. self.flush_analysis_saves(); if self.import_wf.backfill_in_progress { self.import_wf.backfill_in_progress = false; let errors = self.import_wf.analysis_errors.len(); self.import_wf.analysis_errors.clear(); self.import_wf.pending_review_items.clear(); self.refresh_contents(); self.status = if errors == 0 { "Feature backfill complete".to_string() } else { format!("Feature backfill complete ({errors} skipped)") }; return true; } let items = std::mem::take(&mut self.import_wf.pending_review_items); let error_count = self.import_wf.analysis_errors.len(); if self.import_wf.quick_import { // Auto-accept all suggestions in quick mode for item in &items { for s in &item.suggestions { super::log_backend_err( "add_tag (import suggestion)", self.backend.add_tag(&item.hash, &s.suggestion.tag), ); } } self.import_wf.quick_import = false; self.refresh_contents(); self.refresh_vfs_list(); let count = items.len(); self.import_wf.import_mode = ImportMode::None; self.status = if error_count == 0 { format!("Quick import complete: {count} samples analyzed") } else { format!("Quick import complete: {count} samples analyzed ({error_count} errors)") }; } else if items.is_empty() { if self.has_import_errors() { self.import_wf.import_mode = ImportMode::ReviewErrors; } else { self.import_wf.import_mode = ImportMode::None; self.status = "Analysis complete, no results".to_string(); } } else { let count = items.len(); self.import_wf.import_mode = ImportMode::ReviewSuggestions { items, current_idx: 0, sort: crate::state::ReviewSort::ImportOrder, }; self.status = if error_count == 0 { format!("Analyzed {count} samples") } else { format!("Analyzed {count} samples ({error_count} errors)") }; } false } }