Skip to main content

max / audiofiles

40.0 KB · 1091 lines History Blame Raw
1 //! Export pipeline: collect VFS items, optionally convert audio, write to filesystem.
2
3 pub mod convert;
4 pub mod decode;
5 mod dither;
6 pub mod encode;
7 pub mod encode_aiff;
8 pub mod profile;
9 mod resolve;
10 mod runner;
11 pub mod sanitize;
12
13 use std::path::PathBuf;
14
15 use crate::db::Database;
16 use crate::error::Result;
17 use crate::id_types::{NodeId, VfsId};
18
19 use self::profile::NamingRules;
20
21 // Re-export submodule public API so callers can use `export::run_export`, etc.
22 pub use self::resolve::resolve_output_names;
23 pub use self::runner::run_export;
24
25 /// Output format for exported files.
26 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27 pub enum ExportFormat {
28 /// Copy the original file as-is.
29 Original,
30 /// Decode and re-encode as WAV.
31 Wav,
32 /// Decode and re-encode as AIFF.
33 Aiff,
34 }
35
36 /// Target channel count for export.
37 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38 pub enum ExportChannels {
39 /// Keep original channel layout.
40 Original,
41 /// Mix down to mono.
42 Mono,
43 /// Upmix/downmix to stereo.
44 Stereo,
45 }
46
47 /// Configuration for an export operation.
48 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49 pub struct ExportConfig {
50 pub format: ExportFormat,
51 /// Target sample rate, or None to keep original.
52 pub sample_rate: Option<u32>,
53 /// Target bit depth (16 or 24), or None to keep original. Only used with WAV/AIFF format.
54 pub bit_depth: Option<u16>,
55 pub channels: ExportChannels,
56 /// Rename pattern for output filenames, or None to use original VFS names.
57 pub naming_pattern: Option<String>,
58 /// If true, flatten all files into destination root (no subdirectories).
59 pub flatten: bool,
60 /// If true, write a `.audiofiles.json` sidecar alongside each exported file.
61 pub metadata_sidecar: bool,
62 /// Destination directory on the real filesystem.
63 pub destination: PathBuf,
64 /// Device profile name to apply constraints from (e.g. "SP-404 MKII").
65 /// When set, the export pipeline applies the profile's audio, naming, and size constraints.
66 #[serde(default)]
67 pub device_profile: Option<String>,
68 /// Naming rules resolved from the device profile. Applied after rename pattern.
69 #[serde(default)]
70 pub naming_rules: Option<NamingRules>,
71 /// Maximum file size in bytes, resolved from the device profile.
72 /// Files exceeding this limit are removed and reported as errors.
73 #[serde(default)]
74 pub max_file_size_bytes: Option<u64>,
75 /// Pre-computed output filenames (one per item, in order).
76 /// When set, `resolve_output_names()` returns these directly, skipping
77 /// pattern/sanitize/dedup. Used by the backend to inject hook-transformed names.
78 #[serde(default)]
79 pub name_overrides: Option<Vec<String>>,
80 }
81
82 /// A single item to export, collected from the VFS tree.
83 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
84 pub struct ExportItem {
85 pub hash: crate::SampleHash,
86 pub ext: String,
87 /// Path relative to the export root (preserving VFS structure).
88 pub relative_path: PathBuf,
89 /// Display name (VFS node name).
90 pub name: String,
91 // Analysis fields for rename context
92 pub bpm: Option<f64>,
93 pub musical_key: Option<String>,
94 pub classification: Option<String>,
95 pub duration: Option<f64>,
96 /// Tags associated with this sample (populated by `enrich_with_tags`).
97 pub tags: Vec<String>,
98 /// Original file path for loose-files mode samples (populated from samples.source_path).
99 /// When set, the export runner uses this path instead of the store.
100 pub source_path: Option<PathBuf>,
101 }
102
103 /// Summary of a completed export.
104 #[derive(Debug)]
105 pub struct ExportSummary {
106 pub total: usize,
107 pub errors: Vec<(String, String)>,
108 }
109
110 /// Collect all sample nodes under a VFS subtree, building relative paths.
111 ///
112 /// If `parent_id` is `None`, collects all samples in the entire VFS.
113 /// If `parent_id` is `Some`, collects samples under that directory (recursive).
114 pub fn collect_export_items(
115 db: &Database,
116 vfs_id: VfsId,
117 parent_id: Option<NodeId>,
118 ) -> Result<Vec<ExportItem>> {
119 // Use a recursive CTE to walk the subtree and build relative paths.
120 // When parent_id is None, we start from root-level nodes.
121 let sql = if parent_id.is_some() {
122 "WITH RECURSIVE tree(id, path) AS (
123 SELECT n.id, n.name
124 FROM vfs_nodes n
125 WHERE n.vfs_id = ?1 AND n.parent_id = ?2
126 UNION ALL
127 SELECT n.id, t.path || '/' || n.name
128 FROM vfs_nodes n
129 JOIN tree t ON n.parent_id = t.id
130 )
131 SELECT n.sample_hash, s.file_extension, t.path, n.name,
132 a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration),
133 s.source_path
134 FROM tree t
135 JOIN vfs_nodes n ON n.id = t.id
136 LEFT JOIN samples s ON n.sample_hash = s.hash
137 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
138 WHERE n.node_type = 'sample' AND n.sample_hash IS NOT NULL AND s.deleted_at IS NULL
139 ORDER BY t.path"
140 } else {
141 "WITH RECURSIVE tree(id, path) AS (
142 SELECT n.id, n.name
143 FROM vfs_nodes n
144 WHERE n.vfs_id = ?1 AND n.parent_id IS NULL
145 UNION ALL
146 SELECT n.id, t.path || '/' || n.name
147 FROM vfs_nodes n
148 JOIN tree t ON n.parent_id = t.id
149 )
150 SELECT n.sample_hash, s.file_extension, t.path, n.name,
151 a.bpm, a.musical_key, a.classification, COALESCE(a.duration, s.duration),
152 s.source_path
153 FROM tree t
154 JOIN vfs_nodes n ON n.id = t.id
155 LEFT JOIN samples s ON n.sample_hash = s.hash
156 LEFT JOIN audio_analysis a ON n.sample_hash = a.hash
157 WHERE n.node_type = 'sample' AND n.sample_hash IS NOT NULL AND s.deleted_at IS NULL
158 ORDER BY t.path"
159 };
160
161 let mut stmt = db.conn().prepare(sql)?;
162
163 let rows = if let Some(pid) = parent_id {
164 stmt.query_map(rusqlite::params![vfs_id, pid], map_export_item)?
165 } else {
166 stmt.query_map(rusqlite::params![vfs_id], map_export_item)?
167 };
168
169 Ok(rows
170 .filter_map(|r| r.ok())
171 .flatten()
172 .collect())
173 }
174
175 fn map_export_item(row: &rusqlite::Row) -> rusqlite::Result<Option<ExportItem>> {
176 let hash: Option<String> = row.get(0)?;
177 let ext: Option<String> = row.get(1)?;
178 let path: String = row.get(2)?;
179 let name: String = row.get(3)?;
180
181 let (hash, ext) = match (hash, ext) {
182 (Some(h), Some(e)) => (h, e),
183 _ => return Ok(None),
184 };
185
186 let source_path: Option<String> = row.get(8)?;
187
188 Ok(Some(ExportItem {
189 hash: crate::SampleHash::new(hash),
190 ext,
191 relative_path: PathBuf::from(path),
192 name,
193 bpm: row.get(4)?,
194 musical_key: row.get(5)?,
195 classification: row.get(6)?,
196 duration: row.get(7)?,
197 tags: Vec::new(),
198 source_path: source_path.map(PathBuf::from),
199 }))
200 }
201
202 /// Populate the `tags` field on each export item by querying the database.
203 pub fn enrich_with_tags(db: &Database, items: &mut [ExportItem]) {
204 if items.is_empty() {
205 return;
206 }
207
208 // Batch query: fetch all tags for all hashes in one statement.
209 let hashes: Vec<String> = items.iter().map(|i| i.hash.to_string()).collect();
210 let mut tag_map = std::collections::HashMap::<String, Vec<String>>::new();
211
212 // SQLite variable limit is 999 in older builds; chunk to stay safe.
213 for chunk in hashes.chunks(500) {
214 let placeholders: String = chunk.iter().enumerate()
215 .map(|(i, _)| format!("?{}", i + 1))
216 .collect::<Vec<_>>()
217 .join(", ");
218 let sql = format!(
219 "SELECT sample_hash, tag FROM tags WHERE sample_hash IN ({}) ORDER BY tag",
220 placeholders,
221 );
222 if let Ok(mut stmt) = db.conn().prepare(&sql) {
223 let params: Vec<&dyn rusqlite::types::ToSql> = chunk
224 .iter()
225 .map(|h| h as &dyn rusqlite::types::ToSql)
226 .collect();
227 if let Ok(rows) = stmt.query_map(params.as_slice(), |row| {
228 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
229 }) {
230 for row in rows.flatten() {
231 tag_map.entry(row.0).or_default().push(row.1);
232 }
233 }
234 }
235 }
236
237 for item in items.iter_mut() {
238 if let Some(tags) = tag_map.remove(item.hash.as_str()) {
239 item.tags = tags;
240 }
241 }
242 }
243
244 #[cfg(test)]
245 mod tests {
246 use super::*;
247 use crate::db::Database;
248 use crate::store::SampleStore;
249 use crate::vfs;
250 use std::fs;
251 use std::io::Write;
252 use std::path::Path;
253
254 fn setup_vfs_with_samples(db: &Database, store: &SampleStore, dir: &Path) -> crate::VfsId {
255 let vfs_id = vfs::create_vfs(db, "TestVFS").unwrap();
256
257 // Create a real audio file in a temp location, then import it
258 let wav_path = dir.join("kick.wav");
259 write_test_wav(&wav_path, 1, 44100, &[0.5, -0.5, 0.25, 0.0]);
260 let hash = store.import(&wav_path, db).unwrap();
261
262 // Create directory structure in VFS
263 let drums_id = vfs::create_directory(db, vfs_id, None, "Drums").unwrap();
264 vfs::create_sample_link(db, vfs_id, Some(drums_id), "kick.wav", &hash).unwrap();
265
266 vfs_id
267 }
268
269 fn write_test_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) {
270 let bytes_per_sample = 4u16;
271 let block_align = channels * bytes_per_sample;
272 let data_size = (samples.len() as u32) * 4;
273 let file_size = 36 + data_size;
274
275 let mut buf = Vec::with_capacity(44 + data_size as usize);
276 buf.extend_from_slice(b"RIFF");
277 buf.extend_from_slice(&file_size.to_le_bytes());
278 buf.extend_from_slice(b"WAVE");
279 buf.extend_from_slice(b"fmt ");
280 buf.extend_from_slice(&16u32.to_le_bytes());
281 buf.extend_from_slice(&3u16.to_le_bytes());
282 buf.extend_from_slice(&channels.to_le_bytes());
283 buf.extend_from_slice(&sample_rate.to_le_bytes());
284 buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes());
285 buf.extend_from_slice(&block_align.to_le_bytes());
286 buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
287 buf.extend_from_slice(b"data");
288 buf.extend_from_slice(&data_size.to_le_bytes());
289 for &s in samples {
290 buf.extend_from_slice(&s.to_le_bytes());
291 }
292
293 let mut file = fs::File::create(path).unwrap();
294 file.write_all(&buf).unwrap();
295 }
296
297 #[test]
298 fn collect_export_items_builds_relative_paths() {
299 let dir = tempfile::tempdir().unwrap();
300 let db = Database::open_in_memory().unwrap();
301 let store = SampleStore::new(dir.path().join("store")).unwrap();
302 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
303
304 let items = collect_export_items(&db, vfs_id, None).unwrap();
305 assert_eq!(items.len(), 1);
306 assert_eq!(items[0].name, "kick.wav");
307 assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav"));
308 }
309
310 #[test]
311 fn export_single_original_copies_file() {
312 let dir = tempfile::tempdir().unwrap();
313 let db = Database::open_in_memory().unwrap();
314 let store = SampleStore::new(dir.path().join("store")).unwrap();
315 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
316
317 let items = collect_export_items(&db, vfs_id, None).unwrap();
318 let dest_dir = dir.path().join("export");
319
320 let config = ExportConfig {
321 format: ExportFormat::Original,
322 sample_rate: None,
323 bit_depth: None,
324 channels: ExportChannels::Original,
325 naming_pattern: None,
326 flatten: false,
327 metadata_sidecar: false,
328 destination: dest_dir.clone(),
329 device_profile: None,
330 naming_rules: None,
331 max_file_size_bytes: None,
332 name_overrides: None,
333 };
334
335 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
336 assert_eq!(summary.total, 1);
337 assert!(summary.errors.is_empty());
338
339 // Verify the file was copied with directory structure
340 let exported = dest_dir.join("Drums").join("kick.wav");
341 assert!(exported.exists());
342
343 // Verify content matches (hardlink or copy)
344 let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap();
345 let source_bytes = fs::read(&source_path).unwrap();
346 let export_bytes = fs::read(&exported).unwrap();
347 assert_eq!(source_bytes, export_bytes);
348 }
349
350 #[test]
351 fn export_single_wav_16bit() {
352 let dir = tempfile::tempdir().unwrap();
353 let db = Database::open_in_memory().unwrap();
354 let store = SampleStore::new(dir.path().join("store")).unwrap();
355 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
356
357 let items = collect_export_items(&db, vfs_id, None).unwrap();
358 let dest_dir = dir.path().join("export");
359
360 let config = ExportConfig {
361 format: ExportFormat::Wav,
362 sample_rate: Some(44100),
363 bit_depth: Some(16),
364 channels: ExportChannels::Original,
365 naming_pattern: None,
366 flatten: false,
367 metadata_sidecar: false,
368 destination: dest_dir.clone(),
369 device_profile: None,
370 naming_rules: None,
371 max_file_size_bytes: None,
372 name_overrides: None,
373 };
374
375 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
376 assert_eq!(summary.total, 1);
377 assert!(summary.errors.is_empty());
378
379 // Verify the WAV was created
380 let exported = dest_dir.join("Drums").join("kick.wav");
381 assert!(exported.exists());
382
383 // Verify it's a valid 16-bit WAV
384 let reader = hound::WavReader::open(&exported).unwrap();
385 assert_eq!(reader.spec().bits_per_sample, 16);
386 assert_eq!(reader.spec().sample_rate, 44100);
387 }
388
389 #[test]
390 fn export_single_wav_24bit() {
391 let dir = tempfile::tempdir().unwrap();
392 let db = Database::open_in_memory().unwrap();
393 let store = SampleStore::new(dir.path().join("store")).unwrap();
394 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
395
396 let items = collect_export_items(&db, vfs_id, None).unwrap();
397 let dest_dir = dir.path().join("export");
398
399 let config = ExportConfig {
400 format: ExportFormat::Wav,
401 sample_rate: None,
402 bit_depth: Some(24),
403 channels: ExportChannels::Mono,
404 naming_pattern: None,
405 flatten: false,
406 metadata_sidecar: false,
407 destination: dest_dir.clone(),
408 device_profile: None,
409 naming_rules: None,
410 max_file_size_bytes: None,
411 name_overrides: None,
412 };
413
414 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
415 assert!(summary.errors.is_empty());
416
417 let exported = dest_dir.join("Drums").join("kick.wav");
418 let reader = hound::WavReader::open(&exported).unwrap();
419 assert_eq!(reader.spec().bits_per_sample, 24);
420 assert_eq!(reader.spec().channels, 1);
421 }
422
423 #[test]
424 fn export_flat_with_pattern() {
425 let dir = tempfile::tempdir().unwrap();
426 let db = Database::open_in_memory().unwrap();
427 let store = SampleStore::new(dir.path().join("store")).unwrap();
428 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
429
430 let items = collect_export_items(&db, vfs_id, None).unwrap();
431 let dest_dir = dir.path().join("export_flat");
432
433 let config = ExportConfig {
434 format: ExportFormat::Original,
435 sample_rate: None,
436 bit_depth: None,
437 channels: ExportChannels::Original,
438 naming_pattern: Some("{nn}_{name}".to_string()),
439 flatten: true,
440 metadata_sidecar: false,
441 destination: dest_dir.clone(),
442 device_profile: None,
443 naming_rules: None,
444 max_file_size_bytes: None,
445 name_overrides: None,
446 };
447
448 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
449 assert!(summary.errors.is_empty());
450
451 // Should be flat (no Drums/ subdirectory)
452 let exported = dest_dir.join("01_kick.wav");
453 assert!(exported.exists(), "expected 01_kick.wav in flat export");
454 assert!(!dest_dir.join("Drums").exists());
455 }
456
457 #[test]
458 fn split_name_ext_works() {
459 use crate::util::split_name_ext;
460 assert_eq!(split_name_ext("kick.wav"), ("kick".into(), "wav".into()));
461 assert_eq!(split_name_ext("noext"), ("noext".into(), "".into()));
462 assert_eq!(
463 split_name_ext("archive.tar.gz"),
464 ("archive.tar".into(), "gz".into())
465 );
466 }
467
468 #[test]
469 fn export_with_sidecar_writes_json() {
470 let dir = tempfile::tempdir().unwrap();
471 let db = Database::open_in_memory().unwrap();
472 let store = SampleStore::new(dir.path().join("store")).unwrap();
473 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
474
475 let mut items = collect_export_items(&db, vfs_id, None).unwrap();
476 enrich_with_tags(&db, &mut items);
477 let dest_dir = dir.path().join("export_sidecar");
478
479 let config = ExportConfig {
480 format: ExportFormat::Original,
481 sample_rate: None,
482 bit_depth: None,
483 channels: ExportChannels::Original,
484 naming_pattern: None,
485 flatten: false,
486 metadata_sidecar: true,
487 destination: dest_dir.clone(),
488 device_profile: None,
489 naming_rules: None,
490 max_file_size_bytes: None,
491 name_overrides: None,
492 };
493
494 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
495 assert!(summary.errors.is_empty());
496
497 let sidecar = dest_dir.join("Drums").join("kick.wav.audiofiles.json");
498 assert!(sidecar.exists(), "sidecar file should exist");
499
500 let content: serde_json::Value =
501 serde_json::from_str(&fs::read_to_string(&sidecar).unwrap()).unwrap();
502 assert_eq!(content["name"], "kick.wav");
503 assert!(content["hash"].is_string());
504 }
505
506 #[test]
507 fn hardlink_or_copy_works() {
508 let dir = tempfile::tempdir().unwrap();
509 let db = Database::open_in_memory().unwrap();
510 let store = SampleStore::new(dir.path().join("store")).unwrap();
511 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
512
513 let items = collect_export_items(&db, vfs_id, None).unwrap();
514 let dest_dir = dir.path().join("export_hl");
515
516 let config = ExportConfig {
517 format: ExportFormat::Original,
518 sample_rate: None,
519 bit_depth: None,
520 channels: ExportChannels::Original,
521 naming_pattern: None,
522 flatten: false,
523 metadata_sidecar: false,
524 destination: dest_dir.clone(),
525 device_profile: None,
526 naming_rules: None,
527 max_file_size_bytes: None,
528 name_overrides: None,
529 };
530
531 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
532 assert!(summary.errors.is_empty());
533
534 let exported = dest_dir.join("Drums").join("kick.wav");
535 assert!(exported.exists());
536
537 let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap();
538 let source_bytes = fs::read(&source_path).unwrap();
539 let export_bytes = fs::read(&exported).unwrap();
540 assert_eq!(source_bytes, export_bytes);
541 }
542
543 #[test]
544 fn enrich_with_tags_populates() {
545 let dir = tempfile::tempdir().unwrap();
546 let db = Database::open_in_memory().unwrap();
547 let store = SampleStore::new(dir.path().join("store")).unwrap();
548 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
549
550 let mut items = collect_export_items(&db, vfs_id, None).unwrap();
551 assert!(items[0].tags.is_empty());
552
553 // Add tags
554 crate::tags::add_tag(&db, &items[0].hash, "kick").unwrap();
555 crate::tags::add_tag(&db, &items[0].hash, "drums").unwrap();
556
557 enrich_with_tags(&db, &mut items);
558 assert_eq!(items[0].tags.len(), 2);
559 assert!(items[0].tags.contains(&"drums".to_string()));
560 assert!(items[0].tags.contains(&"kick".to_string()));
561 }
562
563 #[test]
564 fn export_with_naming_rules_sanitizes() {
565 use crate::export::profile::{NamingCase, NamingRules};
566
567 let dir = tempfile::tempdir().unwrap();
568 let db = Database::open_in_memory().unwrap();
569 let store = SampleStore::new(dir.path().join("store")).unwrap();
570 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
571
572 let items = collect_export_items(&db, vfs_id, None).unwrap();
573 let dest_dir = dir.path().join("export_sanitize");
574
575 let config = ExportConfig {
576 format: ExportFormat::Original,
577 sample_rate: None,
578 bit_depth: None,
579 channels: ExportChannels::Original,
580 naming_pattern: None,
581 flatten: true,
582 metadata_sidecar: false,
583 destination: dest_dir.clone(),
584 device_profile: None,
585 naming_rules: Some(NamingRules {
586 case: NamingCase::Upper,
587 separator: '_',
588 max_length: 8,
589 strip_special: true,
590 }),
591 max_file_size_bytes: None,
592 name_overrides: None,
593 };
594
595 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
596 assert!(summary.errors.is_empty());
597
598 // "kick" uppercased -> "KICK", truncated to 8 (already short enough)
599 let exported = dest_dir.join("KICK.wav");
600 assert!(exported.exists(), "expected KICK.wav, got: {:?}",
601 fs::read_dir(&dest_dir).unwrap().map(|e| e.unwrap().file_name()).collect::<Vec<_>>());
602 }
603
604 #[test]
605 fn export_with_max_file_size_rejects_oversized() {
606 let dir = tempfile::tempdir().unwrap();
607 let db = Database::open_in_memory().unwrap();
608 let store = SampleStore::new(dir.path().join("store")).unwrap();
609 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
610
611 let items = collect_export_items(&db, vfs_id, None).unwrap();
612 let dest_dir = dir.path().join("export_size");
613
614 let config = ExportConfig {
615 format: ExportFormat::Original,
616 sample_rate: None,
617 bit_depth: None,
618 channels: ExportChannels::Original,
619 naming_pattern: None,
620 flatten: true,
621 metadata_sidecar: false,
622 destination: dest_dir.clone(),
623 device_profile: None,
624 naming_rules: None,
625 max_file_size_bytes: Some(1), // 1 byte -- everything will exceed
626 name_overrides: None,
627 };
628
629 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
630 assert_eq!(summary.errors.len(), 1);
631 assert!(summary.errors[0].1.contains("exceeds device file size limit"));
632
633 // Verify the file was cleaned up
634 let exported = dest_dir.join("kick.wav");
635 assert!(!exported.exists(), "oversized file should have been removed");
636 }
637
638 #[test]
639 fn export_naming_rules_dedup() {
640 use crate::export::profile::{NamingCase, NamingRules};
641
642 let dir = tempfile::tempdir().unwrap();
643 let db = Database::open_in_memory().unwrap();
644 let store = SampleStore::new(dir.path().join("store")).unwrap();
645 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
646
647 // Add a second sample that will collide after sanitization
648 let wav_path = dir.path().join("KICK.wav");
649 write_test_wav(&wav_path, 1, 44100, &[0.1, -0.1]);
650 let hash2 = store.import(&wav_path, &db).unwrap();
651 vfs::create_sample_link(&db, vfs_id, None, "KICK.wav", &hash2).unwrap();
652
653 let items = collect_export_items(&db, vfs_id, None).unwrap();
654 assert_eq!(items.len(), 2);
655
656 let dest_dir = dir.path().join("export_dedup");
657
658 let config = ExportConfig {
659 format: ExportFormat::Original,
660 sample_rate: None,
661 bit_depth: None,
662 channels: ExportChannels::Original,
663 naming_pattern: None,
664 flatten: true,
665 metadata_sidecar: false,
666 destination: dest_dir.clone(),
667 device_profile: None,
668 naming_rules: Some(NamingRules {
669 case: NamingCase::Upper,
670 separator: '_',
671 max_length: 64,
672 strip_special: true,
673 }),
674 max_file_size_bytes: None,
675 name_overrides: None,
676 };
677
678 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
679 assert!(summary.errors.is_empty());
680
681 // Both sanitize to "KICK" -> second gets "_2" suffix
682 let first = dest_dir.join("KICK.wav");
683 let second = dest_dir.join("KICK_2.wav");
684 assert!(first.exists(), "first file should be KICK.wav");
685 assert!(second.exists(), "deduped file should be KICK_2.wav");
686 }
687
688 #[test]
689 fn export_naming_rules_plus_pattern() {
690 use crate::export::profile::{NamingCase, NamingRules};
691
692 let dir = tempfile::tempdir().unwrap();
693 let db = Database::open_in_memory().unwrap();
694 let store = SampleStore::new(dir.path().join("store")).unwrap();
695 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
696
697 let items = collect_export_items(&db, vfs_id, None).unwrap();
698 let dest_dir = dir.path().join("export_pattern_rules");
699
700 // Pattern applies first (adds index prefix), then naming rules sanitize
701 let config = ExportConfig {
702 format: ExportFormat::Original,
703 sample_rate: None,
704 bit_depth: None,
705 channels: ExportChannels::Original,
706 naming_pattern: Some("{nn}_{name}".to_string()),
707 flatten: true,
708 metadata_sidecar: false,
709 destination: dest_dir.clone(),
710 device_profile: None,
711 naming_rules: Some(NamingRules {
712 case: NamingCase::Upper,
713 separator: '_',
714 max_length: 64,
715 strip_special: true,
716 }),
717 max_file_size_bytes: None,
718 name_overrides: None,
719 };
720
721 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
722 assert!(summary.errors.is_empty());
723
724 // Pattern produces "01_kick", rules uppercase to "01_KICK"
725 let exported = dest_dir.join("01_KICK.wav");
726 assert!(exported.exists(), "expected 01_KICK.wav, got: {:?}",
727 fs::read_dir(&dest_dir).unwrap().map(|e| e.unwrap().file_name()).collect::<Vec<_>>());
728 }
729
730 // ── resolve_output_names() direct tests ──────────────────────
731
732 fn make_item(name: &str, ext: &str) -> ExportItem {
733 ExportItem {
734 hash: crate::SampleHash::new("deadbeef"),
735 ext: ext.to_string(),
736 relative_path: PathBuf::from(name),
737 name: name.to_string(),
738 bpm: None,
739 musical_key: None,
740 classification: None,
741 duration: None,
742 tags: vec![],
743 source_path: None,
744 }
745 }
746
747 fn make_item_with_analysis(name: &str, ext: &str, bpm: f64, key: &str) -> ExportItem {
748 ExportItem {
749 hash: crate::SampleHash::new("deadbeef"),
750 ext: ext.to_string(),
751 relative_path: PathBuf::from(name),
752 name: name.to_string(),
753 bpm: Some(bpm),
754 musical_key: Some(key.to_string()),
755 classification: Some("drums".to_string()),
756 duration: Some(1.5),
757 tags: vec![],
758 source_path: None,
759 }
760 }
761
762 fn base_config(dest: PathBuf) -> ExportConfig {
763 ExportConfig {
764 format: ExportFormat::Original,
765 sample_rate: None,
766 bit_depth: None,
767 channels: ExportChannels::Original,
768 naming_pattern: None,
769 flatten: true,
770 metadata_sidecar: false,
771 destination: dest,
772 device_profile: None,
773 naming_rules: None,
774 max_file_size_bytes: None,
775 name_overrides: None,
776 }
777 }
778
779 #[test]
780 fn resolve_no_pattern_keeps_original_names() {
781 let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")];
782 let config = base_config(PathBuf::from("/tmp"));
783 let names = resolve_output_names(&items, &config, None);
784 assert_eq!(names, vec!["kick.wav", "snare.wav"]);
785 }
786
787 #[test]
788 fn resolve_wav_format_changes_extension() {
789 let items = vec![make_item("kick.mp3", "mp3"), make_item("snare.flac", "flac")];
790 let mut config = base_config(PathBuf::from("/tmp"));
791 config.format = ExportFormat::Wav;
792 let names = resolve_output_names(&items, &config, None);
793 assert_eq!(names, vec!["kick.wav", "snare.wav"]);
794 }
795
796 #[test]
797 fn resolve_aiff_format_changes_extension() {
798 let items = vec![make_item("kick.wav", "wav")];
799 let mut config = base_config(PathBuf::from("/tmp"));
800 config.format = ExportFormat::Aiff;
801 let names = resolve_output_names(&items, &config, None);
802 assert_eq!(names, vec!["kick.aiff"]);
803 }
804
805 #[test]
806 fn resolve_with_pattern() {
807 use crate::rename::RenamePattern;
808
809 let items = vec![
810 make_item_with_analysis("kick.wav", "wav", 120.0, "C"),
811 make_item_with_analysis("snare.wav", "wav", 120.0, "D"),
812 ];
813 let config = base_config(PathBuf::from("/tmp"));
814 let pat = RenamePattern::parse("{nn}_{name}").unwrap();
815 let names = resolve_output_names(&items, &config, Some(&pat));
816 assert_eq!(names, vec!["01_kick.wav", "02_snare.wav"]);
817 }
818
819 #[test]
820 fn resolve_with_pattern_and_format_change() {
821 use crate::rename::RenamePattern;
822
823 let items = vec![make_item("kick.mp3", "mp3")];
824 let mut config = base_config(PathBuf::from("/tmp"));
825 config.format = ExportFormat::Wav;
826 let pat = RenamePattern::parse("{name}").unwrap();
827 let names = resolve_output_names(&items, &config, Some(&pat));
828 assert_eq!(names, vec!["kick.wav"]);
829 }
830
831 #[test]
832 fn resolve_name_overrides_bypass_pattern() {
833 use crate::rename::RenamePattern;
834
835 let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")];
836 let mut config = base_config(PathBuf::from("/tmp"));
837 config.name_overrides = Some(vec!["custom1.wav".to_string(), "custom2.wav".to_string()]);
838 let pat = RenamePattern::parse("{nn}_{name}").unwrap();
839 // Overrides take precedence over pattern
840 let names = resolve_output_names(&items, &config, Some(&pat));
841 assert_eq!(names, vec!["custom1.wav", "custom2.wav"]);
842 }
843
844 #[test]
845 fn resolve_name_overrides_wrong_length_ignored() {
846 let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")];
847 let mut config = base_config(PathBuf::from("/tmp"));
848 // Wrong length (1 override for 2 items) — should fall through to normal resolution
849 config.name_overrides = Some(vec!["only_one.wav".to_string()]);
850 let names = resolve_output_names(&items, &config, None);
851 assert_eq!(names, vec!["kick.wav", "snare.wav"]);
852 }
853
854 #[test]
855 fn resolve_empty_items() {
856 let items: Vec<ExportItem> = vec![];
857 let config = base_config(PathBuf::from("/tmp"));
858 let names = resolve_output_names(&items, &config, None);
859 assert!(names.is_empty());
860 }
861
862 #[test]
863 fn resolve_dedup_after_sanitization() {
864 use crate::export::profile::{NamingCase, NamingRules};
865
866 // Two items that sanitize to the same name
867 let items = vec![make_item("Kick!.wav", "wav"), make_item("kick.wav", "wav")];
868 let mut config = base_config(PathBuf::from("/tmp"));
869 config.naming_rules = Some(NamingRules {
870 case: NamingCase::Upper,
871 separator: '_',
872 max_length: 64,
873 strip_special: true,
874 });
875 let names = resolve_output_names(&items, &config, None);
876 assert_eq!(names, vec!["KICK.wav", "KICK_2.wav"]);
877 }
878
879 // ── run_export() additional coverage ─────────────────────────
880
881 #[test]
882 fn export_aiff_format() {
883 let dir = tempfile::tempdir().unwrap();
884 let db = Database::open_in_memory().unwrap();
885 let store = SampleStore::new(dir.path().join("store")).unwrap();
886 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
887
888 let items = collect_export_items(&db, vfs_id, None).unwrap();
889 let dest_dir = dir.path().join("export_aiff");
890
891 let config = ExportConfig {
892 format: ExportFormat::Aiff,
893 sample_rate: None,
894 bit_depth: Some(16),
895 channels: ExportChannels::Original,
896 naming_pattern: None,
897 flatten: true,
898 metadata_sidecar: false,
899 destination: dest_dir.clone(),
900 device_profile: None,
901 naming_rules: None,
902 max_file_size_bytes: None,
903 name_overrides: None,
904 };
905
906 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
907 assert_eq!(summary.total, 1);
908 assert!(summary.errors.is_empty());
909
910 let exported = dest_dir.join("kick.aiff");
911 assert!(exported.exists());
912 // Verify it's a valid AIFF (starts with FORM...AIFF)
913 let bytes = fs::read(&exported).unwrap();
914 assert_eq!(&bytes[0..4], b"FORM");
915 assert_eq!(&bytes[8..12], b"AIFF");
916 }
917
918 #[test]
919 fn export_progress_callback_cancel() {
920 let dir = tempfile::tempdir().unwrap();
921 let db = Database::open_in_memory().unwrap();
922 let store = SampleStore::new(dir.path().join("store")).unwrap();
923 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
924
925 // Add a second sample
926 let wav2 = dir.path().join("snare.wav");
927 write_test_wav(&wav2, 1, 44100, &[0.1, -0.1]);
928 let hash2 = store.import(&wav2, &db).unwrap();
929 vfs::create_sample_link(&db, vfs_id, None, "snare.wav", &hash2).unwrap();
930
931 let items = collect_export_items(&db, vfs_id, None).unwrap();
932 assert!(items.len() >= 2);
933
934 let dest_dir = dir.path().join("export_cancel");
935
936 let config = ExportConfig {
937 format: ExportFormat::Original,
938 sample_rate: None,
939 bit_depth: None,
940 channels: ExportChannels::Original,
941 naming_pattern: None,
942 flatten: true,
943 metadata_sidecar: false,
944 destination: dest_dir.clone(),
945 device_profile: None,
946 naming_rules: None,
947 max_file_size_bytes: None,
948 name_overrides: None,
949 };
950
951 // Cancel after the first item
952 let summary = run_export(&items, &config, &store, |completed, _, _| completed < 1).unwrap();
953 // Total reflects all items, but only 1 should have been processed
954 assert_eq!(summary.total, items.len());
955
956 // At most 1 file should exist (the one processed before cancel)
957 let exported_count = fs::read_dir(&dest_dir)
958 .map(|d| d.count())
959 .unwrap_or(0);
960 assert!(exported_count <= 1);
961 }
962
963 #[test]
964 fn export_missing_source_records_error() {
965 let dir = tempfile::tempdir().unwrap();
966 let store = SampleStore::new(dir.path().join("store")).unwrap();
967 let dest_dir = dir.path().join("export_missing");
968
969 // Create an item with a hash that doesn't exist in the store
970 let items = vec![ExportItem {
971 hash: crate::SampleHash::new("nonexistent_hash_value"),
972 ext: "wav".to_string(),
973 relative_path: PathBuf::from("missing.wav"),
974 name: "missing.wav".to_string(),
975 bpm: None,
976 musical_key: None,
977 classification: None,
978 duration: None,
979 tags: vec![],
980 source_path: None,
981 }];
982
983 let config = ExportConfig {
984 format: ExportFormat::Original,
985 sample_rate: None,
986 bit_depth: None,
987 channels: ExportChannels::Original,
988 naming_pattern: None,
989 flatten: true,
990 metadata_sidecar: false,
991 destination: dest_dir,
992 device_profile: None,
993 naming_rules: None,
994 max_file_size_bytes: None,
995 name_overrides: None,
996 };
997
998 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
999 assert_eq!(summary.total, 1);
1000 assert_eq!(summary.errors.len(), 1);
1001 assert_eq!(summary.errors[0].0, "missing.wav");
1002 }
1003
1004 #[test]
1005 fn export_sidecar_contains_all_fields() {
1006 let dir = tempfile::tempdir().unwrap();
1007 let db = Database::open_in_memory().unwrap();
1008 let store = SampleStore::new(dir.path().join("store")).unwrap();
1009 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
1010
1011 let mut items = collect_export_items(&db, vfs_id, None).unwrap();
1012 // Add analysis metadata to the item
1013 items[0].bpm = Some(120.0);
1014 items[0].musical_key = Some("Am".to_string());
1015 items[0].classification = Some("drums".to_string());
1016 items[0].duration = Some(0.5);
1017 items[0].tags = vec!["one-shot".to_string(), "kick".to_string()];
1018
1019 let dest_dir = dir.path().join("export_sidecar_fields");
1020
1021 let config = ExportConfig {
1022 format: ExportFormat::Original,
1023 sample_rate: None,
1024 bit_depth: None,
1025 channels: ExportChannels::Original,
1026 naming_pattern: None,
1027 flatten: true,
1028 metadata_sidecar: true,
1029 destination: dest_dir.clone(),
1030 device_profile: None,
1031 naming_rules: None,
1032 max_file_size_bytes: None,
1033 name_overrides: None,
1034 };
1035
1036 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
1037 assert!(summary.errors.is_empty());
1038
1039 let sidecar_path = dest_dir.join("kick.wav.audiofiles.json");
1040 assert!(sidecar_path.exists());
1041
1042 let contents = fs::read_to_string(&sidecar_path).unwrap();
1043 let json: serde_json::Value = serde_json::from_str(&contents).unwrap();
1044 assert_eq!(json["bpm"], 120.0);
1045 assert_eq!(json["key"], "Am");
1046 assert_eq!(json["classification"], "drums");
1047 assert_eq!(json["duration"], 0.5);
1048 assert_eq!(json["tags"], serde_json::json!(["one-shot", "kick"]));
1049 assert!(json["hash"].is_string());
1050 assert_eq!(json["name"], "kick.wav");
1051 }
1052
1053 #[test]
1054 fn export_preserves_directory_structure() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let db = Database::open_in_memory().unwrap();
1057 let store = SampleStore::new(dir.path().join("store")).unwrap();
1058 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
1059
1060 let items = collect_export_items(&db, vfs_id, None).unwrap();
1061 // Verify relative_path includes directory
1062 assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav"));
1063
1064 let dest_dir = dir.path().join("export_dirs");
1065
1066 let config = ExportConfig {
1067 format: ExportFormat::Original,
1068 sample_rate: None,
1069 bit_depth: None,
1070 channels: ExportChannels::Original,
1071 naming_pattern: None,
1072 flatten: false, // preserve structure
1073 metadata_sidecar: false,
1074 destination: dest_dir.clone(),
1075 device_profile: None,
1076 naming_rules: None,
1077 max_file_size_bytes: None,
1078 name_overrides: None,
1079 };
1080
1081 let summary = run_export(&items, &config, &store, |_, _, _| true).unwrap();
1082 assert!(summary.errors.is_empty());
1083
1084 // Should be in Drums/ subdirectory
1085 let exported = dest_dir.join("Drums").join("kick.wav");
1086 assert!(exported.exists());
1087 // Flat version should NOT exist
1088 assert!(!dest_dir.join("kick.wav").exists());
1089 }
1090 }
1091