Skip to main content

max / audiofiles

44.7 KB · 1307 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::search::SearchFilter;
20 use audiofiles_core::collections::Collection;
21 use audiofiles_core::store::SampleStore;
22 use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis};
23 use audiofiles_core::{collections, fingerprint, search, similarity, tags, CollectionId, NodeId, VfsId};
24 use parking_lot::Mutex;
25
26 use super::{
27 Backend, BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc,
28 ImportStrategyDesc, ImportedFolderDesc,
29 };
30
31 use crate::cleanup::{CleanupCommand, CleanupHandle};
32 use crate::export::{ExportCommand, ExportHandle};
33 use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy};
34
35 use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle};
36
37 /// Direct backend: talks to SQLite and the sample store in-process.
38 pub struct DirectBackend {
39 db: Mutex<Database>,
40 store: SampleStore,
41 data_dir: PathBuf,
42 // Worker handles for long-running operations
43 import_worker: Mutex<Option<ImportHandle>>,
44 analysis_worker: Mutex<Option<WorkerHandle>>,
45 export_worker: Mutex<Option<ExportHandle>>,
46 cleanup_worker: Mutex<Option<CleanupHandle>>,
47 edit_worker: Mutex<Option<EditWorkerHandle>>,
48 // VP-tree indexes for fast search (lazy, invalidated on new analysis)
49 fingerprint_index: Mutex<Option<fingerprint::FingerprintIndex>>,
50 similarity_index: Mutex<Option<similarity::SimilarityIndex>>,
51 // Device plugin registry (when device-profiles feature is enabled)
52 #[cfg(feature = "device-profiles")]
53 plugin_registry: audiofiles_rhai::registry::PluginRegistry,
54 }
55
56 impl DirectBackend {
57 /// Create a new DirectBackend from a database and sample store.
58 pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self {
59 Self {
60 db: Mutex::new(db),
61 store,
62 data_dir,
63 import_worker: Mutex::new(None),
64 analysis_worker: Mutex::new(None),
65 export_worker: Mutex::new(None),
66 cleanup_worker: Mutex::new(None),
67 edit_worker: Mutex::new(None),
68 fingerprint_index: Mutex::new(None),
69 similarity_index: Mutex::new(None),
70 #[cfg(feature = "device-profiles")]
71 plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|_| {
72 audiofiles_rhai::registry::PluginRegistry::new()
73 }),
74 }
75 }
76
77 /// Access the store (needed for preview decode path in BrowserState).
78 pub fn store(&self) -> &SampleStore {
79 &self.store
80 }
81
82 /// Access the data directory path.
83 pub fn data_dir(&self) -> &Path {
84 &self.data_dir
85 }
86
87 /// Resolve a device profile's constraints into the export config and filter items.
88 ///
89 /// Called before spawning the export worker so profile resolution happens
90 /// on the main thread (where PluginRegistry is accessible).
91 #[cfg(feature = "device-profiles")]
92 #[instrument(skip_all)]
93 fn resolve_device_profile(
94 &self,
95 config: &mut audiofiles_core::export::ExportConfig,
96 items: &mut Vec<ExportItem>,
97 ) {
98 use audiofiles_core::export::profile::ChannelConstraint;
99 use audiofiles_core::export::{ExportChannels, ExportFormat};
100
101 let profile_name = match config.device_profile {
102 Some(ref name) => name.clone(),
103 None => return,
104 };
105
106 let plugin = match self.plugin_registry.get(&profile_name) {
107 Some(p) => p,
108 None => return,
109 };
110
111 let profile = &plugin.profile;
112
113 // Format: if Original, set to profile's first supported format
114 if config.format == ExportFormat::Original {
115 if let Some(fmt) = profile.audio.formats.first() {
116 config.format = fmt.clone();
117 }
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",
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 features = {
568 let db = self.db.lock();
569 similarity::load_features(&db, hash)?
570 };
571 Ok(idx.as_ref().unwrap().find_similar(hash, &features, limit))
572 }
573
574 fn find_near_duplicates(
575 &self,
576 hash: &str,
577 limit: usize,
578 ) -> BackendResult<Vec<fingerprint::DuplicateResult>> {
579 // Build VP-tree index lazily on first query.
580 // Load data under DB lock, release lock, then build tree (CPU-intensive).
581 let mut idx = self.fingerprint_index.lock();
582 if idx.is_none() {
583 let entries = {
584 let db = self.db.lock();
585 fingerprint::FingerprintIndex::load_data(&db)?
586 };
587 *idx = Some(fingerprint::FingerprintIndex::build_from_data(entries));
588 }
589 let reference = {
590 let db = self.db.lock();
591 fingerprint::load_fingerprint(&db, hash)?
592 };
593 Ok(idx
594 .as_ref()
595 .unwrap()
596 .find_near_duplicates(hash, &reference.envelope, limit))
597 }
598
599 // --- Store ---
600
601 fn import_file(&self, path: &Path) -> BackendResult<String> {
602 let db = self.db.lock();
603 Ok(self.store.import(path, &db)?)
604 }
605
606 fn sample_path(&self, hash: &str, ext: &str) -> BackendResult<PathBuf> {
607 let db = self.db.lock();
608 Ok(audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?)
609 }
610
611 fn sample_extension(&self, hash: &str) -> BackendResult<String> {
612 let db = self.db.lock();
613 Ok(audiofiles_core::store::sample_extension(&db, hash)?)
614 }
615
616 fn sample_original_name(&self, hash: &str) -> BackendResult<String> {
617 let db = self.db.lock();
618 Ok(audiofiles_core::store::sample_original_name(&db, hash)?)
619 }
620
621 fn remove_sample(&self, hash: &str) -> BackendResult<()> {
622 let db = self.db.lock();
623 Ok(self.store.remove(hash, &db)?)
624 }
625
626 fn remove_orphaned_samples(&self) -> BackendResult<usize> {
627 let db = self.db.lock();
628 Ok(self.store.remove_orphaned_samples(&db)?)
629 }
630
631 fn sample_source_path(&self, hash: &str) -> BackendResult<Option<String>> {
632 let db = self.db.lock();
633 Ok(audiofiles_core::store::sample_source_path(&db, hash)?)
634 }
635
636 fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()> {
637 let db = self.db.lock();
638 Ok(audiofiles_core::store::relocate_sample(&self.store, &db, hash, new_path)?)
639 }
640
641 fn check_vault_integrity(&self) -> BackendResult<(usize, usize)> {
642 let db = self.db.lock();
643 Ok(audiofiles_core::store::check_loose_files_integrity(&db)?)
644 }
645
646 fn purge_missing_loose_files(&self) -> BackendResult<usize> {
647 let db = self.db.lock();
648 Ok(audiofiles_core::store::purge_missing_loose_files(&db)?)
649 }
650
651 fn relocate_missing_loose_files(
652 &self,
653 search_root: &std::path::Path,
654 ) -> BackendResult<(usize, usize)> {
655 let db = self.db.lock();
656 Ok(audiofiles_core::store::relocate_missing_loose_files(&db, search_root)?)
657 }
658
659 // --- Export ---
660
661 fn collect_export_items(
662 &self,
663 vfs_id: VfsId,
664 parent_id: Option<NodeId>,
665 ) -> BackendResult<Vec<ExportItem>> {
666 let db = self.db.lock();
667 Ok(audiofiles_core::export::collect_export_items(&db, vfs_id, parent_id)?)
668 }
669
670 fn enrich_export_with_tags(&self, items: &mut [ExportItem]) -> BackendResult<()> {
671 let db = self.db.lock();
672 audiofiles_core::export::enrich_with_tags(&db, items);
673 Ok(())
674 }
675
676 // --- Device profiles ---
677
678 fn list_device_profiles(&self) -> BackendResult<Vec<DeviceProfileSummary>> {
679 #[cfg(feature = "device-profiles")]
680 {
681 Ok(self.plugin_registry.list())
682 }
683 #[cfg(not(feature = "device-profiles"))]
684 {
685 Ok(Vec::new())
686 }
687 }
688
689 // --- Config ---
690
691 fn get_config(&self, key: &str) -> BackendResult<Option<String>> {
692 let db = self.db.lock();
693 let result = db
694 .conn()
695 .query_row(
696 "SELECT value FROM user_config WHERE key = ?1",
697 [key],
698 |row| row.get::<_, String>(0),
699 )
700 .ok();
701 Ok(result)
702 }
703
704 fn set_config(&self, key: &str, value: &str) -> BackendResult<()> {
705 let db = self.db.lock();
706 db.conn()
707 .execute(
708 "INSERT OR REPLACE INTO user_config (key, value) VALUES (?1, ?2)",
709 rusqlite::params![key, value],
710 )
711 .map_err(audiofiles_core::error::CoreError::Db)?;
712 Ok(())
713 }
714
715 fn delete_config(&self, key: &str) -> BackendResult<()> {
716 let db = self.db.lock();
717 db.conn()
718 .execute("DELETE FROM user_config WHERE key = ?1", [key])
719 .map_err(audiofiles_core::error::CoreError::Db)?;
720 Ok(())
721 }
722
723 fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()> {
724 let db = self.db.lock();
725 vfs::set_vfs_sync_files(&db, id, enabled)?;
726 Ok(())
727 }
728
729 fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult<bool> {
730 let db = self.db.lock();
731 Ok(vfs::get_vfs_sync_files(&db, id)?)
732 }
733
734 // --- VFS Mirror ---
735
736 fn sync_vfs_mirror(&self, mirror_root: &Path) -> BackendResult<(usize, usize, usize)> {
737 let db = self.db.lock();
738 let config = audiofiles_core::vfs_mirror::MirrorConfig {
739 mirror_root: mirror_root.to_path_buf(),
740 store_root: self.store.root().to_path_buf(),
741 };
742 let stats = audiofiles_core::vfs_mirror::sync_mirror(&db, &config)?;
743 Ok((stats.dirs_created, stats.links_created, stats.entries_removed))
744 }
745
746 // --- Long-running operations ---
747
748 fn start_import(
749 &self,
750 source: &Path,
751 strategy: ImportStrategyDesc,
752 ) -> BackendResult<()> {
753 let db_path = self.data_dir.join("audiofiles.db");
754 let store_root = self.store.root().to_path_buf();
755
756 let import_strategy = match strategy {
757 ImportStrategyDesc::Flat { vfs_id, parent_id } => {
758 ImportStrategy::Flat { vfs_id, parent_id }
759 }
760 ImportStrategyDesc::NewVfs { vfs_name } => ImportStrategy::NewVfs { vfs_name },
761 ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id } => {
762 ImportStrategy::MergeIntoVfs { vfs_id, parent_id }
763 }
764 };
765
766 // Cancel any existing import worker before starting a new one
767 if let Some(old) = self.import_worker.lock().take() {
768 old.send(ImportCommand::Cancel);
769 drop(old); // joins the thread
770 }
771
772 let handle = crate::import::spawn_import_worker(db_path, store_root)
773 .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}")))?;
774 handle.send(ImportCommand::ImportDirectory {
775 source: source.to_path_buf(),
776 strategy: import_strategy,
777 });
778
779 *self.import_worker.lock() = Some(handle);
780 Ok(())
781 }
782
783 fn start_analysis(
784 &self,
785 sample_hashes: Vec<(String, String)>,
786 config: AnalysisConfig,
787 ) -> BackendResult<()> {
788 let samples: Vec<(String, String, PathBuf)> = {
789 let db = self.db.lock();
790 sample_hashes
791 .into_iter()
792 .filter_map(|(hash, ext)| {
793 let path = audiofiles_core::store::resolve_file_path(
794 &self.store, &db, &hash, &ext,
795 ).ok()?;
796 if path.exists() {
797 Some((hash, ext, path))
798 } else {
799 None
800 }
801 })
802 .collect()
803 };
804
805 // Cancel any existing analysis worker before starting a new one
806 if let Some(old) = self.analysis_worker.lock().take() {
807 old.send(WorkerCommand::Cancel);
808 drop(old);
809 }
810
811 let handle = audiofiles_core::analysis::worker::spawn_worker()
812 .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?;
813 handle.send(WorkerCommand::AnalyzeBatch { samples, config });
814 *self.analysis_worker.lock() = Some(handle);
815 Ok(())
816 }
817
818 fn start_export(
819 &self,
820 items: Vec<ExportItemDesc>,
821 config: ExportConfigDesc,
822 ) -> BackendResult<()> {
823 let mut config = config;
824 let mut items = items;
825
826 #[cfg(feature = "device-profiles")]
827 self.resolve_device_profile(&mut config, &mut items);
828
829 let store_root = self.store.root().to_path_buf();
830 // Cancel any existing export worker before starting a new one
831 if let Some(old) = self.export_worker.lock().take() {
832 old.send(ExportCommand::Cancel);
833 drop(old);
834 }
835
836 let handle = crate::export::spawn_export_worker(store_root)
837 .map_err(|e| BackendError::Other(format!("failed to spawn export worker: {e}")))?;
838 handle.send(ExportCommand::Export { items, config });
839 *self.export_worker.lock() = Some(handle);
840 Ok(())
841 }
842
843 fn cancel_import(&self) -> BackendResult<()> {
844 if let Some(worker) = self.import_worker.lock().take() {
845 worker.send(ImportCommand::Cancel);
846 }
847 Ok(())
848 }
849
850 fn cancel_analysis(&self) -> BackendResult<()> {
851 if let Some(worker) = self.analysis_worker.lock().take() {
852 worker.send(WorkerCommand::Cancel);
853 }
854 Ok(())
855 }
856
857 fn cancel_export(&self) -> BackendResult<()> {
858 if let Some(worker) = self.export_worker.lock().take() {
859 worker.send(ExportCommand::Cancel);
860 }
861 Ok(())
862 }
863
864 fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()> {
865 let path = {
866 let db = self.db.lock();
867 audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?
868 };
869 if !path.exists() {
870 return Err(BackendError::Core(
871 audiofiles_core::error::CoreError::SampleNotFound(hash.to_string()),
872 ));
873 }
874
875 // Cancel any existing edit worker before starting a new one
876 if let Some(old) = self.edit_worker.lock().take() {
877 old.send(EditCommand::Cancel);
878 drop(old);
879 }
880
881 let handle = audiofiles_core::edit::worker::spawn_edit_worker()
882 .map_err(|e| BackendError::Other(format!("failed to spawn edit worker: {e}")))?;
883 handle.send(EditCommand::Edit {
884 hash: hash.to_string(),
885 ext: ext.to_string(),
886 path,
887 operation,
888 });
889 *self.edit_worker.lock() = Some(handle);
890 Ok(())
891 }
892
893 fn cancel_edit(&self) -> BackendResult<()> {
894 if let Some(worker) = self.edit_worker.lock().take() {
895 worker.send(EditCommand::Cancel);
896 }
897 Ok(())
898 }
899
900 fn start_cleanup(&self) -> BackendResult<()> {
901 // Cancel any existing cleanup worker to prevent concurrent cleanups
902 if let Some(worker) = self.cleanup_worker.lock().take() {
903 worker.send(CleanupCommand::Cancel);
904 }
905
906 let db_path = self.data_dir.join("audiofiles.db");
907 let store_root = self.store.root().to_path_buf();
908
909 let handle = crate::cleanup::spawn_cleanup_worker(db_path, store_root)
910 .map_err(|e| BackendError::Other(format!("failed to spawn cleanup worker: {e}")))?;
911 handle.send(CleanupCommand::RemoveOrphans);
912 *self.cleanup_worker.lock() = Some(handle);
913 Ok(())
914 }
915
916 fn cancel_cleanup(&self) -> BackendResult<()> {
917 if let Some(worker) = self.cleanup_worker.lock().take() {
918 worker.send(CleanupCommand::Cancel);
919 }
920 Ok(())
921 }
922
923 fn record_edit_history(
924 &self,
925 source_hash: &str,
926 result_hash: &str,
927 operation: &EditOperation,
928 ) -> BackendResult<()> {
929 let db = self.db.lock();
930 let op_name = operation.display_name();
931 let params_json = serde_json::to_string(operation)
932 .map_err(|e| BackendError::Other(format!("serialize edit params: {e}")))?;
933 db.conn()
934 .execute(
935 "INSERT INTO edit_history (source_hash, result_hash, operation, params_json)
936 VALUES (?1, ?2, ?3, ?4)",
937 rusqlite::params![source_hash, result_hash, op_name, params_json],
938 )
939 .map_err(audiofiles_core::error::CoreError::Db)?;
940 Ok(())
941 }
942
943 fn delete_edit_history(
944 &self,
945 source_hash: &str,
946 result_hash: &str,
947 ) -> BackendResult<()> {
948 let db = self.db.lock();
949 db.conn()
950 .execute(
951 "DELETE FROM edit_history
952 WHERE id = (
953 SELECT id FROM edit_history
954 WHERE source_hash = ?1 AND result_hash = ?2
955 ORDER BY id DESC
956 LIMIT 1
957 )",
958 rusqlite::params![source_hash, result_hash],
959 )
960 .map_err(audiofiles_core::error::CoreError::Db)?;
961 Ok(())
962 }
963
964 fn storage_stats(&self) -> BackendResult<super::StorageStats> {
965 let db = self.db.lock();
966 let (sample_count, total_bytes) = db.storage_stats()
967 .map_err(|e| BackendError::Other(e.to_string()))?;
968 let db_path = self.data_dir.join("audiofiles.db");
969 let db_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
970 Ok(super::StorageStats { sample_count, total_bytes, db_bytes })
971 }
972
973 fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)> {
974 let db = self.db.lock();
975 db.vfs_storage_stats(vfs_id.as_i64())
976 .map_err(|e| BackendError::Other(e.to_string()))
977 }
978
979 fn poll_events(&self) -> Vec<BackendEvent> {
980 let mut events = Vec::new();
981
982 // Poll import worker
983 if let Some(ref worker) = *self.import_worker.lock() {
984 while let Some(event) = worker.try_recv() {
985 match event {
986 ImportEvent::WalkProgress { count, total_bytes } => {
987 events.push(BackendEvent::ImportWalkProgress { count, total_bytes });
988 }
989 ImportEvent::WalkComplete { total, total_bytes } => {
990 events.push(BackendEvent::ImportWalkComplete { total, total_bytes });
991 }
992 ImportEvent::Progress {
993 completed,
994 total,
995 current_name,
996 } => {
997 events.push(BackendEvent::ImportProgress {
998 completed,
999 total,
1000 current_name,
1001 });
1002 }
1003 ImportEvent::FileError { path, error } => {
1004 events.push(BackendEvent::ImportFileError { path, error });
1005 }
1006 ImportEvent::Complete {
1007 imported,
1008 total_files,
1009 errors,
1010 duplicates,
1011 folders,
1012 } => {
1013 events.push(BackendEvent::ImportComplete {
1014 imported,
1015 total_files,
1016 errors,
1017 duplicates,
1018 folders: folders
1019 .into_iter()
1020 .map(|f| ImportedFolderDesc {
1021 name: f.name,
1022 samples: f.samples,
1023 })
1024 .collect(),
1025 });
1026 }
1027 }
1028 }
1029 }
1030
1031 // Poll analysis worker
1032 if let Some(ref worker) = *self.analysis_worker.lock() {
1033 while let Some(event) = worker.try_recv() {
1034 match event {
1035 WorkerEvent::Progress {
1036 completed,
1037 total,
1038 current_name,
1039 } => {
1040 events.push(BackendEvent::AnalysisProgress {
1041 completed,
1042 total,
1043 current_name,
1044 });
1045 }
1046 WorkerEvent::SampleDone {
1047 result,
1048 suggestions,
1049 } => {
1050 events.push(BackendEvent::AnalysisSampleDone {
1051 result,
1052 suggestions,
1053 });
1054 }
1055 WorkerEvent::SampleError { hash, error } => {
1056 events.push(BackendEvent::AnalysisSampleError { hash, error });
1057 }
1058 WorkerEvent::BatchComplete => {
1059 events.push(BackendEvent::AnalysisBatchComplete);
1060 }
1061 }
1062 }
1063 }
1064
1065 // Poll export worker
1066 if let Some(ref worker) = *self.export_worker.lock() {
1067 while let Some(event) = worker.try_recv() {
1068 match event {
1069 crate::export::ExportEvent::Progress {
1070 completed,
1071 total,
1072 current_name,
1073 } => {
1074 events.push(BackendEvent::ExportProgress {
1075 completed,
1076 total,
1077 current_name,
1078 });
1079 }
1080 crate::export::ExportEvent::Complete { total, errors } => {
1081 events.push(BackendEvent::ExportComplete { total, errors });
1082 }
1083 }
1084 }
1085 }
1086
1087 // Poll cleanup worker
1088 if let Some(ref worker) = *self.cleanup_worker.lock() {
1089 while let Some(event) = worker.try_recv() {
1090 match event {
1091 crate::cleanup::CleanupEvent::Progress {
1092 completed,
1093 total,
1094 current_name,
1095 } => {
1096 events.push(BackendEvent::CleanupProgress {
1097 completed,
1098 total,
1099 current_name,
1100 });
1101 }
1102 crate::cleanup::CleanupEvent::Complete { removed, errors } => {
1103 events.push(BackendEvent::CleanupComplete { removed, errors });
1104 }
1105 }
1106 }
1107 }
1108
1109 // Poll edit worker
1110 if let Some(ref worker) = *self.edit_worker.lock() {
1111 while let Some(event) = worker.try_recv() {
1112 match event {
1113 EditEvent::Started { hash } => {
1114 events.push(BackendEvent::EditStarted { hash });
1115 }
1116 EditEvent::Complete {
1117 source_hash,
1118 result_path,
1119 operation,
1120 } => {
1121 events.push(BackendEvent::EditComplete {
1122 source_hash,
1123 result_path,
1124 operation,
1125 });
1126 }
1127 EditEvent::Error { hash, error } => {
1128 events.push(BackendEvent::EditError { hash, error });
1129 }
1130 }
1131 }
1132 }
1133
1134 events
1135 }
1136 }
1137
1138 #[cfg(test)]
1139 mod tests {
1140 use super::*;
1141
1142 fn setup() -> DirectBackend {
1143 let dir = tempfile::TempDir::new().unwrap();
1144 let db = Database::open_in_memory().unwrap();
1145 let store = SampleStore::new(dir.path().join("store")).unwrap();
1146 DirectBackend::new(db, store, dir.path().to_path_buf())
1147 }
1148
1149 #[test]
1150 fn list_vfs_empty_then_create() {
1151 let backend = setup();
1152 // Fresh DB has no VFS
1153 let vfs_list = backend.list_vfs().unwrap();
1154 assert!(vfs_list.is_empty());
1155
1156 let id = backend.create_vfs("Library").unwrap();
1157 assert!(id.as_i64() > 0);
1158
1159 let vfs_list = backend.list_vfs().unwrap();
1160 assert_eq!(vfs_list.len(), 1);
1161 assert_eq!(vfs_list[0].name, "Library");
1162 }
1163
1164 #[test]
1165 fn directory_crud() {
1166 let backend = setup();
1167 let vfs_id = backend.create_vfs("Test").unwrap();
1168 let dir_id = backend.create_directory(vfs_id, None, "Drums").unwrap();
1169
1170 let children = backend.list_children_enriched(vfs_id, None).unwrap();
1171 assert_eq!(children.len(), 1);
1172 assert_eq!(children[0].node.name, "Drums");
1173
1174 backend.rename_node(dir_id, "Percussion").unwrap();
1175 let node = backend.get_node(dir_id).unwrap();
1176 assert_eq!(node.name, "Percussion");
1177
1178 backend.delete_node(dir_id).unwrap();
1179 let children = backend.list_children_enriched(vfs_id, None).unwrap();
1180 assert!(children.is_empty());
1181 }
1182
1183 /// Insert a fake sample row for testing (no actual file on disk).
1184 fn insert_fake_sample(db: &Database, hash: &str) {
1185 db.conn()
1186 .execute(
1187 "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified)
1188 VALUES (?1, ?2, 'wav', 100, 0, 0)",
1189 rusqlite::params![hash, format!("{hash}.wav")],
1190 )
1191 .unwrap();
1192 }
1193
1194 #[test]
1195 fn tags_crud() {
1196 let backend = setup();
1197 // Insert a fake sample row for tag FK
1198 {
1199 let db = backend.db.lock();
1200 insert_fake_sample(&db, "testhash");
1201 }
1202
1203 backend.add_tag("testhash", "drums.kick").unwrap();
1204 let tags = backend.get_sample_tags("testhash").unwrap();
1205 assert_eq!(tags, vec!["drums.kick"]);
1206
1207 backend.remove_tag("testhash", "drums.kick").unwrap();
1208 let tags = backend.get_sample_tags("testhash").unwrap();
1209 assert!(tags.is_empty());
1210 }
1211
1212 #[test]
1213 fn config_crud() {
1214 let backend = setup();
1215 assert!(backend.get_config("theme").unwrap().is_none());
1216
1217 backend.set_config("theme", "dark").unwrap();
1218 assert_eq!(backend.get_config("theme").unwrap().unwrap(), "dark");
1219
1220 backend.set_config("theme", "light").unwrap();
1221 assert_eq!(backend.get_config("theme").unwrap().unwrap(), "light");
1222 }
1223
1224 #[test]
1225 fn dynamic_collection_crud() {
1226 let backend = setup();
1227 let filter = SearchFilter::default();
1228
1229 let id = backend.create_dynamic_collection("Kicks", &filter).unwrap();
1230 assert!(id.as_i64() > 0);
1231
1232 let colls = backend.list_collections().unwrap();
1233 let dynamic = colls.iter().find(|c| c.name == "Kicks").unwrap();
1234 assert!(dynamic.is_dynamic());
1235 }
1236
1237 #[test]
1238 fn search_empty_filter_returns_all() {
1239 let backend = setup();
1240 let vfs_id = backend.create_vfs("Test").unwrap();
1241 // Insert fake samples so search has something to find
1242 {
1243 let db = backend.db.lock();
1244 insert_fake_sample(&db, "h1");
1245 insert_fake_sample(&db, "h2");
1246 vfs::create_sample_link(&db, vfs_id, None, "kick.wav", "h1").unwrap();
1247 vfs::create_sample_link(&db, vfs_id, None, "snare.wav", "h2").unwrap();
1248 }
1249
1250 let filter = SearchFilter::default();
1251 let results = backend.search_in_folder(&filter, vfs_id, None).unwrap();
1252 assert_eq!(results.len(), 2);
1253 }
1254
1255 #[test]
1256 fn poll_events_empty_when_no_workers() {
1257 let backend = setup();
1258 let events = backend.poll_events();
1259 assert!(events.is_empty());
1260 }
1261
1262 #[test]
1263 fn breadcrumb_and_subtree() {
1264 let backend = setup();
1265 let vfs_id = backend.create_vfs("Test").unwrap();
1266 let a = backend.create_directory(vfs_id, None, "A").unwrap();
1267 let b = backend.create_directory(vfs_id, Some(a), "B").unwrap();
1268
1269 let crumbs = backend.get_breadcrumb(b).unwrap();
1270 assert_eq!(crumbs.len(), 2);
1271 assert_eq!(crumbs[0].name, "A");
1272 assert_eq!(crumbs[1].name, "B");
1273
1274 let subtree = backend.collect_subtree(a).unwrap();
1275 assert_eq!(subtree.len(), 2);
1276 }
1277
1278 #[test]
1279 fn list_all_directories_works() {
1280 let backend = setup();
1281 let vfs_id = backend.create_vfs("Test").unwrap();
1282 let drums = backend.create_directory(vfs_id, None, "Drums").unwrap();
1283 backend.create_directory(vfs_id, Some(drums), "Kicks").unwrap();
1284
1285 let dirs = backend.list_all_directories(vfs_id).unwrap();
1286 assert_eq!(dirs.len(), 2);
1287 }
1288
1289 #[test]
1290 #[cfg(feature = "device-profiles")]
1291 fn list_device_profiles_returns_bundled() {
1292 let backend = setup();
1293 let profiles = backend.list_device_profiles().unwrap();
1294 // Bundled manifests should be loaded (14 devices)
1295 assert!(
1296 profiles.len() >= 14,
1297 "expected at least 14 bundled profiles, got {}",
1298 profiles.len()
1299 );
1300 // Spot-check a known device
1301 assert!(
1302 profiles.iter().any(|p| p.name == "SP-404 MKII"),
1303 "SP-404 MKII should be in the bundled profiles"
1304 );
1305 }
1306 }
1307