From e1c2e11165ff190c198a1665fc5d5931e1de6773 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 11:17:02 +0200 Subject: [PATCH] Add file watcher for watch folder functionality inotify-based folder watcher using the notify crate that detects new image files, ignores non-image files, and supports start/stop lifecycle. WatchFolder config struct for preset-linked watched directories. --- pixstrip-core/Cargo.toml | 1 + pixstrip-core/src/lib.rs | 1 + pixstrip-core/src/watcher.rs | 121 +++++++++++++++++++++++++++ pixstrip-core/tests/watcher_tests.rs | 115 +++++++++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 pixstrip-core/src/watcher.rs create mode 100644 pixstrip-core/tests/watcher_tests.rs diff --git a/pixstrip-core/Cargo.toml b/pixstrip-core/Cargo.toml index 6afddc3..cfb656b 100644 --- a/pixstrip-core/Cargo.toml +++ b/pixstrip-core/Cargo.toml @@ -20,6 +20,7 @@ little_exif = "0.4" imageproc = "0.25" ab_glyph = "0.2" dirs = "6" +notify = "7" [dev-dependencies] tempfile = "3" diff --git a/pixstrip-core/src/lib.rs b/pixstrip-core/src/lib.rs index e1e9952..a73727e 100644 --- a/pixstrip-core/src/lib.rs +++ b/pixstrip-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod pipeline; pub mod preset; pub mod storage; pub mod types; +pub mod watcher; pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") diff --git a/pixstrip-core/src/watcher.rs b/pixstrip-core/src/watcher.rs new file mode 100644 index 0000000..becd23d --- /dev/null +++ b/pixstrip-core/src/watcher.rs @@ -0,0 +1,121 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; +use std::time::Duration; + +use notify::{Event, EventKind, RecursiveMode, Watcher}; +use serde::{Deserialize, Serialize}; + +use crate::error::{PixstripError, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchFolder { + pub path: PathBuf, + pub preset_name: String, + pub recursive: bool, + pub active: bool, +} + +pub enum WatchEvent { + NewImage(PathBuf), + Error(String), +} + +pub struct FolderWatcher { + running: Arc, +} + +impl FolderWatcher { + pub fn new() -> Self { + Self { + running: Arc::new(AtomicBool::new(false)), + } + } + + pub fn start( + &self, + folder: &WatchFolder, + event_tx: mpsc::Sender, + ) -> Result<()> { + if !folder.path.exists() { + return Err(PixstripError::Config(format!( + "Watch folder does not exist: {}", + folder.path.display() + ))); + } + + self.running.store(true, Ordering::Relaxed); + let running = self.running.clone(); + let watch_path = folder.path.clone(); + let recursive = folder.recursive; + + std::thread::spawn(move || { + let (tx, rx) = mpsc::channel(); + + let mut watcher = match notify::recommended_watcher(move |res: std::result::Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }) { + Ok(w) => w, + Err(e) => { + let _ = event_tx.send(WatchEvent::Error(e.to_string())); + return; + } + }; + + let mode = if recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + + if let Err(e) = watcher.watch(&watch_path, mode) { + let _ = event_tx.send(WatchEvent::Error(e.to_string())); + return; + } + + while running.load(Ordering::Relaxed) { + match rx.recv_timeout(Duration::from_millis(500)) { + Ok(event) => { + if matches!(event.kind, EventKind::Create(_)) { + for path in event.paths { + if is_image_file(&path) { + let _ = event_tx.send(WatchEvent::NewImage(path)); + } + } + } + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + }); + + Ok(()) + } + + pub fn stop(&self) { + self.running.store(false, Ordering::Relaxed); + } + + pub fn is_running(&self) -> bool { + self.running.load(Ordering::Relaxed) + } +} + +impl Default for FolderWatcher { + fn default() -> Self { + Self::new() + } +} + +fn is_image_file(path: &Path) -> bool { + let supported = [ + "jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp", + ]; + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| supported.contains(&ext.to_lowercase().as_str())) +} diff --git a/pixstrip-core/tests/watcher_tests.rs b/pixstrip-core/tests/watcher_tests.rs new file mode 100644 index 0000000..9e18681 --- /dev/null +++ b/pixstrip-core/tests/watcher_tests.rs @@ -0,0 +1,115 @@ +use pixstrip_core::watcher::*; +use std::sync::mpsc; + +#[test] +fn watch_folder_serialization() { + let folder = WatchFolder { + path: "/home/user/photos".into(), + preset_name: "Blog Photos".into(), + recursive: true, + active: true, + }; + + let json = serde_json::to_string(&folder).unwrap(); + let deserialized: WatchFolder = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.preset_name, "Blog Photos"); + assert!(deserialized.recursive); +} + +#[test] +fn watcher_starts_and_stops() { + let dir = tempfile::tempdir().unwrap(); + let folder = WatchFolder { + path: dir.path().to_path_buf(), + preset_name: "Test".into(), + recursive: false, + active: true, + }; + + let watcher = FolderWatcher::new(); + let (tx, _rx) = mpsc::channel(); + + watcher.start(&folder, tx).unwrap(); + assert!(watcher.is_running()); + + watcher.stop(); + // Give the thread time to see the stop signal + std::thread::sleep(std::time::Duration::from_millis(600)); + assert!(!watcher.is_running()); +} + +#[test] +fn watcher_detects_new_image() { + let dir = tempfile::tempdir().unwrap(); + let folder = WatchFolder { + path: dir.path().to_path_buf(), + preset_name: "Test".into(), + recursive: false, + active: true, + }; + + let watcher = FolderWatcher::new(); + let (tx, rx) = mpsc::channel(); + + watcher.start(&folder, tx).unwrap(); + + // Wait for watcher to be ready + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Create an image file + let img_path = dir.path().join("new_photo.jpg"); + std::fs::write(&img_path, b"fake jpeg data").unwrap(); + + // Wait and check for event + let event = rx.recv_timeout(std::time::Duration::from_secs(3)); + watcher.stop(); + + match event { + Ok(WatchEvent::NewImage(path)) => { + assert_eq!(path, img_path); + } + Ok(WatchEvent::Error(e)) => panic!("Unexpected error: {}", e), + Err(_) => panic!("No event received within timeout"), + } +} + +#[test] +fn watcher_ignores_non_image_files() { + let dir = tempfile::tempdir().unwrap(); + let folder = WatchFolder { + path: dir.path().to_path_buf(), + preset_name: "Test".into(), + recursive: false, + active: true, + }; + + let watcher = FolderWatcher::new(); + let (tx, rx) = mpsc::channel(); + + watcher.start(&folder, tx).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(200)); + + // Create a non-image file + std::fs::write(dir.path().join("readme.txt"), b"text file").unwrap(); + + // Should not receive any event + let result = rx.recv_timeout(std::time::Duration::from_secs(1)); + watcher.stop(); + + assert!(result.is_err(), "Should not receive event for non-image file"); +} + +#[test] +fn watcher_rejects_nonexistent_path() { + let folder = WatchFolder { + path: "/nonexistent/path/to/watch".into(), + preset_name: "Test".into(), + recursive: false, + active: true, + }; + + let watcher = FolderWatcher::new(); + let (tx, _rx) = mpsc::channel(); + + assert!(watcher.start(&folder, tx).is_err()); +}