//! Backend abstraction for database and store access. //! //! The [`Backend`] trait defines all operations that BrowserState needs from //! the data layer. Two implementations exist: //! //! - [`DirectBackend`] — wraps `Mutex` + `SampleStore`, calls core functions directly. //! Used in tests, standalone mode, and as a reference implementation. //! - `DaemonBackend` (Phase D) — forwards calls to the daemon over Unix socket via JSON-RPC. pub mod direct; use std::path::{Path, PathBuf}; use audiofiles_core::analysis::config::AnalysisConfig; use audiofiles_core::analysis::suggest::TagSuggestion; use audiofiles_core::analysis::waveform::WaveformData; use audiofiles_core::analysis::AnalysisResult; use audiofiles_core::edit::EditOperation; use audiofiles_core::export::profile::DeviceProfileSummary; use audiofiles_core::export::{ExportConfig, ExportItem}; use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget}; use audiofiles_core::search::SearchFilter; use audiofiles_core::collections::Collection; use audiofiles_core::vfs::{Vfs, VfsNode, VfsNodeWithAnalysis}; use audiofiles_core::{CollectionId, NodeId, VfsId}; pub use direct::DirectBackend; /// Result type for backend operations. pub type BackendResult = Result; /// Unified error type for backend operations. #[derive(Debug, thiserror::Error)] pub enum BackendError { #[error("{0}")] Core(#[from] audiofiles_core::error::CoreError), #[error("{0}")] Other(String), } /// Events from long-running background operations (import, analysis, export). /// /// Unifies the separate ImportEvent, WorkerEvent, and ExportEvent types so that /// a single `poll_events()` call can drain all pending worker notifications. #[derive(Debug, serde::Serialize, serde::Deserialize)] pub enum BackendEvent { // Import events ImportWalkProgress { count: usize, total_bytes: u64, }, ImportWalkComplete { total: usize, total_bytes: u64, }, ImportProgress { completed: usize, total: usize, current_name: String, }, ImportFileError { path: String, error: String, }, ImportComplete { imported: Vec<(String, String)>, total_files: usize, errors: usize, duplicates: usize, folders: Vec, }, // Analysis events AnalysisProgress { completed: usize, total: usize, current_name: String, }, AnalysisSampleDone { result: Box, suggestions: Vec, }, AnalysisSampleError { hash: String, error: String, }, AnalysisBatchComplete, // Export events ExportProgress { completed: usize, total: usize, current_name: String, }, ExportComplete { total: usize, errors: Vec<(String, String)>, }, // Cleanup events CleanupProgress { completed: usize, total: usize, current_name: String, }, CleanupComplete { removed: usize, errors: usize, }, // Edit events EditStarted { hash: String, }, EditComplete { source_hash: String, result_path: std::path::PathBuf, operation: EditOperation, }, EditError { hash: String, error: String, }, } /// Serializable description of an imported folder (for IPC). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ImportedFolderDesc { pub name: String, pub samples: Vec<(String, String)>, } /// Serializable description of an import strategy (for IPC). #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum ImportStrategyDesc { Flat { vfs_id: VfsId, parent_id: Option }, NewVfs { vfs_name: String }, MergeIntoVfs { vfs_id: VfsId, parent_id: Option }, } /// Serializable description of an export item (for IPC). pub type ExportItemDesc = ExportItem; /// Serializable description of an export config (for IPC). pub type ExportConfigDesc = ExportConfig; /// Aggregate storage statistics for a vault. #[derive(Debug, Clone, Default)] pub struct StorageStats { pub sample_count: u64, pub total_bytes: u64, pub db_bytes: u64, } /// User-config key (in the `user_config` table) for the forge's overshoot /// policy. When the stored value is `"true"`, a conform that overshoots full /// scale at an integer target is trimmed to the ceiling (the gentlest reversible /// fix); otherwise (default) the signal is left untouched and the overshoot is /// only reported, leaving the encoder's clamp as the disclosed last resort. pub const FORGE_AUTO_TRIM_OVERSHOOT_KEY: &str = "forge.auto_trim_overshoot"; /// The core abstraction separating UI from data access. /// /// Every method is synchronous and blocking. The trait is `Send + Sync` so it /// can live inside `BrowserState` (which must be Send + Sync for nih-plug). pub trait Backend: Send + Sync { // --- VFS --- /// List all VFS roots, ordered alphabetically. fn list_vfs(&self) -> BackendResult>; /// Create a new VFS root. Returns the new VFS ID. fn create_vfs(&self, name: &str) -> BackendResult; /// Rename a VFS root. fn rename_vfs(&self, id: VfsId, new_name: &str) -> BackendResult<()>; /// Delete a VFS root and all its nodes. fn delete_vfs(&self, id: VfsId) -> BackendResult<()>; /// List children of a directory with analysis data joined in. fn list_children_enriched( &self, vfs_id: VfsId, parent_id: Option, ) -> BackendResult>; /// List direct children (without analysis data). fn list_children( &self, vfs_id: VfsId, parent_id: Option, ) -> BackendResult>; /// Create a directory node. Returns the new node ID. fn create_directory( &self, vfs_id: VfsId, parent_id: Option, name: &str, ) -> BackendResult; /// Create a sample link node. Returns the new node ID. fn create_sample_link( &self, vfs_id: VfsId, parent_id: Option, name: &str, sample_hash: &str, ) -> BackendResult; /// Fetch a single VFS node by ID. fn get_node(&self, id: NodeId) -> BackendResult; /// Walk from a node up to the VFS root, returning root→node path. fn get_breadcrumb(&self, node_id: NodeId) -> BackendResult>; /// Rename a VFS node. fn rename_node(&self, id: NodeId, new_name: &str) -> BackendResult<()>; /// Move a VFS node to a new parent. fn move_node(&self, id: NodeId, new_parent_id: Option) -> BackendResult<()>; /// Delete a VFS node (cascades to children). fn delete_node(&self, id: NodeId) -> BackendResult<()>; /// Re-insert a previously deleted node (for undo). fn restore_node(&self, node: &VfsNode) -> BackendResult<()>; /// Recursively collect a node and all its descendants. fn collect_subtree(&self, node_id: NodeId) -> BackendResult>; /// List all directories in a VFS with full paths. fn list_all_directories(&self, vfs_id: VfsId) -> BackendResult>; /// Find VFS nodes by sample hashes within a specific VFS. fn find_nodes_by_hashes( &self, vfs_id: VfsId, hashes: &[&str], ) -> BackendResult>; // --- Tags --- /// Add a tag to a sample. fn add_tag(&self, hash: &str, tag: &str) -> BackendResult<()>; /// Remove a tag from a sample. fn remove_tag(&self, hash: &str, tag: &str) -> BackendResult<()>; /// Get all tags for a sample. fn get_sample_tags(&self, hash: &str) -> BackendResult>; /// List all tags in the database. fn list_all_tags(&self) -> BackendResult>; /// Add a tag to multiple samples. Returns count of tags added. fn bulk_add_tag(&self, hashes: &[&str], tag: &str) -> BackendResult; /// Remove a tag from multiple samples. Returns count of tags removed. fn bulk_remove_tag(&self, hashes: &[&str], tag: &str) -> BackendResult; /// Rename a tag everywhere it appears. Returns count of samples affected. fn rename_tag_globally(&self, old_tag: &str, new_tag: &str) -> BackendResult; /// Count samples that carry an exact tag (used for rename / remove preview). fn count_samples_with_tag(&self, tag: &str) -> BackendResult; /// Remove a tag from every sample that carries it. Returns count of samples affected. fn remove_tag_globally(&self, tag: &str) -> BackendResult; // --- Search --- /// Search within a specific VFS folder. fn search_in_folder( &self, filter: &SearchFilter, vfs_id: VfsId, parent_id: Option, ) -> BackendResult>; /// Search globally across all VFS roots. fn search_global(&self, filter: &SearchFilter) -> BackendResult>; // --- Smart folders --- // --- Collections --- /// List all collections with member counts. fn list_collections(&self) -> BackendResult>; /// Create a manual collection. Returns the new ID. fn create_collection(&self, name: &str, description: Option<&str>) -> BackendResult; /// Create a dynamic collection (saved search). Returns the new ID. fn create_dynamic_collection(&self, name: &str, filter: &SearchFilter) -> BackendResult; /// Rename a collection. fn rename_collection(&self, id: CollectionId, new_name: &str) -> BackendResult<()>; /// Delete a collection (cascades members). fn delete_collection(&self, id: CollectionId) -> BackendResult<()>; /// Add a sample to a collection (no-op if already present). fn add_to_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()>; /// Remove a sample from a collection. fn remove_from_collection(&self, collection_id: CollectionId, sample_hash: &str) -> BackendResult<()>; /// List sample hashes in a collection. fn list_collection_members(&self, collection_id: CollectionId) -> BackendResult>; /// Get all collections containing a given sample. fn get_sample_collections(&self, sample_hash: &str) -> BackendResult>; // --- Analysis --- /// Get the full analysis result for a sample, if it exists. fn get_analysis(&self, hash: &str) -> BackendResult>; /// Save an analysis result to the database. fn save_analysis(&self, result: &AnalysisResult) -> BackendResult<()>; /// Get waveform display data for a sample. fn get_waveform(&self, hash: &str) -> BackendResult>; // --- Similarity --- /// Find samples similar to the given hash. fn find_similar( &self, hash: &str, limit: usize, ) -> BackendResult>; /// Find near-duplicate samples by fingerprint comparison. fn find_near_duplicates( &self, hash: &str, limit: usize, ) -> BackendResult>; // --- Store --- /// Import a file into the content-addressed store. Returns the hash. fn import_file(&self, path: &Path) -> BackendResult; /// Get the filesystem path for a stored sample. fn sample_path(&self, hash: &str, ext: &str) -> BackendResult; /// Look up the file extension for a sample hash. fn sample_extension(&self, hash: &str) -> BackendResult; /// Look up the original filename for a sample hash. fn sample_original_name(&self, hash: &str) -> BackendResult; /// Remove a sample from the store and database (CASCADE handles VFS nodes, tags, analysis). fn remove_sample(&self, hash: &str) -> BackendResult<()>; /// Remove samples no longer referenced by any VFS node. Returns count removed. fn remove_orphaned_samples(&self) -> BackendResult; /// User-initiated local orphan cleanup. Wraps `remove_orphaned_samples` /// with sync trigger suppression (`applying_remote='1'`) so the cleanup /// stays local to this device — other devices keep their own copies of /// the samples until they independently determine the samples are /// orphaned. Forward-compatible with the planned tombstone-based /// sample-deletion design (see `docs/design-sample-deletion.md`). fn cleanup_orphans_local(&self) -> BackendResult; /// Look up the source_path for an loose-files mode sample. Returns None for normal samples. fn sample_source_path(&self, hash: &str) -> BackendResult>; /// Relocate an loose-files mode sample to a new path (verifies hash match). fn relocate_sample(&self, hash: &str, new_path: &Path) -> BackendResult<()>; /// Check integrity of loose-files mode samples. Returns (valid, missing). fn check_vault_integrity(&self) -> BackendResult<(usize, usize)>; /// Delete all loose-files mode samples whose source files are missing. Returns count purged. fn purge_missing_loose_files(&self) -> BackendResult; /// Search `search_root` for files matching missing loose-files samples by hash; /// update their source_path to the new location. Returns /// `(relocated, still_missing)` so the caller can decide whether to prompt /// again with a different directory. fn relocate_missing_loose_files( &self, search_root: &std::path::Path, ) -> BackendResult<(usize, usize)>; // --- Export --- /// Collect export items from a VFS subtree. fn collect_export_items( &self, vfs_id: VfsId, parent_id: Option, ) -> BackendResult>; /// Populate tags on export items. fn enrich_export_with_tags(&self, items: &mut [ExportItem]) -> BackendResult<()>; // --- Device profiles --- /// List available device profiles for device-aware export. fn list_device_profiles(&self) -> BackendResult>; /// Resolve a device profile into a conform target for a source of the given /// sample rate. Returns `None` when the profile isn't found (or device /// profiles are unavailable in this build). fn device_conform_target( &self, profile_name: &str, source_rate: u32, ) -> BackendResult>; // --- Sample Forge --- /// Compute slice-boundary positions (normalized 0..1 fractions of total /// length) for the given chop method, for waveform overlay preview. fn compute_chop_preview( &self, hash: &str, ext: &str, method: &ChopMethod, ) -> BackendResult>; /// Chop a sample into slices written as new samples in a `"{name}_slices"` /// directory under `parent_id`. Returns the number of slices created. fn chop_sample( &self, vfs_id: VfsId, hash: &str, ext: &str, name: &str, parent_id: Option, method: &ChopMethod, ) -> BackendResult; /// Conform a sample to `target`, writing the result as a new sibling sample /// under `parent_id`. The result carries the new sample's hash plus any /// true-peak overshoot the conform surfaced (see [`ConformResult`]); whether /// an overshoot is trimmed or left for the encoder follows the /// [`FORGE_AUTO_TRIM_OVERSHOOT_KEY`] user-config toggle. fn conform_sample( &self, vfs_id: VfsId, hash: &str, ext: &str, name: &str, parent_id: Option, target: &ConformTarget, ) -> BackendResult; // --- Config --- /// Get a user config value by key. fn get_config(&self, key: &str) -> BackendResult>; /// Set a user config value. fn set_config(&self, key: &str, value: &str) -> BackendResult<()>; /// Delete a user config value by key. No-op if the key does not exist. fn delete_config(&self, key: &str) -> BackendResult<()>; /// Set whether a VFS should sync audio file blobs to cloud. fn set_vfs_sync_files(&self, id: VfsId, enabled: bool) -> BackendResult<()>; /// Get whether a VFS has audio file blob syncing enabled. fn get_vfs_sync_files(&self, id: VfsId) -> BackendResult; // --- VFS Mirror --- /// Synchronise the VFS mirror directory with the current VFS state. /// Returns `(dirs_created, links_created, entries_removed)`. fn sync_vfs_mirror(&self, mirror_root: &Path) -> BackendResult<(usize, usize, usize)>; // --- Long-running operations --- /// Start a folder import in the background. fn start_import( &self, source: &Path, strategy: ImportStrategyDesc, ) -> BackendResult<()>; /// Start analysis on a batch of samples. fn start_analysis( &self, samples: Vec<(String, String)>, config: AnalysisConfig, ) -> BackendResult<()>; /// Start an export operation. fn start_export( &self, items: Vec, config: ExportConfigDesc, ) -> BackendResult<()>; /// Cancel a running import. fn cancel_import(&self) -> BackendResult<()>; /// Cancel a running analysis. fn cancel_analysis(&self) -> BackendResult<()>; /// Cancel a running export. fn cancel_export(&self) -> BackendResult<()>; /// Start an edit operation on a sample. fn start_edit(&self, hash: &str, ext: &str, operation: EditOperation) -> BackendResult<()>; /// Cancel a running edit. fn cancel_edit(&self) -> BackendResult<()>; /// Start background orphaned sample cleanup. fn start_cleanup(&self) -> BackendResult<()>; /// Cancel a running cleanup. fn cancel_cleanup(&self) -> BackendResult<()>; /// Record an edit in the edit_history table. fn record_edit_history( &self, source_hash: &str, result_hash: &str, operation: &EditOperation, ) -> BackendResult<()>; /// Delete the most recent `edit_history` row matching this (source, result) /// pair. Used by `BrowserState::undo_last_edit` to reverse a previously /// recorded edit when the user clicks the inline Undo affordance. fn delete_edit_history( &self, source_hash: &str, result_hash: &str, ) -> BackendResult<()>; /// Get aggregate storage statistics for the current vault. fn storage_stats(&self) -> BackendResult; /// Per-VFS storage stats: `(unique_sample_count, total_bytes)`. Surfaced in /// the sync panel's per-VFS toggle rows so the user can see upload size /// before enabling blob sync for that vault. fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)>; /// Non-blocking poll for worker events. fn poll_events(&self) -> Vec; } #[cfg(test)] mod tests { use super::*; #[test] fn backend_error_from_core() { let core_err = audiofiles_core::error::CoreError::NodeNotFound(NodeId::from(42)); let backend_err: BackendError = core_err.into(); assert!(backend_err.to_string().contains("42")); } #[test] fn backend_error_other() { let err = BackendError::Other("test error".to_string()); assert_eq!(err.to_string(), "test error"); } #[test] fn imported_folder_desc_serializes() { let desc = ImportedFolderDesc { name: "Drums".to_string(), samples: vec![("hash1".to_string(), "wav".to_string())], }; let json = serde_json::to_string(&desc).unwrap(); assert!(json.contains("Drums")); } #[test] fn import_strategy_desc_variants() { let flat = ImportStrategyDesc::Flat { vfs_id: VfsId::from(1), parent_id: None }; let json = serde_json::to_string(&flat).unwrap(); assert!(json.contains("Flat")); let new_vfs = ImportStrategyDesc::NewVfs { vfs_name: "Test".to_string() }; let json = serde_json::to_string(&new_vfs).unwrap(); assert!(json.contains("Test")); } }