Files
momentry_core/src/core/ingestion.rs
Warren 2b23d1cfbd feat: update core API, database layer, and worker modules
- Remove unused imports (n8n_search, universal_search, Client, Arc, etc.)
- Update API endpoints for identity, face recognition, search
- Fix postgres_db.rs search_videos parent_uuid column
- Add snapshot API and identity agent API
- Clean up backup files (.bak, .bak2)
2026-04-30 15:07:02 +08:00

218 lines
7.0 KiB
Rust

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<Option<String>> {
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<chrono::Utc>>(
"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::<f64>().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::<f64>(), den.parse::<f64>()) {
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::<u64>() {
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
}
}