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:
2026-03-06 11:17:02 +02:00
parent 06860163f4
commit e1c2e11165
4 changed files with 238 additions and 0 deletions

View File

@@ -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")

View 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()))
}