//! `DirectBackend` search and classification: folder-scoped and global search, //! plus the k-NN classifier head (training, info, and auto-apply). use super::{ BackendError, BackendResult, ClassifierBackend, DirectBackend, NodeId, Path, SearchBackend, SearchCommand, SearchFilter, SimilarityBackend, TrainedHeadInfo, VfsId, VfsNodeWithAnalysis, WaveformData, instrument, search, }; impl SearchBackend for DirectBackend { // --- Search --- fn search_in_folder( &self, filter: &SearchFilter, vfs_id: VfsId, parent_id: Option, ) -> BackendResult> { let db = self.db.lock(); Ok(search::search_in_folder(&db, filter, vfs_id, parent_id)?) } fn search_global(&self, filter: &SearchFilter) -> BackendResult> { let db = self.db.lock(); Ok(search::search_global(&db, filter)?) } } impl ClassifierBackend for DirectBackend { fn ml_auto_apply_all(&self, k: usize) -> BackendResult { use audiofiles_core::analysis::{exemplar, trained_head}; let db = self.db.lock(); // Route the heavy library-wide pass through the distilled head when one is // current (O(tags) per sample instead of O(library)); else brute-force k-NN. // Detail-panel suggestions stay on k-NN for neighbor explainability. if let Some(head) = trained_head::load(&db)? { Ok(trained_head::auto_apply_library(&db, &head)?) } else { Ok(exemplar::auto_apply_library(&db, k)?) } } fn train_classifier_head(&self) -> BackendResult> { let db = self.db.lock(); Ok( audiofiles_core::analysis::trained_head::train_and_save(&db)?.map(|h| { TrainedHeadInfo { class_count: h.classes.len(), exemplar_count: h.exemplar_count, trained_at: h.trained_at, } }), ) } fn classifier_head_info(&self) -> BackendResult> { let db = self.db.lock(); Ok( audiofiles_core::analysis::trained_head::load(&db)?.map(|h| TrainedHeadInfo { class_count: h.classes.len(), exemplar_count: h.exemplar_count, trained_at: h.trained_at, }), ) } fn clear_classifier_head(&self) -> BackendResult<()> { let db = self.db.lock(); Ok(audiofiles_core::analysis::trained_head::clear(&db)?) } fn classifier_head_readiness(&self) -> BackendResult<(usize, bool)> { use audiofiles_core::analysis::trained_head; let db = self.db.lock(); let n = trained_head::labeled_exemplar_count(&db)?; Ok((n, n >= trained_head::RECOMMENDED_MIN_EXEMPLARS)) } fn start_classifier_job(&self, job: crate::backend::ClassifierJob) -> BackendResult<()> { let db_path = self.data_dir.join("audiofiles.db"); // Detach any prior job instead of dropping (joining) it here: the one-shot // classifier job can run for minutes, so joining on the GUI thread would // freeze the UI. Hand the old handle to a reaper thread that joins it off // the render thread. The UI gates to one active job, so at most one stale // job is reaping at a time. if let Some(old) = self.classifier_worker.lock().take() { let _ = audiofiles_core::worker_runtime::spawn_detached("classifier-reaper", move || { drop(old); }); } let handle = crate::classifier_worker::spawn_classifier_job(db_path, job) .map_err(|e| BackendError::Other(format!("failed to spawn classifier worker: {e}")))?; *self.classifier_worker.lock() = Some(handle); Ok(()) } fn export_afcl( &self, path: &Path, opts: &audiofiles_core::analysis::afcl::ExportOptions, ) -> BackendResult<()> { let db = self.db.lock(); Ok(audiofiles_core::analysis::afcl::export_to_path( &db, path, opts, )?) } fn import_afcl( &self, path: &Path, ) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::analysis::afcl::import_from_path( &db, path, )?) } fn list_classifier_layers( &self, ) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::analysis::afcl::list_layers(&db)?) } fn remove_classifier_layer(&self, id: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(audiofiles_core::analysis::afcl::remove_layer(&db, id)?) } fn set_classifier_layer_enabled(&self, id: &str, enabled: bool) -> BackendResult<()> { let db = self.db.lock(); Ok(audiofiles_core::analysis::afcl::set_layer_enabled( &db, id, enabled, )?) } fn set_classifier_layer_weight(&self, id: &str, weight: f64) -> BackendResult<()> { let db = self.db.lock(); Ok(audiofiles_core::analysis::afcl::set_layer_weight( &db, id, weight, )?) } fn accept_ml_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { let db = self.db.lock(); db.transaction_core(|tx| { audiofiles_core::rules::apply_tag_sourced(&db, tx, hash, tag, "ml").map(|_| ()) })?; Ok(()) } fn get_tag_policy( &self, tag: &str, ) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::analysis::exemplar::get_policy(&db, tag)?) } #[instrument(skip(self))] fn set_tag_policy(&self, tag: &str, review: f64, auto: f64) -> BackendResult<()> { let db = self.db.lock(); Ok(audiofiles_core::analysis::exemplar::set_policy( &db, tag, review, auto, )?) } fn list_tag_policies( &self, ) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::analysis::exemplar::list_policies(&db)?) } fn cluster_library( &self, k: usize, ) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::analysis::cluster::cluster_library(&db, k, 50)?.clusters) } fn apply_cluster_tag(&self, members: &[String], tag: &str) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::analysis::cluster::apply_cluster_tag( &db, members, tag, )?) } fn harvest_folder_labels(&self) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::harvest::harvest_folder_labels(&db)?) } fn apply_harvested_tag(&self, hashes: &[String], tag: &str) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::harvest::apply_harvested_tag( &db, hashes, tag, )?) } fn remove_tags_by_source(&self, source: &str) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::rules::remove_tags_by_source(&db, source)?) } fn get_waveform(&self, hash: &str) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::analysis::waveform::load_waveform( &db, hash, )) } } impl SimilarityBackend for DirectBackend { // --- Similarity --- fn start_find_similar(&self, hash: &str, limit: usize) -> BackendResult<()> { self.ensure_search_worker()?; let mut guard = self.search_worker.lock(); let delivered = guard.as_ref().is_some_and(|w| { w.send(SearchCommand::FindSimilar { hash: hash.to_string(), limit, }) }); // A dead cached worker would never answer; drop it (next call respawns) // and report Err so the caller doesn't set latest_similarity_request and // spin the repaint-while-busy guard forever. if !delivered { *guard = None; return Err(BackendError::Other( "search worker is not running".to_string(), )); } Ok(()) } fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()> { self.ensure_search_worker()?; let mut guard = self.search_worker.lock(); let delivered = guard.as_ref().is_some_and(|w| { w.send(SearchCommand::FindNearDuplicates { hash: hash.to_string(), limit, }) }); if !delivered { *guard = None; return Err(BackendError::Other( "search worker is not running".to_string(), )); } Ok(()) } }