Skip to main content

max / audiofiles

8.9 KB · 258 lines History Blame Raw
1 //! `DirectBackend` search and classification: folder-scoped and global search,
2 //! plus the k-NN classifier head (training, info, and auto-apply).
3
4 use super::{
5 BackendError, BackendResult, ClassifierBackend, DirectBackend, NodeId, Path, SearchBackend,
6 SearchCommand, SearchFilter, SimilarityBackend, TrainedHeadInfo, VfsId, VfsNodeWithAnalysis,
7 WaveformData, instrument, search,
8 };
9
10 impl SearchBackend for DirectBackend {
11 // --- Search ---
12
13 fn search_in_folder(
14 &self,
15 filter: &SearchFilter,
16 vfs_id: VfsId,
17 parent_id: Option<NodeId>,
18 ) -> BackendResult<Vec<VfsNodeWithAnalysis>> {
19 let db = self.db.lock();
20 Ok(search::search_in_folder(&db, filter, vfs_id, parent_id)?)
21 }
22
23 fn search_global(&self, filter: &SearchFilter) -> BackendResult<Vec<VfsNodeWithAnalysis>> {
24 let db = self.db.lock();
25 Ok(search::search_global(&db, filter)?)
26 }
27 }
28
29 impl ClassifierBackend for DirectBackend {
30 fn ml_auto_apply_all(&self, k: usize) -> BackendResult<usize> {
31 use audiofiles_core::analysis::{exemplar, trained_head};
32 let db = self.db.lock();
33 // Route the heavy library-wide pass through the distilled head when one is
34 // current (O(tags) per sample instead of O(library)); else brute-force k-NN.
35 // Detail-panel suggestions stay on k-NN for neighbor explainability.
36 if let Some(head) = trained_head::load(&db)? {
37 Ok(trained_head::auto_apply_library(&db, &head)?)
38 } else {
39 Ok(exemplar::auto_apply_library(&db, k)?)
40 }
41 }
42
43 fn train_classifier_head(&self) -> BackendResult<Option<TrainedHeadInfo>> {
44 let db = self.db.lock();
45 Ok(
46 audiofiles_core::analysis::trained_head::train_and_save(&db)?.map(|h| {
47 TrainedHeadInfo {
48 class_count: h.classes.len(),
49 exemplar_count: h.exemplar_count,
50 trained_at: h.trained_at,
51 }
52 }),
53 )
54 }
55
56 fn classifier_head_info(&self) -> BackendResult<Option<TrainedHeadInfo>> {
57 let db = self.db.lock();
58 Ok(
59 audiofiles_core::analysis::trained_head::load(&db)?.map(|h| TrainedHeadInfo {
60 class_count: h.classes.len(),
61 exemplar_count: h.exemplar_count,
62 trained_at: h.trained_at,
63 }),
64 )
65 }
66
67 fn clear_classifier_head(&self) -> BackendResult<()> {
68 let db = self.db.lock();
69 Ok(audiofiles_core::analysis::trained_head::clear(&db)?)
70 }
71
72 fn classifier_head_readiness(&self) -> BackendResult<(usize, bool)> {
73 use audiofiles_core::analysis::trained_head;
74 let db = self.db.lock();
75 let n = trained_head::labeled_exemplar_count(&db)?;
76 Ok((n, n >= trained_head::RECOMMENDED_MIN_EXEMPLARS))
77 }
78
79 fn start_classifier_job(&self, job: crate::backend::ClassifierJob) -> BackendResult<()> {
80 let db_path = self.data_dir.join("audiofiles.db");
81 // Detach any prior job instead of dropping (joining) it here: the one-shot
82 // classifier job can run for minutes, so joining on the GUI thread would
83 // freeze the UI. Hand the old handle to a reaper thread that joins it off
84 // the render thread. The UI gates to one active job, so at most one stale
85 // job is reaping at a time.
86 if let Some(old) = self.classifier_worker.lock().take() {
87 let _ =
88 audiofiles_core::worker_runtime::spawn_detached("classifier-reaper", move || {
89 drop(old);
90 });
91 }
92 let handle = crate::classifier_worker::spawn_classifier_job(db_path, job)
93 .map_err(|e| BackendError::Other(format!("failed to spawn classifier worker: {e}")))?;
94 *self.classifier_worker.lock() = Some(handle);
95 Ok(())
96 }
97
98 fn export_afcl(
99 &self,
100 path: &Path,
101 opts: &audiofiles_core::analysis::afcl::ExportOptions,
102 ) -> BackendResult<()> {
103 let db = self.db.lock();
104 Ok(audiofiles_core::analysis::afcl::export_to_path(
105 &db, path, opts,
106 )?)
107 }
108
109 fn import_afcl(
110 &self,
111 path: &Path,
112 ) -> BackendResult<audiofiles_core::analysis::afcl::ImportSummary> {
113 let db = self.db.lock();
114 Ok(audiofiles_core::analysis::afcl::import_from_path(
115 &db, path,
116 )?)
117 }
118
119 fn list_classifier_layers(
120 &self,
121 ) -> BackendResult<Vec<audiofiles_core::analysis::afcl::ClassifierLayer>> {
122 let db = self.db.lock();
123 Ok(audiofiles_core::analysis::afcl::list_layers(&db)?)
124 }
125
126 fn remove_classifier_layer(&self, id: &str) -> BackendResult<()> {
127 let db = self.db.lock();
128 Ok(audiofiles_core::analysis::afcl::remove_layer(&db, id)?)
129 }
130
131 fn set_classifier_layer_enabled(&self, id: &str, enabled: bool) -> BackendResult<()> {
132 let db = self.db.lock();
133 Ok(audiofiles_core::analysis::afcl::set_layer_enabled(
134 &db, id, enabled,
135 )?)
136 }
137
138 fn set_classifier_layer_weight(&self, id: &str, weight: f64) -> BackendResult<()> {
139 let db = self.db.lock();
140 Ok(audiofiles_core::analysis::afcl::set_layer_weight(
141 &db, id, weight,
142 )?)
143 }
144
145 fn accept_ml_tag(&self, hash: &str, tag: &str) -> BackendResult<()> {
146 let db = self.db.lock();
147 db.transaction_core(|tx| {
148 audiofiles_core::rules::apply_tag_sourced(&db, tx, hash, tag, "ml").map(|_| ())
149 })?;
150 Ok(())
151 }
152
153 fn get_tag_policy(
154 &self,
155 tag: &str,
156 ) -> BackendResult<audiofiles_core::analysis::exemplar::TagPolicy> {
157 let db = self.db.lock();
158 Ok(audiofiles_core::analysis::exemplar::get_policy(&db, tag)?)
159 }
160
161 #[instrument(skip(self))]
162 fn set_tag_policy(&self, tag: &str, review: f64, auto: f64) -> BackendResult<()> {
163 let db = self.db.lock();
164 Ok(audiofiles_core::analysis::exemplar::set_policy(
165 &db, tag, review, auto,
166 )?)
167 }
168
169 fn list_tag_policies(
170 &self,
171 ) -> BackendResult<Vec<(String, audiofiles_core::analysis::exemplar::TagPolicy)>> {
172 let db = self.db.lock();
173 Ok(audiofiles_core::analysis::exemplar::list_policies(&db)?)
174 }
175
176 fn cluster_library(
177 &self,
178 k: usize,
179 ) -> BackendResult<Vec<audiofiles_core::analysis::cluster::Cluster>> {
180 let db = self.db.lock();
181 Ok(audiofiles_core::analysis::cluster::cluster_library(&db, k, 50)?.clusters)
182 }
183
184 fn apply_cluster_tag(&self, members: &[String], tag: &str) -> BackendResult<usize> {
185 let db = self.db.lock();
186 Ok(audiofiles_core::analysis::cluster::apply_cluster_tag(
187 &db, members, tag,
188 )?)
189 }
190
191 fn harvest_folder_labels(&self) -> BackendResult<Vec<audiofiles_core::harvest::FolderLabel>> {
192 let db = self.db.lock();
193 Ok(audiofiles_core::harvest::harvest_folder_labels(&db)?)
194 }
195
196 fn apply_harvested_tag(&self, hashes: &[String], tag: &str) -> BackendResult<usize> {
197 let db = self.db.lock();
198 Ok(audiofiles_core::harvest::apply_harvested_tag(
199 &db, hashes, tag,
200 )?)
201 }
202
203 fn remove_tags_by_source(&self, source: &str) -> BackendResult<usize> {
204 let db = self.db.lock();
205 Ok(audiofiles_core::rules::remove_tags_by_source(&db, source)?)
206 }
207
208 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> {
209 let db = self.db.lock();
210 Ok(audiofiles_core::analysis::waveform::load_waveform(
211 &db, hash,
212 ))
213 }
214 }
215
216 impl SimilarityBackend for DirectBackend {
217 // --- Similarity ---
218
219 fn start_find_similar(&self, hash: &str, limit: usize) -> BackendResult<()> {
220 self.ensure_search_worker()?;
221 let mut guard = self.search_worker.lock();
222 let delivered = guard.as_ref().is_some_and(|w| {
223 w.send(SearchCommand::FindSimilar {
224 hash: hash.to_string(),
225 limit,
226 })
227 });
228 // A dead cached worker would never answer; drop it (next call respawns)
229 // and report Err so the caller doesn't set latest_similarity_request and
230 // spin the repaint-while-busy guard forever.
231 if !delivered {
232 *guard = None;
233 return Err(BackendError::Other(
234 "search worker is not running".to_string(),
235 ));
236 }
237 Ok(())
238 }
239
240 fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()> {
241 self.ensure_search_worker()?;
242 let mut guard = self.search_worker.lock();
243 let delivered = guard.as_ref().is_some_and(|w| {
244 w.send(SearchCommand::FindNearDuplicates {
245 hash: hash.to_string(),
246 limit,
247 })
248 });
249 if !delivered {
250 *guard = None;
251 return Err(BackendError::Other(
252 "search worker is not running".to_string(),
253 ));
254 }
255 Ok(())
256 }
257 }
258