//! 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::AnalysisResult; use audiofiles_core::analysis::config::AnalysisConfig; use audiofiles_core::analysis::waveform::WaveformData; use audiofiles_core::collections::Collection; use audiofiles_core::db::Database; use audiofiles_core::edit::EditOperation; use audiofiles_core::edit::worker::{EditCommand, EditEvent, EditWorkerHandle}; use audiofiles_core::export::ExportItem; use audiofiles_core::export::profile::DeviceProfileSummary; use audiofiles_core::forge::{ChopMethod, ConformResult, ConformTarget}; use audiofiles_core::search::SearchFilter; use audiofiles_core::store::SampleStore; use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis}; use audiofiles_core::{CollectionId, NodeId, VfsId, collections, search, tags}; use super::search_worker::{self, SearchCommand, SearchEvent}; use parking_lot::Mutex; use super::{ AnalysisBackend, Backend, BackendError, BackendEvent, BackendResult, ClassifierBackend, CollectionBackend, ConfigBackend, ExportBackend, ExportConfigDesc, ExportItemDesc, ForgeBackend, ImportStrategyDesc, ImportedFolderDesc, OperationsBackend, RulesBackend, SearchBackend, SimilarityBackend, StoreBackend, TagBackend, TrainedHeadInfo, VfsBackend, }; use super::forge::{ForgeImportOutcome, ForgePending, forge_import_chop, forge_import_conform}; 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}; use audiofiles_core::forge::{ForgeCommand, ForgeEvent}; /// Convert a GUI-facing [`ImportStrategyDesc`] into the worker's [`ImportStrategy`]. /// Shared by the directory and explicit-file import entry points. fn resolve_import_strategy(strategy: ImportStrategyDesc) -> ImportStrategy { 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 } } } } /// 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>, loose_files_worker: Mutex>, edit_worker: Mutex>, forge_worker: Mutex>, classifier_worker: Mutex>, // Import context for the in-flight forge op, applied when its CPU stage // completes (the worker produces temp files; an import job imports them). forge_pending: Mutex>, // Off-thread forge import (blob copy + DB writes on its own WAL connection), // so the import never stalls the GUI frame under db.lock(). forge_import_job: Mutex>>, // Search worker: owns the similarity / near-duplicate VP-trees and builds + // queries them off the GUI thread. Spawned lazily on first search, // invalidated on new analysis. search_worker: 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 { // One-time maintenance: hard-delete tombstones past their retention // window (docs/design-sample-deletion.md Phase 4). Best-effort, a // failed sweep must never block app start, so errors are logged, not // propagated. Runs against the owned db before it moves into the Mutex. match store.sweep_expired_tombstones(&db) { Ok(0) => {} Ok(n) => tracing::info!("tombstone sweep: hard-deleted {n} expired sample(s)"), Err(e) => tracing::warn!("tombstone sweep failed at startup: {e}"), } 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), loose_files_worker: Mutex::new(None), edit_worker: Mutex::new(None), forge_worker: Mutex::new(None), classifier_worker: Mutex::new(None), forge_pending: Mutex::new(None), forge_import_job: Mutex::new(None), search_worker: Mutex::new(None), #[cfg(feature = "device-profiles")] plugin_registry: audiofiles_rhai::create_registry().unwrap_or_else(|e| { // Embedded (include_str!) profiles are compile-checked, so this // should never trip, but if it does, the conform/M8/MPC wedge // would silently show "No device profiles". Log so the empty // registry is diagnosable instead of mysterious. tracing::error!( "Failed to load device-profile registry, falling back to empty: {e}" ); 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 } /// Lazily spawn the loose-files worker and send it a command. Keeps the /// integrity/relocate/purge filesystem work off the egui frame thread. fn loose_files_send( &self, cmd: crate::loose_files_worker::LooseFilesCommand, ) -> BackendResult<()> { let mut guard = self.loose_files_worker.lock(); if guard.is_none() { let db_path = self.data_dir.join("audiofiles.db"); let handle = crate::loose_files_worker::spawn_loose_files_worker( db_path, self.store.root().to_path_buf(), ) .map_err(|e| BackendError::Other(format!("failed to spawn loose-files worker: {e}")))?; *guard = Some(handle); } // If the cached worker died (e.g. its DB open failed inside the thread), // send returns false. Drop the dead handle so the next call respawns, and // report Err so the caller never sets a busy flag for a command that no // worker will ever answer (which would wedge the repaint-while-busy guard). if !guard .as_ref() .expect("loose-files worker present") .send(cmd) { *guard = None; return Err(BackendError::Other( "loose-files worker is not running".to_string(), )); } Ok(()) } /// Cancel any in-flight import worker and spawn a fresh one, returning its /// handle (not yet stored). Shared by [`start_import`](Self::start_import) and /// [`start_file_import`](Self::start_file_import). fn respawn_import_worker(&self) -> BackendResult { let db_path = self.data_dir.join("audiofiles.db"); let store_root = self.store.root().to_path_buf(); if let Some(old) = self.import_worker.lock().take() { old.send(ImportCommand::Cancel); drop(old); // joins the thread } crate::import::spawn_import_worker(db_path, store_root) .map_err(|e| BackendError::Other(format!("failed to spawn import worker: {e}"))) } /// Lazily spawn the search worker on first use. It opens its own read-only /// connection to the database file (WAL allows concurrent reads). fn ensure_search_worker(&self) -> BackendResult<()> { let mut guard = self.search_worker.lock(); if guard.is_none() { let db_path = self.data_dir.join("audiofiles.db"); let handle = search_worker::spawn_search_worker(db_path) .map_err(|e| BackendError::Other(format!("failed to spawn search worker: {e}")))?; *guard = Some(handle); } Ok(()) } /// 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 Some(plugin) = self.plugin_registry.get(&profile_name) else { 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.clone_from(&profile.naming); // 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 mut infos: Vec<_> = { let db = self.db.lock(); items .iter() .map(|item| super::sample_info::build_sample_info(&db, &self.store, item)) .collect() }; // Probe bit depth off the DB lock and in parallel: N independent // file-header reads that previously ran serially *while holding the // lock* on the GUI thread. rayon bounds the concurrency to the pool. { use rayon::prelude::*; let paths: Vec> = items .iter() .map(|item| self.store.sample_path(&item.hash, &item.ext).ok()) .collect(); let depths: Vec = paths .into_par_iter() .map(|p| { p.and_then(|p| super::sample_info::probe_bit_depth(&p)) .unwrap_or(0) }) .collect(); for (info, depth) in infos.iter_mut().zip(depths) { info.bit_depth = depth; } } let engine = self.plugin_registry.engine(); let mut keep = vec![true; items.len()]; for (i, info) in infos.into_iter().enumerate() { keep[i] = match audiofiles_rhai::hooks::run_validate_sample(engine, ast, info) { // A deliberate `false` from the script rejects the sample. Ok(decision) => decision, // A script *fault* (not a rejection) must not silently shrink // the export, previously `.unwrap_or(false)` dropped the file // and reported success. Fail open (keep the sample) and surface // the error loudly instead. Err(e) => { tracing::error!( profile = %profile_name, error = %e, "device-profile validate_sample script errored; keeping sample in export rather than dropping it silently" ); true } }; } 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, NUL bytes, and a bare `..` // to prevent traversal. resolve_output_names re-sanitizes // overrides too, so this is a first layer, not the only one. let safe_stem = new_stem.replace(['/', '\\', '\0'], "_"); let safe_stem = if safe_stem.is_empty() || safe_stem == ".." || safe_stem == "." { "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; impl Backend for DirectBackend {} mod analysis; mod classify; mod forge; mod library; mod operations; mod store; #[cfg(test)] mod tests;