//! `DirectBackend` long-running operations: import, analysis, export, and VFS //! mirror sync. These spawn workers and report progress through event channels //! rather than blocking, and each carries a matching cancel path. use super::{ AnalysisConfig, BackendError, BackendEvent, BackendResult, CleanupCommand, DirectBackend, EditCommand, EditEvent, EditOperation, ExportCommand, ExportConfigDesc, ExportItemDesc, ForgeEvent, ForgeImportOutcome, ForgePending, ImportCommand, ImportEvent, ImportStrategyDesc, ImportedFolderDesc, OperationsBackend, Path, PathBuf, SearchEvent, WorkerCommand, WorkerEvent, forge_import_chop, forge_import_conform, resolve_import_strategy, }; impl OperationsBackend for DirectBackend { // --- 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 import_strategy = resolve_import_strategy(strategy); let handle = self.respawn_import_worker()?; handle.send(ImportCommand::ImportDirectory { source: source.to_path_buf(), strategy: import_strategy, }); *self.import_worker.lock() = Some(handle); Ok(()) } fn start_file_import( &self, paths: &[PathBuf], strategy: ImportStrategyDesc, ) -> BackendResult<()> { let import_strategy = resolve_import_strategy(strategy); let handle = self.respawn_import_worker()?; handle.send(ImportCommand::ImportFiles { paths: paths.to_vec(), 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_or(0, |m| m.len()); Ok(crate::backend::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 loose-files worker if let Some(ref worker) = *self.loose_files_worker.lock() { use crate::loose_files_worker::LooseFilesEvent as Lfe; while let Some(event) = worker.try_recv() { events.push(match event { Lfe::IntegrityResult { missing } => { BackendEvent::LooseFilesIntegrity { missing } } Lfe::RelocateResult { relocated, still_missing, } => BackendEvent::LooseFilesRelocated { relocated, still_missing, }, Lfe::PurgeResult { purged } => BackendEvent::LooseFilesPurged { purged }, Lfe::VerifyResult { checked, corrupt } => { BackendEvent::StoreIntegrity { checked, corrupt } } Lfe::Failed { message } => BackendEvent::LooseFilesFailed { message }, }); } } // 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 }); } } } } // Poll forge worker. Collect events first (releasing the worker lock), // then run the import stage here on the GUI thread, the worker only did // the CPU work (decode/slice/conform/encode → temp files), so the DB // write is fast and never blocked the render thread. let forge_events: Vec = match *self.forge_worker.lock() { Some(ref worker) => { let mut evs = Vec::new(); while let Some(ev) = worker.try_recv() { evs.push(ev); } evs } None => Vec::new(), }; for ev in forge_events { match ev { ForgeEvent::Started => events.push(BackendEvent::ForgeStarted), ForgeEvent::Error { error } => { self.forge_pending.lock().take(); events.push(BackendEvent::ForgeError { error }); } ForgeEvent::ChopComplete { staging } => { let pending = self.forge_pending.lock().take(); if let Some(ForgePending::Chop { vfs_id, parent_id }) = pending { // Import off the GUI thread on its own WAL connection so the // blob copies + DB writes never hold the GUI's db.lock(). let data_dir = self.data_dir.clone(); let root = self.store.root().to_path_buf(); let job = audiofiles_core::worker_runtime::spawn_job( "forge-import-chop", || ForgeImportOutcome::Error { error: "forge chop import panicked".to_string(), }, move || forge_import_chop(&data_dir, &root, vfs_id, parent_id, staging), ); match job { Ok(handle) => *self.forge_import_job.lock() = Some(handle), Err(e) => events.push(BackendEvent::ForgeError { error: e.to_string(), }), } } else { // No matching pending context (cancelled/superseded), // drop the staged temp files rather than leak them. for (_, p) in &staging.slices { let _ = std::fs::remove_file(p); } } } ForgeEvent::ConformComplete { staging } => { let pending = self.forge_pending.lock().take(); if let Some(ForgePending::Conform { vfs_id, parent_id, sample_rate, bit_depth, }) = pending { // Import off the GUI thread (see ChopComplete). let data_dir = self.data_dir.clone(); let root = self.store.root().to_path_buf(); let job = audiofiles_core::worker_runtime::spawn_job( "forge-import-conform", || ForgeImportOutcome::Error { error: "forge conform import panicked".to_string(), }, move || { forge_import_conform( &data_dir, &root, vfs_id, parent_id, sample_rate, bit_depth, staging, ) }, ); match job { Ok(handle) => *self.forge_import_job.lock() = Some(handle), Err(e) => events.push(BackendEvent::ForgeError { error: e.to_string(), }), } } else { let _ = std::fs::remove_file(&staging.temp_path); } } ForgeEvent::PreviewComplete { marks } => { events.push(BackendEvent::ChopPreviewComplete { marks }); } } } // Drain the off-thread forge import job (chop/conform slices imported on // their own connection), emitting its completion on a later frame. let import_outcome = { let guard = self.forge_import_job.lock(); let outcome = guard .as_ref() .and_then(audiofiles_core::worker_runtime::JobHandle::try_recv); drop(guard); if outcome.is_some() { *self.forge_import_job.lock() = None; } outcome }; if let Some(outcome) = import_outcome { match outcome { ForgeImportOutcome::Chop { slice_count } => { events.push(BackendEvent::ForgeChopComplete { slice_count }); } ForgeImportOutcome::Conform { sample_rate, bit_depth, overshoot, } => events.push(BackendEvent::ForgeConformComplete { sample_rate, bit_depth, overshoot, }), ForgeImportOutcome::Error { error } => { events.push(BackendEvent::ForgeError { error }); } } } // Poll search worker (similarity / near-duplicate VP-tree build + query). let search_events: Vec = match *self.search_worker.lock() { Some(ref worker) => { let mut evs = Vec::new(); while let Some(ev) = worker.try_recv() { evs.push(ev); } evs } None => Vec::new(), }; for ev in search_events { match ev { SearchEvent::SimilarResults { source, hashes } => { events.push(BackendEvent::SimilarResults { source, hashes }); } SearchEvent::NearDuplicateResults { source, hashes } => { events.push(BackendEvent::NearDuplicateResults { source, hashes }); } SearchEvent::Error { error } => { events.push(BackendEvent::SearchError { error }); } } } // Poll classifier worker (one-shot job). let job_done = { let guard = self.classifier_worker.lock(); match guard .as_ref() .and_then(crate::classifier_worker::ClassifierHandle::try_recv) { Some(crate::classifier_worker::ClassifierWorkerEvent::Done(result)) => { events.push(BackendEvent::ClassifierJobDone(result)); true } Some(crate::classifier_worker::ClassifierWorkerEvent::Failed(error)) => { events.push(BackendEvent::ClassifierJobFailed(error)); true } None => false, } }; if job_done { *self.classifier_worker.lock() = None; // join the finished thread } events } }