Skip to main content

max / audiofiles

8.5 KB · 242 lines History Blame Raw
1 //! Export execution: run the export pipeline, convert files, write sidecars.
2
3 use std::fs;
4 use std::path::{Path, PathBuf};
5
6 use crate::error::{io_err, Result};
7 use crate::rename::RenamePattern;
8 use crate::store::SampleStore;
9
10 use super::resolve::resolve_output_names;
11 use super::{convert, decode, encode, encode_aiff};
12 use super::{ExportConfig, ExportFormat, ExportItem, ExportSummary};
13 use tracing::instrument;
14
15 /// If `dest` already exists on disk, return `dest` with a `_1`, `_2`, ...
16 /// suffix inserted before the extension until a free path is found. Prevents
17 /// silent overwrite of existing files in the user-chosen export directory
18 /// (e.g. re-exporting into a folder that contains older masters of the same
19 /// name).
20 fn resolve_collision(dest: &Path) -> PathBuf {
21 if !dest.exists() {
22 return dest.to_path_buf();
23 }
24 let parent = dest.parent().unwrap_or_else(|| Path::new(""));
25 let stem = dest.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
26 let ext = dest.extension().and_then(|s| s.to_str());
27 for n in 1..=9999 {
28 let candidate_name = match ext {
29 Some(e) => format!("{stem}_{n}.{e}"),
30 None => format!("{stem}_{n}"),
31 };
32 let candidate = parent.join(candidate_name);
33 if !candidate.exists() {
34 return candidate;
35 }
36 }
37 // 9999 collisions in one export dir is absurd; just return the last
38 // candidate and let the copy fail / overwrite (extremely unreachable).
39 parent.join(format!("{stem}_overflow"))
40 }
41
42 /// Write a metadata sidecar JSON file alongside an exported sample.
43 fn write_sidecar(dest: &Path, item: &ExportItem) -> Result<()> {
44 let sidecar_path = PathBuf::from(format!("{}.audiofiles.json", dest.display()));
45 let json = serde_json::json!({
46 "hash": item.hash,
47 "name": item.name,
48 "bpm": item.bpm,
49 "key": item.musical_key,
50 "classification": item.classification,
51 "duration": item.duration,
52 "tags": item.tags,
53 });
54 let contents = serde_json::to_string_pretty(&json)
55 .map_err(|e| io_err(&sidecar_path, std::io::Error::other(e)))?;
56 fs::write(&sidecar_path, contents).map_err(|e| io_err(&sidecar_path, e))?;
57 Ok(())
58 }
59
60 /// Run the export pipeline: for each item, resolve source, optionally convert, write.
61 ///
62 /// `progress_callback` is called with `(completed, total, current_name)`.
63 /// Return `false` from the callback to cancel.
64 #[instrument(skip_all)]
65 pub fn run_export(
66 items: &[ExportItem],
67 config: &ExportConfig,
68 store: &SampleStore,
69 mut progress_callback: impl FnMut(usize, usize, &str) -> bool,
70 ) -> Result<ExportSummary> {
71 let total = items.len();
72 let mut errors: Vec<(String, String)> = Vec::new();
73
74 // Parse rename pattern once if provided
75 let pattern = config
76 .naming_pattern
77 .as_ref()
78 .map(|p| RenamePattern::parse(p))
79 .transpose()?;
80
81 // Resolve output names
82 let output_names = resolve_output_names(items, config, pattern.as_ref());
83
84 for (i, item) in items.iter().enumerate() {
85 if !progress_callback(i, total, &item.name) {
86 break; // cancelled
87 }
88
89 let source = if let Some(sp) = &item.source_path {
90 if sp.exists() {
91 sp.clone()
92 } else {
93 // Fallback to store path for loose-files samples whose source moved
94 match store.sample_path(&item.hash, &item.ext) {
95 Ok(p) => p,
96 Err(e) => {
97 errors.push((item.name.clone(), e.to_string()));
98 continue;
99 }
100 }
101 }
102 } else {
103 match store.sample_path(&item.hash, &item.ext) {
104 Ok(p) => p,
105 Err(e) => {
106 errors.push((item.name.clone(), e.to_string()));
107 continue;
108 }
109 }
110 };
111
112 if !source.exists() {
113 errors.push((item.name.clone(), "source file not found".to_string()));
114 continue;
115 }
116
117 let output_name = &output_names[i];
118 let dest = if config.flatten {
119 config.destination.join(output_name)
120 } else {
121 // Preserve directory structure: replace the filename part of relative_path
122 let parent = item.relative_path.parent();
123 match parent {
124 Some(p) if p != Path::new("") => {
125 let dir = config.destination.join(p);
126 dir.join(output_name)
127 }
128 _ => config.destination.join(output_name),
129 }
130 };
131
132 // Ensure parent directory exists
133 if let Some(parent) = dest.parent() {
134 fs::create_dir_all(parent).map_err(|e| io_err(parent, e))?;
135 }
136
137 // Avoid silently overwriting existing files in the user's export dir.
138 let dest = resolve_collision(&dest);
139
140 if let Err(e) = export_single_item(&source, &dest, item, config) {
141 errors.push((item.name.clone(), e.to_string()));
142 continue;
143 }
144
145 // Check file size limit from device profile
146 if let Some(limit) = config.max_file_size_bytes {
147 match fs::metadata(&dest) {
148 Ok(meta) => {
149 let actual = meta.len();
150 if actual > limit {
151 let _ = fs::remove_file(&dest);
152 errors.push((
153 item.name.clone(),
154 format!("exceeds device file size limit ({actual} bytes > {limit} bytes)"),
155 ));
156 continue;
157 }
158 }
159 Err(e) => {
160 errors.push((item.name.clone(), format!("size check: {e}")));
161 continue;
162 }
163 }
164 }
165
166 if config.metadata_sidecar
167 && let Err(e) = write_sidecar(&dest, item) {
168 errors.push((item.name.clone(), format!("sidecar: {e}")));
169 }
170 }
171
172 Ok(ExportSummary { total, errors })
173 }
174
175 /// Returns `dest` with `.audiofiles_tmp` appended. Two exports racing on the
176 /// same dest would both pick the same tmp path, but `resolve_collision` already
177 /// rules that out by the time we get here — each call has a unique dest.
178 fn tmp_path_for(dest: &Path) -> PathBuf {
179 let mut s = dest.as_os_str().to_owned();
180 s.push(".audiofiles_tmp");
181 PathBuf::from(s)
182 }
183
184 /// Run `write` against a temporary path, then `fs::rename` into `dest`. On any
185 /// failure, remove the tmp file so a killed export leaves no partial files in
186 /// the user's chosen export directory.
187 fn write_atomic(dest: &Path, write: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
188 let tmp = tmp_path_for(dest);
189 if let Err(e) = write(&tmp) {
190 let _ = fs::remove_file(&tmp);
191 return Err(e);
192 }
193 if let Err(e) = fs::rename(&tmp, dest) {
194 let _ = fs::remove_file(&tmp);
195 return Err(io_err(dest, e));
196 }
197 Ok(())
198 }
199
200 /// Export a single item: either copy original or decode+convert+encode.
201 fn export_single_item(
202 source: &Path,
203 dest: &Path,
204 _item: &ExportItem,
205 config: &ExportConfig,
206 ) -> Result<()> {
207 match config.format {
208 ExportFormat::Original => {
209 // Always copy — hardlinks would let external edits mutate the
210 // content-addressed store, corrupting the archive.
211 write_atomic(dest, |tmp| {
212 fs::copy(source, tmp).map_err(|e| io_err(tmp, e))?;
213 Ok(())
214 })
215 }
216 ExportFormat::Wav => {
217 let decoded = decode::decode_multichannel(source)?;
218 let converted = convert::apply_conversion(
219 &decoded.samples,
220 decoded.channels,
221 decoded.sample_rate,
222 &config.channels,
223 config.sample_rate,
224 )?;
225 let bit_depth = config.bit_depth.unwrap_or(24);
226 write_atomic(dest, |tmp| encode::encode_wav(&converted, bit_depth, tmp))
227 }
228 ExportFormat::Aiff => {
229 let decoded = decode::decode_multichannel(source)?;
230 let converted = convert::apply_conversion(
231 &decoded.samples,
232 decoded.channels,
233 decoded.sample_rate,
234 &config.channels,
235 config.sample_rate,
236 )?;
237 let bit_depth = config.bit_depth.unwrap_or(24);
238 write_atomic(dest, |tmp| encode_aiff::encode_aiff(&converted, bit_depth, tmp))
239 }
240 }
241 }
242