1use std::collections::{HashMap, HashSet};
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::Duration;
25
26use tokio::sync::broadcast;
27
28use super::fs_watch::{FsWatchEventKind, FsWatchPool, Subscription};
29use super::mps::Broker;
30
31pub const EVENT_MEDIA_FILE_CHANGED: &str = "media:file_changed";
38
39const DEBOUNCE: Duration = Duration::from_millis(300);
40
41struct Inner {
42 watched_dirs: HashMap<PathBuf, HashMap<String, HashSet<String>>>,
46 pool_subs: HashMap<PathBuf, Subscription>,
50}
51
52type DebounceGens = Mutex<HashMap<PathBuf, Arc<AtomicU64>>>;
57
58pub struct MediaFileWatcher {
59 pool: Arc<FsWatchPool>,
60 inner: Mutex<Inner>,
61 debounce_gens: DebounceGens,
62 broker: Arc<Broker>,
63}
64
65impl MediaFileWatcher {
66 pub fn new(pool: Arc<FsWatchPool>, broker: Arc<Broker>) -> Arc<Self> {
70 let this = Arc::new(Self {
71 pool: pool.clone(),
72 inner: Mutex::new(Inner { watched_dirs: HashMap::new(), pool_subs: HashMap::new() }),
73 debounce_gens: Mutex::new(HashMap::new()),
74 broker,
75 });
76
77 let mut events = pool.events();
78 let worker = this.clone();
79 tokio::spawn(async move {
80 loop {
81 match events.recv().await {
82 Ok(ev) if matches!(ev.kind, FsWatchEventKind::Created | FsWatchEventKind::Modified) => {
87 worker.handle_fs_event(ev.path)
88 }
89 Ok(_) => continue,
90 Err(broadcast::error::RecvError::Closed) => break,
91 Err(broadcast::error::RecvError::Lagged(skipped)) => {
92 tracing::warn!(skipped, "media file watcher lagged behind the fs_watch broadcast stream");
93 }
94 }
95 }
96 });
97
98 this
99 }
100
101 pub fn watch_directory(&self, dir: &Path, block_id: &str, extensions: &[String]) {
107 let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
108 let ext_set: HashSet<String> = extensions.iter().map(|e| e.to_lowercase()).collect();
109
110 let mut inner = self.inner.lock().unwrap();
111 let subscribers = inner.watched_dirs.entry(canonical.clone()).or_default();
112 let is_new_dir = subscribers.is_empty();
113 subscribers.insert(block_id.to_string(), ext_set);
114
115 if is_new_dir {
116 let sub = self.pool.subscribe_dir(&canonical);
117 inner.pool_subs.insert(canonical.clone(), sub);
118 }
119 tracing::debug!(dir = %canonical.display(), block_id, "media dir watch: started/updated");
120 }
121
122 pub fn unwatch_directory(&self, dir: &Path, block_id: &str) {
125 let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
126 let mut inner = self.inner.lock().unwrap();
127 let Some(subscribers) = inner.watched_dirs.get_mut(&canonical) else {
128 return;
129 };
130 subscribers.remove(block_id);
131 if !subscribers.is_empty() {
132 return;
133 }
134 inner.watched_dirs.remove(&canonical);
135 if let Some(sub) = inner.pool_subs.remove(&canonical) {
136 self.pool.unsubscribe(sub);
137 }
138 drop(inner);
139
140 let mut gens = self.debounce_gens.lock().unwrap();
144 gens.retain(|path, _| path.parent() != Some(canonical.as_path()));
145
146 tracing::debug!(dir = %canonical.display(), block_id, "media dir watch: stopped");
147 }
148
149 fn handle_fs_event(self: &Arc<Self>, changed_path: PathBuf) {
150 let Some(dir) = changed_path.parent().map(Path::to_path_buf) else {
151 return;
152 };
153 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
154
155 let matched_block_ids: Vec<String> = {
156 let inner = self.inner.lock().unwrap();
157 let subscribers = inner
161 .watched_dirs
162 .get(&canonical_dir)
163 .or_else(|| inner.watched_dirs.get(&dir));
164 let Some(subscribers) = subscribers else { return };
165
166 let ext = changed_path
167 .extension()
168 .and_then(|e| e.to_str())
169 .map(|e| e.to_lowercase())
170 .unwrap_or_default();
171 if ext.is_empty() {
172 return;
173 }
174 subscribers
175 .iter()
176 .filter(|(_, exts)| exts.contains(&ext))
177 .map(|(block_id, _)| block_id.clone())
178 .collect()
179 };
180 if matched_block_ids.is_empty() {
181 return;
182 }
183
184 let gen_counter = {
185 let mut gens = self.debounce_gens.lock().unwrap();
186 gens.entry(changed_path.clone())
187 .or_insert_with(|| Arc::new(AtomicU64::new(0)))
188 .clone()
189 };
190 let my_gen = gen_counter.fetch_add(1, Ordering::SeqCst) + 1;
191
192 let this = self.clone();
193 tokio::spawn(async move {
194 tokio::time::sleep(DEBOUNCE).await;
195 if gen_counter.load(Ordering::SeqCst) != my_gen {
196 return; }
198 if !changed_path.exists() {
207 return;
208 }
209 publish_media_file_changed(&this.broker, &changed_path, &matched_block_ids);
210 });
211 }
212}
213
214fn publish_media_file_changed(broker: &Broker, path: &Path, block_ids: &[String]) {
219 broker.publish_event::<crate::backend::mps_events::MediaFileChanged>(
220 &crate::backend::mps_events::PathChangedEvent { path: path.to_string_lossy().into_owned() },
221 block_ids.iter().map(|id| format!("block:{id}")).collect(),
222 0,
223 );
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::backend::mps::MuxEvent;
230 use std::sync::Mutex as StdMutex;
231
232 struct TestClient {
233 events: StdMutex<Vec<(String, MuxEvent)>>,
234 }
235
236 impl super::super::mps::WpsClient for Arc<TestClient> {
237 fn send_event(&self, route_id: &str, event: MuxEvent) {
238 self.events.lock().unwrap().push((route_id.to_string(), event));
239 }
240 }
241
242 #[tokio::test]
243 async fn test_watch_unwatch_refcounting_does_not_panic() {
244 let broker = Arc::new(Broker::new());
245 let pool = FsWatchPool::new();
246 let watcher = MediaFileWatcher::new(pool, broker);
247
248 let tmp_dir = std::env::temp_dir().join("agentmux_media_watch_test");
249 std::fs::create_dir_all(&tmp_dir).unwrap();
250 let exts = vec!["webm".to_string(), "png".to_string()];
251
252 watcher.watch_directory(&tmp_dir, "block-1", &exts);
253 watcher.watch_directory(&tmp_dir, "block-2", &exts);
254 {
255 let inner = watcher.inner.lock().unwrap();
256 let canonical = tmp_dir.canonicalize().unwrap();
257 assert_eq!(inner.watched_dirs.get(&canonical).map(|s| s.len()), Some(2));
258 }
259
260 watcher.unwatch_directory(&tmp_dir, "block-1");
261 {
262 let inner = watcher.inner.lock().unwrap();
263 let canonical = tmp_dir.canonicalize().unwrap();
264 assert_eq!(inner.watched_dirs.get(&canonical).map(|s| s.len()), Some(1));
265 }
266
267 watcher.unwatch_directory(&tmp_dir, "block-2");
268 {
269 let inner = watcher.inner.lock().unwrap();
270 let canonical = tmp_dir.canonicalize().unwrap();
271 assert!(!inner.watched_dirs.contains_key(&canonical));
272 }
273
274 let _ = std::fs::remove_dir_all(&tmp_dir);
275 }
276
277 #[test]
278 fn test_publish_scopes_to_every_subscribed_block() {
279 let broker = Arc::new(Broker::new());
280 let client = Arc::new(TestClient { events: StdMutex::new(Vec::new()) });
281 broker.set_client(Box::new(client.clone()));
282
283 broker.subscribe(
284 "route-1",
285 super::super::mps::SubscriptionRequest {
286 event: EVENT_MEDIA_FILE_CHANGED.to_string(),
287 scopes: vec!["block:abc".to_string()],
288 allscopes: false,
289 },
290 );
291
292 publish_media_file_changed(&broker, Path::new("/tmp/clips/shot.webm"), &["abc".to_string(), "xyz".to_string()]);
293
294 let events = client.events.lock().unwrap();
295 assert_eq!(events.len(), 1);
296 assert_eq!(events[0].0, "route-1");
297 assert_eq!(events[0].1.event, EVENT_MEDIA_FILE_CHANGED);
298 }
299
300 #[test]
301 fn test_extension_filter_excludes_non_matching_block() {
302 let broker = Arc::new(Broker::new());
305 let client = Arc::new(TestClient { events: StdMutex::new(Vec::new()) });
306 broker.set_client(Box::new(client.clone()));
307
308 broker.subscribe(
309 "route-png-only",
310 super::super::mps::SubscriptionRequest {
311 event: EVENT_MEDIA_FILE_CHANGED.to_string(),
312 scopes: vec!["block:png-block".to_string()],
313 allscopes: false,
314 },
315 );
316
317 publish_media_file_changed(&broker, Path::new("/tmp/clips/shot.webm"), &["webm-block".to_string()]);
320
321 let events = client.events.lock().unwrap();
322 assert_eq!(events.len(), 0, "png-only block must not receive a .webm change event");
323 }
324
325 #[tokio::test]
326 async fn test_end_to_end_real_fs_write_triggers_publish_for_matching_extension() {
327 let broker = Arc::new(Broker::new());
331 let client = Arc::new(TestClient { events: StdMutex::new(Vec::new()) });
332 broker.set_client(Box::new(client.clone()));
333 broker.subscribe(
334 "route-e2e",
335 super::super::mps::SubscriptionRequest {
336 event: EVENT_MEDIA_FILE_CHANGED.to_string(),
337 scopes: vec!["block:e2e".to_string()],
338 allscopes: false,
339 },
340 );
341
342 let pool = FsWatchPool::new();
343 let watcher = MediaFileWatcher::new(pool, broker);
344
345 let dir = std::env::temp_dir().join("agentmux_media_watch_e2e_test");
346 let _ = std::fs::remove_dir_all(&dir);
347 std::fs::create_dir_all(&dir).unwrap();
348
349 watcher.watch_directory(&dir, "e2e", &["webm".to_string()]);
350 tokio::time::sleep(Duration::from_millis(200)).await;
351 std::fs::write(dir.join("render.webm"), b"fake video bytes").unwrap();
352
353 let saw_it = tokio::time::timeout(Duration::from_secs(5), async {
354 loop {
355 if !client.events.lock().unwrap().is_empty() {
356 return true;
357 }
358 tokio::time::sleep(Duration::from_millis(50)).await;
359 }
360 })
361 .await
362 .unwrap_or(false);
363
364 assert!(saw_it, "expected a real .webm write to reach the publish path via the shared pool");
365
366 let _ = std::fs::remove_dir_all(&dir);
367 }
368
369 #[tokio::test]
370 async fn test_removed_event_does_not_trigger_publish() {
371 let broker = Arc::new(Broker::new());
377 let client = Arc::new(TestClient { events: StdMutex::new(Vec::new()) });
378 broker.set_client(Box::new(client.clone()));
379 broker.subscribe(
380 "route-removed",
381 super::super::mps::SubscriptionRequest {
382 event: EVENT_MEDIA_FILE_CHANGED.to_string(),
383 scopes: vec!["block:removed".to_string()],
384 allscopes: false,
385 },
386 );
387
388 let pool = FsWatchPool::new();
389 let watcher = MediaFileWatcher::new(pool, broker);
390
391 let dir = std::env::temp_dir().join("agentmux_media_watch_removed_test");
392 let _ = std::fs::remove_dir_all(&dir);
393 std::fs::create_dir_all(&dir).unwrap();
394 let file = dir.join("render.webm");
395 std::fs::write(&file, b"fake video bytes").unwrap();
396
397 watcher.watch_directory(&dir, "removed", &["webm".to_string()]);
398 tokio::time::sleep(Duration::from_millis(200)).await;
399 std::fs::remove_file(&file).unwrap();
400
401 tokio::time::sleep(Duration::from_millis(500)).await;
404 assert!(
405 client.events.lock().unwrap().is_empty(),
406 "a Removed event must not trigger EVENT_MEDIA_FILE_CHANGED"
407 );
408
409 let _ = std::fs::remove_dir_all(&dir);
410 }
411}