use anyhow::{Context, Result}; use chrono::Utc; use sqlx; use std::path::Path; use tracing::{info, warn}; use crate::core::db::{PostgresDb, VideoRecord, VideoStatus}; use crate::core::probe; use crate::core::storage::uuid as uuid_utils; use crate::core::storage::FileManager; pub struct IngestionService { db: PostgresDb, } impl IngestionService { pub fn new(db: PostgresDb) -> Self { Self { db } } pub async fn ingest(&self, file_path: &str) -> Result> { let path = Path::new(file_path); if !is_video_extension(path) { return Ok(None); } let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let filename = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); // Stable UUID based on MAC + Birthday + Filename. // Moving the file (path change) keeps the SAME identity. // 1. Look for existing Birthday (Identity Anchor) // If the file (by name) was registered before, use its original birth time. let birthday = sqlx::query_scalar::<_, chrono::DateTime>( "SELECT registration_time FROM dev.videos WHERE file_name = $1 AND registration_time IS NOT NULL LIMIT 1" ) .bind(&filename) .fetch_optional(self.db.pool()) .await .ok() .flatten() .map(|t| t.to_rfc3339()) .unwrap_or_else(|| Utc::now().to_rfc3339()); let parent = canonical_path .parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); // 2. Compute UUID let uuid = uuid_utils::compute_birth_uuid( &uuid_utils::get_mac_address(), &birthday, &canonical_path.to_string_lossy(), &filename, ); let parent = canonical_path .parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); let username = uuid_utils::extract_username_from_path(&parent); if let Ok(Some(_)) = self.db.get_video_by_uuid(&uuid).await { info!( "Video already registered: {} ({})", path.file_name().unwrap_or_default().to_string_lossy(), uuid ); return Ok(None); } info!("Starting ingestion for: {} ({})", path.display(), uuid); let probe_result = probe::probe_video(file_path) .with_context(|| format!("Failed to probe video: {}", file_path))?; let duration = probe_result .format .duration .as_ref() .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); let mut width = 0u32; let mut height = 0u32; let mut fps = 0.0; for stream in &probe_result.streams { if stream.codec_type.as_deref() == Some("video") { width = stream.width.unwrap_or(0); height = stream.height.unwrap_or(0); if let Some(fps_str) = &stream.r_frame_rate { if let Some((num, den)) = fps_str.split_once('/') { if let (Ok(n), Ok(d)) = (num.parse::(), den.parse::()) { if d > 0.0 { fps = n / d; } } } } } } let file_manager = FileManager::new(std::path::PathBuf::from(".")); let probe_json_str = serde_json::to_string_pretty(&probe_result)?; if let Err(e) = file_manager.save_json(&uuid, "probe", &probe_json_str) { warn!("Failed to save probe JSON for {}: {}", uuid, e); } else { info!("Probe JSON saved for {}", uuid); } let total_frames = { let video_stream = probe_result .streams .iter() .find(|s| s.codec_type.as_deref() == Some("video")); if let Some(stream) = video_stream { if let Some(nb_frames_str) = &stream.nb_frames { if let Ok(nb_frames) = nb_frames_str.parse::() { info!( "Using nb_frames from ffprobe: {} frames for {}", nb_frames, path.display() ); Some(nb_frames) } else { warn!( "Failed to parse nb_frames, using duration * fps fallback for {}", path.display() ); Some((duration * fps).floor() as u64) } } else { warn!( "nb_frames not available, using duration * fps fallback for {}", path.display() ); Some((duration * fps).floor() as u64) } } else { warn!("No video stream found for {}", path.display()); Some(0) } }; let birth_registration = serde_json::json!({ "registration_source": { "username": username, "original_path": parent, "original_filename": filename } }); let record = VideoRecord { id: 0, file_uuid: uuid.clone(), file_path: canonical_path.to_string_lossy().to_string(), file_name: filename, file_type: None, duration, width, height, fps, probe_json: Some(probe_json_str), storage: Default::default(), status: VideoStatus::Pending, processing_status: Some(serde_json::json!({"phase": "REGISTERED"})), birth_registration: None, user_id: None, job_id: None, created_at: String::new(), registration_time: None, total_frames: total_frames.unwrap_or(0), parent_uuid: None, }; self.db .register_video(&record) .await .with_context(|| "Failed to register video in database")?; self.db .set_registration_time(&uuid) .await .with_context(|| "Failed to set registration_time")?; self.db .update_birth_registration(&uuid, &birth_registration) .await .with_context(|| "Failed to set birth_registration")?; info!( "Successfully registered video: {} (UUID: {}, Birth UUID: {})", record.file_name, uuid, uuid ); Ok(Some(uuid)) } } fn is_video_extension(path: &Path) -> bool { if let Some(ext) = path.extension().and_then(|e| e.to_str()) { let ext = ext.to_lowercase(); matches!(ext.as_str(), "mp4" | "mov" | "mkv" | "avi" | "webm" | "m4v") } else { false } }