Skip to main content

max / audiofiles

7.5 KB · 218 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 if let Err(e) = write_sidecar(&dest, item) {
168 errors.push((item.name.clone(), format!("sidecar: {e}")));
169 }
170 }
171 }
172
173 Ok(ExportSummary { total, errors })
174 }
175
176 /// Export a single item: either copy original or decode+convert+encode.
177 fn export_single_item(
178 source: &Path,
179 dest: &Path,
180 _item: &ExportItem,
181 config: &ExportConfig,
182 ) -> Result<()> {
183 match config.format {
184 ExportFormat::Original => {
185 // Always copy — hardlinks would let external edits mutate the
186 // content-addressed store, corrupting the archive.
187 fs::copy(source, dest).map_err(|e| io_err(dest, e))?;
188 Ok(())
189 }
190 ExportFormat::Wav => {
191 let decoded = decode::decode_multichannel(source)?;
192 let converted = convert::apply_conversion(
193 &decoded.samples,
194 decoded.channels,
195 decoded.sample_rate,
196 &config.channels,
197 config.sample_rate,
198 )?;
199 let bit_depth = config.bit_depth.unwrap_or(24);
200 encode::encode_wav(&converted, bit_depth, dest)?;
201 Ok(())
202 }
203 ExportFormat::Aiff => {
204 let decoded = decode::decode_multichannel(source)?;
205 let converted = convert::apply_conversion(
206 &decoded.samples,
207 decoded.channels,
208 decoded.sample_rate,
209 &config.channels,
210 config.sample_rate,
211 )?;
212 let bit_depth = config.bit_depth.unwrap_or(24);
213 encode_aiff::encode_aiff(&converted, bit_depth, dest)?;
214 Ok(())
215 }
216 }
217 }
218