Skip to main content

max / audiofiles

16.0 KB · 380 lines History Blame Raw
1 //! Direct backend: wraps `Mutex<Database>` + `SampleStore`, calls core functions directly.
2 //!
3 //! This is the "same as before" implementation, every Backend method delegates
4 //! to the corresponding audiofiles-core function. Used in standalone mode, tests,
5 //! and as a reference implementation.
6
7 use std::path::{Path, PathBuf};
8
9 use tracing::instrument;
10
11 use audiofiles_core::analysis::AnalysisResult;
12 use audiofiles_core::analysis::config::AnalysisConfig;
13 use audiofiles_core::analysis::waveform::WaveformData;
14 use audiofiles_core::collections::Collection;
15 use audiofiles_core::db::Database;
16 use audiofiles_core::edit::EditOperation;
17 use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle};
18 use audiofiles_core::export::ExportItem;
19 use audiofiles_core::export::profile::DeviceProfileSummary;
20 use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget};
21 use audiofiles_core::search::SearchFilter;
22 use audiofiles_core::store::SampleStore;
23 use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis};
24 use audiofiles_core::{CollectionId, NodeId, VfsId, collections, search, tags};
25
26 use super::search_worker::{self, SearchCommand, SearchEvent};
27 use parking_lot::Mutex;
28
29 use super::{
30 AnalysisBackend, Backend, BackendError, BackendEvent, BackendResult, ClassifierBackend,
31 CollectionBackend, ConfigBackend, ExportBackend, ExportConfigDesc, ExportItemDesc,
32 ForgeBackend, ImportStrategyDesc, ImportedFolderDesc, OperationsBackend, RulesBackend,
33 SearchBackend, SimilarityBackend, StoreBackend, TagBackend, TrainedHeadInfo, VfsBackend,
34 };
35
36 use super::forge::{ForgeImportOutcome, ForgePending, forge_import_chop, forge_import_conform};
37
38 use crate::cleanup::{CleanupCommand, CleanupHandle};
39 use crate::export::{ExportCommand, ExportHandle};
40 use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy};
41
42 use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle};
43 use audiofiles_core::forge::{ForgeCommand, ForgeEvent};
44
45 /// Convert a GUI-facing [`ImportStrategyDesc`] into the worker's [`ImportStrategy`].
46 /// Shared by the directory and explicit-file import entry points.
47 fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy {
48 match strategy {
49 ImportStrategyDesc::Flat { vfs_id, parent_id } => {
50 ImportStrategy::Flat { vfs_id, parent_id }
51 }
52 ImportStrategyDesc::NewVfs { vfs_name } => ImportStrategy::NewVfs { vfs_name },
53 ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id } => {
54 ImportStrategy::MergeIntoVfs { vfs_id, parent_id }
55 }
56 }
57 }
58
59 /// Direct backend: talks to SQLite and the sample store in-process.
60 pub struct DirectBackend {
61 db: Mutex<Database>,
62 store: SampleStore,
63 data_dir: PathBuf,
64 // Worker handles for long-running operations
65 import_worker: Mutex<Option<ImportHandle>>,
66 analysis_worker: Mutex<Option<WorkerHandle>>,
67 export_worker: Mutex<Option<ExportHandle>>,
68 cleanup_worker: Mutex<Option<CleanupHandle>>,
69 loose_files_worker: Mutex<Option<crate::loose_files_worker::LooseFilesHandle>>,
70 edit_worker: Mutex<Option<EditWorkerHandle>>,
71 forge_worker: Mutex<Option<audiofiles_core::forge::ForgeWorkerHandle>>,
72 classifier_worker: Mutex<Option<crate::classifier_worker::ClassifierHandle>>,
73 // Import context for the in-flight forge op, applied when its CPU stage
74 // completes (the worker produces temp files; an import job imports them).
75 forge_pending: Mutex<Option<ForgePending>>,
76 // Off-thread forge import (blob copy + DB writes on its own WAL connection),
77 // so the import never stalls the GUI frame under db.lock().
78 forge_import_job: Mutex<Option<audiofiles_core::worker_runtime::JobHandle<ForgeImportOutcome>>>,
79 // Search worker: owns the similarity / near-duplicate VP-trees and builds +
80 // queries them off the GUI thread. Spawned lazily on first search,
81 // invalidated on new analysis.
82 search_worker: Mutex<Option<search_worker::SearchWorkerHandle>>,
83 // Device plugin registry (when device-profiles feature is enabled)
84 #[cfg(feature = "device-profiles")]
85 plugin_registry: audiofiles_rhai::registry::PluginRegistry,
86 }
87
88 impl DirectBackend {
89 /// Create a new DirectBackend from a database and sample store.
90 pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self {
91 // One-time maintenance: hard-delete tombstones past their retention
92 // window (docs/design-sample-deletion.md Phase 4). Best-effort, a
93 // failed sweep must never block app start, so errors are logged, not
94 // propagated. Runs against the owned db before it moves into the Mutex.
95 match store.sweep_expired_tombstones(&db) {
96 Ok(0) => {}
97 Ok(n) => tracing::info!("tombstone sweep: hard-deleted {n} expired sample(s)"),
98 Err(e) => tracing::warn!("tombstone sweep failed at startup: {e}"),
99 }
100 Self {
101 db: Mutex::new(db),
102 store,
103 data_dir,
104 import_worker: Mutex::new(None),
105 analysis_worker: Mutex::new(None),
106 export_worker: Mutex::new(None),
107 cleanup_worker: Mutex::new(None),
108 loose_files_worker: Mutex::new(None),
109 edit_worker: Mutex::new(None),
110 forge_worker: Mutex::new(None),
111 classifier_worker: Mutex::new(None),
112 forge_pending: Mutex::new(None),
113 forge_import_job: Mutex::new(None),
114 search_worker: Mutex::new(None),
115 #[cfg(feature = "device-profiles")]
116 plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|e| {
117 // Embedded (include_str!) profiles are compile-checked, so this
118 // should never trip, but if it does, the conform/M8/MPC wedge
119 // would silently show "No device profiles". Log so the empty
120 // registry is diagnosable instead of mysterious.
121 tracing::error!(
122 "Failed to load device-profile registry, falling back to empty: {e}"
123 );
124 audiofiles_rhai::registry::PluginRegistry::new()
125 }),
126 }
127 }
128
129 /// Access the store (needed for preview decode path in BrowserState).
130 pub fn store(&self) -> &SampleStore {
131 &self.store
132 }
133
134 /// Access the data directory path.
135 pub fn data_dir(&self) -> &Path {
136 &self.data_dir
137 }
138
139 /// Lazily spawn the loose-files worker and send it a command. Keeps the
140 /// integrity/relocate/purge filesystem work off the egui frame thread.
141 fn loose_files_send(
142 &self,
143 cmd: crate::loose_files_worker::LooseFilesCommand,
144 ) -> BackendResult<()> {
145 let mut guard = self.loose_files_worker.lock();
146 if guard.is_none() {
147 let db_path = self.data_dir.join("audiofiles.db");
148 let handle = crate::loose_files_worker::spawn_loose_files_worker(
149 db_path,
150 self.store.root().to_path_buf(),
151 )
152 .map_err(|e| BackendError::Other(format!("failed to spawn loose-files worker: {e}")))?;
153 *guard = Some(handle);
154 }
155 // If the cached worker died (e.g. its DB open failed inside the thread),
156 // send returns false. Drop the dead handle so the next call respawns, and
157 // report Err so the caller never sets a busy flag for a command that no
158 // worker will ever answer (which would wedge the repaint-while-busy guard).
159 if !guard
160 .as_ref()
161 .expect("loose-files worker present")
162 .send(cmd)
163 {
164 *guard = None;
165 return Err(BackendError::Other(
166 "loose-files worker is not running".to_string(),
167 ));
168 }
169 Ok(())
170 }
171
172 /// Cancel any in-flight import worker and spawn a fresh one, returning its
173 /// handle (not yet stored). Shared by [`start_import`](Self::start_import) and
174 /// [`start_file_import`](Self::start_file_import).
175 fn respawn_import_worker(&self) -> BackendResult<ImportHandle> {
176 let db_path = self.data_dir.join("audiofiles.db");
177 let store_root = self.store.root().to_path_buf();
178 if let Some(old) = self.import_worker.lock().take() {
179 old.send(ImportCommand::Cancel);
180 drop(old); // joins the thread
181 }
182 crate::import::spawn_import_worker(db_path, store_root)
183 .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}")))
184 }
185
186 /// Lazily spawn the search worker on first use. It opens its own read-only
187 /// connection to the database file (WAL allows concurrent reads).
188 fn ensure_search_worker(&self) -> BackendResult<()> {
189 let mut guard = self.search_worker.lock();
190 if guard.is_none() {
191 let db_path = self.data_dir.join("audiofiles.db");
192 let handle = search_worker::spawn_search_worker(db_path)
193 .map_err(|e| BackendError::Other(format!("failed to spawn search worker: {e}")))?;
194 *guard = Some(handle);
195 }
196 Ok(())
197 }
198
199 /// Resolve a device profile's constraints into the export config and filter items.
200 ///
201 /// Called before spawning the export worker so profile resolution happens
202 /// on the main thread (where PluginRegistry is accessible).
203 #[cfg(feature = "device-profiles")]
204 #[instrument(skip_all)]
205 fn resolve_device_profile(
206 &self,
207 config: &mut audiofiles_core::export::ExportConfig,
208 items: &mut Vec<ExportItem>,
209 ) {
210 use audiofiles_core::export::profile::ChannelConstraint;
211 use audiofiles_core::export::{ExportChannels, ExportFormat};
212
213 let profile_name = match config.device_profile {
214 Some(ref name) => name.clone(),
215 None => return,
216 };
217
218 let Some(plugin) = self.plugin_registry.get(&profile_name) else {
219 return;
220 };
221
222 let profile = &plugin.profile;
223
224 // Format: if Original, set to profile's first supported format
225 if config.format == ExportFormat::Original
226 && let Some(fmt) = profile.audio.formats.first()
227 {
228 config.format = fmt.clone();
229 }
230
231 // Sample rate: if not set, use profile's first rate
232 if config.sample_rate.is_none() {
233 config.sample_rate = profile.audio.sample_rates.first().copied();
234 }
235
236 // Bit depth: if not set, use profile's first depth
237 if config.bit_depth.is_none() {
238 config.bit_depth = profile.audio.bit_depths.first().copied();
239 }
240
241 // Channels
242 match profile.audio.channels {
243 ChannelConstraint::Mono => config.channels = ExportChannels::Mono,
244 ChannelConstraint::Stereo => config.channels = ExportChannels::Stereo,
245 ChannelConstraint::Both => {} // leave as-is
246 }
247
248 // Naming rules
249 config.naming_rules.clone_from(&profile.naming);
250
251 // File size limit
252 config.max_file_size_bytes = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes);
253
254 // validate_sample hook: filter items through Rhai script
255 if let Some(ref ast) = plugin.hooks.validate_sample {
256 // Collect all sample info with the lock held, then drop it before running scripts
257 // to avoid holding the DB lock during potentially slow Rhai execution.
258 let mut infos: Vec<_> = {
259 let db = self.db.lock();
260 items
261 .iter()
262 .map(|item| super::sample_info::build_sample_info(&db, &self.store, item))
263 .collect()
264 };
265 // Probe bit depth off the DB lock and in parallel: N independent
266 // file-header reads that previously ran serially *while holding the
267 // lock* on the GUI thread. rayon bounds the concurrency to the pool.
268 {
269 use rayon::prelude::*;
270 let paths: Vec<Option<std::path::PathBuf>> = items
271 .iter()
272 .map(|item| self.store.sample_path(&item.hash, &item.ext).ok())
273 .collect();
274 let depths: Vec<u16> = paths
275 .into_par_iter()
276 .map(|p| {
277 p.and_then(|p| super::sample_info::probe_bit_depth(&p))
278 .unwrap_or(0)
279 })
280 .collect();
281 for (info, depth) in infos.iter_mut().zip(depths) {
282 info.bit_depth = depth;
283 }
284 }
285 let engine = self.plugin_registry.engine();
286 let mut keep = vec![true; items.len()];
287 for (i, info) in infos.into_iter().enumerate() {
288 keep[i] = match audiofiles_rhai::hooks::run_validate_sample(engine, ast, info) {
289 // A deliberate `false` from the script rejects the sample.
290 Ok(decision) => decision,
291 // A script *fault* (not a rejection) must not silently shrink
292 // the export, previously `.unwrap_or(false)` dropped the file
293 // and reported success. Fail open (keep the sample) and surface
294 // the error loudly instead.
295 Err(e) => {
296 tracing::error!(
297 profile = %profile_name,
298 error = %e,
299 "device-profile validate_sample script errored; keeping sample in export rather than dropping it silently"
300 );
301 true
302 }
303 };
304 }
305 let mut ki = 0;
306 items.retain(|_| {
307 let k = keep[ki];
308 ki += 1;
309 k
310 });
311 }
312
313 // transform_filename hook: pre-compute output names with custom naming logic
314 if let Some(ref ast) = plugin.hooks.transform_filename {
315 let pattern = config
316 .naming_pattern
317 .as_ref()
318 .and_then(|p| audiofiles_core::rename::RenamePattern::parse(p).ok());
319
320 let mut names =
321 audiofiles_core::export::resolve_output_names(items, config, pattern.as_ref());
322
323 let device_name = profile.name.clone();
324 let destination = config.destination.display().to_string();
325 let total = names.len() as i64;
326
327 for (i, name) in names.iter_mut().enumerate() {
328 let (stem, ext) = split_name_ext(name);
329 let ctx = audiofiles_rhai::types::RhaiExportContext {
330 device_name: device_name.clone(),
331 destination: destination.clone(),
332 filename: stem.clone(),
333 extension: ext.clone(),
334 index: i as i64,
335 total,
336 };
337 if let Ok(new_stem) = audiofiles_rhai::hooks::run_transform_filename(
338 self.plugin_registry.engine(),
339 ast,
340 stem,
341 ctx,
342 ) {
343 // Sanitize: strip path separators, NUL bytes, and a bare `..`
344 // to prevent traversal. resolve_output_names re-sanitizes
345 // overrides too, so this is a first layer, not the only one.
346 let safe_stem = new_stem.replace(['/', '\\', '\0'], "_");
347 let safe_stem = if safe_stem.is_empty() || safe_stem == ".." || safe_stem == "."
348 {
349 "untitled".to_string()
350 } else {
351 safe_stem
352 };
353 *name = if ext.is_empty() {
354 safe_stem
355 } else {
356 format!("{safe_stem}.{ext}")
357 };
358 }
359 }
360
361 config.name_overrides = Some(names);
362 }
363 }
364 }
365
366 #[cfg(feature = "device-profiles")]
367 use audiofiles_core::util::split_name_ext;
368
369 impl Backend for DirectBackend {}
370
371 mod analysis;
372 mod classify;
373 mod forge;
374 mod library;
375 mod operations;
376 mod store;
377
378 #[cfg(test)]
379 mod tests;
380