//! Export execution: run the export pipeline, convert files, write sidecars. use std::fs; use std::path::{Path, PathBuf}; use crate::error::{io_err, Result}; use crate::rename::RenamePattern; use crate::store::SampleStore; use super::resolve::resolve_output_names; use super::{convert, decode, encode, encode_aiff}; use super::{ExportConfig, ExportFormat, ExportItem, ExportSummary}; use tracing::instrument; /// If `dest` already exists on disk, return `dest` with a `_1`, `_2`, ... /// suffix inserted before the extension until a free path is found. Prevents /// silent overwrite of existing files in the user-chosen export directory /// (e.g. re-exporting into a folder that contains older masters of the same /// name). fn resolve_collision(dest: &Path) -> PathBuf { if !dest.exists() { return dest.to_path_buf(); } let parent = dest.parent().unwrap_or_else(|| Path::new("")); let stem = dest.file_stem().and_then(|s| s.to_str()).unwrap_or("file"); let ext = dest.extension().and_then(|s| s.to_str()); for n in 1..=9999 { let candidate_name = match ext { Some(e) => format!("{stem}_{n}.{e}"), None => format!("{stem}_{n}"), }; let candidate = parent.join(candidate_name); if !candidate.exists() { return candidate; } } // 9999 collisions in one export dir is absurd; just return the last // candidate and let the copy fail / overwrite (extremely unreachable). parent.join(format!("{stem}_overflow")) } /// Write a metadata sidecar JSON file alongside an exported sample. fn write_sidecar(dest: &Path, item: &ExportItem) -> Result<()> { let sidecar_path = PathBuf::from(format!("{}.audiofiles.json", dest.display())); let json = serde_json::json!({ "hash": item.hash, "name": item.name, "bpm": item.bpm, "key": item.musical_key, "classification": item.classification, "duration": item.duration, "tags": item.tags, }); let contents = serde_json::to_string_pretty(&json) .map_err(|e| io_err(&sidecar_path, std::io::Error::other(e)))?; fs::write(&sidecar_path, contents).map_err(|e| io_err(&sidecar_path, e))?; Ok(()) } /// Run the export pipeline: for each item, resolve source, optionally convert, write. /// /// `progress_callback` is called with `(completed, total, current_name)`. /// Return `false` from the callback to cancel. #[instrument(skip_all)] pub fn run_export( items: &[ExportItem], config: &ExportConfig, store: &SampleStore, mut progress_callback: impl FnMut(usize, usize, &str) -> bool, ) -> Result { let total = items.len(); let mut errors: Vec<(String, String)> = Vec::new(); // Parse rename pattern once if provided let pattern = config .naming_pattern .as_ref() .map(|p| RenamePattern::parse(p)) .transpose()?; // Resolve output names let output_names = resolve_output_names(items, config, pattern.as_ref()); for (i, item) in items.iter().enumerate() { if !progress_callback(i, total, &item.name) { break; // cancelled } let source = if let Some(sp) = &item.source_path { if sp.exists() { sp.clone() } else { // Fallback to store path for loose-files samples whose source moved match store.sample_path(&item.hash, &item.ext) { Ok(p) => p, Err(e) => { errors.push((item.name.clone(), e.to_string())); continue; } } } } else { match store.sample_path(&item.hash, &item.ext) { Ok(p) => p, Err(e) => { errors.push((item.name.clone(), e.to_string())); continue; } } }; if !source.exists() { errors.push((item.name.clone(), "source file not found".to_string())); continue; } let output_name = &output_names[i]; let dest = if config.flatten { config.destination.join(output_name) } else { // Preserve directory structure: replace the filename part of relative_path let parent = item.relative_path.parent(); match parent { Some(p) if p != Path::new("") => { let dir = config.destination.join(p); dir.join(output_name) } _ => config.destination.join(output_name), } }; // Ensure parent directory exists if let Some(parent) = dest.parent() { fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?; } // Avoid silently overwriting existing files in the user's export dir. let dest = resolve_collision(&dest); if let Err(e) = export_single_item(&source, &dest, item, config) { errors.push((item.name.clone(), e.to_string())); continue; } // Check file size limit from device profile if let Some(limit) = config.max_file_size_bytes { match fs::metadata(&dest) { Ok(meta) => { let actual = meta.len(); if actual > limit { let _ = fs::remove_file(&dest); errors.push(( item.name.clone(), format!("exceeds device file size limit ({actual} bytes > {limit} bytes)"), )); continue; } } Err(e) => { errors.push((item.name.clone(), format!("size check: {e}"))); continue; } } } if config.metadata_sidecar && let Err(e) = write_sidecar(&dest, item) { errors.push((item.name.clone(), format!("sidecar: {e}"))); } } Ok(ExportSummary { total, errors }) } /// Returns `dest` with `.audiofiles_tmp` appended. Two exports racing on the /// same dest would both pick the same tmp path, but `resolve_collision` already /// rules that out by the time we get here — each call has a unique dest. fn tmp_path_for(dest: &Path) -> PathBuf { let mut s = dest.as_os_str().to_owned(); s.push(".audiofiles_tmp"); PathBuf::from(s) } /// Run `write` against a temporary path, then `fs::rename` into `dest`. On any /// failure, remove the tmp file so a killed export leaves no partial files in /// the user's chosen export directory. fn write_atomic(dest: &Path, write: impl FnOnce(&Path) -> Result<()>) -> Result<()> { let tmp = tmp_path_for(dest); if let Err(e) = write(&tmp) { let _ = fs::remove_file(&tmp); return Err(e); } if let Err(e) = fs::rename(&tmp, dest) { let _ = fs::remove_file(&tmp); return Err(io_err(dest, e)); } Ok(()) } /// Export a single item: either copy original or decode+convert+encode. fn export_single_item( source: &Path, dest: &Path, _item: &ExportItem, config: &ExportConfig, ) -> Result<()> { match config.format { ExportFormat::Original => { // Always copy — hardlinks would let external edits mutate the // content-addressed store, corrupting the archive. write_atomic(dest, |tmp| { fs::copy(source, tmp).map_err(|e| io_err(tmp, e))?; Ok(()) }) } ExportFormat::Wav => { let decoded = decode::decode_multichannel(source)?; let converted = convert::apply_conversion( &decoded.samples, decoded.channels, decoded.sample_rate, &config.channels, config.sample_rate, )?; let bit_depth = config.bit_depth.unwrap_or(24); write_atomic(dest, |tmp| encode::encode_wav(&converted, bit_depth, tmp)) } ExportFormat::Aiff => { let decoded = decode::decode_multichannel(source)?; let converted = convert::apply_conversion( &decoded.samples, decoded.channels, decoded.sample_rate, &config.channels, config.sample_rate, )?; let bit_depth = config.bit_depth.unwrap_or(24); write_atomic(dest, |tmp| encode_aiff::encode_aiff(&converted, bit_depth, tmp)) } } }