1use crate::audio_extensions::AUDIO_EXTENSIONS;
27use crate::bulk_stat::{read_dir_bulk, BulkEntry};
28use crate::history::{AudioSample, DawProject, PdfFile, PresetFile};
29use crate::scanner_skip_dirs::SCANNER_SKIP_DIRS as SKIP_DIRS;
30use rayon::prelude::*;
31use dashmap::DashSet;
32use std::collections::{HashMap, HashSet};
33use std::fs;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
36use std::sync::{Arc, Mutex};
37
38fn directory_incremental_key(dir: &Path) -> String {
40 let orig = normalize_macos_path(dir.to_path_buf());
41 let canon = fs::canonicalize(dir).ok().map(normalize_macos_path);
42 let key = canon.unwrap_or(orig);
43 key.to_string_lossy().to_string()
44}
45
46fn dir_mtime_secs(dir: &Path) -> i64 {
47 fs::metadata(dir)
48 .and_then(|m| m.modified())
49 .ok()
50 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
51 .map(|d| d.as_secs() as i64)
52 .unwrap_or(0)
53}
54
55pub struct IncrementalDirState {
59 mtimes: Arc<Mutex<HashMap<String, i64>>>,
60 pending: Arc<Mutex<Vec<(String, i64)>>>,
61}
62
63impl IncrementalDirState {
64 pub fn new(snapshot: HashMap<String, i64>) -> Self {
65 Self {
66 mtimes: Arc::new(Mutex::new(snapshot)),
67 pending: Arc::new(Mutex::new(Vec::new())),
68 }
69 }
70
71 pub fn should_skip(&self, dir: &Path) -> bool {
74 let key = directory_incremental_key(dir);
75 let cur = dir_mtime_secs(dir);
76 if cur <= 0 {
77 return false;
78 }
79 let map = self.mtimes.lock().unwrap_or_else(|e| e.into_inner());
80 if let Some(&stored) = map.get(&key) {
81 if cur <= stored {
82 return true;
83 }
84 }
85 false
86 }
87
88 pub fn record_scanned_dir(&self, dir: &Path) {
91 let key = directory_incremental_key(dir);
92 let cur = dir_mtime_secs(dir);
93 if cur <= 0 {
94 return;
95 }
96 let mut map = self.mtimes.lock().unwrap_or_else(|e| e.into_inner());
97 map.insert(key.clone(), cur);
98 drop(map);
99 self.pending
100 .lock()
101 .unwrap_or_else(|e| e.into_inner())
102 .push((key, cur));
103 }
104
105 pub fn take_pending(&self) -> Vec<(String, i64)> {
106 let mut p = self.pending.lock().unwrap_or_else(|e| e.into_inner());
107 std::mem::take(&mut *p)
108 }
109}
110
111fn fmt_mtime_ymd(mtime_secs: i64) -> String {
114 if mtime_secs <= 0 {
115 return String::new();
116 }
117 chrono::DateTime::<chrono::Utc>::from_timestamp(mtime_secs, 0)
118 .map(|dt| dt.format("%Y-%m-%d").to_string())
119 .unwrap_or_default()
120}
121
122fn normalize_macos_path(p: PathBuf) -> PathBuf {
123 #[cfg(target_os = "macos")]
124 {
125 let s = p.to_string_lossy();
126 if s.starts_with("/System/Volumes/Data/") {
127 return PathBuf::from(&s["/System/Volumes/Data".len()..]);
128 }
129 }
130 p
131}
132
133#[cfg(target_os = "macos")]
138fn network_fs_type(path: &Path) -> Option<String> {
139 use std::ffi::CString;
140 let c_path = CString::new(path.as_os_str().to_string_lossy().as_bytes()).ok()?;
141 let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
142 let rc = unsafe { libc::statfs(c_path.as_ptr(), &mut stat) };
143 if rc != 0 {
144 return None;
145 }
146 let fstype = unsafe {
147 std::ffi::CStr::from_ptr(stat.f_fstypename.as_ptr())
148 .to_string_lossy()
149 .to_string()
150 };
151 const MNT_LOCAL: u32 = 0x00001000;
153 if stat.f_flags & MNT_LOCAL == 0 {
154 Some(fstype)
155 } else {
156 None
157 }
158}
159
160#[cfg(not(target_os = "macos"))]
161fn network_fs_type(_path: &Path) -> Option<String> {
162 None
163}
164
165fn is_network_path(path: &Path) -> bool {
169 network_fs_type(path).is_some()
170}
171
172const DAW_EXTENSIONS: &[&str] = &[
175 ".als",
176 ".logicx",
177 ".flp",
178 ".cpr",
179 ".npr",
180 ".bwproject",
181 ".rpp",
182 ".rpp-bak",
183 ".ptx",
184 ".ptf",
185 ".song",
186 ".reason",
187 ".aup",
188 ".aup3",
189 ".band",
190 ".ardour",
191 ".dawproject",
192];
193
194const DAW_PACKAGE_EXTENSIONS: &[&str] = &[".logicx", ".band"];
195
196const DAW_PLUGIN_BUNDLE_EXTENSIONS: &[&str] = &[
197 ".vst",
198 ".vst3",
199 ".component",
200 ".aaxplugin",
201 ".app",
202 ".framework",
203 ".bundle",
204 ".plugin",
205 ".dpm",
206 ".clap",
207];
208
209const DAW_BACKUP_DIRS: &[&str] = &["Backup", "Crash"];
210
211const PRESET_EXTENSIONS: &[&str] = &[
212 ".fxp",
213 ".fxb",
214 ".vstpreset",
215 ".aupreset",
216 ".adv",
217 ".adg",
218 ".nki",
219 ".nksn",
220 ".h2p",
221 ".syx",
222 ".tfx",
223 ".pjunoxl",
224 ];
226
227const PDF_EXTENSION: &str = ".pdf";
228
229fn format_size(bytes: u64) -> String {
230 crate::format_size(bytes)
231}
232
233fn daw_name_for_format(format: &str) -> &'static str {
234 match format {
235 "ALS" => "Ableton Live",
236 "LOGICX" => "Logic Pro",
237 "FLP" => "FL Studio",
238 "CPR" => "Cubase",
239 "NPR" => "Nuendo",
240 "BWPROJECT" => "Bitwig Studio",
241 "RPP" | "RPP-BAK" => "REAPER",
242 "PTX" | "PTF" => "Pro Tools",
243 "SONG" => "Studio One",
244 "REASON" => "Reason",
245 "AUP" | "AUP3" => "Audacity",
246 "BAND" => "GarageBand",
247 "ARDOUR" => "Ardour",
248 "DAWPROJECT" => "DAWproject",
249 _ => "Unknown",
250 }
251}
252
253fn is_valid_band_package(path: &Path) -> bool {
254 let pd = path.join("projectData");
255 if !pd.exists() {
256 return false;
257 }
258 if let Ok(mut f) = fs::File::open(&pd) {
259 use std::io::Read;
260 let mut magic = [0u8; 6];
261 if f.read_exact(&mut magic).is_err() || &magic != b"bplist" {
262 return false;
263 }
264 } else {
265 return false;
266 }
267 path.join("Media").is_dir()
268 || path.join("Output").is_dir()
269 || path.join("Freeze Files").is_dir()
270}
271
272fn get_directory_size(path: &Path) -> u64 {
273 get_directory_size_depth(path, 0)
274}
275
276fn get_directory_size_depth(path: &Path, depth: u32) -> u64 {
277 if depth > 10 {
278 return 0;
279 }
280 let mut total = 0u64;
281 if let Ok(entries) = fs::read_dir(path) {
282 for entry in entries.flatten() {
283 let p = entry.path();
284 if p.is_dir() {
287 total += get_directory_size_depth(&p, depth + 1);
288 } else if let Ok(meta) = fs::metadata(&p) {
289 total += meta.len();
290 }
291 }
292 }
293 total
294}
295
296fn under_any_root(path: &Path, roots: &[PathBuf]) -> bool {
299 if roots.is_empty() {
300 return false;
301 }
302 roots.iter().any(|r| path.starts_with(r))
303}
304
305fn ext_match(name_lower: &str, exts: &[&str]) -> Option<String> {
308 for e in exts {
309 if name_lower.ends_with(e) {
310 return Some(e.to_string());
311 }
312 }
313 None
314}
315
316#[derive(Debug, Clone, Default)]
318pub struct UnifiedSpec {
319 pub audio_roots: Vec<PathBuf>,
320 pub audio_exclude: HashSet<String>,
321 pub daw_roots: Vec<PathBuf>,
322 pub daw_exclude: HashSet<String>,
323 pub daw_include_backups: bool,
324 pub preset_roots: Vec<PathBuf>,
325 pub preset_exclude: HashSet<String>,
326 pub pdf_roots: Vec<PathBuf>,
327 pub pdf_exclude: HashSet<String>,
328}
329
330#[derive(Debug)]
332pub enum ClassifiedBatch {
333 Audio(Vec<AudioSample>),
334 Daw(Vec<DawProject>),
335 Preset(Vec<PresetFile>),
336 Pdf(Vec<PdfFile>),
337}
338
339#[derive(Debug, Clone, Copy, Default)]
341pub struct UnifiedCounts {
342 pub audio: usize,
343 pub daw: usize,
344 pub preset: usize,
345 pub pdf: usize,
346}
347
348pub fn walk_unified(
353 spec: &UnifiedSpec,
354 on_batch: &mut dyn FnMut(ClassifiedBatch, UnifiedCounts),
355 should_stop: &(dyn Fn() -> bool + Sync),
356 active_dirs: Vec<Arc<Mutex<Vec<String>>>>,
357 incremental: Option<Arc<IncrementalDirState>>,
358) {
359 let batch_size = 100;
360 let stop = Arc::new(AtomicBool::new(false));
361 let active: Vec<Arc<Mutex<Vec<String>>>> = if active_dirs.is_empty() {
364 vec![Arc::new(Mutex::new(Vec::new()))]
365 } else {
366 active_dirs
367 };
368 let (tx, rx) = std::sync::mpsc::sync_channel::<ClassifiedBatch>(256);
369 let visited = Arc::new(DashSet::new());
370
371 let mut union: Vec<PathBuf> = Vec::new();
373 for list in [
374 &spec.audio_roots,
375 &spec.daw_roots,
376 &spec.preset_roots,
377 &spec.pdf_roots,
378 ] {
379 for r in list {
380 union.push(r.clone());
381 }
382 }
383 union.sort();
384 union.dedup();
385 let union: Vec<PathBuf> = union.into_iter().filter(|r| r.exists()).collect();
386
387 for r in &union {
390 if let Some(fs) = network_fs_type(r) {
391 let p = r.display().to_string();
392 crate::write_app_log_verbose(format!(
393 "SCAN ROOT — unified | {} [NETWORK: {}]",
394 p, fs,
395 ));
396 }
397 }
398
399 let audio_found = Arc::new(AtomicUsize::new(0));
400 let daw_found = Arc::new(AtomicUsize::new(0));
401 let preset_found = Arc::new(AtomicUsize::new(0));
402 let pdf_found = Arc::new(AtomicUsize::new(0));
403
404 let spec = Arc::new(spec.clone());
405 let stop2 = stop.clone();
406 let audio_f2 = audio_found.clone();
407 let daw_f2 = daw_found.clone();
408 let preset_f2 = preset_found.clone();
409 let pdf_f2 = pdf_found.clone();
410
411 let pool = rayon::ThreadPoolBuilder::new()
412 .num_threads(num_cpus::get().max(4))
413 .build()
414 .unwrap();
415
416 let tcc_denied: Arc<DashSet<PathBuf>> = Arc::new(DashSet::new());
417 let tcc_summary = tcc_denied.clone();
418 std::thread::spawn(move || {
419 pool.install(|| {
420 union.par_iter().for_each(|root| {
421 if stop2.load(Ordering::Relaxed) {
422 return;
423 }
424 walk_dir_parallel(
425 root, 0, &visited, &tx, &audio_f2, &daw_f2, &preset_f2, &pdf_f2, batch_size,
426 &stop2, &spec, &active, &tcc_denied, incremental.clone(),
427 );
428 });
429 });
430 drop(pool);
431 });
432
433 loop {
434 if should_stop() {
435 stop.store(true, Ordering::Relaxed);
436 while rx.try_recv().is_ok() {}
437 break;
438 }
439 match rx.recv_timeout(std::time::Duration::from_millis(10)) {
440 Ok(batch) => {
441 let counts = UnifiedCounts {
442 audio: audio_found.load(Ordering::Relaxed),
443 daw: daw_found.load(Ordering::Relaxed),
444 preset: preset_found.load(Ordering::Relaxed),
445 pdf: pdf_found.load(Ordering::Relaxed),
446 };
447 on_batch(batch, counts);
448 }
449 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
450 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
451 }
452 }
453
454 if !tcc_summary.is_empty() {
455 let paths: Vec<_> = tcc_summary.iter().map(|p| p.key().display().to_string()).collect();
456 crate::write_app_log(format!(
457 "SCAN TCC SUMMARY — {} path(s) denied by macOS TCC: {} \
458 (grant access in System Settings → Privacy & Security → Files and Folders)",
459 paths.len(),
460 paths.join(", "),
461 ));
462 }
463}
464
465#[allow(clippy::too_many_arguments)]
466fn walk_dir_parallel(
467 dir: &Path,
468 depth: u32,
469 visited: &Arc<DashSet<PathBuf>>,
470 tx: &std::sync::mpsc::SyncSender<ClassifiedBatch>,
471 audio_found: &Arc<AtomicUsize>,
472 daw_found: &Arc<AtomicUsize>,
473 preset_found: &Arc<AtomicUsize>,
474 pdf_found: &Arc<AtomicUsize>,
475 batch_size: usize,
476 stop: &Arc<AtomicBool>,
477 spec: &Arc<UnifiedSpec>,
478 active_dirs: &[Arc<Mutex<Vec<String>>>],
479 tcc_denied: &Arc<DashSet<PathBuf>>,
480 incremental: Option<Arc<IncrementalDirState>>,
481) {
482 if depth > 30 || stop.load(Ordering::Relaxed) {
483 return;
484 }
485
486 if tcc_denied.iter().any(|d| dir.starts_with(d.key())) {
488 return;
489 }
490
491 {
492 let orig = normalize_macos_path(dir.to_path_buf());
495 let canon_result = fs::canonicalize(dir);
496 if is_network_path(dir) {
497 if let Err(ref e) = canon_result {
498 crate::write_app_log_verbose(format!(
499 "SCAN NETWORK CANONICALIZE FAIL — unified | {} | {} (using original path as dedup key)",
500 dir.display(), e,
501 ));
502 }
503 }
504 let canon = canon_result.ok().map(normalize_macos_path);
505 let key = canon.clone().unwrap_or_else(|| orig.clone());
506 if !visited.insert(key.clone()) {
507 if is_network_path(dir) {
508 crate::write_app_log_verbose(format!(
509 "SCAN DEDUP SKIP — unified | orig={} | canon={} | key={}",
510 orig.display(),
511 canon.as_ref().map(|p| p.display().to_string())
512 .unwrap_or_else(|| "<canonicalize failed>".into()),
513 key.display(),
514 ));
515 }
516 return;
517 }
518 visited.insert(orig);
519 }
520
521 if let Some(ref inc) = incremental {
522 if inc.should_skip(dir) {
523 return;
524 }
525 }
526
527 let dir_str = dir.to_string_lossy().to_string();
528
529 if depth <= 2 {
532 if let Some(fstype) = network_fs_type(dir) {
533 crate::write_app_log_verbose(format!(
534 "SCAN NETWORK ENTER — unified | {} | fs={} | depth={}",
535 dir.display(), fstype, depth,
536 ));
537 }
538 }
539 {
540 for sink in active_dirs {
542 let mut ad = sink.lock().unwrap_or_else(|e| e.into_inner());
543 ad.push(dir_str.clone());
544 if ad.len() > 200 {
548 let excess = ad.len() - 200;
549 ad.drain(..excess);
550 }
551 }
552 }
553
554 let is_net = is_network_path(dir);
559 let entries: Vec<BulkEntry> = match read_dir_bulk(dir) {
560 Ok(e) => e,
561 Err(first_err) => {
562 if first_err.raw_os_error() == Some(1) {
566 if !tcc_denied.iter().any(|d| dir.starts_with(d.key())) {
567 tcc_denied.insert(dir.to_path_buf());
568 crate::write_app_log(format!(
569 "SCAN TCC DENIED — unified | {} | {} \
570 (grant Full Disk Access or Files and Folders permission)",
571 dir.display(), first_err,
572 ));
573 }
574 return;
575 }
576 if !is_net {
578 return;
579 }
580 const MAX_RETRIES: u32 = 3;
584 const BASE_DELAY_MS: u64 = 50;
585 crate::write_app_log_verbose(format!(
586 "SCAN NETWORK RETRY — unified | {} | first error: {} | up to {} retries",
587 dir.display(), first_err, MAX_RETRIES,
588 ));
589 let mut last_err = first_err;
590 let mut recovered = None;
591 for attempt in 0..MAX_RETRIES {
592 let delay = BASE_DELAY_MS * (1 << attempt); std::thread::sleep(std::time::Duration::from_millis(delay));
594 match read_dir_bulk(dir) {
595 Ok(e) => {
596 crate::write_app_log_verbose(format!(
597 "SCAN NETWORK RECOVERED — unified | {} | succeeded on retry {}",
598 dir.display(), attempt + 1,
599 ));
600 recovered = Some(e);
601 break;
602 }
603 Err(e) => {
604 last_err = e;
605 }
606 }
607 }
608 match recovered {
609 Some(e) => e,
610 None => {
611 let fsinfo = network_fs_type(dir)
612 .map(|fs| format!(" (fs={})", fs))
613 .unwrap_or_default();
614 crate::write_app_log(format!(
615 "SCAN READDIR ERROR — unified | {}{} | {} retries exhausted | last: {}",
616 dir.display(), fsinfo, MAX_RETRIES, last_err,
617 ));
618 return;
619 }
620 }
621 }
622 };
623
624 if depth <= 2 {
626 let n = entries.len();
627 let d = dir.to_path_buf();
628 crate::app_log_verbose(move || {
629 format!(
630 "SCAN VERBOSE — unified | depth={} | dir={} | entries={}",
631 depth,
632 d.display(),
633 n
634 )
635 });
636 }
637
638 let mut audio_batch: Vec<AudioSample> = Vec::new();
640 let mut daw_batch: Vec<DawProject> = Vec::new();
641 let mut preset_batch: Vec<PresetFile> = Vec::new();
642 let mut pdf_batch: Vec<PdfFile> = Vec::new();
643 let mut subdirs: Vec<PathBuf> = Vec::new();
644
645 for entry in &entries {
646 let name_str = entry.name.as_str();
647 if name_str.starts_with('.') || name_str.starts_with('@') || SKIP_DIRS.contains(&name_str) {
652 continue;
653 }
654 if !spec.daw_include_backups && DAW_BACKUP_DIRS.contains(&name_str) {
655 continue;
659 }
660
661 let path = entry.path.clone();
662 let name_lower = name_str.to_lowercase();
663
664 let (is_dir, is_file, size, mtime_secs) = if entry.is_symlink {
666 match fs::metadata(&path) {
667 Ok(m) => {
668 let is_d = m.is_dir();
669 let is_f = m.is_file();
670 let sz = if is_f { m.len() } else { 0 };
671 let mt = m
672 .modified()
673 .ok()
674 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
675 .map(|d| d.as_secs() as i64)
676 .unwrap_or(0);
677 (is_d, is_f, sz, mt)
678 }
679 Err(_) => continue,
680 }
681 } else {
682 (entry.is_dir, entry.is_file, entry.size, entry.mtime_secs)
683 };
684
685 if is_dir {
686 let is_plugin_bundle = DAW_PLUGIN_BUNDLE_EXTENSIONS
689 .iter()
690 .any(|ext| name_lower.ends_with(ext));
691
692 let is_daw_pkg = DAW_PACKAGE_EXTENSIONS
695 .iter()
696 .any(|ext| name_lower.ends_with(ext));
697
698 if is_daw_pkg && under_any_root(&path, &spec.daw_roots) {
699 if let Some(ext_with_dot) = ext_match(&name_lower, DAW_EXTENSIONS) {
700 let format = ext_with_dot.strip_prefix('.').unwrap_or("").to_uppercase();
701 if !(format == "BAND" && !is_valid_band_package(&path)) {
702 let path_str = path.to_string_lossy().to_string();
703 if !spec.daw_exclude.contains(&path_str) {
704 let size = get_directory_size(&path);
707 let modified = if mtime_secs > 0 {
712 fmt_mtime_ymd(mtime_secs)
713 } else {
714 fs::metadata(&path)
715 .ok()
716 .and_then(|m| m.modified().ok())
717 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
718 .map(|d| fmt_mtime_ymd(d.as_secs() as i64))
719 .unwrap_or_default()
720 };
721 let project_name = path
722 .file_stem()
723 .map(|s| s.to_string_lossy().to_string())
724 .unwrap_or_default();
725 let daw = daw_name_for_format(&format).to_string();
726 daw_batch.push(DawProject {
727 name: project_name,
728 path: path_str,
729 directory: dir.to_string_lossy().to_string(),
730 format,
731 daw,
732 size,
733 size_formatted: format_size(size),
734 modified,
735 });
736 daw_found.fetch_add(1, Ordering::Relaxed);
737 }
738 }
739 }
740 }
741
742 if is_plugin_bundle {
743 if !spec.preset_roots.is_empty()
746 || !spec.pdf_roots.is_empty()
747 || !spec.audio_roots.is_empty()
748 {
749 subdirs.push(path);
750 }
751 continue;
752 }
753
754 if is_daw_pkg {
755 continue;
757 }
758
759 subdirs.push(path);
760 continue;
761 }
762
763 if !is_file {
764 continue;
765 }
766
767 let ext_with_dot = match name_lower.rfind('.') {
769 Some(i) => &name_lower[i..],
770 None => continue,
771 };
772 let modified = fmt_mtime_ymd(mtime_secs);
773
774 if AUDIO_EXTENSIONS.contains(&ext_with_dot) && under_any_root(&path, &spec.audio_roots) {
776 let path_str = path.to_string_lossy().to_string();
777 if !spec.audio_exclude.contains(&path_str) && size > 0 {
778 let sample_name = path
779 .file_stem()
780 .map(|s| s.to_string_lossy().to_string())
781 .unwrap_or_default();
782 let am = crate::audio_scanner::get_audio_metadata(&path_str);
783 let (dur, ch, sr, bps) =
784 (am.duration, am.channels, am.sample_rate, am.bits_per_sample);
785 audio_batch.push(AudioSample {
786 name: sample_name,
787 path: path_str.clone(),
788 directory: dir.to_string_lossy().to_string(),
789 format: ext_with_dot.strip_prefix('.').unwrap_or("").to_uppercase(),
790 size,
791 size_formatted: format_size(size),
792 modified: modified.clone(),
793 duration: dur,
794 channels: ch,
795 sample_rate: sr,
796 bits_per_sample: bps,
797 });
798 audio_found.fetch_add(1, Ordering::Relaxed);
799 }
800 }
801
802 if DAW_EXTENSIONS.contains(&ext_with_dot) && under_any_root(&path, &spec.daw_roots) {
804 let path_str = path.to_string_lossy().to_string();
805 if !spec.daw_exclude.contains(&path_str) {
806 let format = ext_with_dot.strip_prefix('.').unwrap_or("").to_uppercase();
807 if format != "BAND"
810 && ((format != "PTX" && format != "PTF")
811 || crate::daw_scanner::is_valid_pro_tools_session_file(&path))
812 {
813 let project_name = path
814 .file_stem()
815 .map(|s| s.to_string_lossy().to_string())
816 .unwrap_or_default();
817 let daw = daw_name_for_format(&format).to_string();
818 daw_batch.push(DawProject {
819 name: project_name,
820 path: path_str,
821 directory: dir.to_string_lossy().to_string(),
822 format,
823 daw,
824 size,
825 size_formatted: format_size(size),
826 modified: modified.clone(),
827 });
828 daw_found.fetch_add(1, Ordering::Relaxed);
829 }
830 }
831 }
832
833 if PRESET_EXTENSIONS.contains(&ext_with_dot) && under_any_root(&path, &spec.preset_roots) {
835 let path_str = path.to_string_lossy().to_string();
836 if !spec.preset_exclude.contains(&path_str) {
837 let preset_name = path
838 .file_stem()
839 .map(|s| s.to_string_lossy().to_string())
840 .unwrap_or_default();
841 preset_batch.push(PresetFile {
842 name: preset_name,
843 path: path_str,
844 directory: dir.to_string_lossy().to_string(),
845 format: ext_with_dot.strip_prefix('.').unwrap_or("").to_uppercase(),
846 size,
847 size_formatted: format_size(size),
848 modified: modified.clone(),
849 });
850 preset_found.fetch_add(1, Ordering::Relaxed);
851 }
852 }
853
854 if ext_with_dot == PDF_EXTENSION && under_any_root(&path, &spec.pdf_roots) {
856 let path_str = path.to_string_lossy().to_string();
857 if !spec.pdf_exclude.contains(&path_str) {
858 let pdf_name = path
859 .file_stem()
860 .map(|s| s.to_string_lossy().to_string())
861 .unwrap_or_default();
862 pdf_batch.push(PdfFile {
863 name: pdf_name,
864 path: path_str,
865 directory: dir.to_string_lossy().to_string(),
866 size,
867 size_formatted: format_size(size),
868 modified,
869 });
870 pdf_found.fetch_add(1, Ordering::Relaxed);
871 }
872 }
873
874 if audio_batch.len() >= batch_size {
876 let _ = tx.send(ClassifiedBatch::Audio(std::mem::take(&mut audio_batch)));
877 }
878 if daw_batch.len() >= batch_size {
879 let _ = tx.send(ClassifiedBatch::Daw(std::mem::take(&mut daw_batch)));
880 }
881 if preset_batch.len() >= batch_size {
882 let _ = tx.send(ClassifiedBatch::Preset(std::mem::take(&mut preset_batch)));
883 }
884 if pdf_batch.len() >= batch_size {
885 let _ = tx.send(ClassifiedBatch::Pdf(std::mem::take(&mut pdf_batch)));
886 }
887 }
888
889 if !audio_batch.is_empty() {
891 let _ = tx.send(ClassifiedBatch::Audio(audio_batch));
892 }
893 if !daw_batch.is_empty() {
894 let _ = tx.send(ClassifiedBatch::Daw(daw_batch));
895 }
896 if !preset_batch.is_empty() {
897 let _ = tx.send(ClassifiedBatch::Preset(preset_batch));
898 }
899 if !pdf_batch.is_empty() {
900 let _ = tx.send(ClassifiedBatch::Pdf(pdf_batch));
901 }
902
903 subdirs.par_iter().for_each(|subdir| {
904 walk_dir_parallel(
905 subdir,
906 depth + 1,
907 visited,
908 tx,
909 audio_found,
910 daw_found,
911 preset_found,
912 pdf_found,
913 batch_size,
914 stop,
915 spec,
916 active_dirs,
917 tcc_denied,
918 incremental.clone(),
919 );
920 });
921
922 if let Some(ref inc) = incremental {
923 inc.record_scanned_dir(dir);
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930 use std::collections::HashMap;
931 use std::fs::{self, File};
932 use std::io::Write;
933 use std::path::Path;
934 use std::sync::Arc;
935
936 fn try_symlink(target: &Path, link: &Path) -> bool {
937 #[cfg(unix)]
938 {
939 std::os::unix::fs::symlink(target, link).is_ok()
940 }
941 #[cfg(windows)]
942 {
943 std::os::windows::fs::symlink_file(target, link).is_ok()
944 }
945 #[cfg(not(any(unix, windows)))]
946 {
947 let _ = (target, link);
948 false
949 }
950 }
951
952 struct TestDir {
953 path: PathBuf,
954 }
955 impl TestDir {
956 fn new(name: &str) -> Self {
957 let path = std::env::temp_dir().join(format!(
958 "upum_uw_{}_{}",
959 name,
960 std::time::SystemTime::now()
961 .duration_since(std::time::UNIX_EPOCH)
962 .unwrap()
963 .as_nanos()
964 ));
965 let _ = fs::remove_dir_all(&path);
966 fs::create_dir_all(&path).unwrap();
967 Self { path }
968 }
969 }
970 impl Drop for TestDir {
971 fn drop(&mut self) {
972 let _ = fs::remove_dir_all(&self.path);
973 }
974 }
975
976 fn touch(p: &Path, content: &[u8]) {
977 if let Some(parent) = p.parent() {
978 fs::create_dir_all(parent).unwrap();
979 }
980 let mut f = File::create(p).unwrap();
981 f.write_all(content).unwrap();
982 }
983
984 #[test]
985 fn test_under_any_root() {
986 let roots = vec![PathBuf::from("/a/b"), PathBuf::from("/x/y")];
987 assert!(under_any_root(Path::new("/a/b/c.wav"), &roots));
988 assert!(under_any_root(Path::new("/x/y/z/1.pdf"), &roots));
989 assert!(!under_any_root(Path::new("/a/other.wav"), &roots));
990 assert!(!under_any_root(Path::new("/q.wav"), &roots));
991 assert!(!under_any_root(Path::new("/a/b/c.wav"), &[]));
992 }
993
994 #[test]
995 fn test_ext_match() {
996 assert_eq!(
997 ext_match("song.wav", AUDIO_EXTENSIONS),
998 Some(".wav".to_string())
999 );
1000 assert_eq!(
1001 ext_match("mix.flp", DAW_EXTENSIONS),
1002 Some(".flp".to_string())
1003 );
1004 assert_eq!(ext_match("readme.txt", AUDIO_EXTENSIONS), None);
1005 }
1006
1007 #[test]
1008 fn test_walk_unified_classifies_by_extension() {
1009 let tmp = TestDir::new("classify");
1010 let root = tmp.path.clone();
1011 touch(&root.join("a.wav"), b"RIFF");
1012 touch(&root.join("b.pdf"), b"%PDF-1.4");
1013 touch(&root.join("c.fxp"), b"CcnK");
1014 touch(&root.join("d.als"), b"<?xml");
1015 touch(&root.join("e.mid"), b"MThd");
1016 touch(&root.join("skip.txt"), b"junk");
1017
1018 let spec = UnifiedSpec {
1019 audio_roots: vec![root.clone()],
1020 daw_roots: vec![root.clone()],
1021 preset_roots: vec![root.clone()],
1022 pdf_roots: vec![root.clone()],
1023 ..Default::default()
1024 };
1025
1026 let mut audio = Vec::new();
1027 let mut daw = Vec::new();
1028 let mut preset = Vec::new();
1029 let mut pdf = Vec::new();
1030
1031 walk_unified(
1032 &spec,
1033 &mut |batch, _counts| match batch {
1034 ClassifiedBatch::Audio(b) => audio.extend(b),
1035 ClassifiedBatch::Daw(b) => daw.extend(b),
1036 ClassifiedBatch::Preset(b) => preset.extend(b),
1037 ClassifiedBatch::Pdf(b) => pdf.extend(b),
1038 },
1039 &|| false,
1040 Vec::new(),
1041 None,
1042 );
1043
1044 assert_eq!(audio.len(), 1, "expected 1 audio file");
1045 assert_eq!(audio[0].format, "WAV");
1046 assert_eq!(pdf.len(), 1, "expected 1 pdf");
1047 assert_eq!(daw.len(), 1, "expected 1 daw");
1048 assert_eq!(daw[0].format, "ALS");
1049 assert_eq!(
1050 preset.len(),
1051 1,
1052 "expected 1 preset (fxp) — .mid routes to MIDI bucket"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_walk_unified_follows_symlink_to_als() {
1058 let tmp = TestDir::new("symlink_als");
1059 let root = tmp.path.clone();
1060 touch(&root.join("real.als"), b"<?xml");
1061 let link = root.join("link.als");
1062 if !try_symlink(&root.join("real.als"), &link) {
1063 return;
1064 }
1065
1066 let spec = UnifiedSpec {
1067 daw_roots: vec![root.clone()],
1068 ..Default::default()
1069 };
1070
1071 let mut daw = Vec::new();
1072 walk_unified(
1073 &spec,
1074 &mut |batch, _counts| {
1075 if let ClassifiedBatch::Daw(b) = batch {
1076 daw.extend(b);
1077 }
1078 },
1079 &|| false,
1080 Vec::new(),
1081 None,
1082 );
1083
1084 assert_eq!(daw.len(), 2, "real file + symlink path both classify as DAW");
1085 assert!(daw.iter().any(|d| d.path.ends_with("link.als")));
1086 assert!(daw.iter().any(|d| d.path.ends_with("real.als")));
1087 }
1088
1089 #[test]
1090 fn test_walk_unified_respects_per_type_roots() {
1091 let tmp = TestDir::new("pertype");
1094 let root = tmp.path.clone();
1095 touch(&root.join("a.wav"), b"RIFF");
1096 touch(&root.join("b.pdf"), b"%PDF-1.4");
1097 touch(&root.join("c.fxp"), b"CcnK");
1098
1099 let spec = UnifiedSpec {
1100 audio_roots: vec![root.clone()],
1101 ..Default::default()
1102 };
1103
1104 let mut audio = 0usize;
1105 let mut other = 0usize;
1106 walk_unified(
1107 &spec,
1108 &mut |batch, _| match batch {
1109 ClassifiedBatch::Audio(b) => audio += b.len(),
1110 _ => other += 1,
1111 },
1112 &|| false,
1113 Vec::new(),
1114 None,
1115 );
1116 assert_eq!(audio, 1);
1117 assert_eq!(other, 0, "no batches for unconfigured types");
1118 }
1119
1120 #[test]
1121 fn test_walk_unified_skips_hidden_and_skip_dirs() {
1122 let tmp = TestDir::new("skipdirs");
1123 let root = tmp.path.clone();
1124 touch(&root.join("keep.wav"), b"RIFF");
1125 touch(&root.join(".hidden.wav"), b"RIFF");
1126 touch(&root.join("node_modules/dep.wav"), b"RIFF");
1127 touch(&root.join("bower_components/legacy.wav"), b"RIFF");
1128 touch(&root.join("target/debug.wav"), b"RIFF");
1129 touch(&root.join("htmlcov/cov.wav"), b"RIFF");
1130 touch(&root.join("coverage/lcov.wav"), b"RIFF");
1131 touch(&root.join("Caches/thing.wav"), b"RIFF");
1132 touch(&root.join("DerivedData/build.wav"), b"RIFF");
1133 touch(&root.join("@eaDir/thumb.wav"), b"RIFF");
1135 touch(&root.join("@tmp/work.wav"), b"RIFF");
1136 touch(&root.join("@SynoDrive/sync.wav"), b"RIFF");
1137 touch(&root.join("#snapshot/old.wav"), b"RIFF");
1138 touch(&root.join("#recycle/trash.wav"), b"RIFF");
1139
1140 let spec = UnifiedSpec {
1141 audio_roots: vec![root.clone()],
1142 ..Default::default()
1143 };
1144
1145 let mut names: Vec<String> = Vec::new();
1146 walk_unified(
1147 &spec,
1148 &mut |batch, _| {
1149 if let ClassifiedBatch::Audio(b) = batch {
1150 names.extend(b.into_iter().map(|s| s.name));
1151 }
1152 },
1153 &|| false,
1154 Vec::new(),
1155 None,
1156 );
1157 assert_eq!(names, vec!["keep".to_string()]);
1158 }
1159
1160 #[test]
1161 fn test_walk_unified_path_not_under_root_excluded() {
1162 let tmp = TestDir::new("rootcheck");
1165 let root = tmp.path.clone();
1166 let audio_dir = root.join("samples");
1167 let pdf_dir = root.join("docs");
1168 touch(&audio_dir.join("a.wav"), b"RIFF");
1169 touch(&pdf_dir.join("cross.wav"), b"RIFF"); touch(&pdf_dir.join("doc.pdf"), b"%PDF");
1171
1172 let spec = UnifiedSpec {
1173 audio_roots: vec![audio_dir.clone()],
1174 pdf_roots: vec![pdf_dir.clone()],
1175 ..Default::default()
1176 };
1177
1178 let mut audio = Vec::new();
1179 let mut pdf = Vec::new();
1180 walk_unified(
1181 &spec,
1182 &mut |batch, _| match batch {
1183 ClassifiedBatch::Audio(b) => audio.extend(b),
1184 ClassifiedBatch::Pdf(b) => pdf.extend(b),
1185 _ => {}
1186 },
1187 &|| false,
1188 Vec::new(),
1189 None,
1190 );
1191 assert_eq!(audio.len(), 1, "only audio under audio_roots counts");
1192 assert_eq!(audio[0].name, "a");
1193 assert_eq!(pdf.len(), 1);
1194 }
1195
1196 #[test]
1197 fn test_walk_unified_stop_flag() {
1198 let tmp = TestDir::new("stopflag");
1199 let root = tmp.path.clone();
1200 for i in 0..200 {
1201 touch(&root.join(format!("f{}.wav", i)), b"RIFF");
1202 }
1203 let spec = UnifiedSpec {
1204 audio_roots: vec![root.clone()],
1205 ..Default::default()
1206 };
1207 walk_unified(&spec, &mut |_, _| {}, &|| true, Vec::new(), None);
1208 }
1211
1212 #[test]
1213 fn test_walk_unified_excludes_specific_paths() {
1214 let tmp = TestDir::new("exclude");
1215 let root = tmp.path.clone();
1216 touch(&root.join("keep.wav"), b"RIFF");
1217 touch(&root.join("skip.wav"), b"RIFF");
1218 let skip_path = root.join("skip.wav").to_string_lossy().to_string();
1219 let mut excl = HashSet::new();
1220 excl.insert(skip_path);
1221 let spec = UnifiedSpec {
1222 audio_roots: vec![root.clone()],
1223 audio_exclude: excl,
1224 ..Default::default()
1225 };
1226 let mut names = Vec::new();
1227 walk_unified(
1228 &spec,
1229 &mut |batch, _| {
1230 if let ClassifiedBatch::Audio(b) = batch {
1231 names.extend(b.into_iter().map(|s| s.name));
1232 }
1233 },
1234 &|| false,
1235 Vec::new(),
1236 None,
1237 );
1238 assert_eq!(names, vec!["keep".to_string()]);
1239 }
1240
1241 #[test]
1242 fn test_walk_unified_empty_spec() {
1243 let tmp = TestDir::new("empty");
1244 let root = tmp.path.clone();
1245 touch(&root.join("a.wav"), b"RIFF");
1246 let spec = UnifiedSpec::default();
1247 let mut batches = 0;
1248 walk_unified(
1249 &spec,
1250 &mut |_, _| {
1251 batches += 1;
1252 },
1253 &|| false,
1254 Vec::new(),
1255 None,
1256 );
1257 assert_eq!(batches, 0, "empty spec produces no output");
1258 }
1259
1260 #[test]
1261 fn test_incremental_skips_unchanged_directory_tree() {
1262 let tmp = TestDir::new("incskip");
1263 let root = tmp.path.clone();
1264 touch(&root.join("a.wav"), b"RIFF");
1265 let key = directory_incremental_key(&root);
1266 let m = dir_mtime_secs(&root);
1267 let mut snap = HashMap::new();
1268 snap.insert(key, m);
1269 let spec = UnifiedSpec {
1270 audio_roots: vec![root.clone()],
1271 ..Default::default()
1272 };
1273 let mut count = 0usize;
1274 walk_unified(
1275 &spec,
1276 &mut |batch, _| {
1277 if let ClassifiedBatch::Audio(b) = batch {
1278 count += b.len();
1279 }
1280 },
1281 &|| false,
1282 Vec::new(),
1283 Some(Arc::new(IncrementalDirState::new(snap))),
1284 );
1285 assert_eq!(
1286 count, 0,
1287 "snapshot matching current dir mtime skips subtree"
1288 );
1289 }
1290
1291 #[test]
1293 fn daw_name_for_format_maps_all_known_codes() {
1294 assert_eq!(daw_name_for_format("ALS"), "Ableton Live");
1295 assert_eq!(daw_name_for_format("LOGICX"), "Logic Pro");
1296 assert_eq!(daw_name_for_format("FLP"), "FL Studio");
1297 assert_eq!(daw_name_for_format("CPR"), "Cubase");
1298 assert_eq!(daw_name_for_format("NPR"), "Nuendo");
1299 assert_eq!(daw_name_for_format("BWPROJECT"), "Bitwig Studio");
1300 assert_eq!(daw_name_for_format("RPP"), "REAPER");
1301 assert_eq!(daw_name_for_format("RPP-BAK"), "REAPER");
1302 assert_eq!(daw_name_for_format("PTX"), "Pro Tools");
1303 assert_eq!(daw_name_for_format("PTF"), "Pro Tools");
1304 assert_eq!(daw_name_for_format("SONG"), "Studio One");
1305 assert_eq!(daw_name_for_format("REASON"), "Reason");
1306 assert_eq!(daw_name_for_format("AUP"), "Audacity");
1307 assert_eq!(daw_name_for_format("AUP3"), "Audacity");
1308 assert_eq!(daw_name_for_format("BAND"), "GarageBand");
1309 assert_eq!(daw_name_for_format("ARDOUR"), "Ardour");
1310 assert_eq!(daw_name_for_format("DAWPROJECT"), "DAWproject");
1311 assert_eq!(daw_name_for_format("___unknown___"), "Unknown");
1312 assert_eq!(daw_name_for_format(""), "Unknown");
1313 }
1314
1315 #[test]
1316 fn normalize_macos_path_strips_system_data_volume_prefix() {
1317 let p = PathBuf::from("/System/Volumes/Data/Users/foo/bar");
1318 let n = normalize_macos_path(p);
1319 #[cfg(target_os = "macos")]
1320 {
1321 assert_eq!(n, PathBuf::from("/Users/foo/bar"));
1322 }
1323 #[cfg(not(target_os = "macos"))]
1324 {
1325 assert_eq!(n, PathBuf::from("/System/Volumes/Data/Users/foo/bar"));
1326 }
1327 }
1328
1329 #[test]
1330 fn normalize_macos_path_noop_when_not_data_volume() {
1331 let p = PathBuf::from("/home/user/Projects");
1332 assert_eq!(
1333 normalize_macos_path(p),
1334 PathBuf::from("/home/user/Projects")
1335 );
1336 }
1337}