app_lib/
file_watcher.rs

1//! Filesystem watcher for auto-scanning new/changed audio files, DAW projects, presets, plugins, PDFs, and MIDI.
2//!
3//! Uses the `notify` crate (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows) to watch
4//! configured scan directories. On create/modify, classifies paths by extension, maps each path to a **scan root**
5//! (parent dir for files; bundle dirs as-is), debounces 2s, collapses nested roots, then emits `file-watcher-change`
6//! with `roots_by_category` so the UI runs each scanner **only on those subtrees**.
7
8use crate::audio_extensions::is_audio_extension_lowercase;
9use crate::daw_scanner::is_daw_extension_lowercase;
10use crate::preset_scanner::is_preset_extension_lowercase;
11use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17use tauri::{AppHandle, Emitter};
18
19/// Plugin extensions.
20const PLUGIN_EXTS: &[&str] = &["dll", "vst3", "component", "clap", "aaxplugin"];
21
22/// Directory to pass to scanners: bundle dirs (`.vst3`, `.logicx`, …) as-is; files use their parent folder.
23fn scan_root_for_changed_path(path: &Path) -> Option<PathBuf> {
24    if path.is_dir() {
25        Some(path.to_path_buf())
26    } else {
27        path.parent().map(Path::to_path_buf)
28    }
29}
30
31/// Drop redundant roots: if `/a` and `/a/b` both changed, keep only `/a`.
32fn minimize_scan_roots(paths: Vec<PathBuf>) -> Vec<PathBuf> {
33    if paths.is_empty() {
34        return Vec::new();
35    }
36    let mut paths: Vec<PathBuf> = paths.into_iter().collect();
37    paths.sort_by_key(|p| p.components().count());
38    let mut out: Vec<PathBuf> = Vec::new();
39    for p in paths {
40        if out.iter().any(|r| p.starts_with(r)) {
41            continue;
42        }
43        out.push(p);
44    }
45    out
46}
47
48/// Classify a file path into a change category.
49fn classify(path: &Path) -> Option<&'static str> {
50    let ext = path.extension()?.to_str()?.to_lowercase();
51    if is_audio_extension_lowercase(ext.as_str()) {
52        Some("audio")
53    } else if is_daw_extension_lowercase(ext.as_str()) {
54        Some("daw")
55    } else if is_preset_extension_lowercase(ext.as_str()) {
56        Some("preset")
57    } else if PLUGIN_EXTS.contains(&ext.as_str()) {
58        Some("plugin")
59    } else if ext == "pdf" {
60        Some("pdf")
61    } else if ext == "mid" || ext == "midi" {
62        Some("midi")
63    } else {
64        None
65    }
66}
67
68/// State for the file watcher.
69pub struct FileWatcherState {
70    watcher: Mutex<Option<RecommendedWatcher>>,
71    watching: AtomicBool,
72    watched_dirs: Mutex<Vec<String>>,
73}
74
75impl Default for FileWatcherState {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl FileWatcherState {
82    pub fn new() -> Self {
83        Self {
84            watcher: Mutex::new(None),
85            watching: AtomicBool::new(false),
86            watched_dirs: Mutex::new(Vec::new()),
87        }
88    }
89}
90
91/// Start watching the given directories for file changes.
92/// Debounces events and emits `file-watcher-change` to the frontend.
93pub fn start_watching(
94    app: &AppHandle,
95    state: &FileWatcherState,
96    dirs: Vec<String>,
97) -> Result<(), String> {
98    // Stop existing watcher first
99    stop_watching(state);
100
101    let app_handle = app.clone();
102
103    // Debounce: collect per-category scan roots for 2 seconds before emitting
104    let pending: Arc<Mutex<HashMap<String, HashSet<String>>>> =
105        Arc::new(Mutex::new(HashMap::new()));
106    let pending_clone = pending.clone();
107    let last_emit = Arc::new(Mutex::new(Instant::now()));
108    let last_emit_clone = last_emit.clone();
109
110    let mut watcher = RecommendedWatcher::new(
111        move |result: Result<Event, notify::Error>| {
112            let event = match result {
113                Ok(e) => e,
114                Err(_) => return,
115            };
116
117            // Only care about create/modify events
118            match event.kind {
119                EventKind::Create(_) | EventKind::Modify(_) => {}
120                _ => return,
121            }
122
123            for path in &event.paths {
124                let Some(category) = classify(path) else {
125                    continue;
126                };
127                let Some(mut root) = scan_root_for_changed_path(path) else {
128                    continue;
129                };
130                if let Ok(canonical) = root.canonicalize() {
131                    root = canonical;
132                }
133                let mut p = pending_clone.lock().unwrap();
134                p.entry(category.to_string())
135                    .or_insert_with(HashSet::new)
136                    .insert(root.to_string_lossy().to_string());
137            }
138
139            // Debounce: emit after 2 seconds of quiet
140            let mut last = last_emit_clone.lock().unwrap();
141            *last = Instant::now();
142            let pending_ref = pending_clone.clone();
143            let app_ref = app_handle.clone();
144            let last_ref = last_emit_clone.clone();
145
146            std::thread::spawn(move || {
147                std::thread::sleep(Duration::from_secs(2));
148                let last = last_ref.lock().unwrap();
149                if last.elapsed() < Duration::from_millis(1900) {
150                    return; // More events came in, skip
151                }
152                drop(last);
153
154                let mut map = pending_ref.lock().unwrap();
155                if map.is_empty() {
156                    return;
157                }
158                let categories: Vec<String> = map.keys().cloned().collect();
159                let mut roots_by_category = serde_json::Map::new();
160                for (cat, path_strs) in map.drain() {
161                    let paths: Vec<PathBuf> = path_strs.into_iter().map(PathBuf::from).collect();
162                    let minimized = minimize_scan_roots(paths);
163                    let arr: Vec<String> = minimized
164                        .into_iter()
165                        .map(|p| p.to_string_lossy().to_string())
166                        .collect();
167                    roots_by_category.insert(cat, serde_json::json!(arr));
168                }
169                let _ = app_ref.emit(
170                    "file-watcher-change",
171                    serde_json::json!({
172                        "categories": categories,
173                        "roots_by_category": roots_by_category,
174                        "timestamp": chrono::Utc::now().to_rfc3339(),
175                    }),
176                );
177            });
178        },
179        Config::default().with_poll_interval(Duration::from_secs(5)),
180    )
181    .map_err(|e| format!("Failed to create watcher: {e}"))?;
182
183    // Watch each directory
184    let mut watched = Vec::new();
185    for dir in &dirs {
186        let path = Path::new(dir);
187        if path.exists() && path.is_dir() && watcher.watch(path, RecursiveMode::Recursive).is_ok() {
188            watched.push(dir.clone());
189        }
190    }
191
192    *state.watcher.lock().unwrap() = Some(watcher);
193    *state.watched_dirs.lock().unwrap() = watched;
194    state.watching.store(true, Ordering::SeqCst);
195
196    Ok(())
197}
198
199/// Stop the file watcher.
200pub fn stop_watching(state: &FileWatcherState) {
201    let mut w = state.watcher.lock().unwrap();
202    *w = None; // Dropping the watcher stops it
203    state.watching.store(false, Ordering::SeqCst);
204    state.watched_dirs.lock().unwrap().clear();
205}
206
207/// Check if the watcher is active.
208pub fn is_watching(state: &FileWatcherState) -> bool {
209    state.watching.load(Ordering::SeqCst)
210}
211
212/// Get the list of currently watched directories.
213pub fn get_watched_dirs(state: &FileWatcherState) -> Vec<String> {
214    state.watched_dirs.lock().unwrap().clone()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use std::path::Path;
221
222    #[test]
223    fn test_classify_audio() {
224        for ext in &[
225            "wav",
226            "mp3",
227            "flac",
228            "ogg",
229            "aif",
230            "aiff",
231            "m4a",
232            "wma",
233            "opus",
234            "aac",
235            "rex",
236            "rx2",
237            "sf2",
238            "sfz",
239        ] {
240            let name = format!("test.{ext}");
241            assert_eq!(
242                classify(Path::new(&name)),
243                Some("audio"),
244                "expected audio for .{ext}"
245            );
246        }
247    }
248
249    #[test]
250    fn test_classify_daw() {
251        for ext in &[
252            "als",
253            "logicx",
254            "flp",
255            "cpr",
256            "npr",
257            "bwproject",
258            "rpp",
259            "rpp-bak",
260            "ptx",
261            "ptf",
262            "song",
263            "reason",
264            "aup",
265            "aup3",
266            "band",
267            "ardour",
268            "dawproject",
269        ] {
270            let name = format!("project.{ext}");
271            assert_eq!(
272                classify(Path::new(&name)),
273                Some("daw"),
274                "expected daw for .{ext}"
275            );
276        }
277    }
278
279    #[test]
280    fn test_classify_preset() {
281        for ext in &[
282            "fxp",
283            "fxb",
284            "vstpreset",
285            "aupreset",
286            "adv",
287            "adg",
288            "nki",
289            "nksn",
290            "h2p",
291            "syx",
292            "tfx",
293            "pjunoxl",
294        ] {
295            let name = format!("preset.{ext}");
296            assert_eq!(
297                classify(Path::new(&name)),
298                Some("preset"),
299                "expected preset for .{ext}"
300            );
301        }
302    }
303
304    #[test]
305    fn test_classify_plugin() {
306        for ext in &["dll", "vst3", "component", "clap", "aaxplugin"] {
307            let name = format!("plugin.{ext}");
308            assert_eq!(
309                classify(Path::new(&name)),
310                Some("plugin"),
311                "expected plugin for .{ext}"
312            );
313        }
314    }
315
316    #[test]
317    fn test_classify_vst2_bundle_ext_not_watched_as_plugin() {
318        // Legacy `.vst` dirs are plugins but watcher only lists modern bundle extensions
319        assert_eq!(classify(Path::new("LegacySynth.vst")), None);
320    }
321
322    #[test]
323    fn test_classify_unknown_returns_none() {
324        assert_eq!(classify(Path::new("readme.txt")), None);
325        assert_eq!(classify(Path::new("photo.png")), None);
326        assert_eq!(classify(Path::new("data.json")), None);
327        assert_eq!(classify(Path::new("noext")), None);
328    }
329
330    #[test]
331    fn test_classify_pdf_and_midi() {
332        assert_eq!(classify(Path::new("manual.pdf")), Some("pdf"));
333        assert_eq!(classify(Path::new("x.PDF")), Some("pdf"));
334        assert_eq!(classify(Path::new("song.mid")), Some("midi"));
335        assert_eq!(classify(Path::new("track.midi")), Some("midi"));
336    }
337
338    #[test]
339    fn test_minimize_scan_roots_drops_nested() {
340        let a = PathBuf::from("music");
341        let b = PathBuf::from("music/sub");
342        let out = minimize_scan_roots(vec![b, a.clone()]);
343        assert_eq!(out.len(), 1);
344        assert_eq!(out[0], a);
345    }
346
347    #[test]
348    fn test_minimize_scan_roots_keeps_siblings() {
349        let a = PathBuf::from("a/x");
350        let b = PathBuf::from("a/y");
351        let out = minimize_scan_roots(vec![a.clone(), b.clone()]);
352        assert_eq!(out.len(), 2);
353        assert!(out.contains(&a));
354        assert!(out.contains(&b));
355    }
356
357    #[test]
358    fn test_scan_root_file_is_parent() {
359        let p = Path::new("folder/track.wav");
360        assert_eq!(
361            scan_root_for_changed_path(p),
362            Some(PathBuf::from("folder"))
363        );
364    }
365
366    #[test]
367    fn test_scan_root_dir_is_self() {
368        let tmp = std::env::temp_dir().join("audio_haxor_fw_test_logicx");
369        let _ = std::fs::remove_dir_all(&tmp);
370        let bundle = tmp.join("Proj.logicx");
371        std::fs::create_dir_all(&bundle).unwrap();
372        assert!(bundle.is_dir());
373        assert_eq!(scan_root_for_changed_path(&bundle), Some(bundle.clone()));
374        let _ = std::fs::remove_dir_all(&tmp);
375    }
376
377    #[test]
378    fn test_classify_archive_double_extension_is_last_segment() {
379        // `extension()` is only the final segment — `.gz` is not audio
380        assert_eq!(classify(Path::new("backup.tar.gz")), None);
381    }
382
383    #[test]
384    fn test_classify_preset_nmsv_not_indexed() {
385        assert_eq!(
386            classify(Path::new("preset.nmsv")),
387            None,
388            ".nmsv is not in preset_scanner::PRESET_EXTENSIONS — watcher must not flag preset scans"
389        );
390    }
391
392    #[test]
393    fn test_classify_preset_clap_hyphen_not_indexed() {
394        assert_eq!(
395            classify(Path::new("Analog.clap-preset")),
396            None,
397            ".clap-preset is not in PRESET_EXTENSIONS"
398        );
399    }
400
401    #[test]
402    fn test_classify_audio_opus() {
403        assert_eq!(classify(Path::new("track.opus")), Some("audio"));
404    }
405
406    #[test]
407    fn test_classify_daw_bwproject() {
408        assert_eq!(classify(Path::new("song.bwproject")), Some("daw"));
409    }
410
411    #[test]
412    fn test_classify_daw_reaper_backup_rpp_bak() {
413        assert_eq!(
414            classify(Path::new("session.rpp-bak")),
415            Some("daw"),
416            "REAPER backups must match DAW scanner .rpp-bak"
417        );
418    }
419
420    #[test]
421    fn test_classify_preset_nkm_not_indexed() {
422        assert_eq!(classify(Path::new("Bank.nkm")), None);
423    }
424
425    #[test]
426    fn test_classify_preset_bwpreset_not_indexed() {
427        assert_eq!(classify(Path::new("Analog.bwpreset")), None);
428    }
429
430    #[test]
431    fn test_classify_preset_agr_not_indexed() {
432        assert_eq!(classify(Path::new("Swing.agr")), None);
433    }
434
435    #[test]
436    fn test_classify_case_insensitive() {
437        assert_eq!(classify(Path::new("test.WAV")), Some("audio"));
438        assert_eq!(classify(Path::new("test.Flp")), Some("daw"));
439        assert_eq!(classify(Path::new("track.RPP")), Some("daw"));
440        assert_eq!(classify(Path::new("test.FXP")), Some("preset"));
441        assert_eq!(classify(Path::new("test.DLL")), Some("plugin"));
442    }
443
444    #[test]
445    fn test_file_watcher_state_new() {
446        let state = FileWatcherState::new();
447        assert!(!state.watching.load(Ordering::SeqCst));
448        assert!(state.watcher.lock().unwrap().is_none());
449        assert!(state.watched_dirs.lock().unwrap().is_empty());
450    }
451
452    #[test]
453    fn test_is_watching_default_false() {
454        let state = FileWatcherState::new();
455        assert!(!is_watching(&state));
456    }
457
458    #[test]
459    fn test_get_watched_dirs_default_empty() {
460        let state = FileWatcherState::new();
461        assert!(get_watched_dirs(&state).is_empty());
462    }
463
464    #[test]
465    fn test_stop_watching_noop_on_fresh_state() {
466        let state = FileWatcherState::new();
467        stop_watching(&state);
468        assert!(!is_watching(&state));
469        assert!(get_watched_dirs(&state).is_empty());
470    }
471}