Skip to main content

max / audiofiles

Move similarity / near-duplicate VP-build off the egui thread (Chronic #1, 3/3) find_similar / find_near_duplicates built their VP-trees lazily on the first query, on the egui frame thread — a multi-hundred-ms freeze on large libraries (ultra-fuzz Perf S3). A new SearchWorkerHandle owns both indexes and its own read-only Database connection (WAL permits concurrent reads), so the build and query run entirely off the render thread. Results return as ranked hashes via BackendEvent::SimilarResults / NearDuplicateResults and are resolved to nodes in poll_workers (apply_similarity_results). The cached trees drop on Invalidate, sent when new analysis lands (save_analysis). The Backend trait's synchronous find_similar / find_near_duplicates are replaced by start_find_similar / start_find_near_duplicates, and the lazy index Mutexes are gone from DirectBackend. BrowserState::find_similar/find_near_duplicates keep their signatures (UI callers unchanged) but now dispatch and show a searching status. This is the last of the four Chronic #1 sites. With it landed, no decode_* / SimilarityIndex::build is reachable from a symbol in an egui update() stack: preview/instrument decode and chop-preview decode go through workers, decode_to_f32 is module-private, decode_multichannel is pub(crate) in core, and the VP build lives behind the search worker. Writing a synchronous decode/build in browser UI code is now a compile error. Tests: browser 224 green (new search-worker smoke test), core 531 green, clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:19 UTC
Commit: da3d3dd09e1a94a953f6a2068964517e34bf8499
Parent: 559712f
5 files changed, +371 insertions, -89 deletions
@@ -21,7 +21,9 @@ use audiofiles_core::search::SearchFilter;
21 21 use audiofiles_core::collections::Collection;
22 22 use audiofiles_core::store::SampleStore;
23 23 use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis};
24 - use audiofiles_core::{collections, fingerprint, search, similarity, tags, CollectionId, NodeId, VfsId};
24 + use audiofiles_core::{collections, search, tags, CollectionId, NodeId, VfsId};
25 +
26 + use super::search_worker::{self, SearchCommand, SearchEvent};
25 27 use parking_lot::Mutex;
26 28
27 29 use super::{
@@ -69,9 +71,10 @@ pub struct DirectBackend {
69 71 // Import context for the in-flight forge op, applied when its CPU stage
70 72 // completes (the worker produces temp files; the GUI thread imports them).
71 73 forge_pending: Mutex<Option<ForgePending>>,
72 - // VP-tree indexes for fast search (lazy, invalidated on new analysis)
73 - fingerprint_index: Mutex<Option<fingerprint::FingerprintIndex>>,
74 - similarity_index: Mutex<Option<similarity::SimilarityIndex>>,
74 + // Search worker: owns the similarity / near-duplicate VP-trees and builds +
75 + // queries them off the GUI thread. Spawned lazily on first search,
76 + // invalidated on new analysis.
77 + search_worker: Mutex<Option<search_worker::SearchWorkerHandle>>,
75 78 // Device plugin registry (when device-profiles feature is enabled)
76 79 #[cfg(feature = "device-profiles")]
77 80 plugin_registry: audiofiles_rhai::registry::PluginRegistry,
@@ -92,8 +95,7 @@ impl DirectBackend {
92 95 forge_worker: Mutex::new(None),
93 96 classifier_worker: Mutex::new(None),
94 97 forge_pending: Mutex::new(None),
95 - fingerprint_index: Mutex::new(None),
96 - similarity_index: Mutex::new(None),
98 + search_worker: Mutex::new(None),
97 99 #[cfg(feature = "device-profiles")]
98 100 plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|e| {
99 101 // Embedded (include_str!) profiles are compile-checked, so this
@@ -116,6 +118,19 @@ impl DirectBackend {
116 118 &self.data_dir
117 119 }
118 120
121 + /// Lazily spawn the search worker on first use. It opens its own read-only
122 + /// connection to the database file (WAL allows concurrent reads).
123 + fn ensure_search_worker(&self) -> BackendResult<()> {
124 + let mut guard = self.search_worker.lock();
125 + if guard.is_none() {
126 + let db_path = self.data_dir.join("audiofiles.db");
127 + let handle = search_worker::spawn_search_worker(db_path)
128 + .map_err(|e| BackendError::Other(format!("failed to spawn search worker: {e}")))?;
129 + *guard = Some(handle);
130 + }
131 + Ok(())
132 + }
133 +
119 134 /// Resolve a device profile's constraints into the export config and filter items.
120 135 ///
121 136 /// Called before spawning the export worker so profile resolution happens
@@ -564,11 +579,11 @@ impl Backend for DirectBackend {
564 579 fn save_analysis(&self, result: &AnalysisResult) -> BackendResult<()> {
565 580 let db = self.db.lock();
566 581 audiofiles_core::analysis::save_analysis(&db, result)?;
567 - // Invalidate search indexes — new analysis data changes normalization ranges
568 - // and may add a new fingerprint.
569 - *self.similarity_index.lock() = None;
570 - if result.fingerprint.is_some() {
571 - *self.fingerprint_index.lock() = None;
582 + // Invalidate the search worker's cached VP-trees — new analysis data
583 + // changes normalization ranges and may add a new fingerprint, so both
584 + // indexes must rebuild on the next query.
585 + if let Some(w) = self.search_worker.lock().as_ref() {
586 + w.send(SearchCommand::Invalidate);
572 587 }
573 588 Ok(())
574 589 }
@@ -795,50 +810,26 @@ impl Backend for DirectBackend {
795 810
796 811 // --- Similarity ---
797 812
798 - fn find_similar(
799 - &self,
800 - hash: &str,
801 - limit: usize,
802 - ) -> BackendResult<Vec<similarity::SimilarResult>> {
803 - // Build VP-tree index lazily on first query.
804 - // Load data under DB lock, release lock, then build tree (CPU-intensive).
805 - let mut idx = self.similarity_index.lock();
806 - if idx.is_none() {
807 - let data = {
808 - let db = self.db.lock();
809 - similarity::SimilarityIndex::load_data(&db)?
810 - };
811 - *idx = Some(similarity::SimilarityIndex::build_from_data(data));
813 + fn start_find_similar(&self, hash: &str, limit: usize) -> BackendResult<()> {
814 + self.ensure_search_worker()?;
815 + if let Some(w) = self.search_worker.lock().as_ref() {
816 + w.send(SearchCommand::FindSimilar {
817 + hash: hash.to_string(),
818 + limit,
819 + });
812 820 }
813 - let built = idx.as_ref().expect("set on the line above");
814 - let features = {
815 - let db = self.db.lock();
816 - similarity::load_features(&db, hash)?
817 - };
818 - Ok(built.find_similar(hash, &features, limit))
821 + Ok(())
819 822 }
820 823
821 - fn find_near_duplicates(
822 - &self,
823 - hash: &str,
824 - limit: usize,
825 - ) -> BackendResult<Vec<fingerprint::DuplicateResult>> {
826 - // Build VP-tree index lazily on first query.
827 - // Load data under DB lock, release lock, then build tree (CPU-intensive).
828 - let mut idx = self.fingerprint_index.lock();
829 - if idx.is_none() {
830 - let entries = {
831 - let db = self.db.lock();
832 - fingerprint::FingerprintIndex::load_data(&db)?
833 - };
834 - *idx = Some(fingerprint::FingerprintIndex::build_from_data(entries));
824 + fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()> {
825 + self.ensure_search_worker()?;
826 + if let Some(w) = self.search_worker.lock().as_ref() {
827 + w.send(SearchCommand::FindNearDuplicates {
828 + hash: hash.to_string(),
829 + limit,
830 + });
835 831 }
836 - let built = idx.as_ref().expect("set on the line above");
837 - let reference = {
838 - let db = self.db.lock();
839 - fingerprint::load_fingerprint(&db, hash)?
840 - };
841 - Ok(built.find_near_duplicates(hash, &reference.envelope, limit))
832 + Ok(())
842 833 }
843 834
844 835 // --- Store ---
@@ -1690,6 +1681,31 @@ impl Backend for DirectBackend {
1690 1681 }
1691 1682 }
1692 1683
1684 + // Poll search worker (similarity / near-duplicate VP-tree build + query).
1685 + let search_events: Vec<SearchEvent> = match *self.search_worker.lock() {
1686 + Some(ref worker) => {
1687 + let mut evs = Vec::new();
1688 + while let Some(ev) = worker.try_recv() {
1689 + evs.push(ev);
1690 + }
1691 + evs
1692 + }
1693 + None => Vec::new(),
1694 + };
1695 + for ev in search_events {
1696 + match ev {
1697 + SearchEvent::SimilarResults { source, hashes } => {
1698 + events.push(BackendEvent::SimilarResults { source, hashes });
1699 + }
1700 + SearchEvent::NearDuplicateResults { source, hashes } => {
1701 + events.push(BackendEvent::NearDuplicateResults { source, hashes });
1702 + }
1703 + SearchEvent::Error { error } => {
1704 + events.push(BackendEvent::SearchError { error });
1705 + }
1706 + }
1707 + }
1708 +
1693 1709 // Poll classifier worker (one-shot job).
1694 1710 let job_done = {
1695 1711 let guard = self.classifier_worker.lock();
@@ -8,6 +8,7 @@
8 8 //! - `DaemonBackend` (Phase D) — forwards calls to the daemon over Unix socket via JSON-RPC.
9 9
10 10 pub mod direct;
11 + pub mod search_worker;
11 12
12 13 use std::path::{Path, PathBuf};
13 14
@@ -142,6 +143,22 @@ pub enum BackendEvent {
142 143 error: String,
143 144 },
144 145
146 + // Search events (similarity / near-duplicate VP-tree build + query run on a
147 + // worker thread; the GUI resolves the ranked result hashes to nodes when they
148 + // land). Only the hashes cross the boundary — distance/score is unused by the
149 + // list view, so the result structs needn't be serde/Debug.
150 + SimilarResults {
151 + source: String,
152 + hashes: Vec<String>,
153 + },
154 + NearDuplicateResults {
155 + source: String,
156 + hashes: Vec<String>,
157 + },
158 + SearchError {
159 + error: String,
160 + },
161 +
145 162 // Classifier events (train / auto-tag / cluster / export run on a worker thread)
146 163 ClassifierJobDone(ClassifierJobResult),
147 164 ClassifierJobFailed(String),
@@ -534,19 +551,14 @@ pub trait Backend: Send + Sync {
534 551
535 552 // --- Similarity ---
536 553
537 - /// Find samples similar to the given hash.
538 - fn find_similar(
539 - &self,
540 - hash: &str,
541 - limit: usize,
542 - ) -> BackendResult<Vec<audiofiles_core::similarity::SimilarResult>>;
554 + /// Start a similarity search for `hash`. The VP-tree build + query run on the
555 + /// search worker; results arrive via [`BackendEvent::SimilarResults`] (or
556 + /// `SearchError`) in `poll_events`.
557 + fn start_find_similar(&self, hash: &str, limit: usize) -> BackendResult<()>;
543 558
544 - /// Find near-duplicate samples by fingerprint comparison.
545 - fn find_near_duplicates(
546 - &self,
547 - hash: &str,
548 - limit: usize,
549 - ) -> BackendResult<Vec<audiofiles_core::fingerprint::DuplicateResult>>;
559 + /// Start a near-duplicate search for `hash` (fingerprint comparison). Results
560 + /// arrive via [`BackendEvent::NearDuplicateResults`] (or `SearchError`).
561 + fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()>;
550 562
551 563 // --- Store ---
552 564
@@ -0,0 +1,239 @@
1 + //! Background search worker: builds and queries the similarity / near-duplicate
2 + //! VP-trees off the GUI thread.
3 + //!
4 + //! Building a VP-tree over the whole library is CPU-bound (the ultra-fuzz audit
5 + //! flagged the lazy first-query build freezing the egui frame on large
6 + //! libraries). This worker owns both indexes and its own read-only `Database`
7 + //! connection (WAL lets it read alongside the main connection), so the build and
8 + //! query happen entirely off the render thread. Results return via [`SearchEvent`]
9 + //! and are applied in `poll_workers`.
10 + //!
11 + //! The indexes are cached across queries and dropped on [`SearchCommand::Invalidate`]
12 + //! (sent when new analysis lands), so they rebuild from fresh data on the next query.
13 +
14 + use std::path::{Path, PathBuf};
15 + use std::sync::{mpsc, Mutex};
16 + use std::thread;
17 +
18 + use audiofiles_core::db::Database;
19 + use audiofiles_core::{fingerprint, similarity};
20 +
21 + /// Command sent from the GUI thread to the search worker.
22 + pub enum SearchCommand {
23 + /// Find samples similar to `hash` (builds the similarity index if needed).
24 + FindSimilar { hash: String, limit: usize },
25 + /// Find near-duplicates of `hash` by fingerprint (builds the index if needed).
26 + FindNearDuplicates { hash: String, limit: usize },
27 + /// Drop both cached indexes so the next query rebuilds from fresh data.
28 + Invalidate,
29 + /// Shut the worker thread down.
30 + Shutdown,
31 + }
32 +
33 + /// Event sent from the search worker back to the GUI thread.
34 + pub enum SearchEvent {
35 + /// Ranked similar-sample hashes for `source`.
36 + SimilarResults { source: String, hashes: Vec<String> },
37 + /// Ranked near-duplicate hashes for `source`.
38 + NearDuplicateResults { source: String, hashes: Vec<String> },
39 + /// A search failed (DB open / load / build error).
40 + Error { error: String },
41 + }
42 +
43 + /// Handle for communicating with the background search worker.
44 + pub struct SearchWorkerHandle {
45 + cmd_tx: mpsc::Sender<SearchCommand>,
46 + event_rx: Mutex<mpsc::Receiver<SearchEvent>>,
47 + thread: Option<thread::JoinHandle<()>>,
48 + }
49 +
50 + impl SearchWorkerHandle {
51 + /// Poll for the next event without blocking.
52 + pub fn try_recv(&self) -> Option<SearchEvent> {
53 + self.event_rx.lock().ok()?.try_recv().ok()
54 + }
55 +
56 + /// Send a command to the worker.
57 + pub fn send(&self, cmd: SearchCommand) {
58 + let _ = self.cmd_tx.send(cmd);
59 + }
60 + }
61 +
62 + impl Drop for SearchWorkerHandle {
63 + fn drop(&mut self) {
64 + let _ = self.cmd_tx.send(SearchCommand::Shutdown);
65 + if let Some(handle) = self.thread.take() {
66 + let _ = handle.join();
67 + }
68 + }
69 + }
70 +
71 + /// Spawn the background search worker. It opens its own connection to the
72 + /// database at `db_path` on first use.
73 + pub fn spawn_search_worker(db_path: PathBuf) -> std::io::Result<SearchWorkerHandle> {
74 + let (cmd_tx, cmd_rx) = mpsc::channel::<SearchCommand>();
75 + let (event_tx, event_rx) = mpsc::channel::<SearchEvent>();
76 +
77 + let thread = thread::Builder::new()
78 + .name("search-worker".to_string())
79 + .spawn(move || search_worker_loop(db_path, cmd_rx, event_tx))?;
80 +
81 + Ok(SearchWorkerHandle {
82 + cmd_tx,
83 + event_rx: Mutex::new(event_rx),
84 + thread: Some(thread),
85 + })
86 + }
87 +
88 + fn search_worker_loop(
89 + db_path: PathBuf,
90 + cmd_rx: mpsc::Receiver<SearchCommand>,
91 + event_tx: mpsc::Sender<SearchEvent>,
92 + ) {
93 + let mut similarity_index: Option<similarity::SimilarityIndex> = None;
94 + let mut fingerprint_index: Option<fingerprint::FingerprintIndex> = None;
95 + // Opened lazily on the first query and reused.
96 + let mut db: Option<Database> = None;
97 +
98 + while let Ok(cmd) = cmd_rx.recv() {
99 + match cmd {
100 + SearchCommand::Shutdown => break,
101 + SearchCommand::Invalidate => {
102 + similarity_index = None;
103 + fingerprint_index = None;
104 + }
105 + SearchCommand::FindSimilar { hash, limit } => {
106 + let database = match ensure_db(&mut db, &db_path) {
107 + Ok(d) => d,
108 + Err(e) => {
109 + let _ = event_tx.send(SearchEvent::Error { error: e });
110 + continue;
111 + }
112 + };
113 + // Isolate panics: a malformed feature row must report an error,
114 + // not unwind the worker and silently stop all future searches.
115 + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
116 + if similarity_index.is_none() {
117 + let data = similarity::SimilarityIndex::load_data(database)?;
118 + similarity_index = Some(similarity::SimilarityIndex::build_from_data(data));
119 + }
120 + let built = similarity_index.as_ref().expect("set above");
121 + let features = similarity::load_features(database, &hash)?;
122 + let results = built.find_similar(&hash, &features, limit);
123 + Ok::<_, audiofiles_core::error::CoreError>(
124 + results.into_iter().map(|r| r.hash).collect::<Vec<String>>(),
125 + )
126 + }));
127 + let event = match outcome {
128 + Ok(Ok(hashes)) => SearchEvent::SimilarResults {
129 + source: hash,
130 + hashes,
131 + },
132 + Ok(Err(e)) => SearchEvent::Error {
133 + error: e.to_string(),
134 + },
135 + Err(_) => SearchEvent::Error {
136 + error: "similarity search panicked (internal error)".to_string(),
137 + },
138 + };
139 + let _ = event_tx.send(event);
140 + }
141 + SearchCommand::FindNearDuplicates { hash, limit } => {
142 + let database = match ensure_db(&mut db, &db_path) {
143 + Ok(d) => d,
144 + Err(e) => {
145 + let _ = event_tx.send(SearchEvent::Error { error: e });
146 + continue;
147 + }
148 + };
149 + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
150 + if fingerprint_index.is_none() {
151 + let entries = fingerprint::FingerprintIndex::load_data(database)?;
152 + fingerprint_index =
153 + Some(fingerprint::FingerprintIndex::build_from_data(entries));
154 + }
155 + let built = fingerprint_index.as_ref().expect("set above");
156 + let reference = fingerprint::load_fingerprint(database, &hash)?;
157 + let results = built.find_near_duplicates(&hash, &reference.envelope, limit);
158 + Ok::<_, audiofiles_core::error::CoreError>(
159 + results.into_iter().map(|r| r.hash).collect::<Vec<String>>(),
160 + )
161 + }));
162 + let event = match outcome {
163 + Ok(Ok(hashes)) => SearchEvent::NearDuplicateResults {
164 + source: hash,
165 + hashes,
166 + },
167 + Ok(Err(e)) => SearchEvent::Error {
168 + error: e.to_string(),
169 + },
170 + Err(_) => SearchEvent::Error {
171 + error: "duplicate search panicked (internal error)".to_string(),
172 + },
173 + };
174 + let _ = event_tx.send(event);
175 + }
176 + }
177 + }
178 + }
179 +
180 + /// Open the worker's own database connection on first use, then reuse it.
181 + fn ensure_db<'a>(db: &'a mut Option<Database>, db_path: &Path) -> Result<&'a Database, String> {
182 + if db.is_none() {
183 + *db = Some(Database::open(db_path).map_err(|e| e.to_string())?);
184 + }
185 + Ok(db.as_ref().expect("opened above"))
186 + }
187 +
188 + #[cfg(test)]
189 + mod tests {
190 + use super::*;
191 +
192 + /// Block (with a generous wall-clock bound) for one event so a regression
193 + /// that wedges the worker fails the test instead of hanging the suite. The
194 + /// bound is time-based, not iteration-based, so it stays reliable under the
195 + /// load of the full parallel test run.
196 + fn recv_one(handle: &SearchWorkerHandle) -> SearchEvent {
197 + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
198 + while std::time::Instant::now() < deadline {
199 + if let Some(ev) = handle.try_recv() {
200 + return ev;
201 + }
202 + std::thread::sleep(std::time::Duration::from_millis(1));
203 + }
204 + panic!("search worker produced no event");
205 + }
206 +
207 + #[test]
208 + fn worker_responds_on_empty_db() {
209 + let dir = tempfile::tempdir().unwrap();
210 + let db_path = dir.path().join("audiofiles.db");
211 + // Create + migrate the file so the worker opens a valid (empty) schema.
212 + let _ = Database::open(&db_path).unwrap();
213 +
214 + let handle = spawn_search_worker(db_path).unwrap();
215 + // A query against an empty library must still produce an event (empty
216 + // results or a clean error), never a hang or a panic that kills the worker.
217 + handle.send(SearchCommand::FindSimilar {
218 + hash: "deadbeef".to_string(),
219 + limit: 10,
220 + });
221 + match recv_one(&handle) {
222 + SearchEvent::SimilarResults { source, hashes } => {
223 + assert_eq!(source, "deadbeef");
224 + assert!(hashes.is_empty());
225 + }
226 + SearchEvent::Error { .. } => {} // acceptable: no features for the hash
227 + SearchEvent::NearDuplicateResults { .. } => panic!("wrong event kind"),
228 + }
229 +
230 + // Invalidate + a second query still respond (worker survived).
231 + handle.send(SearchCommand::Invalidate);
232 + handle.send(SearchCommand::FindNearDuplicates {
233 + hash: "deadbeef".to_string(),
234 + limit: 10,
235 + });
236 + let _ = recv_one(&handle);
237 + drop(handle); // Drop joins the thread; a wedged worker would hang here.
238 + }
239 + }
@@ -771,6 +771,19 @@ impl BrowserState {
771 771 self.status = format!("Forge failed: {error}");
772 772 }
773 773
774 + // --- Search events (similarity / near-duplicate, off-thread) ---
775 + BackendEvent::SimilarResults { source, hashes } => {
776 + let refs: Vec<&str> = hashes.iter().map(String::as_str).collect();
777 + self.apply_similarity_results(&source, &refs, "similar samples");
778 + }
779 + BackendEvent::NearDuplicateResults { source, hashes } => {
780 + let refs: Vec<&str> = hashes.iter().map(String::as_str).collect();
781 + self.apply_similarity_results(&source, &refs, "near-duplicates");
782 + }
783 + BackendEvent::SearchError { error } => {
784 + self.status = format!("Search failed: {error}");
785 + }
786 +
774 787 // --- Classifier worker (train / auto-tag / cluster / export) ---
775 788 BackendEvent::ClassifierJobDone(result) => {
776 789 self.classifier.busy = None;
@@ -248,20 +248,13 @@ impl BrowserState {
248 248
249 249 // --- Similarity search ---
250 250
251 - /// Find samples similar to the given hash and display them.
251 + /// Start a similarity search for the given hash. The VP-tree build + query
252 + /// run on the search worker; the results land in `poll_workers` via
253 + /// [`apply_similarity_results`](Self::apply_similarity_results).
252 254 pub fn find_similar(&mut self, hash: &str) {
253 - match self.backend.find_similar(hash, 50) {
254 - Ok(results) => {
255 - let Some(vfs_id) = self.current_vfs_id() else { return };
256 - let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect();
257 - let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes)
258 - .unwrap_or_default();
259 - let count = nodes.len();
260 - self.contents = Arc::new(nodes);
261 - self.similarity_search_hash = Some(hash.to_string());
262 - self.similarity_source_name = self.backend.sample_original_name(hash).ok();
263 - self.selection.clear();
264 - self.status = format!("Found {count} similar samples");
255 + match self.backend.start_find_similar(hash, 50) {
256 + Ok(()) => {
257 + self.status = "Searching for similar samples...".to_string();
265 258 }
266 259 Err(e) => {
267 260 self.status = format!("Similarity search failed: {e}");
@@ -269,20 +262,12 @@ impl BrowserState {
269 262 }
270 263 }
271 264
272 - /// Find near-duplicates of the given sample by comparing peak envelope fingerprints.
265 + /// Start a near-duplicate search (peak-envelope fingerprint comparison).
266 + /// Results land in `poll_workers`.
273 267 pub fn find_near_duplicates(&mut self, hash: &str) {
274 - match self.backend.find_near_duplicates(hash, 50) {
275 - Ok(results) => {
276 - let Some(vfs_id) = self.current_vfs_id() else { return };
277 - let hashes: Vec<&str> = results.iter().map(|r| r.hash.as_str()).collect();
278 - let nodes = self.backend.find_nodes_by_hashes(vfs_id, &hashes)
279 - .unwrap_or_default();
280 - let count = nodes.len();
281 - self.contents = Arc::new(nodes);
282 - self.similarity_search_hash = Some(hash.to_string());
283 - self.similarity_source_name = self.backend.sample_original_name(hash).ok();
284 - self.selection.clear();
285 - self.status = format!("Found {count} near-duplicates");
268 + match self.backend.start_find_near_duplicates(hash, 50) {
269 + Ok(()) => {
270 + self.status = "Searching for near-duplicates...".to_string();
286 271 }
287 272 Err(e) => {
288 273 self.status = format!("Duplicate search failed: {e}");
@@ -290,6 +275,23 @@ impl BrowserState {
290 275 }
291 276 }
292 277
278 + /// Apply search results to the file list (called from the poll loop).
279 + /// `noun` is the human label for the status line ("similar samples" /
280 + /// "near-duplicates"). `hashes` are the result hashes in rank order.
281 + pub fn apply_similarity_results(&mut self, source_hash: &str, hashes: &[&str], noun: &str) {
282 + let Some(vfs_id) = self.current_vfs_id() else { return };
283 + let nodes = self
284 + .backend
285 + .find_nodes_by_hashes(vfs_id, hashes)
286 + .unwrap_or_default();
287 + let count = nodes.len();
288 + self.contents = Arc::new(nodes);
289 + self.similarity_search_hash = Some(source_hash.to_string());
290 + self.similarity_source_name = self.backend.sample_original_name(source_hash).ok();
291 + self.selection.clear();
292 + self.status = format!("Found {count} {noun}");
293 + }
294 +
293 295 /// Clear similarity search mode and return to normal browsing.
294 296 pub fn clear_similarity_search(&mut self) {
295 297 self.similarity_search_hash = None;