Skip to main content

max / audiofiles

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