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.
This commit is contained in:
@@ -20,6 +20,7 @@ little_exif = "0.4"
|
|||||||
imageproc = "0.25"
|
imageproc = "0.25"
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
notify = "7"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod pipeline;
|
|||||||
pub mod preset;
|
pub mod preset;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod watcher;
|
||||||
|
|
||||||
pub fn version() -> &'static str {
|
pub fn version() -> &'static str {
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
|
|||||||
121
pixstrip-core/src/watcher.rs
Normal file
121
pixstrip-core/src/watcher.rs
Normal file
@@ -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<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FolderWatcher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
running: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(
|
||||||
|
&self,
|
||||||
|
folder: &WatchFolder,
|
||||||
|
event_tx: mpsc::Sender<WatchEvent>,
|
||||||
|
) -> 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<Event, notify::Error>| {
|
||||||
|
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()))
|
||||||
|
}
|
||||||
115
pixstrip-core/tests/watcher_tests.rs
Normal file
115
pixstrip-core/tests/watcher_tests.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user