audiorouter_core/
monitor.rs1use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::time::{Duration, Instant};
20
21use crate::device_inventory::{DevicesResponse, device_diff, list_audio_devices};
22
23pub struct DevicePoller {
36 interval: Duration,
37 last_poll: Instant,
38 prev: DevicesResponse,
39}
40
41impl DevicePoller {
42 pub fn new(interval: Duration) -> Self {
47 let prev = list_audio_devices().unwrap_or_default();
48 Self {
49 interval,
50 last_poll: Instant::now(),
51 prev,
52 }
53 }
54
55 pub fn poll(&mut self) -> Option<Vec<String>> {
60 if self.last_poll.elapsed() < self.interval {
61 return None;
62 }
63 self.last_poll = Instant::now();
64 let curr = list_audio_devices().ok()?;
65 let events = device_diff(&self.prev, &curr);
66 if events.is_empty() {
67 return None;
68 }
69 self.prev = curr;
70 Some(events)
71 }
72
73 pub fn snapshot(&self) -> &DevicesResponse {
75 &self.prev
76 }
77}
78
79pub struct ConfigFileWatcher {
90 config_changed: Arc<AtomicBool>,
91}
92
93impl ConfigFileWatcher {
94 pub fn new(config_path: &Path) -> Self {
99 let config_changed = Arc::new(AtomicBool::new(false));
100 let flag = config_changed.clone();
101 let watch_path = config_path.to_path_buf();
102
103 std::thread::spawn(move || {
104 use notify::{EventKind, RecursiveMode, Watcher};
105
106 let (tx, rx) = std::sync::mpsc::channel();
107 let mut watcher = match notify::recommended_watcher(tx) {
108 Ok(w) => w,
109 Err(e) => {
110 tracing::warn!("config watch disabled: {e}");
111 return;
112 }
113 };
114
115 let canonical_watch_path = std::fs::canonicalize(&watch_path).ok();
116
117 for watch_dir in config_watch_dirs(&watch_path, canonical_watch_path.as_deref()) {
118 if let Err(e) = watcher.watch(&watch_dir, RecursiveMode::NonRecursive) {
119 tracing::warn!("config watch disabled: {e}");
120 return;
121 }
122 }
123
124 for event in rx.into_iter().flatten() {
125 let is_config_event = config_event_matches(
126 &event.paths,
127 &watch_path,
128 canonical_watch_path.as_deref(),
129 );
130 if !is_config_event {
131 continue;
132 }
133 if matches!(
134 event.kind,
135 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
136 ) {
137 flag.store(true, Ordering::SeqCst);
138 }
139 }
140 });
141
142 Self { config_changed }
143 }
144
145 pub fn poll(&self) -> bool {
147 self.config_changed.swap(false, Ordering::SeqCst)
148 }
149}
150
151fn config_watch_dirs(watch_path: &Path, canonical_watch_path: Option<&Path>) -> Vec<PathBuf> {
159 let mut dirs = Vec::new();
160 push_unique_path(
161 &mut dirs,
162 watch_path.parent().unwrap_or(Path::new(".")).to_path_buf(),
163 );
164 if let Some(canonical_watch_path) = canonical_watch_path {
165 push_unique_path(
166 &mut dirs,
167 canonical_watch_path
168 .parent()
169 .unwrap_or(Path::new("."))
170 .to_path_buf(),
171 );
172 }
173 let mut seen: HashSet<PathBuf> = HashSet::new();
175 dirs.retain(|d| seen.insert(d.clone()));
176 dirs
177}
178
179fn config_event_matches(
180 event_paths: &[PathBuf],
181 watch_path: &Path,
182 canonical_watch_path: Option<&Path>,
183) -> bool {
184 event_paths
185 .iter()
186 .any(|p| p == watch_path || canonical_watch_path.is_some_and(|canonical| p == canonical))
187}
188
189fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
190 if !paths.iter().any(|existing| existing == &path) {
191 paths.push(path);
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn config_symlink_watches_and_matches_real_target_file() {
201 let link_path = PathBuf::from("/tmp/audiorouter-test/link/config.toml");
202 let target_path = PathBuf::from("/tmp/audiorouter-test/real/config.toml");
203 let dirs = config_watch_dirs(&link_path, Some(&target_path));
204 assert!(dirs.iter().any(|d| d.ends_with("link")));
205 assert!(dirs.iter().any(|d| d.ends_with("real")));
206 }
207
208 #[test]
209 fn config_event_matches_direct_and_canonical() {
210 let watch = Path::new("/tmp/audiorouter-test/config.toml");
211 let canonical = Path::new("/tmp/audiorouter-test/real/config.toml");
212
213 assert!(config_event_matches(&[watch.to_path_buf()], watch, None));
214 assert!(config_event_matches(
215 &[canonical.to_path_buf()],
216 watch,
217 Some(canonical)
218 ));
219 assert!(!config_event_matches(
220 &[PathBuf::from("/tmp/other.toml")],
221 watch,
222 Some(canonical)
223 ));
224 }
225}