1use crate::history::PresetFile;
12use crate::scanner_skip_dirs::SCANNER_SKIP_DIRS as SKIP_DIRS;
13use crate::unified_walker::IncrementalDirState;
14use rayon::prelude::*;
15use dashmap::DashSet;
16use std::collections::HashSet;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
20use std::sync::{Arc, Mutex};
21
22fn normalize_macos_path(p: PathBuf) -> PathBuf {
23 #[cfg(target_os = "macos")]
24 {
25 let s = p.to_string_lossy();
26 if s.starts_with("/System/Volumes/Data/") {
27 return PathBuf::from(&s["/System/Volumes/Data".len()..]);
28 }
29 }
30 p
31}
32
33pub(crate) const PRESET_EXTENSIONS: &[&str] = &[
34 ".fxp", ".fxb", ".vstpreset", ".aupreset", ".adv", ".adg", ".nki", ".nksn", ".h2p", ".syx", ".tfx", ".pjunoxl", ];
50
51fn format_size(bytes: u64) -> String {
52 crate::format_size(bytes)
53}
54
55#[inline]
57pub(crate) fn is_preset_extension_lowercase(ext: &str) -> bool {
58 PRESET_EXTENSIONS
59 .iter()
60 .any(|e| e.strip_prefix('.') == Some(ext))
61}
62
63pub fn get_preset_roots() -> Vec<PathBuf> {
64 let home = dirs::home_dir().unwrap_or_default();
65 let mut roots = Vec::new();
66
67 if !home.as_os_str().is_empty() {
68 roots.push(home.clone());
69 }
70
71 #[cfg(target_os = "macos")]
72 {
73 roots.push(PathBuf::from("/Library/Audio/Presets"));
74 }
75
76 #[cfg(target_os = "windows")]
77 {
78 if let Ok(pf) = std::env::var("ProgramFiles") {
79 roots.push(PathBuf::from(&pf).join("Common Files").join("VST3 Presets"));
80 }
81 }
82
83 roots.sort();
84 roots.dedup();
85 roots.into_iter().filter(|r| r.exists()).collect()
86}
87
88pub fn walk_for_presets(
89 roots: &[PathBuf],
90 on_batch: &mut dyn FnMut(&[PresetFile], usize),
91 should_stop: &(dyn Fn() -> bool + Sync),
92 exclude: Option<HashSet<String>>,
93 active_dirs: Option<Arc<Mutex<Vec<String>>>>,
94 incremental: Option<Arc<IncrementalDirState>>,
95) {
96 let batch_size = 100;
97 let stop = Arc::new(AtomicBool::new(false));
98 let found = Arc::new(AtomicUsize::new(0));
99 let active = active_dirs.unwrap_or_else(|| Arc::new(Mutex::new(Vec::new())));
100 let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<PresetFile>>(256);
101 let visited = Arc::new(DashSet::new());
102 let exclude = Arc::new(exclude.unwrap_or_default());
103
104 let roots_owned: Vec<PathBuf> = roots.to_vec();
105 let stop2 = stop.clone();
106 let found2 = found.clone();
107 let incremental = incremental.clone();
108 let pool = rayon::ThreadPoolBuilder::new()
109 .num_threads(num_cpus::get().max(4))
110 .build()
111 .unwrap();
112 std::thread::spawn(move || {
113 pool.install(|| {
114 roots_owned.par_iter().for_each(|root| {
115 if stop2.load(Ordering::Relaxed) {
116 return;
117 }
118 walk_dir_parallel(
119 root,
120 0,
121 &visited,
122 &tx,
123 &found2,
124 batch_size,
125 &stop2,
126 &exclude,
127 &active,
128 incremental.clone(),
129 );
130 });
131 });
132 drop(pool);
133 });
134
135 let mut total_found = 0usize;
136 loop {
137 if should_stop() {
138 stop.store(true, Ordering::Relaxed);
139 while rx.try_recv().is_ok() {}
140 break;
141 }
142 match rx.recv_timeout(std::time::Duration::from_millis(10)) {
143 Ok(presets) => {
144 total_found += presets.len();
145 on_batch(&presets, total_found);
146 }
147 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
148 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
149 }
150 }
151}
152
153#[allow(clippy::too_many_arguments)]
154fn walk_dir_parallel(
155 dir: &Path,
156 depth: u32,
157 visited: &Arc<DashSet<PathBuf>>,
158 tx: &std::sync::mpsc::SyncSender<Vec<PresetFile>>,
159 found: &Arc<AtomicUsize>,
160 batch_size: usize,
161 stop: &Arc<AtomicBool>,
162 exclude: &Arc<HashSet<String>>,
163 active_dirs: &Arc<Mutex<Vec<String>>>,
164 incremental: Option<Arc<IncrementalDirState>>,
165) {
166 if depth > 30 || stop.load(Ordering::Relaxed) {
167 return;
168 }
169
170 {
171 let orig = normalize_macos_path(dir.to_path_buf());
172 let canon = fs::canonicalize(dir).ok().map(normalize_macos_path);
173 let key = canon.unwrap_or_else(|| orig.clone());
174 if !visited.insert(key) {
175 return;
176 }
177 visited.insert(orig);
178 }
179
180 if let Some(ref inc) = incremental {
181 if inc.should_skip(dir) {
182 return;
183 }
184 }
185
186 let dir_str = dir.to_string_lossy().to_string();
187 {
188 let mut ad = active_dirs.lock().unwrap_or_else(|e| e.into_inner());
189 ad.push(dir_str.clone());
190 if ad.len() > 200 {
191 let excess = ad.len() - 200;
192 ad.drain(..excess);
193 }
194 }
195
196 let entries: Vec<_> = match fs::read_dir(dir) {
197 Ok(e) => e.flatten().collect(),
198 Err(_e) => {
199 return;
200 }
201 };
202
203 let mut files = Vec::new();
204 let mut subdirs = Vec::new();
205
206 for entry in &entries {
207 let name = entry.file_name();
208 let name_str = name.to_string_lossy();
209 if name_str.starts_with('.')
211 || name_str.starts_with('@')
212 || SKIP_DIRS.contains(&name_str.as_ref())
213 || exclude.contains(name_str.as_ref())
214 {
215 continue;
216 }
217 let ft = match entry.file_type() {
219 Ok(f) => f,
220 Err(_) => continue,
221 };
222 let path = entry.path();
223 if ft.is_dir() {
224 subdirs.push(path);
225 } else if ft.is_file() {
226 files.push((path, dir.to_path_buf()));
227 } else if ft.is_symlink() {
228 match fs::metadata(&path) {
229 Ok(m) if m.is_dir() => {
230 subdirs.push(path);
231 }
232 Ok(m) if m.is_file() => {
233 files.push((path, dir.to_path_buf()));
234 }
235 _ => {}
236 }
237 }
238 }
239
240 let mut batch = Vec::new();
241 for (path, parent) in files {
242 let ext = path
243 .extension()
244 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
245 .unwrap_or_default();
246
247 if PRESET_EXTENSIONS.contains(&ext.as_str()) {
248 let path_str = path.to_string_lossy().to_string();
249 if exclude.contains(&path_str) {
250 continue;
251 }
252 if let Ok(meta) = fs::metadata(&path) {
253 let preset_name = path
254 .file_stem()
255 .map(|s| s.to_string_lossy().to_string())
256 .unwrap_or_default();
257 let modified = meta
258 .modified()
259 .ok()
260 .map(|t| {
261 let dt: chrono::DateTime<chrono::Utc> = t.into();
262 dt.format("%Y-%m-%d").to_string()
263 })
264 .unwrap_or_default();
265
266 batch.push(PresetFile {
267 name: preset_name,
268 path: path_str,
269 directory: parent.to_string_lossy().to_string(),
270 format: ext[1..].to_uppercase(),
271 size: meta.len(),
272 size_formatted: format_size(meta.len()),
273 modified,
274 });
275 found.fetch_add(1, Ordering::Relaxed);
276
277 if batch.len() >= batch_size {
278 let _ = tx.send(batch);
279 batch = Vec::new();
280 }
281 }
282 }
283 }
284 if !batch.is_empty() {
285 let _ = tx.send(batch);
286 }
287
288 subdirs.par_iter().for_each(|subdir| {
289 walk_dir_parallel(
290 subdir,
291 depth + 1,
292 visited,
293 tx,
294 found,
295 batch_size,
296 stop,
297 exclude,
298 active_dirs,
299 incremental.clone(),
300 );
301 });
302
303 if let Some(ref inc) = incremental {
304 inc.record_scanned_dir(dir);
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use std::slice::from_ref;
312
313 #[test]
314 fn test_preset_extensions_complete() {
315 for ext in &[".fxp", ".fxb", ".vstpreset", ".aupreset"] {
316 assert!(
317 PRESET_EXTENSIONS.contains(ext),
318 "PRESET_EXTENSIONS should contain {}",
319 ext
320 );
321 }
322 }
323
324 #[test]
325 fn test_get_preset_roots_not_empty() {
326 let roots = get_preset_roots();
327 assert!(!roots.is_empty());
328 }
329
330 #[test]
331 fn test_preset_extensions_includes_common() {
332 for ext in &[".fxp", ".fxb", ".vstpreset"] {
333 assert!(
334 PRESET_EXTENSIONS.contains(ext),
335 "PRESET_EXTENSIONS must contain {}",
336 ext
337 );
338 }
339 }
340
341 #[test]
342 fn test_preset_extensions_excludes_midi() {
343 for ext in &[".mid", ".midi"] {
346 assert!(
347 !PRESET_EXTENSIONS.contains(ext),
348 "PRESET_EXTENSIONS must NOT list MIDI {} — use midi_scanner instead",
349 ext
350 );
351 }
352 }
353
354 #[test]
355 fn test_preset_extensions_includes_ableton_kontakt_extras() {
356 for ext in &[".adv", ".adg", ".nksn", ".syx", ".pjunoxl"] {
357 assert!(
358 PRESET_EXTENSIONS.contains(ext),
359 "PRESET_EXTENSIONS should contain {}",
360 ext
361 );
362 }
363 }
364
365 #[test]
366 fn test_normalize_macos_path() {
367 let p = PathBuf::from("/System/Volumes/Data/Users/example");
368 let n = normalize_macos_path(p);
369 #[cfg(target_os = "macos")]
370 assert_eq!(n, PathBuf::from("/Users/example"));
371 #[cfg(not(target_os = "macos"))]
372 assert_eq!(n, PathBuf::from("/System/Volumes/Data/Users/example"));
373 }
374
375 #[test]
376 fn test_preset_roots_exist() {
377 let roots = get_preset_roots();
378 assert!(
379 !roots.is_empty(),
380 "At least one preset root directory should exist on this system"
381 );
382 for root in &roots {
383 assert!(root.exists(), "Returned root should exist: {:?}", root);
384 }
385 }
386
387 #[test]
388 fn test_walk_for_presets_empty_dir() {
389 let tmp = std::env::temp_dir().join("upum_test_preset_empty");
390 let _ = fs::remove_dir_all(&tmp);
391 fs::create_dir_all(&tmp).unwrap();
392
393 let mut found = Vec::new();
394 walk_for_presets(
395 from_ref(&tmp),
396 &mut |batch, _count| {
397 found.extend_from_slice(batch);
398 },
399 &|| false,
400 None,
401 None,
402 None,
403 );
404 assert!(found.is_empty());
405 let _ = fs::remove_dir_all(&tmp);
406 }
407
408 #[test]
409 fn test_walk_for_presets_finds_tfx_and_h2p() {
410 let tmp = std::env::temp_dir().join("upum_test_preset_tfx_h2p");
411 let _ = fs::remove_dir_all(&tmp);
412 fs::create_dir_all(&tmp).unwrap();
413 fs::write(tmp.join("tone.tfx"), b"tone2").unwrap();
414 fs::write(tmp.join("diva.h2p"), b"u-he").unwrap();
415
416 let mut found = Vec::new();
417 walk_for_presets(
418 from_ref(&tmp),
419 &mut |batch, _count| found.extend_from_slice(batch),
420 &|| false,
421 None,
422 None,
423 None,
424 );
425 let formats: Vec<&str> = found.iter().map(|p| p.format.as_str()).collect();
426 assert!(formats.contains(&"TFX"), "expected TFX, got {:?}", formats);
427 assert!(formats.contains(&"H2P"), "expected H2P, got {:?}", formats);
428 let _ = fs::remove_dir_all(&tmp);
429 }
430
431 #[test]
432 fn test_walk_for_presets_finds_nksn_kontakt_snapshot() {
433 let tmp = std::env::temp_dir().join("upum_test_preset_nksn");
434 let _ = fs::remove_dir_all(&tmp);
435 fs::create_dir_all(&tmp).unwrap();
436 fs::write(tmp.join("snap.nksn"), b"kontakt").unwrap();
437
438 let mut found = Vec::new();
439 walk_for_presets(
440 from_ref(&tmp),
441 &mut |batch, _count| found.extend_from_slice(batch),
442 &|| false,
443 None,
444 None,
445 None,
446 );
447 assert_eq!(found.len(), 1);
448 assert_eq!(found[0].format, "NKSN");
449 let _ = fs::remove_dir_all(&tmp);
450 }
451
452 #[test]
453 fn test_walk_for_presets_finds_files() {
454 let tmp = std::env::temp_dir().join("upum_test_preset_find");
455 let _ = fs::remove_dir_all(&tmp);
456 fs::create_dir_all(&tmp).unwrap();
457 fs::write(tmp.join("lead.fxp"), b"fake preset").unwrap();
458 fs::write(tmp.join("bank.fxb"), b"fake bank").unwrap();
459 fs::write(tmp.join("pad.vstpreset"), b"fake vstpreset").unwrap();
460 fs::write(tmp.join("not_a_preset.txt"), b"nope").unwrap();
461
462 let mut found = Vec::new();
463 walk_for_presets(
464 from_ref(&tmp),
465 &mut |batch, _count| {
466 found.extend_from_slice(batch);
467 },
468 &|| false,
469 None,
470 None,
471 None,
472 );
473 assert_eq!(found.len(), 3);
474 let formats: Vec<&str> = found.iter().map(|p| p.format.as_str()).collect();
475 assert!(formats.contains(&"FXP"));
476 assert!(formats.contains(&"FXB"));
477 assert!(formats.contains(&"VSTPRESET"));
478 let _ = fs::remove_dir_all(&tmp);
479 }
480
481 #[test]
482 fn test_walk_for_presets_stop_signal() {
483 let tmp = std::env::temp_dir().join("upum_test_preset_stop");
484 let _ = fs::remove_dir_all(&tmp);
485 fs::create_dir_all(&tmp).unwrap();
486 for i in 0..20 {
487 fs::write(tmp.join(format!("preset{}.fxp", i)), b"fake").unwrap();
488 }
489
490 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
491 let c2 = counter.clone();
492 let stop_after = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
493 let s2 = stop_after.clone();
494
495 walk_for_presets(
496 from_ref(&tmp),
497 &mut |batch, _count| {
498 c2.fetch_add(batch.len(), std::sync::atomic::Ordering::Relaxed);
499 s2.store(true, std::sync::atomic::Ordering::Relaxed);
500 },
501 &|| stop_after.load(std::sync::atomic::Ordering::Relaxed),
502 None,
503 None,
504 None,
505 );
506 let _ = fs::remove_dir_all(&tmp);
508 }
509
510 #[test]
511 fn test_walk_for_presets_exclude_set() {
512 let tmp = std::env::temp_dir().join("upum_test_preset_exclude");
513 let _ = fs::remove_dir_all(&tmp);
514 fs::create_dir_all(&tmp).unwrap();
515 let included = tmp.join("keep.fxp");
516 let excluded = tmp.join("skip.fxp");
517 fs::write(&included, b"keep").unwrap();
518 fs::write(&excluded, b"skip").unwrap();
519
520 let mut exclude = HashSet::new();
521 exclude.insert(excluded.to_string_lossy().to_string());
522
523 let mut found = Vec::new();
524 walk_for_presets(
525 from_ref(&tmp),
526 &mut |batch, _count| {
527 found.extend_from_slice(batch);
528 },
529 &|| false,
530 Some(exclude),
531 None,
532 None,
533 );
534 assert_eq!(found.len(), 1);
535 assert!(found[0].path.contains("keep.fxp"));
536 let _ = fs::remove_dir_all(&tmp);
537 }
538
539 #[test]
540 fn test_walk_for_presets_skips_hidden_and_blacklisted_dirs() {
541 let tmp = std::env::temp_dir().join("upum_test_preset_skip");
542 let _ = fs::remove_dir_all(&tmp);
543 fs::create_dir_all(tmp.join(".hidden_dir")).unwrap();
544 fs::create_dir_all(tmp.join("node_modules")).unwrap();
545 fs::create_dir_all(tmp.join("normal")).unwrap();
546 fs::write(tmp.join(".hidden_dir/a.fxp"), b"h").unwrap();
547 fs::write(tmp.join("node_modules/b.fxp"), b"n").unwrap();
548 fs::write(tmp.join("normal/c.fxp"), b"ok").unwrap();
549
550 let mut found = Vec::new();
551 walk_for_presets(
552 from_ref(&tmp),
553 &mut |batch, _count| found.extend_from_slice(batch),
554 &|| false,
555 None,
556 None,
557 None,
558 );
559 assert_eq!(found.len(), 1);
560 assert!(found[0].path.contains("normal"));
561 let _ = fs::remove_dir_all(&tmp);
562 }
563
564 #[test]
565 fn test_walk_for_presets_deduplicates_symlinks() {
566 let tmp = std::env::temp_dir().join("upum_test_preset_symlink");
567 let _ = fs::remove_dir_all(&tmp);
568 fs::create_dir_all(tmp.join("real")).unwrap();
569 fs::write(tmp.join("real/a.fxp"), b"preset").unwrap();
570
571 #[cfg(unix)]
572 {
573 let _ = std::os::unix::fs::symlink(tmp.join("real"), tmp.join("link"));
574 let mut found = Vec::new();
575 walk_for_presets(
576 &[tmp.join("real"), tmp.join("link")],
577 &mut |batch, _count| found.extend_from_slice(batch),
578 &|| false,
579 None,
580 None,
581 None,
582 );
583 assert_eq!(found.len(), 1, "Symlinked duplicate should be deduped");
584 }
585 let _ = fs::remove_dir_all(&tmp);
586 }
587
588 #[test]
589 fn test_walk_for_presets_deduplicates_overlapping_roots() {
590 let tmp = std::env::temp_dir().join("upum_test_preset_overlap");
591 let _ = fs::remove_dir_all(&tmp);
592 let child = tmp.join("sub");
593 fs::create_dir_all(&child).unwrap();
594 fs::write(child.join("overlap.fxp"), b"preset").unwrap();
595 fs::write(tmp.join("top.fxp"), b"preset").unwrap();
596
597 let mut found = Vec::new();
598 walk_for_presets(
599 &[tmp.clone(), child.clone()],
600 &mut |batch, _| found.extend_from_slice(batch),
601 &|| false,
602 None,
603 None,
604 None,
605 );
606 let overlap_count = found.iter().filter(|p| p.name == "overlap").count();
607 assert_eq!(
608 overlap_count, 1,
609 "overlap.fxp found {} times",
610 overlap_count
611 );
612 assert!(found.iter().any(|p| p.name == "top"));
613 let _ = fs::remove_dir_all(&tmp);
614 }
615
616 #[test]
617 fn test_walk_for_presets_consistent_counts() {
618 let tmp = std::env::temp_dir().join("upum_test_preset_consistent");
619 let _ = fs::remove_dir_all(&tmp);
620 for i in 0..5 {
621 let d = tmp.join(format!("dir{}", i));
622 fs::create_dir_all(&d).unwrap();
623 fs::write(d.join(format!("p{}.fxp", i)), b"preset").unwrap();
624 }
625 let mut c1 = 0;
626 walk_for_presets(
627 &[tmp.clone()],
628 &mut |b, _| c1 += b.len(),
629 &|| false,
630 None,
631 None,
632 None,
633 );
634 let mut c2 = 0;
635 walk_for_presets(
636 &[tmp.clone()],
637 &mut |b, _| c2 += b.len(),
638 &|| false,
639 None,
640 None,
641 None,
642 );
643 assert_eq!(c1, c2, "two scans should match: {} vs {}", c1, c2);
644 assert_eq!(c1, 5);
645 let _ = fs::remove_dir_all(&tmp);
646 }
647
648 #[test]
649 fn test_walk_for_presets_batching() {
650 let tmp = std::env::temp_dir().join("upum_test_preset_batch");
651 let _ = fs::remove_dir_all(&tmp);
652 fs::create_dir_all(&tmp).unwrap();
653 for i in 0..5 {
654 fs::write(tmp.join(format!("p{}.fxp", i)), b"fake").unwrap();
655 }
656
657 let mut total = 0usize;
658 walk_for_presets(
659 from_ref(&tmp),
660 &mut |batch, count| {
661 assert!(!batch.is_empty());
662 total = count;
663 },
664 &|| false,
665 None,
666 None,
667 None,
668 );
669 assert_eq!(total, 5);
670 let _ = fs::remove_dir_all(&tmp);
671 }
672}