max / audiofiles
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; |