Skip to main content

max / audiofiles

12.2 KB · 292 lines History Blame Raw
1 //! Analysis workflow: feature extraction, backfill, and the tag-suggestion
2 //! review flow. Drives the analysis worker and owns the review/backfill
3 //! bookkeeping applied to its `BackendEvent`s in `poll_workers`.
4
5 use super::{
6 AnalysisConfig, AnalysisResult, BrowserState, ImportMode, ReviewItem, SuggestionState,
7 };
8
9 /// How many analysis results to accumulate before flushing them to the DB in one
10 /// transaction. Bounds buffered memory while keeping per-result fsyncs rare.
11 const ANALYSIS_SAVE_BATCH: usize = 64;
12
13 impl BrowserState {
14 /// Persist buffered analysis results in one transaction, then apply tag rules
15 /// to the flushed hashes (also one transaction). Rules run after the save so
16 /// they evaluate against the persisted analysis. No-op when the buffer empty.
17 fn flush_analysis_saves(&mut self) {
18 if self.import_wf.analysis_save_buffer.is_empty() {
19 return;
20 }
21 let results = std::mem::take(&mut self.import_wf.analysis_save_buffer);
22 let hashes: Vec<String> = results.iter().map(|r| r.hash.clone()).collect();
23 let n = results.len();
24 if let Err(e) = self.backend.save_analysis_batch(&results) {
25 // The transaction is atomic (no partial write), but the user should
26 // know the analysis didn't persist rather than see silent loss.
27 self.status = format!("Failed to save analysis for {n} samples: {e}");
28 return;
29 }
30 super::log_backend_err(
31 "apply_rules_to_samples",
32 self.backend.apply_rules_to_samples(&hashes),
33 );
34 }
35
36 /// Silently recompute feature vectors for samples that lack a current-version one
37 /// (libraries imported before the `sample_features` store existed, or after a
38 /// feature-extractor version bump). Reuses the analysis worker but suppresses the
39 /// review/progress screens, and yields to user-initiated analysis (which cancels and
40 /// replaces the worker). Resumable: leftover samples are picked up on the next call.
41 pub fn start_backfill(&mut self) {
42 if self.import_wf.backfill_in_progress
43 || !matches!(self.import_wf.import_mode, ImportMode::None)
44 {
45 return;
46 }
47 let needed = self.backend.samples_needing_features().unwrap_or_default();
48 if needed.is_empty() {
49 return;
50 }
51 let count = needed.len();
52 self.import_wf.backfill_in_progress = true;
53 self.status = format!("Backfilling features for {count} samples…");
54 if self
55 .backend
56 .start_analysis(needed, AnalysisConfig::default())
57 .is_err()
58 {
59 self.import_wf.backfill_in_progress = false;
60 self.status = "Feature backfill could not start".to_string();
61 }
62 }
63
64 /// Begin the analysis workflow by showing the configuration screen.
65 pub fn start_analysis_flow(&mut self, sample_hashes: Vec<(String, String)>) {
66 if sample_hashes.is_empty() {
67 return;
68 }
69 self.import_wf.import_mode = ImportMode::ConfigureAnalysis {
70 sample_hashes,
71 config: AnalysisConfig::default(),
72 };
73 }
74
75 /// Spawn the background analysis worker and start processing samples.
76 pub fn run_analysis(&mut self, sample_hashes: Vec<(String, String)>, config: AnalysisConfig) {
77 // This is a foreground, user-initiated analysis (it drives the Analyzing
78 // screen and populates pending_review_items). If a background Settings
79 // "Backfill audio features" run was in flight, its flag must not survive
80 // into this batch: the completion handler routes results by
81 // `backfill_in_progress`, so a leaked flag would make this import's
82 // Analyzing screen freeze at 0/total and silently skip tag review. The
83 // foreground analysis supersedes the backfill. (Mirrors cancel_import's
84 // quick_import reset, the flag-leak class from fuzz-2026-07-06 B1.)
85 self.import_wf.backfill_in_progress = false;
86
87 // Stash parameters so the retry button can restart analysis.
88 self.import_wf
89 .last_analysis_hashes
90 .clone_from(&sample_hashes);
91 self.import_wf.last_analysis_config = Some(config.clone());
92
93 let total = sample_hashes.len();
94
95 self.import_wf.pending_review_items.clear();
96 self.import_wf.analysis_errors.clear();
97 self.import_wf.operation_progress = Some(crate::state::OperationProgress::new());
98 self.import_wf.import_mode = ImportMode::Analyzing {
99 completed: 0,
100 total,
101 current_name: String::new(),
102 };
103 self.status = format!("Analyzing {total} samples...");
104
105 super::log_backend_err(
106 "start_analysis (import)",
107 self.backend.start_analysis(sample_hashes, config),
108 );
109 }
110
111 /// Cancel the running analysis batch and land in the acknowledgement
112 /// screen (C-3) so the user sees what was analysed vs what was discarded.
113 /// Falls through to `None` only when there's no meaningful progress to
114 /// acknowledge (cancel before any work happened).
115 pub fn cancel_analysis(&mut self) {
116 let progress = match &self.import_wf.import_mode {
117 ImportMode::Analyzing {
118 completed, total, ..
119 } => Some((*completed, *total)),
120 _ => None,
121 };
122 let _ = self.backend.cancel_analysis();
123 // Persist whatever finished before the cancel landed (matches the old
124 // save-per-result behavior), then clear the in-flight flags so a later
125 // import isn't silently treated as quick/backfill (they only reset on the
126 // happy path otherwise, the flag-leak finding).
127 self.flush_analysis_saves();
128 self.import_wf.quick_import = false;
129 self.import_wf.backfill_in_progress = false;
130 self.import_wf.pending_review_items.clear();
131 self.status = "Analysis cancelled".to_string();
132 self.import_wf.import_mode = match progress {
133 Some((completed, total)) if total > 0 => ImportMode::OperationCancelled {
134 kind: crate::state::CancelKind::Analysis,
135 completed,
136 total,
137 destination: None,
138 },
139 _ => ImportMode::None,
140 };
141 }
142
143 /// Cancel the current analysis and restart it with the same parameters.
144 pub fn retry_analysis(&mut self) {
145 self.cancel_analysis();
146 let hashes = std::mem::take(&mut self.import_wf.last_analysis_hashes);
147 if let Some(config) = self.import_wf.last_analysis_config.take()
148 && !hashes.is_empty()
149 {
150 self.run_analysis(hashes, config);
151 }
152 }
153
154 /// Update the Analyzing screen (or a silent backfill status line) as samples
155 /// are processed. `poll_workers` handler for `AnalysisProgress`.
156 pub(super) fn on_analysis_progress(
157 &mut self,
158 completed: usize,
159 total: usize,
160 current_name: String,
161 ) {
162 if self.import_wf.backfill_in_progress {
163 // Silent: keep the normal browser view, just a status line.
164 self.status = format!("Backfilling features… {completed}/{total}");
165 } else {
166 self.import_wf.import_mode = ImportMode::Analyzing {
167 completed,
168 total,
169 current_name,
170 };
171 }
172 }
173
174 /// Buffer a finished sample's analysis for batched persistence and, outside
175 /// backfill, queue it for tag review. `poll_workers` handler for
176 /// `AnalysisSampleDone`.
177 pub(super) fn on_analysis_sample_done(
178 &mut self,
179 result: AnalysisResult,
180 suggestions: Vec<audiofiles_core::analysis::suggest::TagSuggestion>,
181 ) {
182 // Buffer the DB write; flushed in one transaction every
183 // ANALYSIS_SAVE_BATCH results and on AnalysisBatchComplete.
184 // (Rules are applied per flushed batch, after the save, so they
185 // still see the persisted analysis.)
186 self.import_wf.analysis_save_buffer.push(result.clone());
187 if self.import_wf.analysis_save_buffer.len() >= ANALYSIS_SAVE_BATCH {
188 self.flush_analysis_saves();
189 }
190
191 // Backfill: persist vectors + apply rules only, no review queue.
192 if !self.import_wf.backfill_in_progress {
193 let hash = audiofiles_core::SampleHash::from_trusted(result.hash.clone());
194 let name = self
195 .backend
196 .sample_original_name(&hash)
197 .unwrap_or_else(|_| hash.to_string());
198
199 self.import_wf.pending_review_items.push(ReviewItem {
200 hash,
201 name,
202 suggestions: suggestions
203 .into_iter()
204 .map(|s| SuggestionState {
205 accepted: true,
206 suggestion: s,
207 })
208 .collect(),
209 result,
210 });
211 }
212 }
213
214 /// Record a per-sample analysis failure for the error-review screen.
215 /// `poll_workers` handler for `AnalysisSampleError`.
216 pub(super) fn on_analysis_sample_error(&mut self, hash: String, error: String) {
217 let name = self
218 .backend
219 .sample_original_name(&hash)
220 .unwrap_or_else(|_| hash.clone());
221 self.import_wf
222 .analysis_errors
223 .push(super::AnalysisFileError { hash, name, error });
224 }
225
226 /// Flush buffered analysis, then route to the review, backfill-done, or
227 /// error screen. Returns `true` when the caller (`poll_workers`) should stop
228 /// draining this event batch and return immediately (backfill completion).
229 /// `poll_workers` handler for `AnalysisBatchComplete`.
230 pub(super) fn on_analysis_batch_complete(&mut self) -> bool {
231 // Persist any analysis results still buffered, then apply rules,
232 // before the review/backfill bookkeeping below.
233 self.flush_analysis_saves();
234 if self.import_wf.backfill_in_progress {
235 self.import_wf.backfill_in_progress = false;
236 let errors = self.import_wf.analysis_errors.len();
237 self.import_wf.analysis_errors.clear();
238 self.import_wf.pending_review_items.clear();
239 self.refresh_contents();
240 self.status = if errors == 0 {
241 "Feature backfill complete".to_string()
242 } else {
243 format!("Feature backfill complete ({errors} skipped)")
244 };
245 return true;
246 }
247 let items = std::mem::take(&mut self.import_wf.pending_review_items);
248 let error_count = self.import_wf.analysis_errors.len();
249 if self.import_wf.quick_import {
250 // Auto-accept all suggestions in quick mode
251 for item in &items {
252 for s in &item.suggestions {
253 super::log_backend_err(
254 "add_tag (import suggestion)",
255 self.backend.add_tag(&item.hash, &s.suggestion.tag),
256 );
257 }
258 }
259 self.import_wf.quick_import = false;
260 self.refresh_contents();
261 self.refresh_vfs_list();
262 let count = items.len();
263 self.import_wf.import_mode = ImportMode::None;
264 self.status = if error_count == 0 {
265 format!("Quick import complete: {count} samples analyzed")
266 } else {
267 format!("Quick import complete: {count} samples analyzed ({error_count} errors)")
268 };
269 } else if items.is_empty() {
270 if self.has_import_errors() {
271 self.import_wf.import_mode = ImportMode::ReviewErrors;
272 } else {
273 self.import_wf.import_mode = ImportMode::None;
274 self.status = "Analysis complete, no results".to_string();
275 }
276 } else {
277 let count = items.len();
278 self.import_wf.import_mode = ImportMode::ReviewSuggestions {
279 items,
280 current_idx: 0,
281 sort: crate::state::ReviewSort::ImportOrder,
282 };
283 self.status = if error_count == 0 {
284 format!("Analyzed {count} samples")
285 } else {
286 format!("Analyzed {count} samples ({error_count} errors)")
287 };
288 }
289 false
290 }
291 }
292