use axum::{ extract::{Path, Query, State}, http::StatusCode, response::Json, routing::{get, post}, Router, }; use serde::{Deserialize, Serialize}; use sqlx::Row; use uuid::Uuid; use crate::core::db::ResourceRecord; pub fn identity_routes() -> Router { Router::new() .route("/api/v1/files", get(list_files)) .route("/api/v1/file/:file_uuid", get(get_file_detail)) .route( "/api/v1/file/:file_uuid/identities", get(get_file_identities), ) .route( "/api/v1/identity/:identity_uuid", get(get_identity_detail).delete(delete_identity), ) .route( "/api/v1/identity/:identity_uuid/files", get(get_identity_files), ) .route( "/api/v1/identity/:identity_uuid/chunks", get(get_identity_chunks), ) .route("/api/v1/resource/register", post(register_resource)) .route("/api/v1/resource/heartbeat", post(heartbeat_resource)) .route("/api/v1/resources", get(list_resources)) // Experiment: identity text search (non-polluting, separate endpoint) .route("/api/v1/search/identity_text", get(search_identity_text)) .route("/api/v1/identities/search", get(search_identities_by_text)) } // --- Files Endpoints --- #[derive(Debug, Deserialize)] pub struct FilesQuery { page: Option, page_size: Option, uuid: Option, // Add uuid filter } async fn list_files( State(state): State, Query(params): Query, ) -> Result, (StatusCode, String)> { let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); // If UUID is provided, fetch that specific file and return it as a list item if let Some(ref uuid) = params.uuid { let video = state .db .get_video_by_uuid(uuid) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data = if let Some(v) = video { vec![FileItem { file_uuid: v.file_uuid, file_name: v.file_name, file_path: v.file_path, status: v.status.as_str().to_string(), }] } else { vec![] }; return Ok(Json(FilesResponse { success: true, total: data.len() as i64, page, page_size, data, })); } // Default: List files with pagination let offset = ((page - 1) as i64) * (page_size as i64); let records = state .db .list_files(page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data = records .into_iter() .map(|r| FileItem { file_uuid: r.file_uuid, file_name: r.file_name, file_path: r.file_path, status: r.status.unwrap_or_default(), }) .collect(); let total = state.db.count_files().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(FilesResponse { success: true, total, page, page_size, data, })) } #[derive(Debug, Serialize)] pub struct FilesResponse { pub success: bool, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct FileItem { pub file_uuid: String, pub file_name: String, pub file_path: String, pub status: String, } #[derive(Debug, Serialize)] pub struct FileDetailResponse { pub success: bool, pub file_uuid: String, pub file_name: String, pub file_path: String, pub metadata: Option, pub created_at: Option>, } async fn get_file_detail( State(state): State, Path(file_uuid): Path, ) -> Result, (StatusCode, String)> { let file = state .db .get_file_by_uuid(&file_uuid) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; match file { Some(f) => Ok(Json(FileDetailResponse { success: true, file_uuid: f.file_uuid, file_name: f.file_name, file_path: f.file_path, metadata: f.probe_json, created_at: f.created_at, })), None => Err(( StatusCode::NOT_FOUND, format!("File not found: {}", file_uuid), )), } } #[derive(Debug, Serialize)] pub struct FileIdentitiesResponse { pub success: bool, pub file_uuid: String, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct FileIdentityItem { pub identity_id: i32, pub name: String, pub metadata: serde_json::Value, pub face_count: Option, pub speaker_count: Option, pub first_appearance: Option, pub last_appearance: Option, pub confidence: Option, } async fn get_file_identities( State(state): State, Path(file_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let offset = ((page - 1) as i64) * (page_size as i64); let records = state .db .get_file_identities(&file_uuid, page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| FileIdentityItem { identity_id: r.identity_id, name: r.name, metadata: r.metadata, face_count: r.face_count, speaker_count: r.speaker_count, first_appearance: r.first_appearance, last_appearance: r.last_appearance, confidence: r.confidence, }) .collect(); Ok(Json(FileIdentitiesResponse { success: true, file_uuid: file_uuid, total: data.len() as i64, page, page_size, data, })) } #[derive(Debug, Serialize)] pub struct IdentityDetailResponse { pub success: bool, pub uuid: Uuid, pub name: String, pub identity_type: Option, pub source: Option, pub status: Option, pub metadata: serde_json::Value, pub reference_data: serde_json::Value, pub tmdb_id: Option, pub tmdb_profile: Option, pub created_at: Option>, pub updated_at: Option>, } fn strip_uuid(u: &uuid::Uuid) -> String { u.to_string().replace('-', "") } async fn get_identity_detail( State(state): State, Path(identity_uuid): Path, ) -> Result, (StatusCode, String)> { let uuid_str = identity_uuid; let uuid = Uuid::parse_str(&uuid_str) .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?; let identity = state .db .get_identity_by_uuid(&uuid) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; match identity { Some(i) => Ok(Json(IdentityDetailResponse { success: true, uuid: i.uuid, name: i.name, identity_type: i.identity_type, source: i.source, status: i.status, metadata: i.metadata, reference_data: i.reference_data, tmdb_id: i.tmdb_id, tmdb_profile: i.tmdb_profile, created_at: i.created_at, updated_at: i.updated_at, })), None => Err(( StatusCode::NOT_FOUND, format!("Identity not found: {}", uuid), )), } } #[derive(Debug, Serialize)] pub struct IdentityFilesResponse { pub success: bool, pub identity_uuid: Uuid, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } async fn delete_identity( State(state): State, Path(identity_uuid): Path, ) -> Result { let table = crate::core::db::schema::table_name("face_detections"); let id_table = crate::core::db::schema::table_name("identities"); // Get identity_id from identity_uuid let row: Option<(i32,)> = sqlx::query_as(&format!( "SELECT id FROM {} WHERE replace(uuid::text, '-', '') = $1", id_table )) .bind(&identity_uuid) .fetch_optional(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let (identity_id,) = row.ok_or(StatusCode::NOT_FOUND)?; // Unbind all faces sqlx::query(&format!( "UPDATE {} SET identity_id = NULL, identity_confidence = NULL WHERE identity_id = $1", table )) .bind(identity_id) .execute(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Delete identity sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table)) .bind(identity_id) .execute(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Serialize)] pub struct IdentityFileItem { pub file_uuid: String, pub file_name: String, pub file_path: String, pub status: String, pub face_count: Option, pub speaker_count: Option, pub first_appearance: Option, pub last_appearance: Option, pub confidence: Option, } async fn get_identity_files( State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let uuid_str = identity_uuid; let uuid = Uuid::parse_str(&uuid_str) .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let offset = ((page - 1) as i64) * (page_size as i64); let records = state .db .get_identity_files(&uuid, page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| IdentityFileItem { file_uuid: r.file_uuid, file_name: r.file_name, file_path: r.file_path, status: r.status, face_count: r.face_count, speaker_count: r.speaker_count, first_appearance: r.first_appearance, last_appearance: r.last_appearance, confidence: r.confidence, }) .collect(); Ok(Json(IdentityFilesResponse { success: true, identity_uuid: uuid, total: data.len() as i64, page, page_size, data, })) } #[derive(Debug, Serialize)] pub struct IdentityFacesResponse { pub success: bool, pub identity_uuid: Uuid, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct IdentityFaceItem { pub id: i64, pub file_uuid: String, pub frame_number: i64, pub timestamp_secs: f64, pub face_id: Option, pub bbox: BBox, pub confidence: f64, } #[derive(Debug, Serialize)] pub struct BBox { pub x: f64, pub y: f64, pub width: f64, pub height: f64, } async fn get_identity_faces( State(state): State, Path(uuid_str): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let uuid = Uuid::parse_str(&uuid_str) .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(50); let offset = ((page - 1) as i64) * (page_size as i64); let records = state .db .get_identity_faces(&uuid, page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| IdentityFaceItem { id: r.id, file_uuid: r.file_uuid, frame_number: r.frame_number, timestamp_secs: r.timestamp_secs, face_id: r.face_id, bbox: BBox { x: r.x, y: r.y, width: r.width, height: r.height, }, confidence: r.confidence, }) .collect(); Ok(Json(IdentityFacesResponse { success: true, identity_uuid: uuid, total: data.len() as i64, page, page_size, data, })) } #[derive(Debug, Serialize)] pub struct IdentityChunksResponse { pub success: bool, pub identity_uuid: Uuid, pub total: i64, pub page: usize, pub page_size: usize, pub data: Vec, } #[derive(Debug, Serialize)] pub struct IdentityChunkItem { pub id: i64, pub file_uuid: String, pub chunk_id: String, pub chunk_type: String, pub start_time: Option, pub end_time: Option, pub text_content: Option, } async fn get_identity_chunks( State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { let uuid_str = identity_uuid; let uuid = Uuid::parse_str(&uuid_str) .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?; let page = params.page.unwrap_or(1); let page_size = params.page_size.unwrap_or(20); let offset = ((page - 1) as i64) * (page_size as i64); let records = state .db .get_identity_chunks(&uuid, page_size as i32, offset) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| IdentityChunkItem { id: r.id as i64, file_uuid: r.file_uuid, chunk_id: r.chunk_id, chunk_type: r.chunk_type, start_time: r.start_time, end_time: r.end_time, text_content: r.text_content, }) .collect(); Ok(Json(IdentityChunksResponse { success: true, identity_uuid: uuid, total: data.len() as i64, page, page_size, data, })) } // --- Resource Registry Endpoints (Phase 5) --- #[derive(Debug, Deserialize)] pub struct RegisterResourceRequest { pub resource_id: String, pub resource_type: String, pub category: String, pub capabilities: Option, pub config: Option, pub metadata: Option, } #[derive(Debug, Serialize)] pub struct ResourceResponse { pub success: bool, pub message: String, pub data: Option, } #[derive(Debug, Serialize)] pub struct ResourceItem { pub resource_id: String, pub resource_type: String, pub category: String, pub capabilities: Option, pub status: String, pub last_heartbeat: Option>, } async fn register_resource( State(state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let resource = ResourceRecord { resource_id: req.resource_id.clone(), resource_type: req.resource_type.clone(), category: req.category.clone(), capabilities: req.capabilities, config: req.config, metadata: req.metadata, status: "online".to_string(), last_heartbeat: None, created_at: None, }; state .db .register_resource(resource) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(ResourceResponse { success: true, message: "Resource registered successfully".to_string(), data: None, // We could return the full record, but simplified for now })) } #[derive(Debug, Deserialize)] pub struct HeartbeatRequest { pub resource_id: String, pub status: Option, } async fn heartbeat_resource( State(state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let status = req.status.unwrap_or("online".to_string()); state .db .heartbeat_resource(&req.resource_id, &status) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(ResourceResponse { success: true, message: "Heartbeat received".to_string(), data: None, })) } async fn list_resources( State(state): State, ) -> Result, (StatusCode, String)> { let records = state .db .list_resources() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let data: Vec = records .into_iter() .map(|r| ResourceItem { resource_id: r.resource_id, resource_type: r.resource_type, category: r.category, capabilities: r.capabilities, status: r.status, last_heartbeat: r.last_heartbeat, }) .collect(); Ok(Json(ResourceResponse { success: true, message: "Resources listed".to_string(), data: None, })) } // ── Experiment: Identity Text Search ────────────────────────── // Separate endpoints — do not modify existing API behavior. #[derive(Debug, Deserialize)] struct IdentityTextQuery { uuid: String, q: String, limit: Option, } #[derive(Debug, Serialize)] struct IdentityTextHit { file_uuid: String, chunk_id: String, start_time: f64, end_time: f64, text_content: String, identity_id: Option, identity_name: Option, identity_source: Option, trace_id: Option, } #[derive(Debug, Serialize)] struct IdentityTextResponse { success: bool, total: i64, results: Vec, } /// Path A: Search chunk text → associated identities async fn search_identity_text( State(state): State, Query(params): Query, ) -> Result, StatusCode> { use crate::core::db::schema; let chunk_table = schema::table_name("chunk"); let fd_table = schema::table_name("face_detections"); let id_table = schema::table_name("identities"); let like_q = format!("%{}%", params.q.replace('%', "%%")); let limit = params.limit.unwrap_or(50).min(100); let query = format!( r#"SELECT c.file_uuid, c.chunk_id, c.start_time, c.end_time, c.text_content, fd.identity_id, i.name AS identity_name, i.source AS identity_source, fd.trace_id FROM {} c LEFT JOIN {} fd ON fd.file_uuid = c.file_uuid AND fd.frame_number BETWEEN c.start_frame AND c.end_frame AND fd.identity_id IS NOT NULL LEFT JOIN {} i ON i.id = fd.identity_id WHERE c.file_uuid = $1 AND LOWER(c.text_content) LIKE LOWER($2) ORDER BY c.start_time LIMIT $3"#, chunk_table, fd_table, id_table ); let rows = sqlx::query_as::<_, (String, String, f64, f64, String, Option, Option, Option, Option)>(&query) .bind(¶ms.uuid).bind(&like_q).bind(limit) .fetch_all(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let results: Vec = rows .into_iter() .map(|(fu, cid, st, et, txt, iid, iname, isrc, tid)| IdentityTextHit { file_uuid: fu, chunk_id: cid, start_time: st, end_time: et, text_content: txt, identity_id: iid, identity_name: iname, identity_source: isrc, trace_id: tid, }) .collect(); let total = results.len() as i64; Ok(Json(IdentityTextResponse { success: true, total, results })) } #[derive(Debug, Deserialize)] struct IdentitySearchQuery { q: String, uuid: Option, limit: Option, } #[derive(Debug, Serialize)] struct IdentitySearchHit { identity_id: i32, name: String, source: Option, tmdb_id: Option, file_uuid: String, trace_id: Option, chunk_id: String, start_time: f64, text_content: String, } #[derive(Debug, Serialize)] struct IdentitySearchResponse { success: bool, total: i64, results: Vec, } /// Path B: Search identity name → associated chunk text async fn search_identities_by_text( State(state): State, Query(params): Query, ) -> Result, StatusCode> { use crate::core::db::schema; let id_table = schema::table_name("identities"); let ib_table = schema::table_name("identity_bindings"); let fd_table = schema::table_name("face_detections"); let chunk_table = schema::table_name("chunk"); let like_q = format!("%{}%", params.q.replace('%', "%%")); let limit = params.limit.unwrap_or(50).min(100); let query = format!( r#"SELECT i.id, i.name, i.source, i.tmdb_id, fd.file_uuid, fd.trace_id, c.chunk_id, c.start_time, c.text_content FROM {} i JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace' JOIN {} fd ON fd.trace_id = ib.identity_value::int JOIN {} c ON c.file_uuid = fd.file_uuid AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0) AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0) WHERE i.name ILIKE $1 AND ($2::text IS NULL OR fd.file_uuid = $2) ORDER BY i.name, c.start_time LIMIT $3"#, id_table, ib_table, fd_table, chunk_table ); let rows = sqlx::query_as::<_, (i32, String, Option, Option, String, Option, String, f64, String)>(&query) .bind(&like_q).bind(¶ms.uuid).bind(limit) .fetch_all(state.db.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let results: Vec = rows .into_iter() .map(|(iid, name, src, tid, fu, trace_id, cid, st, txt)| IdentitySearchHit { identity_id: iid, name, source: src, tmdb_id: tid, file_uuid: fu, trace_id, chunk_id: cid, start_time: st, text_content: txt, }) .collect(); let total = results.len() as i64; Ok(Json(IdentitySearchResponse { success: true, total, results })) }