//! Direct backend: wraps `Mutex` + `SampleStore`, calls core functions directly. //! //! This is the "same as before" implementation — every Backend method delegates //! to the corresponding audiofiles-core function. Used in standalone mode, tests, //! and as a reference implementation. use std::path::{Path, PathBuf}; use tracing::instrument; use audiofiles_core::analysis::config::AnalysisConfig; use audiofiles_core::analysis::waveform::WaveformData; use audiofiles_core::analysis::AnalysisResult; use audiofiles_core::db::Database; use audiofiles_core::edit::EditOperation; use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle}; use audiofiles_core::export::profile::DeviceProfileSummary; use audiofiles_core::export::ExportItem; use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget}; use audiofiles_core::search::SearchFilter; use audiofiles_core::collections::Collection; use audiofiles_core::store::SampleStore; use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis}; use audiofiles_core::{collections, fingerprint, search, similarity, tags, CollectionId, NodeId, VfsId}; use parking_lot::Mutex; use super::{ Backend, BackendError, BackendEvent, BackendResult, ExportConfigDesc, ExportItemDesc, ImportStrategyDesc, ImportedFolderDesc, }; use crate::cleanup::{CleanupCommand, CleanupHandle}; use crate::export::{ExportCommand, ExportHandle}; use crate::import::{ImportCommand, ImportEvent, ImportHandle, ImportStrategy}; use audiofiles_core::analysis::worker::{WorkerCommand, WorkerEvent, WorkerHandle}; /// Direct backend: talks to SQLite and the sample store in-process. pub struct DirectBackend { db: Mutex, store: SampleStore, data_dir: PathBuf, // Worker handles for long-running operations import_worker: Mutex>, analysis_worker: Mutex>, export_worker: Mutex>, cleanup_worker: Mutex>, edit_worker: Mutex>, // VP-tree indexes for fast search (lazy, invalidated on new analysis) fingerprint_index: Mutex>, similarity_index: Mutex>, // Device plugin registry (when device-profiles feature is enabled) #[cfg(feature = "device-profiles")] plugin_registry: audiofiles_rhai::registry::PluginRegistry, } impl DirectBackend { /// Create a new DirectBackend from a database and sample store. pub fn new(db: Database, store: SampleStore, data_dir: PathBuf) -> Self { Self { db: Mutex::new(db), store, data_dir, import_worker: Mutex::new(None), analysis_worker: Mutex::new(None), export_worker: Mutex::new(None), cleanup_worker: Mutex::new(None), edit_worker: Mutex::new(None), fingerprint_index: Mutex::new(None), similarity_index: Mutex::new(None), #[cfg(feature = "device-profiles")] plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|_| { audiofiles_rhai::registry::PluginRegistry::new() }), } } /// Access the store (needed for preview decode path in BrowserState). pub fn store(&self) -> &SampleStore { &self.store } /// Access the data directory path. pub fn data_dir(&self) -> &Path { &self.data_dir } /// Resolve a device profile's constraints into the export config and filter items. /// /// Called before spawning the export worker so profile resolution happens /// on the main thread (where PluginRegistry is accessible). #[cfg(feature = "device-profiles")] #[instrument(skip_all)] fn resolve_device_profile( &self, config: &mut audiofiles_core::export::ExportConfig, items: &mut Vec, ) { use audiofiles_core::export::profile::ChannelConstraint; use audiofiles_core::export::{ExportChannels, ExportFormat}; let profile_name = match config.device_profile { Some(ref name) => name.clone(), None => return, }; let plugin = match self.plugin_registry.get(&profile_name) { Some(p) => p, None => return, }; let profile = &plugin.profile; // Format: if Original, set to profile's first supported format if config.format == ExportFormat::Original && let Some(fmt) = profile.audio.formats.first() { config.format = fmt.clone(); } // Sample rate: if not set, use profile's first rate if config.sample_rate.is_none() { config.sample_rate = profile.audio.sample_rates.first().copied(); } // Bit depth: if not set, use profile's first depth if config.bit_depth.is_none() { config.bit_depth = profile.audio.bit_depths.first().copied(); } // Channels match profile.audio.channels { ChannelConstraint::Mono => config.channels = ExportChannels::Mono, ChannelConstraint::Stereo => config.channels = ExportChannels::Stereo, ChannelConstraint::Both => {} // leave as-is } // Naming rules config.naming_rules = profile.naming.clone(); // File size limit config.max_file_size_bytes = profile.limits.as_ref().and_then(|l| l.max_file_size_bytes); // validate_sample hook: filter items through Rhai script if let Some(ref ast) = plugin.hooks.validate_sample { // Collect all sample info with the lock held, then drop it before running scripts // to avoid holding the DB lock during potentially slow Rhai execution. let infos: Vec<_> = { let db = self.db.lock(); items.iter().map(|item| build_sample_info(&db, &self.store, item)).collect() }; let engine = self.plugin_registry.engine(); let mut keep = vec![true; items.len()]; for (i, info) in infos.into_iter().enumerate() { keep[i] = audiofiles_rhai::hooks::run_validate_sample(engine, ast, info) .unwrap_or(false); } let mut ki = 0; items.retain(|_| { let k = keep[ki]; ki += 1; k }); } // transform_filename hook: pre-compute output names with custom naming logic if let Some(ref ast) = plugin.hooks.transform_filename { let pattern = config .naming_pattern .as_ref() .and_then(|p| audiofiles_core::rename::RenamePattern::parse(p).ok()); let mut names = audiofiles_core::export::resolve_output_names( items, config, pattern.as_ref(), ); let device_name = profile.name.clone(); let destination = config.destination.display().to_string(); let total = names.len() as i64; for (i, name) in names.iter_mut().enumerate() { let (stem, ext) = split_name_ext(name); let ctx = audiofiles_rhai::types::RhaiExportContext { device_name: device_name.clone(), destination: destination.clone(), filename: stem.clone(), extension: ext.clone(), index: i as i64, total, }; if let Ok(new_stem) = audiofiles_rhai::hooks::run_transform_filename( self.plugin_registry.engine(), ast, stem, ctx, ) { // Sanitize: strip path separators and NUL bytes to prevent traversal let safe_stem = new_stem.replace(['/', '\\', '\0'], "_"); let safe_stem = if safe_stem.is_empty() { "untitled".to_string() } else { safe_stem }; *name = if ext.is_empty() { safe_stem } else { format!("{safe_stem}.{ext}") }; } } config.name_overrides = Some(names); } } } #[cfg(feature = "device-profiles")] use audiofiles_core::util::split_name_ext; /// Build a RhaiSampleInfo from an ExportItem and database lookups. #[cfg(feature = "device-profiles")] fn build_sample_info( db: &audiofiles_core::db::Database, store: &audiofiles_core::store::SampleStore, item: &ExportItem, ) -> audiofiles_rhai::types::RhaiSampleInfo { // Query audio_analysis for sample_rate and channels let (sample_rate, channels, duration) = db .conn() .query_row( "SELECT sample_rate, channels, duration FROM audio_analysis WHERE hash = ?1", [&item.hash], |row| { Ok(( row.get::<_, u32>(0)?, row.get::<_, u16>(1)?, row.get::<_, f64>(2)?, )) }, ) .unwrap_or_else(|e| { if !matches!(e, rusqlite::Error::QueryReturnedNoRows) { tracing::warn!("Failed to query audio_analysis for {}: {e}", &item.hash[..8]); } (0, 0, item.duration.unwrap_or(0.0)) }); // Query samples for file_size let file_size = db .conn() .query_row( "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL", [&item.hash], |row| row.get::<_, u64>(0), ) .unwrap_or_else(|_| { // Fallback: read from store store .sample_path(&item.hash, &item.ext) .ok() .and_then(|p| std::fs::metadata(p).ok()) .map(|m| m.len()) .unwrap_or(0) }); // Probe bit depth from the file header (cheap — reads only the header) let bit_depth = store .sample_path(&item.hash, &item.ext) .ok() .and_then(|p| probe_bit_depth(&p)) .unwrap_or(0); audiofiles_rhai::types::RhaiSampleInfo { hash: item.hash.to_string(), name: item.name.clone(), extension: item.ext.clone(), sample_rate, bit_depth, channels, duration, file_size, } } /// Probe bit depth from a WAV/AIFF file header without full decode. #[cfg(feature = "device-profiles")] fn probe_bit_depth(path: &std::path::Path) -> Option { // Try hound first (WAV) if let Ok(reader) = hound::WavReader::open(path) { return Some(reader.spec().bits_per_sample); } // Try symphonia for AIFF/other formats let file = std::fs::File::open(path).ok()?; let mss = symphonia::core::io::MediaSourceStream::new(Box::new(file), Default::default()); let mut hint = symphonia::core::probe::Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } let probed = symphonia::default::get_probe() .format( &hint, mss, &symphonia::core::formats::FormatOptions::default(), &symphonia::core::meta::MetadataOptions::default(), ) .ok()?; let track = probed.format.default_track()?; track.codec_params.bits_per_sample.map(|b| b as u16) } impl Backend for DirectBackend { // --- VFS --- fn list_vfs(&self) -> BackendResult> { let db = self.db.lock(); Ok(vfs::list_vfs(&db)?) } fn create_vfs(&self, name: &str) -> BackendResult { let db = self.db.lock(); Ok(vfs::create_vfs(&db, name)?) } fn rename_vfs(&self, id: VfsId, new_name: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(vfs::rename_vfs(&db, id, new_name)?) } fn delete_vfs(&self, id: VfsId) -> BackendResult<()> { let db = self.db.lock(); Ok(vfs::delete_vfs(&db, id)?) } fn list_children_enriched( &self, vfs_id: VfsId, parent_id: Option, ) -> BackendResult> { let db = self.db.lock(); Ok(vfs::list_children_enriched(&db, vfs_id, parent_id)?) } fn list_children( &self, vfs_id: VfsId, parent_id: Option, ) -> BackendResult> { let db = self.db.lock(); Ok(vfs::list_children(&db, vfs_id, parent_id)?) } fn create_directory( &self, vfs_id: VfsId, parent_id: Option, name: &str, ) -> BackendResult { let db = self.db.lock(); Ok(vfs::create_directory(&db, vfs_id, parent_id, name)?) } fn create_sample_link( &self, vfs_id: VfsId, parent_id: Option, name: &str, sample_hash: &str, ) -> BackendResult { let db = self.db.lock(); Ok(vfs::create_sample_link(&db, vfs_id, parent_id, name, sample_hash)?) } fn get_node(&self, id: NodeId) -> BackendResult { let db = self.db.lock(); Ok(vfs::get_node(&db, id)?) } fn get_breadcrumb(&self, node_id: NodeId) -> BackendResult> { let db = self.db.lock(); Ok(vfs::get_breadcrumb(&db, node_id)?) } fn rename_node(&self, id: NodeId, new_name: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(vfs::rename_node(&db, id, new_name)?) } fn move_node(&self, id: NodeId, new_parent_id: Option) -> BackendResult<()> { let db = self.db.lock(); Ok(vfs::move_node(&db, id, new_parent_id)?) } fn delete_node(&self, id: NodeId) -> BackendResult<()> { let db = self.db.lock(); Ok(vfs::delete_node(&db, id)?) } fn restore_node(&self, node: &VfsNode) -> BackendResult<()> { let db = self.db.lock(); Ok(vfs::restore_node(&db, node)?) } fn collect_subtree(&self, node_id: NodeId) -> BackendResult> { let db = self.db.lock(); Ok(vfs::collect_subtree(&db, node_id)?) } fn list_all_directories(&self, vfs_id: VfsId) -> BackendResult> { let db = self.db.lock(); Ok(vfs::list_all_directories(&db, vfs_id)?) } fn find_nodes_by_hashes( &self, vfs_id: VfsId, hashes: &[&str], ) -> BackendResult> { let db = self.db.lock(); Ok(vfs::find_nodes_by_hashes(&db, vfs_id, hashes)?) } // --- Tags --- fn add_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(tags::add_tag(&db, hash, tag)?) } fn remove_tag(&self, hash: &str, tag: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(tags::remove_tag(&db, hash, tag)?) } fn get_sample_tags(&self, hash: &str) -> BackendResult> { let db = self.db.lock(); Ok(tags::get_sample_tags(&db, hash)?) } fn list_all_tags(&self) -> BackendResult> { let db = self.db.lock(); Ok(tags::list_all_tags(&db)?) } fn bulk_add_tag(&self, hashes: &[&str], tag: &str) -> BackendResult { let db = self.db.lock(); Ok(tags::bulk_add_tag(&db, hashes, tag)?) } fn bulk_remove_tag(&self, hashes: &[&str], tag: &str) -> BackendResult { let db = self.db.lock(); Ok(tags::bulk_remove_tag(&db, hashes, tag)?) } fn rename_tag_globally(&self, old_tag: &str, new_tag: &str) -> BackendResult { let db = self.db.lock(); Ok(tags::rename_tag_globally(&db, old_tag, new_tag)?) } fn count_samples_with_tag(&self, tag: &str) -> BackendResult { let db = self.db.lock(); Ok(tags::find_by_tag(&db, tag)?.len()) } fn remove_tag_globally(&self, tag: &str) -> BackendResult { let db = self.db.lock(); Ok(tags::remove_tag_globally(&db, tag)?) } // --- Search --- fn search_in_folder( &self, filter: &SearchFilter, vfs_id: VfsId, parent_id: Option, ) -> BackendResult> { let db = self.db.lock(); Ok(search::search_in_folder(&db, filter, vfs_id, parent_id)?) } fn search_global(&self, filter: &SearchFilter) -> BackendResult> { let db = self.db.lock(); Ok(search::search_global(&db, filter)?) } // --- Collections --- fn list_collections(&self) -> BackendResult> { let db = self.db.lock(); Ok(collections::list_collections(&db)?) } fn create_collection(&self, name: &str, description: Option<&str>) -> BackendResult { let db = self.db.lock(); Ok(collections::create_collection(&db, name, description)?) } fn create_dynamic_collection(&self, name: &str, filter: &SearchFilter) -> BackendResult { let db = self.db.lock(); Ok(collections::create_dynamic_collection(&db, name, filter)?) } fn rename_collection(&self, id: CollectionId, new_name: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(collections::rename_collection(&db, id, new_name)?) } fn delete_collection(&self, id: CollectionId) -> BackendResult<()> { let db = self.db.lock(); Ok(collections::delete_collection(&db, id)?) } fn add_to_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(collections::add_to_collection(&db, collection_id, sample_hash)?) } fn remove_from_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(collections::remove_from_collection(&db, collection_id, sample_hash)?) } fn list_collection_members(&self, collection_id: CollectionId) -> BackendResult> { let db = self.db.lock(); Ok(collections::list_collection_members(&db, collection_id)?) } fn get_sample_collections(&self, sample_hash: &str) -> BackendResult> { let db = self.db.lock(); Ok(collections::get_sample_collections(&db, sample_hash)?) } // --- Analysis --- fn get_analysis(&self, hash: &str) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::analysis::load_analysis(&db, hash)) } fn save_analysis(&self, result: &AnalysisResult) -> BackendResult<()> { let db = self.db.lock(); audiofiles_core::analysis::save_analysis(&db, result)?; // Invalidate search indexes — new analysis data changes normalization ranges // and may add a new fingerprint. *self.similarity_index.lock() = None; if result.fingerprint.is_some() { *self.fingerprint_index.lock() = None; } Ok(()) } fn get_waveform(&self, hash: &str) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::analysis::waveform::load_waveform(&db, hash)) } // --- Similarity --- fn find_similar( &self, hash: &str, limit: usize, ) -> BackendResult> { // Build VP-tree index lazily on first query. // Load data under DB lock, release lock, then build tree (CPU-intensive). let mut idx = self.similarity_index.lock(); if idx.is_none() { let data = { let db = self.db.lock(); similarity::SimilarityIndex::load_data(&db)? }; *idx = Some(similarity::SimilarityIndex::build_from_data(data)); } let built = idx.as_ref().expect("set on the line above"); let features = { let db = self.db.lock(); similarity::load_features(&db, hash)? }; Ok(built.find_similar(hash, &features, limit)) } fn find_near_duplicates( &self, hash: &str, limit: usize, ) -> BackendResult> { // Build VP-tree index lazily on first query. // Load data under DB lock, release lock, then build tree (CPU-intensive). let mut idx = self.fingerprint_index.lock(); if idx.is_none() { let entries = { let db = self.db.lock(); fingerprint::FingerprintIndex::load_data(&db)? }; *idx = Some(fingerprint::FingerprintIndex::build_from_data(entries)); } let built = idx.as_ref().expect("set on the line above"); let reference = { let db = self.db.lock(); fingerprint::load_fingerprint(&db, hash)? }; Ok(built.find_near_duplicates(hash, &reference.envelope, limit)) } // --- Store --- fn import_file(&self, path: &Path) -> BackendResult { let db = self.db.lock(); Ok(self.store.import(path, &db)?) } fn sample_path(&self, hash: &str, ext: &str) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?) } fn sample_extension(&self, hash: &str) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::store::sample_extension(&db, hash)?) } fn sample_original_name(&self, hash: &str) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::store::sample_original_name(&db, hash)?) } fn remove_sample(&self, hash: &str) -> BackendResult<()> { let db = self.db.lock(); Ok(self.store.remove(hash, &db)?) } fn remove_orphaned_samples(&self) -> BackendResult { let db = self.db.lock(); Ok(self.store.remove_orphaned_samples(&db)?) } fn cleanup_orphans_local(&self) -> BackendResult { // Flip `sync_state.applying_remote` to '1' so the sync DELETE // triggers don't push the orphan removals over the wire. The // current behavior of pushing them (inherited from the M007 trigger // design) would cascade-wipe placements on every synced device — // exactly the surprise the planned tombstone work // (docs/design-sample-deletion.md) is meant to fix. // // Local cleanup is the right semantics today: each device has its // own set of orphans (samples it no longer references), and // collecting disk space is a local operation, not a cross-device // statement. Set + run + reset in a single connection scope so a // panic during the cleanup can't leave the flag stuck at '1'. let db = self.db.lock(); db.conn() .execute( "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'", [], ) .map_err(|e| BackendError::Other(format!("flip applying_remote: {e}")))?; let result = self.store.remove_orphaned_samples(&db); let _ = db.conn().execute( "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'", [], ); Ok(result?) } fn sample_source_path(&self, hash: &str) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::store::sample_source_path(&db, hash)?) } fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()> { let db = self.db.lock(); Ok(audiofiles_core::store::relocate_sample(&self.store, &db, hash, new_path)?) } fn check_vault_integrity(&self) -> BackendResult<(usize, usize)> { let db = self.db.lock(); Ok(audiofiles_core::store::check_loose_files_integrity(&db)?) } fn purge_missing_loose_files(&self) -> BackendResult { let db = self.db.lock(); Ok(audiofiles_core::store::purge_missing_loose_files(&db)?) } fn relocate_missing_loose_files( &self, search_root: &std::path::Path, ) -> BackendResult<(usize, usize)> { let db = self.db.lock(); Ok(audiofiles_core::store::relocate_missing_loose_files(&db, search_root)?) } // --- Export --- fn collect_export_items( &self, vfs_id: VfsId, parent_id: Option, ) -> BackendResult> { let db = self.db.lock(); Ok(audiofiles_core::export::collect_export_items(&db, vfs_id, parent_id)?) } fn enrich_export_with_tags(&self, items: &mut [ExportItem]) -> BackendResult<()> { let db = self.db.lock(); audiofiles_core::export::enrich_with_tags(&db, items); Ok(()) } // --- Device profiles --- fn list_device_profiles(&self) -> BackendResult> { #[cfg(feature = "device-profiles")] { Ok(self.plugin_registry.list()) } #[cfg(not(feature = "device-profiles"))] { Ok(Vec::new()) } } fn device_conform_target( &self, profile_name: &str, source_rate: u32, ) -> BackendResult> { #[cfg(feature = "device-profiles")] { Ok(self .plugin_registry .get(profile_name) .map(|plugin| ConformTarget::for_device(&plugin.profile, source_rate))) } #[cfg(not(feature = "device-profiles"))] { let _ = (profile_name, source_rate); Ok(None) } } // --- Sample Forge --- #[instrument(skip_all)] fn compute_chop_preview( &self, hash: &str, ext: &str, method: &ChopMethod, ) -> BackendResult> { let db = self.db.lock(); let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?; let decoded = audiofiles_core::export::decode::decode_multichannel(&path)?; let total_frames = (decoded.samples.len() / decoded.channels.max(1) as usize).max(1); let slices = audiofiles_core::forge::compute_slices( &decoded.samples, decoded.channels, decoded.sample_rate, method, )?; // Boundary fractions: each slice's start, plus the final end (1.0). let mut marks: Vec = slices .iter() .map(|s| s.start_frame as f32 / total_frames as f32) .collect(); marks.push(1.0); Ok(marks) } #[instrument(skip_all)] fn chop_sample( &self, vfs_id: VfsId, hash: &str, ext: &str, name: &str, parent_id: Option, method: &ChopMethod, ) -> BackendResult { let db = self.db.lock(); let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?; let result = audiofiles_core::forge::chop_to_vfs( &self.store, &db, vfs_id, &path, name, parent_id, method, )?; Ok(result.slice_count) } #[instrument(skip_all)] fn conform_sample( &self, vfs_id: VfsId, hash: &str, ext: &str, name: &str, parent_id: Option, target: &ConformTarget, ) -> BackendResult { let db = self.db.lock(); // Overshoot policy: trim to the ceiling only when the user has opted in; // the default leaves the signal untouched and merely reports. let auto_trim = db .conn() .query_row( "SELECT value FROM user_config WHERE key = ?1", [super::FORGE_AUTO_TRIM_OVERSHOOT_KEY], |row| row.get::<_, String>(0), ) .ok() .is_some_and(|v| v == "true"); let path = audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)?; Ok(audiofiles_core::forge::conform_to_vfs( &self.store, &db, vfs_id, &path, name, parent_id, target, auto_trim, )?) } // --- Config --- fn get_config(&self, key: &str) -> BackendResult> { let db = self.db.lock(); let result = db .conn() .query_row( "SELECT value FROM user_config WHERE key = ?1", [key], |row| row.get::<_, String>(0), ) .ok(); Ok(result) } fn set_config(&self, key: &str, value: &str) -> BackendResult<()> { let db = self.db.lock(); db.conn() .execute( "INSERT OR REPLACE INTO user_config (key, value) VALUES (?1, ?2)", rusqlite::params![key, value], ) .map_err(audiofiles_core::error::CoreError::Db)?; Ok(()) } fn delete_config(&self, key: &str) -> BackendResult<()> { let db = self.db.lock(); db.conn() .execute("DELETE FROM user_config WHERE key = ?1", [key]) .map_err(audiofiles_core::error::CoreError::Db)?; Ok(()) } fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()> { let db = self.db.lock(); vfs::set_vfs_sync_files(&db, id, enabled)?; Ok(()) } fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult { let db = self.db.lock(); Ok(vfs::get_vfs_sync_files(&db, id)?) } // --- VFS Mirror --- fn sync_vfs_mirror(&self, mirror_root: &Path) -> BackendResult<(usize, usize, usize)> { let db = self.db.lock(); let config = audiofiles_core::vfs_mirror::MirrorConfig { mirror_root: mirror_root.to_path_buf(), store_root: self.store.root().to_path_buf(), }; let stats = audiofiles_core::vfs_mirror::sync_mirror(&db, &config)?; Ok((stats.dirs_created, stats.links_created, stats.entries_removed)) } // --- Long-running operations --- fn start_import( &self, source: &Path, strategy: ImportStrategyDesc, ) -> BackendResult<()> { let db_path = self.data_dir.join("audiofiles.db"); let store_root = self.store.root().to_path_buf(); let import_strategy = match strategy { ImportStrategyDesc::Flat { vfs_id, parent_id } => { ImportStrategy::Flat { vfs_id, parent_id } } ImportStrategyDesc::NewVfs { vfs_name } => ImportStrategy::NewVfs { vfs_name }, ImportStrategyDesc::MergeIntoVfs { vfs_id, parent_id } => { ImportStrategy::MergeIntoVfs { vfs_id, parent_id } } }; // Cancel any existing import worker before starting a new one if let Some(old) = self.import_worker.lock().take() { old.send(ImportCommand::Cancel); drop(old); // joins the thread } let handle = crate::import::spawn_import_worker(db_path, store_root) .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}")))?; handle.send(ImportCommand::ImportDirectory { source: source.to_path_buf(), strategy: import_strategy, }); *self.import_worker.lock() = Some(handle); Ok(()) } fn start_analysis( &self, sample_hashes: Vec<(String, String)>, config: AnalysisConfig, ) -> BackendResult<()> { let samples: Vec<(String, String, PathBuf)> = { let db = self.db.lock(); sample_hashes .into_iter() .filter_map(|(hash, ext)| { let path = audiofiles_core::store::resolve_file_path( &self.store, &db, &hash, &ext, ).ok()?; if path.exists() { Some((hash, ext, path)) } else { None } }) .collect() }; // Cancel any existing analysis worker before starting a new one if let Some(old) = self.analysis_worker.lock().take() { old.send(WorkerCommand::Cancel); drop(old); } let handle = audiofiles_core::analysis::worker::spawn_worker() .map_err(|e| BackendError::Other(format!("failed to spawn analysis worker: {e}")))?; handle.send(WorkerCommand::AnalyzeBatch { samples, config }); *self.analysis_worker.lock() = Some(handle); Ok(()) } fn start_export( &self, items: Vec, config: ExportConfigDesc, ) -> BackendResult<()> { let mut config = config; let mut items = items; #[cfg(feature = "device-profiles")] self.resolve_device_profile(&mut config, &mut items); let store_root = self.store.root().to_path_buf(); // Cancel any existing export worker before starting a new one if let Some(old) = self.export_worker.lock().take() { old.send(ExportCommand::Cancel); drop(old); } let handle = crate::export::spawn_export_worker(store_root) .map_err(|e| BackendError::Other(format!("failed to spawn export worker: {e}")))?; handle.send(ExportCommand::Export { items, config }); *self.export_worker.lock() = Some(handle); Ok(()) } fn cancel_import(&self) -> BackendResult<()> { if let Some(worker) = self.import_worker.lock().take() { worker.send(ImportCommand::Cancel); } Ok(()) } fn cancel_analysis(&self) -> BackendResult<()> { if let Some(worker) = self.analysis_worker.lock().take() { worker.send(WorkerCommand::Cancel); } Ok(()) } fn cancel_export(&self) -> BackendResult<()> { if let Some(worker) = self.export_worker.lock().take() { worker.send(ExportCommand::Cancel); } Ok(()) } fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()> { let path = { let db = self.db.lock(); audiofiles_core::store::resolve_file_path(&self.store, &db, hash, ext)? }; if !path.exists() { return Err(BackendError::Core( audiofiles_core::error::CoreError::SampleNotFound(hash.to_string()), )); } // Cancel any existing edit worker before starting a new one if let Some(old) = self.edit_worker.lock().take() { old.send(EditCommand::Cancel); drop(old); } let handle = audiofiles_core::edit::worker::spawn_edit_worker() .map_err(|e| BackendError::Other(format!("failed to spawn edit worker: {e}")))?; handle.send(EditCommand::Edit { hash: hash.to_string(), ext: ext.to_string(), path, operation, }); *self.edit_worker.lock() = Some(handle); Ok(()) } fn cancel_edit(&self) -> BackendResult<()> { if let Some(worker) = self.edit_worker.lock().take() { worker.send(EditCommand::Cancel); } Ok(()) } fn start_cleanup(&self) -> BackendResult<()> { // Cancel any existing cleanup worker to prevent concurrent cleanups if let Some(worker) = self.cleanup_worker.lock().take() { worker.send(CleanupCommand::Cancel); } let db_path = self.data_dir.join("audiofiles.db"); let store_root = self.store.root().to_path_buf(); let handle = crate::cleanup::spawn_cleanup_worker(db_path, store_root) .map_err(|e| BackendError::Other(format!("failed to spawn cleanup worker: {e}")))?; handle.send(CleanupCommand::RemoveOrphans); *self.cleanup_worker.lock() = Some(handle); Ok(()) } fn cancel_cleanup(&self) -> BackendResult<()> { if let Some(worker) = self.cleanup_worker.lock().take() { worker.send(CleanupCommand::Cancel); } Ok(()) } fn record_edit_history( &self, source_hash: &str, result_hash: &str, operation: &EditOperation, ) -> BackendResult<()> { let db = self.db.lock(); let op_name = operation.display_name(); let params_json = serde_json::to_string(operation) .map_err(|e| BackendError::Other(format!("serialize edit params: {e}")))?; db.conn() .execute( "INSERT INTO edit_history (source_hash, result_hash, operation, params_json) VALUES (?1, ?2, ?3, ?4)", rusqlite::params![source_hash, result_hash, op_name, params_json], ) .map_err(audiofiles_core::error::CoreError::Db)?; Ok(()) } fn delete_edit_history( &self, source_hash: &str, result_hash: &str, ) -> BackendResult<()> { let db = self.db.lock(); db.conn() .execute( "DELETE FROM edit_history WHERE id = ( SELECT id FROM edit_history WHERE source_hash = ?1 AND result_hash = ?2 ORDER BY id DESC LIMIT 1 )", rusqlite::params![source_hash, result_hash], ) .map_err(audiofiles_core::error::CoreError::Db)?; Ok(()) } fn storage_stats(&self) -> BackendResult { let db = self.db.lock(); let (sample_count, total_bytes) = db.storage_stats() .map_err(|e| BackendError::Other(e.to_string()))?; let db_path = self.data_dir.join("audiofiles.db"); let db_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); Ok(super::StorageStats { sample_count, total_bytes, db_bytes }) } fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)> { let db = self.db.lock(); db.vfs_storage_stats(vfs_id.as_i64()) .map_err(|e| BackendError::Other(e.to_string())) } fn poll_events(&self) -> Vec { let mut events = Vec::new(); // Poll import worker if let Some(ref worker) = *self.import_worker.lock() { while let Some(event) = worker.try_recv() { match event { ImportEvent::WalkProgress { count, total_bytes } => { events.push(BackendEvent::ImportWalkProgress { count, total_bytes }); } ImportEvent::WalkComplete { total, total_bytes } => { events.push(BackendEvent::ImportWalkComplete { total, total_bytes }); } ImportEvent::Progress { completed, total, current_name, } => { events.push(BackendEvent::ImportProgress { completed, total, current_name, }); } ImportEvent::FileError { path, error } => { events.push(BackendEvent::ImportFileError { path, error }); } ImportEvent::Complete { imported, total_files, errors, duplicates, folders, } => { events.push(BackendEvent::ImportComplete { imported, total_files, errors, duplicates, folders: folders .into_iter() .map(|f| ImportedFolderDesc { name: f.name, samples: f.samples, }) .collect(), }); } } } } // Poll analysis worker if let Some(ref worker) = *self.analysis_worker.lock() { while let Some(event) = worker.try_recv() { match event { WorkerEvent::Progress { completed, total, current_name, } => { events.push(BackendEvent::AnalysisProgress { completed, total, current_name, }); } WorkerEvent::SampleDone { result, suggestions, } => { events.push(BackendEvent::AnalysisSampleDone { result, suggestions, }); } WorkerEvent::SampleError { hash, error } => { events.push(BackendEvent::AnalysisSampleError { hash, error }); } WorkerEvent::BatchComplete => { events.push(BackendEvent::AnalysisBatchComplete); } } } } // Poll export worker if let Some(ref worker) = *self.export_worker.lock() { while let Some(event) = worker.try_recv() { match event { crate::export::ExportEvent::Progress { completed, total, current_name, } => { events.push(BackendEvent::ExportProgress { completed, total, current_name, }); } crate::export::ExportEvent::Complete { total, errors } => { events.push(BackendEvent::ExportComplete { total, errors }); } } } } // Poll cleanup worker if let Some(ref worker) = *self.cleanup_worker.lock() { while let Some(event) = worker.try_recv() { match event { crate::cleanup::CleanupEvent::Progress { completed, total, current_name, } => { events.push(BackendEvent::CleanupProgress { completed, total, current_name, }); } crate::cleanup::CleanupEvent::Complete { removed, errors } => { events.push(BackendEvent::CleanupComplete { removed, errors }); } } } } // Poll edit worker if let Some(ref worker) = *self.edit_worker.lock() { while let Some(event) = worker.try_recv() { match event { EditEvent::Started { hash } => { events.push(BackendEvent::EditStarted { hash }); } EditEvent::Complete { source_hash, result_path, operation, } => { events.push(BackendEvent::EditComplete { source_hash, result_path, operation, }); } EditEvent::Error { hash, error } => { events.push(BackendEvent::EditError { hash, error }); } } } } events } } #[cfg(test)] mod tests { use super::*; fn setup() -> DirectBackend { let dir = tempfile::TempDir::new().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); DirectBackend::new(db, store, dir.path().to_path_buf()) } #[test] fn list_vfs_empty_then_create() { let backend = setup(); // Fresh DB has no VFS let vfs_list = backend.list_vfs().unwrap(); assert!(vfs_list.is_empty()); let id = backend.create_vfs("Library").unwrap(); assert!(id.as_i64() > 0); let vfs_list = backend.list_vfs().unwrap(); assert_eq!(vfs_list.len(), 1); assert_eq!(vfs_list[0].name, "Library"); } #[test] fn directory_crud() { let backend = setup(); let vfs_id = backend.create_vfs("Test").unwrap(); let dir_id = backend.create_directory(vfs_id, None, "Drums").unwrap(); let children = backend.list_children_enriched(vfs_id, None).unwrap(); assert_eq!(children.len(), 1); assert_eq!(children[0].node.name, "Drums"); backend.rename_node(dir_id, "Percussion").unwrap(); let node = backend.get_node(dir_id).unwrap(); assert_eq!(node.name, "Percussion"); backend.delete_node(dir_id).unwrap(); let children = backend.list_children_enriched(vfs_id, None).unwrap(); assert!(children.is_empty()); } /// Insert a fake sample row for testing (no actual file on disk). fn insert_fake_sample(db: &Database, hash: &str) { db.conn() .execute( "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) VALUES (?1, ?2, 'wav', 100, 0, 0)", rusqlite::params![hash, format!("{hash}.wav")], ) .unwrap(); } #[test] fn tags_crud() { let backend = setup(); // Insert a fake sample row for tag FK { let db = backend.db.lock(); insert_fake_sample(&db, "testhash"); } backend.add_tag("testhash", "drums.kick").unwrap(); let tags = backend.get_sample_tags("testhash").unwrap(); assert_eq!(tags, vec!["drums.kick"]); backend.remove_tag("testhash", "drums.kick").unwrap(); let tags = backend.get_sample_tags("testhash").unwrap(); assert!(tags.is_empty()); } #[test] fn config_crud() { let backend = setup(); assert!(backend.get_config("theme").unwrap().is_none()); backend.set_config("theme", "dark").unwrap(); assert_eq!(backend.get_config("theme").unwrap().unwrap(), "dark"); backend.set_config("theme", "light").unwrap(); assert_eq!(backend.get_config("theme").unwrap().unwrap(), "light"); } #[test] fn dynamic_collection_crud() { let backend = setup(); let filter = SearchFilter::default(); let id = backend.create_dynamic_collection("Kicks", &filter).unwrap(); assert!(id.as_i64() > 0); let colls = backend.list_collections().unwrap(); let dynamic = colls.iter().find(|c| c.name == "Kicks").unwrap(); assert!(dynamic.is_dynamic()); } #[test] fn search_empty_filter_returns_all() { let backend = setup(); let vfs_id = backend.create_vfs("Test").unwrap(); // Insert fake samples so search has something to find { let db = backend.db.lock(); insert_fake_sample(&db, "h1"); insert_fake_sample(&db, "h2"); vfs::create_sample_link(&db, vfs_id, None, "kick.wav", "h1").unwrap(); vfs::create_sample_link(&db, vfs_id, None, "snare.wav", "h2").unwrap(); } let filter = SearchFilter::default(); let results = backend.search_in_folder(&filter, vfs_id, None).unwrap(); assert_eq!(results.len(), 2); } #[test] fn poll_events_empty_when_no_workers() { let backend = setup(); let events = backend.poll_events(); assert!(events.is_empty()); } #[test] fn breadcrumb_and_subtree() { let backend = setup(); let vfs_id = backend.create_vfs("Test").unwrap(); let a = backend.create_directory(vfs_id, None, "A").unwrap(); let b = backend.create_directory(vfs_id, Some(a), "B").unwrap(); let crumbs = backend.get_breadcrumb(b).unwrap(); assert_eq!(crumbs.len(), 2); assert_eq!(crumbs[0].name, "A"); assert_eq!(crumbs[1].name, "B"); let subtree = backend.collect_subtree(a).unwrap(); assert_eq!(subtree.len(), 2); } #[test] fn list_all_directories_works() { let backend = setup(); let vfs_id = backend.create_vfs("Test").unwrap(); let drums = backend.create_directory(vfs_id, None, "Drums").unwrap(); backend.create_directory(vfs_id, Some(drums), "Kicks").unwrap(); let dirs = backend.list_all_directories(vfs_id).unwrap(); assert_eq!(dirs.len(), 2); } /// Write a minimal float-PCM WAV for forge integration tests. fn write_float_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { use std::io::Write; let bytes_per_sample = 4u16; let block_align = channels * bytes_per_sample; let data_size = (samples.len() as u32) * 4; let file_size = 36 + data_size; let mut buf = Vec::with_capacity(44 + data_size as usize); buf.extend_from_slice(b"RIFF"); buf.extend_from_slice(&file_size.to_le_bytes()); buf.extend_from_slice(b"WAVE"); buf.extend_from_slice(b"fmt "); buf.extend_from_slice(&16u32.to_le_bytes()); buf.extend_from_slice(&3u16.to_le_bytes()); buf.extend_from_slice(&channels.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes()); buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); buf.extend_from_slice(&block_align.to_le_bytes()); buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); buf.extend_from_slice(b"data"); buf.extend_from_slice(&data_size.to_le_bytes()); for &s in samples { buf.extend_from_slice(&s.to_le_bytes()); } std::fs::File::create(path).unwrap().write_all(&buf).unwrap(); } #[test] fn chop_sample_creates_slice_folder() { use audiofiles_core::forge::ChopMethod; // Keep the temp dir alive for the whole test (store lives under it). let dir = tempfile::TempDir::new().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let backend = DirectBackend::new(db, store, dir.path().to_path_buf()); let vfs_id = backend.create_vfs("Test").unwrap(); let samples: Vec = (0..2000).map(|i| ((i % 40) as f32 / 40.0) - 0.5).collect(); let src = dir.path().join("loop.wav"); write_float_wav(&src, 1, 44100, &samples); let hash = backend.import_file(&src).unwrap(); // Preview returns slice boundaries (N starts + trailing 1.0). let marks = backend .compute_chop_preview(&hash, "wav", &ChopMethod::EqualDivisions(4)) .unwrap(); assert_eq!(marks.len(), 5); assert_eq!(marks.last().copied(), Some(1.0)); let count = backend .chop_sample(vfs_id, &hash, "wav", "loop.wav", None, &ChopMethod::EqualDivisions(4)) .unwrap(); assert_eq!(count, 4); // A "loop_slices" directory now holds 4 samples. let roots = backend.list_children(vfs_id, None).unwrap(); let slice_dir = roots.iter().find(|n| n.name == "loop_slices").unwrap(); let slices = backend.list_children(vfs_id, Some(slice_dir.id)).unwrap(); assert_eq!(slices.len(), 4); } #[test] #[cfg(feature = "device-profiles")] fn device_conform_target_resolves_bundled() { let backend = setup(); // A bundled mono device resolves to a mono target. let target = backend .device_conform_target("SP-404 MKII", 48000) .unwrap(); assert!(target.is_some(), "SP-404 MKII should resolve to a target"); } #[test] #[cfg(feature = "device-profiles")] fn list_device_profiles_returns_bundled() { let backend = setup(); let profiles = backend.list_device_profiles().unwrap(); // Bundled manifests should be loaded (14 devices) assert!( profiles.len() >= 14, "expected at least 14 bundled profiles, got {}", profiles.len() ); // Spot-check a known device assert!( profiles.iter().any(|p| p.name == "SP-404 MKII"), "SP-404 MKII should be in the bundled profiles" ); } }