1use 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
19const PLUGIN_EXTS: &[&str] = &["dll", "vst3", "component", "clap", "aaxplugin"];
21
22fn 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
31fn 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
48fn 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
68pub 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
91pub fn start_watching(
94 app: &AppHandle,
95 state: &FileWatcherState,
96 dirs: Vec<String>,
97) -> Result<(), String> {
98 stop_watching(state);
100
101 let app_handle = app.clone();
102
103 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 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 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; }
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 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
199pub fn stop_watching(state: &FileWatcherState) {
201 let mut w = state.watcher.lock().unwrap();
202 *w = None; state.watching.store(false, Ordering::SeqCst);
204 state.watched_dirs.lock().unwrap().clear();
205}
206
207pub fn is_watching(state: &FileWatcherState) -> bool {
209 state.watching.load(Ordering::SeqCst)
210}
211
212pub 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 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 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}