use std::path::Path; use anyhow::Result; use tokio::time; use tracing::{info, warn}; use crate::core::db::{PostgresDb, VideoRecord, VideoStatus}; pub struct WatcherConfig { pub directories: Vec, pub poll_interval_ms: u64, } impl Default for WatcherConfig { fn default() -> Self { let default_dir = std::env::var("MOMENTRY_SFTP_ROOT") .unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data/demo/".to_string()); Self { directories: vec![default_dir], poll_interval_ms: 60000, } } } /// Starts the file watcher in the background. /// Detects new files and logs them. pub async fn run_watcher() -> Result<()> { let config = WatcherConfig::default(); let dirs = config.directories.clone(); if dirs.is_empty() { warn!("No directories configured for watching."); return Err(anyhow::anyhow!("No watch directories")); } info!("Starting File Watcher (detection only, no auto-modification)..."); info!("Watch directories: {:?}", dirs); tokio::spawn(async move { let mut interval = time::interval(std::time::Duration::from_millis(config.poll_interval_ms)); let mut known = std::collections::HashSet::new(); loop { interval.tick().await; report_new_files(&dirs, &mut known).await; } }); Ok(()) } async fn report_new_files(directories: &[String], known: &mut std::collections::HashSet) { for dir in directories { let dir_path = Path::new(dir); if !dir_path.is_dir() { continue; } let entries = match std::fs::read_dir(dir_path) { Ok(e) => e, _ => continue, }; let mut current_files = std::collections::HashSet::new(); for entry in entries.flatten() { let file_path = entry.path(); if !file_path.is_file() { continue; } let fname = match file_path.file_name().and_then(|n| n.to_str()) { Some(n) if !n.starts_with('.') && !n.ends_with(".pre.json") => n.to_string(), _ => continue, }; current_files.insert(fname.clone()); if !known.contains(&fname) { info!("[WATCHER] New file detected: {} in {}", fname, dir); if crate::core::config::get_watcher_auto_register() { let fpath = file_path.to_string_lossy().to_string(); tokio::spawn(async move { auto_register_file(&fpath).await; }); } known.insert(fname); } } known.retain(|k| current_files.contains(k)); } } async fn auto_register_file(file_path: &str) { let file_uuid = match pre_process_file(file_path).await { Some(u) => u, None => return, }; let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); let pre_path = std::path::PathBuf::from(&output_dir).join(format!("{}.pre.json", file_uuid)); let pre_content = match std::fs::read_to_string(&pre_path) { Ok(c) => c, Err(e) => { warn!("[WATCHER] Failed to read pre.json: {}", e); return; } }; let pre: serde_json::Value = match serde_json::from_str(&pre_content) { Ok(v) => v, Err(e) => { warn!("[WATCHER] Failed to parse pre.json: {}", e); return; } }; let file_name = pre .get("file_name") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); let probe = pre.get("probe_json").cloned().unwrap_or_default(); let file_type = pre .get("file_type") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); let canonical_path = pre .get("file_path") .and_then(|v| v.as_str()) .unwrap_or(file_path) .to_string(); let duration = probe .get("format") .and_then(|f| f.get("duration")) .and_then(|v| v.as_f64()) .unwrap_or(0.0); let width = probe .get("format") .and_then(|f| f.get("width")) .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; let height = probe .get("format") .and_then(|f| f.get("height")) .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; let fps_val = probe .get("format") .and_then(|f| f.get("fps")) .and_then(|v| v.as_f64()) .unwrap_or(0.0); let record = VideoRecord { id: 0, file_uuid, file_path: canonical_path, file_name, file_type: Some(file_type), duration, width, height, fps: fps_val, probe_json: Some(probe), storage: Default::default(), status: VideoStatus::Registered, processing_status: None, birth_registration: None, user_id: None, job_id: None, created_at: String::new(), registration_time: None, total_frames: 0, parent_uuid: None, cut_done: false, cut_count: 0, cut_max_duration: 0.0, scene_done: false, audio_tracks: None, }; let database_url = crate::core::config::DATABASE_URL.as_str(); let db = match PostgresDb::new(database_url).await { Ok(d) => d, Err(e) => { warn!("[WATCHER] Failed to connect DB for auto-register: {}", e); return; } }; match db.register_video(&record).await { Ok(id) => info!("[WATCHER] Auto-registered {} (id={})", record.file_uuid, id), Err(e) => warn!( "[WATCHER] Auto-register failed for {}: {}", record.file_uuid, e ), } } /// Pre-process a single file: compute SHA256 + probe + UUID → .pre.json pub async fn pre_process_file(file_path: &str) -> Option { let path = std::path::Path::new(file_path); if !path.is_file() { return None; } let canonical = path.canonicalize().ok()?; let canonical_str = canonical.to_string_lossy().to_string(); let filename = path.file_name()?.to_string_lossy().to_string(); let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); let birthday = std::fs::metadata(&path) .ok() .and_then(|m| m.modified().ok()) .map(|t| { let secs = t .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); chrono::DateTime::from_timestamp(secs as i64, 0) .map(|dt| dt.to_rfc3339()) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()) }) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); let mac = crate::core::storage::uuid::get_mac_address(); let file_uuid = crate::core::storage::uuid::compute_birth_uuid(&mac, &birthday, &canonical_str, &filename); let pre_path = std::path::PathBuf::from(&output_dir).join(format!("{}.pre.json", file_uuid)); if pre_path.exists() { info!("[PRE-PROCESS] Already pre-processed: {}", filename); return Some(file_uuid); } info!("[PRE-PROCESS] Pre-processing: {} → {}", filename, file_uuid); let content_hash = crate::core::storage::content_hash::compute_sha256(&path).unwrap_or_default(); let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR") .unwrap_or_else(|_| "/Users/accusys/momentry_core_0.1/scripts".to_string()); let python_path = std::env::var("MOMENTRY_PYTHON_PATH") .unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string()); let probe_json = crate::core::probe::unified::unified_probe(&path, &scripts_dir, &python_path).await; let file_type = probe_json .get("format") .and_then(|f| f.get("file_type")) .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); let pre_data = serde_json::json!({ "file_name": filename, "file_path": canonical_str, "content_hash": content_hash, "probe_json": probe_json, "birthday": birthday, "file_uuid": file_uuid, "file_size": std::fs::metadata(&path).ok().map(|m| m.len()).unwrap_or(0), "file_type": file_type, "pre_processed_at": chrono::Utc::now().to_rfc3339(), }); if let Ok(content) = serde_json::to_string_pretty(&pre_data) { if std::fs::write(&pre_path, content).is_ok() { info!("[PRE-PROCESS] {} → {}.pre.json", filename, file_uuid); } } Some(file_uuid) }