//! Background export worker: writes VFS samples to the filesystem off the GUI thread. //! //! Mirrors the pattern in `import.rs` — dedicated thread with its own SampleStore, //! communicating via channels. The GUI thread polls events each frame. use std::path::PathBuf; use std::sync::{mpsc, Mutex}; use std::thread; use tracing::{error, instrument}; use audiofiles_core::export::{ExportConfig, ExportItem, ExportSummary}; use audiofiles_core::store::SampleStore; /// Command sent from the GUI thread to the export worker. pub enum ExportCommand { /// Export the given items with the provided configuration. Export { items: Vec, config: ExportConfig, }, /// Cancel the current export. Cancel, /// Shut down the worker thread. Shutdown, } /// Event sent from the export worker back to the GUI thread. pub enum ExportEvent { /// Progress update for one file processed. Progress { completed: usize, total: usize, current_name: String, }, /// The export is complete. Complete { total: usize, errors: Vec<(String, String)>, }, } /// Handle for communicating with the background export worker. /// /// The receiver is wrapped in a `Mutex` so `BrowserState` remains `Sync` (required by nih-plug). /// Only the GUI thread actually calls `try_recv`, so contention is zero. pub struct ExportHandle { cmd_tx: mpsc::Sender, event_rx: Mutex>, _thread: Option>, } impl ExportHandle { /// Poll for the next event without blocking. pub fn try_recv(&self) -> Option { self.event_rx.lock().ok()?.try_recv().ok() } /// Send a command to the worker. pub fn send(&self, cmd: ExportCommand) { let _ = self.cmd_tx.send(cmd); } } impl Drop for ExportHandle { fn drop(&mut self) { let _ = self.cmd_tx.send(ExportCommand::Shutdown); if let Some(handle) = self._thread.take() { let _ = handle.join(); } } } /// Spawn the background export worker thread. /// /// The worker opens its own `SampleStore` to avoid Mutex contention with the GUI thread. #[instrument(skip_all)] pub fn spawn_export_worker(store_root: PathBuf) -> std::io::Result { let (cmd_tx, cmd_rx) = mpsc::channel::(); let (event_tx, event_rx) = mpsc::channel::(); let thread = thread::Builder::new() .name("export-worker".to_string()) .spawn(move || { worker_loop(cmd_rx, event_tx, &store_root); })?; Ok(ExportHandle { cmd_tx, event_rx: Mutex::new(event_rx), _thread: Some(thread), }) } #[instrument(skip_all)] fn worker_loop( cmd_rx: mpsc::Receiver, event_tx: mpsc::Sender, store_root: &std::path::Path, ) { let store = match SampleStore::new(store_root) { Ok(s) => s, Err(e) => { let _ = event_tx.send(ExportEvent::Complete { total: 0, errors: vec![("init".to_string(), e.to_string())], }); error!("Export worker failed to open store: {e}"); return; } }; while let Ok(cmd) = cmd_rx.recv() { match cmd { ExportCommand::Shutdown => break, ExportCommand::Cancel => continue, ExportCommand::Export { items, config } => { let cancelled = std::sync::atomic::AtomicBool::new(false); let summary = audiofiles_core::export::run_export( &items, &config, &store, |completed, total, current_name| { // Check for cancel between files if let Ok(ExportCommand::Cancel) | Ok(ExportCommand::Shutdown) = cmd_rx.try_recv() { cancelled.store(true, std::sync::atomic::Ordering::Relaxed); return false; } let _ = event_tx.send(ExportEvent::Progress { completed, total, current_name: current_name.to_string(), }); true }, ); match summary { Ok(ExportSummary { total, errors }) => { let _ = event_tx.send(ExportEvent::Complete { total, errors }); } Err(e) => { let _ = event_tx.send(ExportEvent::Complete { total: 0, errors: vec![("export".to_string(), e.to_string())], }); } } } } } } #[cfg(test)] mod tests { use super::*; #[test] fn export_command_variants_constructible() { let _export = ExportCommand::Export { items: vec![], config: ExportConfig { format: audiofiles_core::export::ExportFormat::Original, sample_rate: None, bit_depth: None, channels: audiofiles_core::export::ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: PathBuf::from("/tmp/export"), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }, }; let _cancel = ExportCommand::Cancel; let _shutdown = ExportCommand::Shutdown; } #[test] fn export_event_variants_constructible() { let _progress = ExportEvent::Progress { completed: 5, total: 10, current_name: "kick.wav".to_string(), }; let _complete = ExportEvent::Complete { total: 10, errors: vec![], }; } #[test] fn spawn_and_drop_does_not_hang() { let dir = tempfile::TempDir::new().unwrap(); let store_root = dir.path().join("store"); std::fs::create_dir_all(&store_root).unwrap(); let handle = spawn_export_worker(store_root).unwrap(); assert!(handle.try_recv().is_none()); drop(handle); // Should send Shutdown and join cleanly } }