Skip to main content

max / audiofiles

52.4 KB · 1512 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::config::AnalysisConfig;
12 use audiofiles_core::analysis::waveform::WaveformData;
13 use audiofiles_core::analysis::AnalysisResult;
14 use audiofiles_core::db::Database;
15 use audiofiles_core::edit::EditOperation;
16 use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle};
17 use audiofiles_core::export::profile::DeviceProfileSummary;
18 use audiofiles_core::export::ExportItem;
19 use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget};
20 use audiofiles_core::search::SearchFilter;
21 use audiofiles_core::collections::Collection;
22 use audiofiles_core::store::SampleStore;
23 use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis};
24 use audiofiles_core::{collections, fingerprint, search, similarity, tags, CollectionId, NodeId, VfsId};
25 use parking_lot::Mutex;
26
27 use super::{
28 Backend, BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc,
29 ImportStrategyDesc, ImportedFolderDesc,
30 };
31
32 use crate::cleanup::{CleanupCommand, CleanupHandle};
33 use crate::export::{ExportCommand, ExportHandle};
34 use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy};
35
36 use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle};
37
38 /// Direct backend: talks to SQLite and the sample store in-process.
39 pub struct DirectBackend {
40 db: Mutex<Database>,
41 store: SampleStore,
42 data_dir: PathBuf,
43 // Worker handles for long-running operations
44 import_worker: Mutex<Option<ImportHandle>>,
45 analysis_worker: Mutex<Option<WorkerHandle>>,
46 export_worker: Mutex<Option<ExportHandle>>,
47 cleanup_worker: Mutex<Option<CleanupHandle>>,
48 edit_worker: Mutex<Option<EditWorkerHandle>>,
49 // VP-tree indexes for fast search (lazy, invalidated on new analysis)
50 fingerprint_index: Mutex<Option<fingerprint::FingerprintIndex>>,
51 similarity_index: Mutex<Option<similarity::SimilarityIndex>>,
52 // Device plugin registry (when device-profiles feature is enabled)
53 #[cfg(feature = "device-profiles")]
54 plugin_registry: audiofiles_rhai::registry::PluginRegistry,
55 }
56
57 impl DirectBackend {
58 /// Create a new DirectBackend from a database and sample store.
59 pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self {
60 Self {
61 db: Mutex::new(db),
62 store,
63 data_dir,
64 import_worker: Mutex::new(None),
65 analysis_worker: Mutex::new(None),
66 export_worker: Mutex::new(None),
67 cleanup_worker: Mutex::new(None),
68 edit_worker: Mutex::new(None),
69 fingerprint_index: Mutex::new(None),
70 similarity_index: Mutex::new(None),
71 #[cfg(feature = "device-profiles")]
72 plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|_| {
73 audiofiles_rhai::registry::PluginRegistry::new()
74 }),
75 }
76 }
77
78 /// Access the store (needed for preview decode path in BrowserState).
79 pub fn store(&self) -> &SampleStore {
80 &self.store
81 }
82
83 /// Access the data directory path.
84 pub fn data_dir(&self) -> &Path {
85 &self.data_dir
86 }
87
88 /// Resolve a device profile's constraints into the export config and filter items.
89 ///
90 /// Called before spawning the export worker so profile resolution happens
91 /// on the main thread (where PluginRegistry is accessible).
92 #[cfg(feature = "device-profiles")]
93 #[instrument(skip_all)]
94 fn resolve_device_profile(
95 &self,
96 config: &mut audiofiles_core::export::ExportConfig,
97 items: &mut Vec<ExportItem>,
98 ) {
99 use audiofiles_core::export::profile::ChannelConstraint;
100 use audiofiles_core::export::{ExportChannels, ExportFormat};
101
102 let profile_name = match config.device_profile {
103 Some(ref name) => name.clone(),
104 None => return,
105 };
106
107 let plugin = match self.plugin_registry.get(&profile_name) {
108 Some(p) => p,
109 None => return,
110 };
111
112 let profile = &plugin.profile;
113
114 // Format: if Original, set to profile's first supported format
115 if config.format == ExportFormat::Original
116 && let Some(fmt) = profile.audio.formats.first() {
117 config.format = fmt.clone();
118 }
119
120 // Sample rate: if not set, use profile's first rate
121 if config.sample_rate.is_none() {
122 config.sample_rate = profile.audio.sample_rates.first().copied();
123 }
124
125 // Bit depth: if not set, use profile's first depth
126 if config.bit_depth.is_none() {
127 config.bit_depth = profile.audio.bit_depths.first().copied();
128 }
129
130 // Channels
131 match profile.audio.channels {
132 ChannelConstraint::Mono => config.channels = ExportChannels::Mono,
133 ChannelConstraint::Stereo => config.channels = ExportChannels::Stereo,
134 ChannelConstraint::Both => {} // leave as-is
135 }
136
137 // Naming rules
138 config.naming_rules = profile.naming.clone();
139
140 // File size limit
141 config.max_file_size_bytes = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes);
142
143 // validate_sample hook: filter items through Rhai script
144 if let Some(ref ast) = plugin.hooks.validate_sample {
145 // Collect all sample info with the lock held, then drop it before running scripts
146 // to avoid holding the DB lock during potentially slow Rhai execution.
147 let infos: Vec<_> = {
148 let db = self.db.lock();
149 items.iter().map(|item| build_sample_info(&db, &self.store, item)).collect()
150 };
151 let engine = self.plugin_registry.engine();
152 let mut keep = vec![true; items.len()];
153 for (i, info) in infos.into_iter().enumerate() {
154 keep[i] = audiofiles_rhai::hooks::run_validate_sample(engine, ast, info)
155 .unwrap_or(false);
156 }
157 let mut ki = 0;
158 items.retain(|_| { let k = keep[ki]; ki += 1; k });
159 }
160
161 // transform_filename hook: pre-compute output names with custom naming logic
162 if let Some(ref ast) = plugin.hooks.transform_filename {
163 let pattern = config
164 .naming_pattern
165 .as_ref()
166 .and_then(|p| audiofiles_core::rename::RenamePattern::parse(p).ok());
167
168 let mut names = audiofiles_core::export::resolve_output_names(
169 items,
170 config,
171 pattern.as_ref(),
172 );
173
174 let device_name = profile.name.clone();
175 let destination = config.destination.display().to_string();
176 let total = names.len() as i64;
177
178 for (i, name) in names.iter_mut().enumerate() {
179 let (stem, ext) = split_name_ext(name);
180 let ctx = audiofiles_rhai::types::RhaiExportContext {
181 device_name: device_name.clone(),
182 destination: destination.clone(),
183 filename: stem.clone(),
184 extension: ext.clone(),
185 index: i as i64,
186 total,
187 };
188 if let Ok(new_stem) = audiofiles_rhai::hooks::run_transform_filename(
189 self.plugin_registry.engine(),
190 ast,
191 stem,
192 ctx,
193 ) {
194 // Sanitize: strip path separators and NUL bytes to prevent traversal
195 let safe_stem = new_stem.replace(['/', '\\', '\0'], "_");
196 let safe_stem = if safe_stem.is_empty() { "untitled".to_string() } else { safe_stem };
197 *name = if ext.is_empty() {
198 safe_stem
199 } else {
200 format!("{safe_stem}.{ext}")
201 };
202 }
203 }
204
205 config.name_overrides = Some(names);
206 }
207 }
208 }
209
210 #[cfg(feature = "device-profiles")]
211 use audiofiles_core::util::split_name_ext;
212
213 /// Build a RhaiSampleInfo from an ExportItem and database lookups.
214 #[cfg(feature = "device-profiles")]
215 fn build_sample_info(
216 db: &audiofiles_core::db::Database,
217 store: &audiofiles_core::store::SampleStore,
218 item: &ExportItem,
219 ) -> audiofiles_rhai::types::RhaiSampleInfo {
220 // Query audio_analysis for sample_rate and channels
221 let (sample_rate, channels, duration) = db
222 .conn()
223 .query_row(
224 "SELECT sample_rate, channels, duration FROM audio_analysis WHERE hash = ?1",
225 [&item.hash],
226 |row| {
227 Ok((
228 row.get::<_, u32>(0)?,
229 row.get::<_, u16>(1)?,
230 row.get::<_, f64>(2)?,
231 ))
232 },
233 )
234 .unwrap_or_else(|e| {
235 if !matches!(e, rusqlite::Error::QueryReturnedNoRows) {
236 tracing::warn!("Failed to query audio_analysis for {}: {e}", &item.hash[..8]);
237 }
238 (0, 0, item.duration.unwrap_or(0.0))
239 });
240
241 // Query samples for file_size
242 let file_size = db
243 .conn()
244 .query_row(
245 "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL",
246 [&item.hash],
247 |row| row.get::<_, u64>(0),
248 )
249 .unwrap_or_else(|_| {
250 // Fallback: read from store
251 store
252 .sample_path(&item.hash, &item.ext)
253 .ok()
254 .and_then(|p| std::fs::metadata(p).ok())
255 .map(|m| m.len())
256 .unwrap_or(0)
257 });
258
259 // Probe bit depth from the file header (cheap — reads only the header)
260 let bit_depth = store
261 .sample_path(&item.hash, &item.ext)
262 .ok()
263 .and_then(|p| probe_bit_depth(&p))
264 .unwrap_or(0);
265
266 audiofiles_rhai::types::RhaiSampleInfo {
267 hash: item.hash.to_string(),
268 name: item.name.clone(),
269 extension: item.ext.clone(),
270 sample_rate,
271 bit_depth,
272 channels,
273 duration,
274 file_size,
275 }
276 }
277
278 /// Probe bit depth from a WAV/AIFF file header without full decode.
279 #[cfg(feature = "device-profiles")]
280 fn probe_bit_depth(path: &std::path::Path) -> Option<u16> {
281 // Try hound first (WAV)
282 if let Ok(reader) = hound::WavReader::open(path) {
283 return Some(reader.spec().bits_per_sample);
284 }
285 // Try symphonia for AIFF/other formats
286 let file = std::fs::File::open(path).ok()?;
287 let mss = symphonia::core::io::MediaSourceStream::new(Box::new(file), Default::default());
288 let mut hint = symphonia::core::probe::Hint::new();
289 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
290 hint.with_extension(ext);
291 }
292 let probed = symphonia::default::get_probe()
293 .format(
294 &hint,
295 mss,
296 &symphonia::core::formats::FormatOptions::default(),
297 &symphonia::core::meta::MetadataOptions::default(),
298 )
299 .ok()?;
300 let track = probed.format.default_track()?;
301 track.codec_params.bits_per_sample.map(|b| b as u16)
302 }
303
304 impl Backend for DirectBackend {
305 // --- VFS ---
306
307 fn list_vfs(&self) -> BackendResult<Vec<Vfs>> {
308 let db = self.db.lock();
309 Ok(vfs::list_vfs(&db)?)
310 }
311
312 fn create_vfs(&self, name: &str) -> BackendResult<VfsId> {
313 let db = self.db.lock();
314 Ok(vfs::create_vfs(&db, name)?)
315 }
316
317 fn rename_vfs(&self, id: VfsId, new_name: &str) -> BackendResult<()> {
318 let db = self.db.lock();
319 Ok(vfs::rename_vfs(&db, id, new_name)?)
320 }
321
322 fn delete_vfs(&self, id: VfsId) -> BackendResult<()> {
323 let db = self.db.lock();
324 Ok(vfs::delete_vfs(&db, id)?)
325 }
326
327 fn list_children_enriched(
328 &self,
329 vfs_id: VfsId,
330 parent_id: Option<NodeId>,
331 ) -> BackendResult<Vec<VfsNodeWithAnalysis>> {
332 let db = self.db.lock();
333 Ok(vfs::list_children_enriched(&db, vfs_id, parent_id)?)
334 }
335
336 fn list_children(
337 &self,
338 vfs_id: VfsId,
339 parent_id: Option<NodeId>,
340 ) -> BackendResult<Vec<VfsNode>> {
341 let db = self.db.lock();
342 Ok(vfs::list_children(&db, vfs_id, parent_id)?)
343 }
344
345 fn create_directory(
346 &self,
347 vfs_id: VfsId,
348 parent_id: Option<NodeId>,
349 name: &str,
350 ) -> BackendResult<NodeId> {
351 let db = self.db.lock();
352 Ok(vfs::create_directory(&db, vfs_id, parent_id, name)?)
353 }
354
355 fn create_sample_link(
356 &self,
357 vfs_id: VfsId,
358 parent_id: Option<NodeId>,
359 name: &str,
360 sample_hash: &str,
361 ) -> BackendResult<NodeId> {
362 let db = self.db.lock();
363 Ok(vfs::create_sample_link(&db, vfs_id, parent_id, name, sample_hash)?)
364 }
365
366 fn get_node(&self, id: NodeId) -> BackendResult<VfsNode> {
367 let db = self.db.lock();
368 Ok(vfs::get_node(&db, id)?)
369 }
370
371 fn get_breadcrumb(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>> {
372 let db = self.db.lock();
373 Ok(vfs::get_breadcrumb(&db, node_id)?)
374 }
375
376 fn rename_node(&self, id: NodeId, new_name: &str) -> BackendResult<()> {
377 let db = self.db.lock();
378 Ok(vfs::rename_node(&db, id, new_name)?)
379 }
380
381 fn move_node(&self, id: NodeId, new_parent_id: Option<NodeId>) -> BackendResult<()> {
382 let db = self.db.lock();
383 Ok(vfs::move_node(&db, id, new_parent_id)?)
384 }
385
386 fn delete_node(&self, id: NodeId) -> BackendResult<()> {
387 let db = self.db.lock();
388 Ok(vfs::delete_node(&db, id)?)
389 }
390
391 fn restore_node(&self, node: &VfsNode) -> BackendResult<()> {
392 let db = self.db.lock();
393 Ok(vfs::restore_node(&db, node)?)
394 }
395
396 fn collect_subtree(&self, node_id: NodeId) -> BackendResult<Vec<VfsNode>> {
397 let db = self.db.lock();
398 Ok(vfs::collect_subtree(&db, node_id)?)
399 }
400
401 fn list_all_directories(&self, vfs_id: VfsId) -> BackendResult<Vec<(NodeId, String)>> {
402 let db = self.db.lock();
403 Ok(vfs::list_all_directories(&db, vfs_id)?)
404 }
405
406 fn find_nodes_by_hashes(
407 &self,
408 vfs_id: VfsId,
409 hashes: &[&str],
410 ) -> BackendResult<Vec<VfsNodeWithAnalysis>> {
411 let db = self.db.lock();
412 Ok(vfs::find_nodes_by_hashes(&db, vfs_id, hashes)?)
413 }
414
415 // --- Tags ---
416
417 fn add_tag(&self, hash: &str, tag: &str) -> BackendResult<()> {
418 let db = self.db.lock();
419 Ok(tags::add_tag(&db, hash, tag)?)
420 }
421
422 fn remove_tag(&self, hash: &str, tag: &str) -> BackendResult<()> {
423 let db = self.db.lock();
424 Ok(tags::remove_tag(&db, hash, tag)?)
425 }
426
427 fn get_sample_tags(&self, hash: &str) -> BackendResult<Vec<String>> {
428 let db = self.db.lock();
429 Ok(tags::get_sample_tags(&db, hash)?)
430 }
431
432 fn list_all_tags(&self) -> BackendResult<Vec<String>> {
433 let db = self.db.lock();
434 Ok(tags::list_all_tags(&db)?)
435 }
436
437 fn bulk_add_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize> {
438 let db = self.db.lock();
439 Ok(tags::bulk_add_tag(&db, hashes, tag)?)
440 }
441
442 fn bulk_remove_tag(&self, hashes: &[&str], tag: &str) -> BackendResult<usize> {
443 let db = self.db.lock();
444 Ok(tags::bulk_remove_tag(&db, hashes, tag)?)
445 }
446
447 fn rename_tag_globally(&self, old_tag: &str, new_tag: &str) -> BackendResult<usize> {
448 let db = self.db.lock();
449 Ok(tags::rename_tag_globally(&db, old_tag, new_tag)?)
450 }
451
452 fn count_samples_with_tag(&self, tag: &str) -> BackendResult<usize> {
453 let db = self.db.lock();
454 Ok(tags::find_by_tag(&db, tag)?.len())
455 }
456
457 fn remove_tag_globally(&self, tag: &str) -> BackendResult<usize> {
458 let db = self.db.lock();
459 Ok(tags::remove_tag_globally(&db, tag)?)
460 }
461
462 // --- Search ---
463
464 fn search_in_folder(
465 &self,
466 filter: &SearchFilter,
467 vfs_id: VfsId,
468 parent_id: Option<NodeId>,
469 ) -> BackendResult<Vec<VfsNodeWithAnalysis>> {
470 let db = self.db.lock();
471 Ok(search::search_in_folder(&db, filter, vfs_id, parent_id)?)
472 }
473
474 fn search_global(&self, filter: &SearchFilter) -> BackendResult<Vec<VfsNodeWithAnalysis>> {
475 let db = self.db.lock();
476 Ok(search::search_global(&db, filter)?)
477 }
478
479 // --- Collections ---
480
481 fn list_collections(&self) -> BackendResult<Vec<Collection>> {
482 let db = self.db.lock();
483 Ok(collections::list_collections(&db)?)
484 }
485
486 fn create_collection(&self, name: &str, description: Option<&str>) -> BackendResult<CollectionId> {
487 let db = self.db.lock();
488 Ok(collections::create_collection(&db, name, description)?)
489 }
490
491 fn create_dynamic_collection(&self, name: &str, filter: &SearchFilter) -> BackendResult<CollectionId> {
492 let db = self.db.lock();
493 Ok(collections::create_dynamic_collection(&db, name, filter)?)
494 }
495
496 fn rename_collection(&self, id: CollectionId, new_name: &str) -> BackendResult<()> {
497 let db = self.db.lock();
498 Ok(collections::rename_collection(&db, id, new_name)?)
499 }
500
501 fn delete_collection(&self, id: CollectionId) -> BackendResult<()> {
502 let db = self.db.lock();
503 Ok(collections::delete_collection(&db, id)?)
504 }
505
506 fn add_to_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()> {
507 let db = self.db.lock();
508 Ok(collections::add_to_collection(&db, collection_id, sample_hash)?)
509 }
510
511 fn remove_from_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()> {
512 let db = self.db.lock();
513 Ok(collections::remove_from_collection(&db, collection_id, sample_hash)?)
514 }
515
516 fn list_collection_members(&self, collection_id: CollectionId) -> BackendResult<Vec<String>> {
517 let db = self.db.lock();
518 Ok(collections::list_collection_members(&db, collection_id)?)
519 }
520
521 fn get_sample_collections(&self, sample_hash: &str) -> BackendResult<Vec<Collection>> {
522 let db = self.db.lock();
523 Ok(collections::get_sample_collections(&db, sample_hash)?)
524 }
525
526 // --- Analysis ---
527
528 fn get_analysis(&self, hash: &str) -> BackendResult<Option<AnalysisResult>> {
529 let db = self.db.lock();
530 Ok(audiofiles_core::analysis::load_analysis(&db, hash))
531 }
532
533 fn save_analysis(&self, result: &AnalysisResult) -> BackendResult<()> {
534 let db = self.db.lock();
535 audiofiles_core::analysis::save_analysis(&db, result)?;
536 // Invalidate search indexes — new analysis data changes normalization ranges
537 // and may add a new fingerprint.
538 *self.similarity_index.lock() = None;
539 if result.fingerprint.is_some() {
540 *self.fingerprint_index.lock() = None;
541 }
542 Ok(())
543 }
544
545 fn get_waveform(&self, hash: &str) -> BackendResult<Option<WaveformData>> {
546 let db = self.db.lock();
547 Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash))
548 }
549
550 // --- Similarity ---
551
552 fn find_similar(
553 &self,
554 hash: &str,
555 limit: usize,
556 ) -> BackendResult<Vec<similarity::SimilarResult>> {
557 // Build VP-tree index lazily on first query.
558 // Load data under DB lock, release lock, then build tree (CPU-intensive).
559 let mut idx = self.similarity_index.lock();
560 if idx.is_none() {
561 let data = {
562 let db = self.db.lock();
563 similarity::SimilarityIndex::load_data(&db)?
564 };
565 *idx = Some(similarity::SimilarityIndex::build_from_data(data));
566 }
567 let built = idx.as_ref().expect("set on the line above");
568 let features = {
569 let db = self.db.lock();
570 similarity::load_features(&db, hash)?
571 };
572 Ok(built.find_similar(hash, &features, limit))
573 }
574
575 fn find_near_duplicates(
576 &self,
577 hash: &str,
578 limit: usize,
579 ) -> BackendResult<Vec<fingerprint::DuplicateResult>> {
580 // Build VP-tree index lazily on first query.
581 // Load data under DB lock, release lock, then build tree (CPU-intensive).
582 let mut idx = self.fingerprint_index.lock();
583 if idx.is_none() {
584 let entries = {
585 let db = self.db.lock();
586 fingerprint::FingerprintIndex::load_data(&db)?
587 };
588 *idx = Some(fingerprint::FingerprintIndex::build_from_data(entries));
589 }
590 let built = idx.as_ref().expect("set on the line above");
591 let reference = {
592 let db = self.db.lock();
593 fingerprint::load_fingerprint(&db, hash)?
594 };
595 Ok(built.find_near_duplicates(hash, &reference.envelope, limit))
596 }
597
598 // --- Store ---
599
600 fn import_file(&self, path: &Path) -> BackendResult<String> {
601 let db = self.db.lock();
602 Ok(self.store.import(path, &db)?)
603 }
604
605 fn sample_path(&self, hash: &str, ext: &str) -> BackendResult<PathBuf> {
606 let db = self.db.lock();
607 Ok(audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?)
608 }
609
610 fn sample_extension(&self, hash: &str) -> BackendResult<String> {
611 let db = self.db.lock();
612 Ok(audiofiles_core::store::sample_extension(&db, hash)?)
613 }
614
615 fn sample_original_name(&self, hash: &str) -> BackendResult<String> {
616 let db = self.db.lock();
617 Ok(audiofiles_core::store::sample_original_name(&db, hash)?)
618 }
619
620 fn remove_sample(&self, hash: &str) -> BackendResult<()> {
621 let db = self.db.lock();
622 Ok(self.store.remove(hash, &db)?)
623 }
624
625 fn remove_orphaned_samples(&self) -> BackendResult<usize> {
626 let db = self.db.lock();
627 Ok(self.store.remove_orphaned_samples(&db)?)
628 }
629
630 fn cleanup_orphans_local(&self) -> BackendResult<usize> {
631 // Flip `sync_state.applying_remote` to '1' so the sync DELETE
632 // triggers don't push the orphan removals over the wire. The
633 // current behavior of pushing them (inherited from the M007 trigger
634 // design) would cascade-wipe placements on every synced device —
635 // exactly the surprise the planned tombstone work
636 // (docs/design-sample-deletion.md) is meant to fix.
637 //
638 // Local cleanup is the right semantics today: each device has its
639 // own set of orphans (samples it no longer references), and
640 // collecting disk space is a local operation, not a cross-device
641 // statement. Set + run + reset in a single connection scope so a
642 // panic during the cleanup can't leave the flag stuck at '1'.
643 let db = self.db.lock();
644 db.conn()
645 .execute(
646 "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'",
647 [],
648 )
649 .map_err(|e| BackendError::Other(format!("flip applying_remote: {e}")))?;
650 let result = self.store.remove_orphaned_samples(&db);
651 let _ = db.conn().execute(
652 "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'",
653 [],
654 );
655 Ok(result?)
656 }
657
658 fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>> {
659 let db = self.db.lock();
660 Ok(audiofiles_core::store::sample_source_path(&db, hash)?)
661 }
662
663 fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()> {
664 let db = self.db.lock();
665 Ok(audiofiles_core::store::relocate_sample(&self.store, &db, hash, new_path)?)
666 }
667
668 fn check_vault_integrity(&self) -> BackendResult<(usize, usize)> {
669 let db = self.db.lock();
670 Ok(audiofiles_core::store::check_loose_files_integrity(&db)?)
671 }
672
673 fn purge_missing_loose_files(&self) -> BackendResult<usize> {
674 let db = self.db.lock();
675 Ok(audiofiles_core::store::purge_missing_loose_files(&db)?)
676 }
677
678 fn relocate_missing_loose_files(
679 &self,
680 search_root: &std::path::Path,
681 ) -> BackendResult<(usize, usize)> {
682 let db = self.db.lock();
683 Ok(audiofiles_core::store::relocate_missing_loose_files(&db, search_root)?)
684 }
685
686 // --- Export ---
687
688 fn collect_export_items(
689 &self,
690 vfs_id: VfsId,
691 parent_id: Option<NodeId>,
692 ) -> BackendResult<Vec<ExportItem>> {
693 let db = self.db.lock();
694 Ok(audiofiles_core::export::collect_export_items(&db, vfs_id, parent_id)?)
695 }
696
697 fn enrich_export_with_tags(&self, items: &mut [ExportItem]) -> BackendResult<()> {
698 let db = self.db.lock();
699 audiofiles_core::export::enrich_with_tags(&db, items);
700 Ok(())
701 }
702
703 // --- Device profiles ---
704
705 fn list_device_profiles(&self) -> BackendResult<Vec<DeviceProfileSummary>> {
706 #[cfg(feature = "device-profiles")]
707 {
708 Ok(self.plugin_registry.list())
709 }
710 #[cfg(not(feature = "device-profiles"))]
711 {
712 Ok(Vec::new())
713 }
714 }
715
716 fn device_conform_target(
717 &self,
718 profile_name: &str,
719 source_rate: u32,
720 ) -> BackendResult<Option<ConformTarget>> {
721 #[cfg(feature = "device-profiles")]
722 {
723 Ok(self
724 .plugin_registry
725 .get(profile_name)
726 .map(|plugin| ConformTarget::for_device(&plugin.profile, source_rate)))
727 }
728 #[cfg(not(feature = "device-profiles"))]
729 {
730 let _ = (profile_name, source_rate);
731 Ok(None)
732 }
733 }
734
735 // --- Sample Forge ---
736
737 #[instrument(skip_all)]
738 fn compute_chop_preview(
739 &self,
740 hash: &str,
741 ext: &str,
742 method: &ChopMethod,
743 ) -> BackendResult<Vec<f32>> {
744 let db = self.db.lock();
745 let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?;
746 let decoded = audiofiles_core::export::decode::decode_multichannel(&path)?;
747 let total_frames = (decoded.samples.len() / decoded.channels.max(1) as usize).max(1);
748 let slices = audiofiles_core::forge::compute_slices(
749 &decoded.samples,
750 decoded.channels,
751 decoded.sample_rate,
752 method,
753 )?;
754 // Boundary fractions: each slice's start, plus the final end (1.0).
755 let mut marks: Vec<f32> = slices
756 .iter()
757 .map(|s| s.start_frame as f32 / total_frames as f32)
758 .collect();
759 marks.push(1.0);
760 Ok(marks)
761 }
762
763 #[instrument(skip_all)]
764 fn chop_sample(
765 &self,
766 vfs_id: VfsId,
767 hash: &str,
768 ext: &str,
769 name: &str,
770 parent_id: Option<NodeId>,
771 method: &ChopMethod,
772 ) -> BackendResult<usize> {
773 let db = self.db.lock();
774 let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?;
775 let result = audiofiles_core::forge::chop_to_vfs(
776 &self.store,
777 &db,
778 vfs_id,
779 &path,
780 name,
781 parent_id,
782 method,
783 )?;
784 Ok(result.slice_count)
785 }
786
787 #[instrument(skip_all)]
788 fn conform_sample(
789 &self,
790 vfs_id: VfsId,
791 hash: &str,
792 ext: &str,
793 name: &str,
794 parent_id: Option<NodeId>,
795 target: &ConformTarget,
796 ) -> BackendResult<ConformResult> {
797 let db = self.db.lock();
798 // Overshoot policy: trim to the ceiling only when the user has opted in;
799 // the default leaves the signal untouched and merely reports.
800 let auto_trim = db
801 .conn()
802 .query_row(
803 "SELECT value FROM user_config WHERE key = ?1",
804 [super::FORGE_AUTO_TRIM_OVERSHOOT_KEY],
805 |row| row.get::<_, String>(0),
806 )
807 .ok()
808 .is_some_and(|v| v == "true");
809 let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?;
810 Ok(audiofiles_core::forge::conform_to_vfs(
811 &self.store,
812 &db,
813 vfs_id,
814 &path,
815 name,
816 parent_id,
817 target,
818 auto_trim,
819 )?)
820 }
821
822 // --- Config ---
823
824 fn get_config(&self, key: &str) -> BackendResult<Option<String>> {
825 let db = self.db.lock();
826 let result = db
827 .conn()
828 .query_row(
829 "SELECT value FROM user_config WHERE key = ?1",
830 [key],
831 |row| row.get::<_, String>(0),
832 )
833 .ok();
834 Ok(result)
835 }
836
837 fn set_config(&self, key: &str, value: &str) -> BackendResult<()> {
838 let db = self.db.lock();
839 db.conn()
840 .execute(
841 "INSERT OR REPLACE INTO user_config (key, value) VALUES (?1, ?2)",
842 rusqlite::params![key, value],
843 )
844 .map_err(audiofiles_core::error::CoreError::Db)?;
845 Ok(())
846 }
847
848 fn delete_config(&self, key: &str) -> BackendResult<()> {
849 let db = self.db.lock();
850 db.conn()
851 .execute("DELETE FROM user_config WHERE key = ?1", [key])
852 .map_err(audiofiles_core::error::CoreError::Db)?;
853 Ok(())
854 }
855
856 fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()> {
857 let db = self.db.lock();
858 vfs::set_vfs_sync_files(&db, id, enabled)?;
859 Ok(())
860 }
861
862 fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult<bool> {
863 let db = self.db.lock();
864 Ok(vfs::get_vfs_sync_files(&db, id)?)
865 }
866
867 // --- VFS Mirror ---
868
869 fn sync_vfs_mirror(&self, mirror_root: &Path) -> BackendResult<(usize, usize, usize)> {
870 let db = self.db.lock();
871 let config = audiofiles_core::vfs_mirror::MirrorConfig {
872 mirror_root: mirror_root.to_path_buf(),
873 store_root: self.store.root().to_path_buf(),
874 };
875 let stats = audiofiles_core::vfs_mirror::sync_mirror(&db, &config)?;
876 Ok((stats.dirs_created, stats.links_created, stats.entries_removed))
877 }
878
879 // --- Long-running operations ---
880
881 fn start_import(
882 &self,
883 source: &Path,
884 strategy: ImportStrategyDesc,
885 ) -> BackendResult<()> {
886 let db_path = self.data_dir.join("audiofiles.db");
887 let store_root = self.store.root().to_path_buf();
888
889 let import_strategy = match strategy {
890 ImportStrategyDesc::Flat { vfs_id, parent_id } => {
891 ImportStrategy::Flat { vfs_id, parent_id }
892 }
893 ImportStrategyDesc::NewVfs { vfs_name } => ImportStrategy::NewVfs { vfs_name },
894 ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id } => {
895 ImportStrategy::MergeIntoVfs { vfs_id, parent_id }
896 }
897 };
898
899 // Cancel any existing import worker before starting a new one
900 if let Some(old) = self.import_worker.lock().take() {
901 old.send(ImportCommand::Cancel);
902 drop(old); // joins the thread
903 }
904
905 let handle = crate::import::spawn_import_worker(db_path, store_root)
906 .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}")))?;
907 handle.send(ImportCommand::ImportDirectory {
908 source: source.to_path_buf(),
909 strategy: import_strategy,
910 });
911
912 *self.import_worker.lock() = Some(handle);
913 Ok(())
914 }
915
916 fn start_analysis(
917 &self,
918 sample_hashes: Vec<(String, String)>,
919 config: AnalysisConfig,
920 ) -> BackendResult<()> {
921 let samples: Vec<(String, String, PathBuf)> = {
922 let db = self.db.lock();
923 sample_hashes
924 .into_iter()
925 .filter_map(|(hash, ext)| {
926 let path = audiofiles_core::store::resolve_file_path(
927 &self.store, &db, &hash, &ext,
928 ).ok()?;
929 if path.exists() {
930 Some((hash, ext, path))
931 } else {
932 None
933 }
934 })
935 .collect()
936 };
937
938 // Cancel any existing analysis worker before starting a new one
939 if let Some(old) = self.analysis_worker.lock().take() {
940 old.send(WorkerCommand::Cancel);
941 drop(old);
942 }
943
944 let handle = audiofiles_core::analysis::worker::spawn_worker()
945 .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?;
946 handle.send(WorkerCommand::AnalyzeBatch { samples, config });
947 *self.analysis_worker.lock() = Some(handle);
948 Ok(())
949 }
950
951 fn start_export(
952 &self,
953 items: Vec<ExportItemDesc>,
954 config: ExportConfigDesc,
955 ) -> BackendResult<()> {
956 let mut config = config;
957 let mut items = items;
958
959 #[cfg(feature = "device-profiles")]
960 self.resolve_device_profile(&mut config, &mut items);
961
962 let store_root = self.store.root().to_path_buf();
963 // Cancel any existing export worker before starting a new one
964 if let Some(old) = self.export_worker.lock().take() {
965 old.send(ExportCommand::Cancel);
966 drop(old);
967 }
968
969 let handle = crate::export::spawn_export_worker(store_root)
970 .map_err(|e| BackendError::Other(format!("failed to spawn export worker: {e}")))?;
971 handle.send(ExportCommand::Export { items, config });
972 *self.export_worker.lock() = Some(handle);
973 Ok(())
974 }
975
976 fn cancel_import(&self) -> BackendResult<()> {
977 if let Some(worker) = self.import_worker.lock().take() {
978 worker.send(ImportCommand::Cancel);
979 }
980 Ok(())
981 }
982
983 fn cancel_analysis(&self) -> BackendResult<()> {
984 if let Some(worker) = self.analysis_worker.lock().take() {
985 worker.send(WorkerCommand::Cancel);
986 }
987 Ok(())
988 }
989
990 fn cancel_export(&self) -> BackendResult<()> {
991 if let Some(worker) = self.export_worker.lock().take() {
992 worker.send(ExportCommand::Cancel);
993 }
994 Ok(())
995 }
996
997 fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()> {
998 let path = {
999 let db = self.db.lock();
1000 audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?
1001 };
1002 if !path.exists() {
1003 return Err(BackendError::Core(
1004 audiofiles_core::error::CoreError::SampleNotFound(hash.to_string()),
1005 ));
1006 }
1007
1008 // Cancel any existing edit worker before starting a new one
1009 if let Some(old) = self.edit_worker.lock().take() {
1010 old.send(EditCommand::Cancel);
1011 drop(old);
1012 }
1013
1014 let handle = audiofiles_core::edit::worker::spawn_edit_worker()
1015 .map_err(|e| BackendError::Other(format!("failed to spawn edit worker: {e}")))?;
1016 handle.send(EditCommand::Edit {
1017 hash: hash.to_string(),
1018 ext: ext.to_string(),
1019 path,
1020 operation,
1021 });
1022 *self.edit_worker.lock() = Some(handle);
1023 Ok(())
1024 }
1025
1026 fn cancel_edit(&self) -> BackendResult<()> {
1027 if let Some(worker) = self.edit_worker.lock().take() {
1028 worker.send(EditCommand::Cancel);
1029 }
1030 Ok(())
1031 }
1032
1033 fn start_cleanup(&self) -> BackendResult<()> {
1034 // Cancel any existing cleanup worker to prevent concurrent cleanups
1035 if let Some(worker) = self.cleanup_worker.lock().take() {
1036 worker.send(CleanupCommand::Cancel);
1037 }
1038
1039 let db_path = self.data_dir.join("audiofiles.db");
1040 let store_root = self.store.root().to_path_buf();
1041
1042 let handle = crate::cleanup::spawn_cleanup_worker(db_path, store_root)
1043 .map_err(|e| BackendError::Other(format!("failed to spawn cleanup worker: {e}")))?;
1044 handle.send(CleanupCommand::RemoveOrphans);
1045 *self.cleanup_worker.lock() = Some(handle);
1046 Ok(())
1047 }
1048
1049 fn cancel_cleanup(&self) -> BackendResult<()> {
1050 if let Some(worker) = self.cleanup_worker.lock().take() {
1051 worker.send(CleanupCommand::Cancel);
1052 }
1053 Ok(())
1054 }
1055
1056 fn record_edit_history(
1057 &self,
1058 source_hash: &str,
1059 result_hash: &str,
1060 operation: &EditOperation,
1061 ) -> BackendResult<()> {
1062 let db = self.db.lock();
1063 let op_name = operation.display_name();
1064 let params_json = serde_json::to_string(operation)
1065 .map_err(|e| BackendError::Other(format!("serialize edit params: {e}")))?;
1066 db.conn()
1067 .execute(
1068 "INSERT INTO edit_history (source_hash, result_hash, operation, params_json)
1069 VALUES (?1, ?2, ?3, ?4)",
1070 rusqlite::params![source_hash, result_hash, op_name, params_json],
1071 )
1072 .map_err(audiofiles_core::error::CoreError::Db)?;
1073 Ok(())
1074 }
1075
1076 fn delete_edit_history(
1077 &self,
1078 source_hash: &str,
1079 result_hash: &str,
1080 ) -> BackendResult<()> {
1081 let db = self.db.lock();
1082 db.conn()
1083 .execute(
1084 "DELETE FROM edit_history
1085 WHERE id = (
1086 SELECT id FROM edit_history
1087 WHERE source_hash = ?1 AND result_hash = ?2
1088 ORDER BY id DESC
1089 LIMIT 1
1090 )",
1091 rusqlite::params![source_hash, result_hash],
1092 )
1093 .map_err(audiofiles_core::error::CoreError::Db)?;
1094 Ok(())
1095 }
1096
1097 fn storage_stats(&self) -> BackendResult<super::StorageStats> {
1098 let db = self.db.lock();
1099 let (sample_count, total_bytes) = db.storage_stats()
1100 .map_err(|e| BackendError::Other(e.to_string()))?;
1101 let db_path = self.data_dir.join("audiofiles.db");
1102 let db_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
1103 Ok(super::StorageStats { sample_count, total_bytes, db_bytes })
1104 }
1105
1106 fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)> {
1107 let db = self.db.lock();
1108 db.vfs_storage_stats(vfs_id.as_i64())
1109 .map_err(|e| BackendError::Other(e.to_string()))
1110 }
1111
1112 fn poll_events(&self) -> Vec<BackendEvent> {
1113 let mut events = Vec::new();
1114
1115 // Poll import worker
1116 if let Some(ref worker) = *self.import_worker.lock() {
1117 while let Some(event) = worker.try_recv() {
1118 match event {
1119 ImportEvent::WalkProgress { count, total_bytes } => {
1120 events.push(BackendEvent::ImportWalkProgress { count, total_bytes });
1121 }
1122 ImportEvent::WalkComplete { total, total_bytes } => {
1123 events.push(BackendEvent::ImportWalkComplete { total, total_bytes });
1124 }
1125 ImportEvent::Progress {
1126 completed,
1127 total,
1128 current_name,
1129 } => {
1130 events.push(BackendEvent::ImportProgress {
1131 completed,
1132 total,
1133 current_name,
1134 });
1135 }
1136 ImportEvent::FileError { path, error } => {
1137 events.push(BackendEvent::ImportFileError { path, error });
1138 }
1139 ImportEvent::Complete {
1140 imported,
1141 total_files,
1142 errors,
1143 duplicates,
1144 folders,
1145 } => {
1146 events.push(BackendEvent::ImportComplete {
1147 imported,
1148 total_files,
1149 errors,
1150 duplicates,
1151 folders: folders
1152 .into_iter()
1153 .map(|f| ImportedFolderDesc {
1154 name: f.name,
1155 samples: f.samples,
1156 })
1157 .collect(),
1158 });
1159 }
1160 }
1161 }
1162 }
1163
1164 // Poll analysis worker
1165 if let Some(ref worker) = *self.analysis_worker.lock() {
1166 while let Some(event) = worker.try_recv() {
1167 match event {
1168 WorkerEvent::Progress {
1169 completed,
1170 total,
1171 current_name,
1172 } => {
1173 events.push(BackendEvent::AnalysisProgress {
1174 completed,
1175 total,
1176 current_name,
1177 });
1178 }
1179 WorkerEvent::SampleDone {
1180 result,
1181 suggestions,
1182 } => {
1183 events.push(BackendEvent::AnalysisSampleDone {
1184 result,
1185 suggestions,
1186 });
1187 }
1188 WorkerEvent::SampleError { hash, error } => {
1189 events.push(BackendEvent::AnalysisSampleError { hash, error });
1190 }
1191 WorkerEvent::BatchComplete => {
1192 events.push(BackendEvent::AnalysisBatchComplete);
1193 }
1194 }
1195 }
1196 }
1197
1198 // Poll export worker
1199 if let Some(ref worker) = *self.export_worker.lock() {
1200 while let Some(event) = worker.try_recv() {
1201 match event {
1202 crate::export::ExportEvent::Progress {
1203 completed,
1204 total,
1205 current_name,
1206 } => {
1207 events.push(BackendEvent::ExportProgress {
1208 completed,
1209 total,
1210 current_name,
1211 });
1212 }
1213 crate::export::ExportEvent::Complete { total, errors } => {
1214 events.push(BackendEvent::ExportComplete { total, errors });
1215 }
1216 }
1217 }
1218 }
1219
1220 // Poll cleanup worker
1221 if let Some(ref worker) = *self.cleanup_worker.lock() {
1222 while let Some(event) = worker.try_recv() {
1223 match event {
1224 crate::cleanup::CleanupEvent::Progress {
1225 completed,
1226 total,
1227 current_name,
1228 } => {
1229 events.push(BackendEvent::CleanupProgress {
1230 completed,
1231 total,
1232 current_name,
1233 });
1234 }
1235 crate::cleanup::CleanupEvent::Complete { removed, errors } => {
1236 events.push(BackendEvent::CleanupComplete { removed, errors });
1237 }
1238 }
1239 }
1240 }
1241
1242 // Poll edit worker
1243 if let Some(ref worker) = *self.edit_worker.lock() {
1244 while let Some(event) = worker.try_recv() {
1245 match event {
1246 EditEvent::Started { hash } => {
1247 events.push(BackendEvent::EditStarted { hash });
1248 }
1249 EditEvent::Complete {
1250 source_hash,
1251 result_path,
1252 operation,
1253 } => {
1254 events.push(BackendEvent::EditComplete {
1255 source_hash,
1256 result_path,
1257 operation,
1258 });
1259 }
1260 EditEvent::Error { hash, error } => {
1261 events.push(BackendEvent::EditError { hash, error });
1262 }
1263 }
1264 }
1265 }
1266
1267 events
1268 }
1269 }
1270
1271 #[cfg(test)]
1272 mod tests {
1273 use super::*;
1274
1275 fn setup() -> DirectBackend {
1276 let dir = tempfile::TempDir::new().unwrap();
1277 let db = Database::open_in_memory().unwrap();
1278 let store = SampleStore::new(dir.path().join("store")).unwrap();
1279 DirectBackend::new(db, store, dir.path().to_path_buf())
1280 }
1281
1282 #[test]
1283 fn list_vfs_empty_then_create() {
1284 let backend = setup();
1285 // Fresh DB has no VFS
1286 let vfs_list = backend.list_vfs().unwrap();
1287 assert!(vfs_list.is_empty());
1288
1289 let id = backend.create_vfs("Library").unwrap();
1290 assert!(id.as_i64() > 0);
1291
1292 let vfs_list = backend.list_vfs().unwrap();
1293 assert_eq!(vfs_list.len(), 1);
1294 assert_eq!(vfs_list[0].name, "Library");
1295 }
1296
1297 #[test]
1298 fn directory_crud() {
1299 let backend = setup();
1300 let vfs_id = backend.create_vfs("Test").unwrap();
1301 let dir_id = backend.create_directory(vfs_id, None, "Drums").unwrap();
1302
1303 let children = backend.list_children_enriched(vfs_id, None).unwrap();
1304 assert_eq!(children.len(), 1);
1305 assert_eq!(children[0].node.name, "Drums");
1306
1307 backend.rename_node(dir_id, "Percussion").unwrap();
1308 let node = backend.get_node(dir_id).unwrap();
1309 assert_eq!(node.name, "Percussion");
1310
1311 backend.delete_node(dir_id).unwrap();
1312 let children = backend.list_children_enriched(vfs_id, None).unwrap();
1313 assert!(children.is_empty());
1314 }
1315
1316 /// Insert a fake sample row for testing (no actual file on disk).
1317 fn insert_fake_sample(db: &Database, hash: &str) {
1318 db.conn()
1319 .execute(
1320 "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified)
1321 VALUES (?1, ?2, 'wav', 100, 0, 0)",
1322 rusqlite::params![hash, format!("{hash}.wav")],
1323 )
1324 .unwrap();
1325 }
1326
1327 #[test]
1328 fn tags_crud() {
1329 let backend = setup();
1330 // Insert a fake sample row for tag FK
1331 {
1332 let db = backend.db.lock();
1333 insert_fake_sample(&db, "testhash");
1334 }
1335
1336 backend.add_tag("testhash", "drums.kick").unwrap();
1337 let tags = backend.get_sample_tags("testhash").unwrap();
1338 assert_eq!(tags, vec!["drums.kick"]);
1339
1340 backend.remove_tag("testhash", "drums.kick").unwrap();
1341 let tags = backend.get_sample_tags("testhash").unwrap();
1342 assert!(tags.is_empty());
1343 }
1344
1345 #[test]
1346 fn config_crud() {
1347 let backend = setup();
1348 assert!(backend.get_config("theme").unwrap().is_none());
1349
1350 backend.set_config("theme", "dark").unwrap();
1351 assert_eq!(backend.get_config("theme").unwrap().unwrap(), "dark");
1352
1353 backend.set_config("theme", "light").unwrap();
1354 assert_eq!(backend.get_config("theme").unwrap().unwrap(), "light");
1355 }
1356
1357 #[test]
1358 fn dynamic_collection_crud() {
1359 let backend = setup();
1360 let filter = SearchFilter::default();
1361
1362 let id = backend.create_dynamic_collection("Kicks", &filter).unwrap();
1363 assert!(id.as_i64() > 0);
1364
1365 let colls = backend.list_collections().unwrap();
1366 let dynamic = colls.iter().find(|c| c.name == "Kicks").unwrap();
1367 assert!(dynamic.is_dynamic());
1368 }
1369
1370 #[test]
1371 fn search_empty_filter_returns_all() {
1372 let backend = setup();
1373 let vfs_id = backend.create_vfs("Test").unwrap();
1374 // Insert fake samples so search has something to find
1375 {
1376 let db = backend.db.lock();
1377 insert_fake_sample(&db, "h1");
1378 insert_fake_sample(&db, "h2");
1379 vfs::create_sample_link(&db, vfs_id, None, "kick.wav", "h1").unwrap();
1380 vfs::create_sample_link(&db, vfs_id, None, "snare.wav", "h2").unwrap();
1381 }
1382
1383 let filter = SearchFilter::default();
1384 let results = backend.search_in_folder(&filter, vfs_id, None).unwrap();
1385 assert_eq!(results.len(), 2);
1386 }
1387
1388 #[test]
1389 fn poll_events_empty_when_no_workers() {
1390 let backend = setup();
1391 let events = backend.poll_events();
1392 assert!(events.is_empty());
1393 }
1394
1395 #[test]
1396 fn breadcrumb_and_subtree() {
1397 let backend = setup();
1398 let vfs_id = backend.create_vfs("Test").unwrap();
1399 let a = backend.create_directory(vfs_id, None, "A").unwrap();
1400 let b = backend.create_directory(vfs_id, Some(a), "B").unwrap();
1401
1402 let crumbs = backend.get_breadcrumb(b).unwrap();
1403 assert_eq!(crumbs.len(), 2);
1404 assert_eq!(crumbs[0].name, "A");
1405 assert_eq!(crumbs[1].name, "B");
1406
1407 let subtree = backend.collect_subtree(a).unwrap();
1408 assert_eq!(subtree.len(), 2);
1409 }
1410
1411 #[test]
1412 fn list_all_directories_works() {
1413 let backend = setup();
1414 let vfs_id = backend.create_vfs("Test").unwrap();
1415 let drums = backend.create_directory(vfs_id, None, "Drums").unwrap();
1416 backend.create_directory(vfs_id, Some(drums), "Kicks").unwrap();
1417
1418 let dirs = backend.list_all_directories(vfs_id).unwrap();
1419 assert_eq!(dirs.len(), 2);
1420 }
1421
1422 /// Write a minimal float-PCM WAV for forge integration tests.
1423 fn write_float_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) {
1424 use std::io::Write;
1425 let bytes_per_sample = 4u16;
1426 let block_align = channels * bytes_per_sample;
1427 let data_size = (samples.len() as u32) * 4;
1428 let file_size = 36 + data_size;
1429 let mut buf = Vec::with_capacity(44 + data_size as usize);
1430 buf.extend_from_slice(b"RIFF");
1431 buf.extend_from_slice(&file_size.to_le_bytes());
1432 buf.extend_from_slice(b"WAVE");
1433 buf.extend_from_slice(b"fmt ");
1434 buf.extend_from_slice(&16u32.to_le_bytes());
1435 buf.extend_from_slice(&3u16.to_le_bytes());
1436 buf.extend_from_slice(&channels.to_le_bytes());
1437 buf.extend_from_slice(&sample_rate.to_le_bytes());
1438 buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes());
1439 buf.extend_from_slice(&block_align.to_le_bytes());
1440 buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
1441 buf.extend_from_slice(b"data");
1442 buf.extend_from_slice(&data_size.to_le_bytes());
1443 for &s in samples {
1444 buf.extend_from_slice(&s.to_le_bytes());
1445 }
1446 std::fs::File::create(path).unwrap().write_all(&buf).unwrap();
1447 }
1448
1449 #[test]
1450 fn chop_sample_creates_slice_folder() {
1451 use audiofiles_core::forge::ChopMethod;
1452 // Keep the temp dir alive for the whole test (store lives under it).
1453 let dir = tempfile::TempDir::new().unwrap();
1454 let db = Database::open_in_memory().unwrap();
1455 let store = SampleStore::new(dir.path().join("store")).unwrap();
1456 let backend = DirectBackend::new(db, store, dir.path().to_path_buf());
1457
1458 let vfs_id = backend.create_vfs("Test").unwrap();
1459 let samples: Vec<f32> = (0..2000).map(|i| ((i % 40) as f32 / 40.0) - 0.5).collect();
1460 let src = dir.path().join("loop.wav");
1461 write_float_wav(&src, 1, 44100, &samples);
1462 let hash = backend.import_file(&src).unwrap();
1463
1464 // Preview returns slice boundaries (N starts + trailing 1.0).
1465 let marks = backend
1466 .compute_chop_preview(&hash, "wav", &ChopMethod::EqualDivisions(4))
1467 .unwrap();
1468 assert_eq!(marks.len(), 5);
1469 assert_eq!(marks.last().copied(), Some(1.0));
1470
1471 let count = backend
1472 .chop_sample(vfs_id, &hash, "wav", "loop.wav", None, &ChopMethod::EqualDivisions(4))
1473 .unwrap();
1474 assert_eq!(count, 4);
1475
1476 // A "loop_slices" directory now holds 4 samples.
1477 let roots = backend.list_children(vfs_id, None).unwrap();
1478 let slice_dir = roots.iter().find(|n| n.name == "loop_slices").unwrap();
1479 let slices = backend.list_children(vfs_id, Some(slice_dir.id)).unwrap();
1480 assert_eq!(slices.len(), 4);
1481 }
1482
1483 #[test]
1484 #[cfg(feature = "device-profiles")]
1485 fn device_conform_target_resolves_bundled() {
1486 let backend = setup();
1487 // A bundled mono device resolves to a mono target.
1488 let target = backend
1489 .device_conform_target("SP-404 MKII", 48000)
1490 .unwrap();
1491 assert!(target.is_some(), "SP-404 MKII should resolve to a target");
1492 }
1493
1494 #[test]
1495 #[cfg(feature = "device-profiles")]
1496 fn list_device_profiles_returns_bundled() {
1497 let backend = setup();
1498 let profiles = backend.list_device_profiles().unwrap();
1499 // Bundled manifests should be loaded (14 devices)
1500 assert!(
1501 profiles.len() >= 14,
1502 "expected at least 14 bundled profiles, got {}",
1503 profiles.len()
1504 );
1505 // Spot-check a known device
1506 assert!(
1507 profiles.iter().any(|p| p.name == "SP-404 MKII"),
1508 "SP-404 MKII should be in the bundled profiles"
1509 );
1510 }
1511 }
1512