feat: media API (video/bbox/thumbnail), UUID unification, dot matrix text, portal fixes, API dictionary V1.3
This commit is contained in:
@@ -1,944 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::db::{schema, Database, PostgresDb};
|
||||
use crate::core::processor::face_recognition::{
|
||||
process_face_recognition, register_face, FaceRecognitionResult, FaceRegistrationResult,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceRecognitionRequest {
|
||||
pub file_uuid: String,
|
||||
pub enable_recognition: Option<bool>,
|
||||
pub enable_tracking: Option<bool>,
|
||||
pub enable_clustering: Option<bool>,
|
||||
pub database_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FaceRecognitionResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub result: Option<FaceRecognitionResult>,
|
||||
pub processing_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceRegistrationRequest {
|
||||
pub file_uuid: String,
|
||||
pub name: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FaceRegistrationApiResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub result: Option<FaceRegistrationResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceSearchRequest {
|
||||
pub file_uuid: String,
|
||||
pub embedding: Vec<f32>,
|
||||
pub similarity_threshold: Option<f64>,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FaceSearchResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub results: Vec<FaceSearchResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FaceSearchResult {
|
||||
pub face_id: String,
|
||||
pub name: Option<String>,
|
||||
pub similarity: f64,
|
||||
pub attributes: Option<serde_json::Value>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceListQuery {
|
||||
pub file_uuid: String,
|
||||
pub page: Option<usize>,
|
||||
pub page_size: Option<usize>,
|
||||
pub active_only: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FaceListResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub faces: Vec<FaceListItem>,
|
||||
pub count: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FaceListItem {
|
||||
pub face_id: String,
|
||||
pub name: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub is_active: bool,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub fn face_recognition_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/face/recognize", post(recognize_faces))
|
||||
.route("/api/v1/face/register", post(register_face_api))
|
||||
.route("/api/v1/face/search", post(search_faces))
|
||||
.route("/api/v1/face/list", get(list_faces))
|
||||
.route(
|
||||
"/api/v1/files/:file_uuid/faces/:face_id",
|
||||
get(get_face_details),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/files/:file_uuid/faces/:face_id",
|
||||
axum::routing::delete(delete_face),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/face/results/:file_uuid",
|
||||
get(get_recognition_results),
|
||||
)
|
||||
}
|
||||
|
||||
async fn recognize_faces(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Json(request): Json<FaceRecognitionRequest>,
|
||||
) -> Result<Json<FaceRecognitionResponse>, (StatusCode, String)> {
|
||||
let processing_id = Uuid::new_v4().to_string();
|
||||
|
||||
tracing::info!(
|
||||
"[FACE_RECOGNITION] Starting recognition for video: {}, processing_id: {}",
|
||||
request.file_uuid,
|
||||
processing_id
|
||||
);
|
||||
|
||||
// Get video path from database
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let video_record = match db.get_video_by_uuid(&request.file_uuid).await {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Video not found: {}", request.file_uuid),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to fetch video: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let video_path = video_record.file_path;
|
||||
let output_path = format!(
|
||||
"{}/face_recognition_{}.json",
|
||||
crate::core::config::OUTPUT_DIR.as_str(),
|
||||
processing_id
|
||||
);
|
||||
|
||||
// Process face recognition
|
||||
let result = match process_face_recognition(
|
||||
&video_path,
|
||||
&output_path,
|
||||
Some(&processing_id),
|
||||
request.enable_recognition.unwrap_or(true),
|
||||
request.enable_tracking.unwrap_or(true),
|
||||
request.enable_clustering.unwrap_or(true),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Face recognition failed: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Store results in database
|
||||
if let Err(e) = store_recognition_results(&db, &request.file_uuid, &result).await {
|
||||
tracing::warn!("Failed to store recognition results: {}", e);
|
||||
}
|
||||
|
||||
Ok(Json(FaceRecognitionResponse {
|
||||
success: true,
|
||||
message: format!("Face recognition completed for {}", request.file_uuid),
|
||||
result: Some(result),
|
||||
processing_id,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn register_face_api(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<FaceRegistrationApiResponse>, (StatusCode, String)> {
|
||||
let mut image_path: Option<String> = None;
|
||||
let mut name: Option<String> = None;
|
||||
let mut metadata: Option<serde_json::Value> = None;
|
||||
|
||||
// Parse multipart form data
|
||||
while let Some(field) = multipart.next_field().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to parse form data: {}", e),
|
||||
)
|
||||
})? {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
|
||||
match field_name.as_str() {
|
||||
"image" => {
|
||||
// Save uploaded image
|
||||
let file_name = format!("face_registration_{}.jpg", Uuid::new_v4());
|
||||
let file_path = format!("/tmp/{}", file_name);
|
||||
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to read image data: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
tokio::fs::write(&file_path, &data).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to save image: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
image_path = Some(file_path);
|
||||
}
|
||||
"name" => {
|
||||
let value = field.text().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to read name: {}", e),
|
||||
)
|
||||
})?;
|
||||
name = Some(value);
|
||||
}
|
||||
"metadata" => {
|
||||
let value = field.text().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to read metadata: {}", e),
|
||||
)
|
||||
})?;
|
||||
metadata = Some(serde_json::from_str(&value).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid JSON metadata: {}", e),
|
||||
)
|
||||
})?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
let image_path =
|
||||
image_path.ok_or((StatusCode::BAD_REQUEST, "Image is required".to_string()))?;
|
||||
|
||||
let name = name.ok_or((StatusCode::BAD_REQUEST, "Name is required".to_string()))?;
|
||||
|
||||
// Register face
|
||||
let result = match register_face(&image_path, &name, metadata.clone()).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
// Clean up temporary file
|
||||
let _ = tokio::fs::remove_file(&image_path).await;
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Face registration failed: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up temporary file
|
||||
let _ = tokio::fs::remove_file(&image_path).await;
|
||||
|
||||
// Store in PostgreSQL face_identities table
|
||||
if result.success && !result.embedding.is_empty() {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!("[FACE_REGISTRATION] Failed to connect to DB: {}", e);
|
||||
// Return success even if DB write fails (embedding is still in JSON output)
|
||||
return Ok(Json(FaceRegistrationApiResponse {
|
||||
success: result.success,
|
||||
message: format!("{} (Warning: DB write failed: {})", result.message, e),
|
||||
result: Some(result),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Convert embedding to PostgreSQL vector format
|
||||
let embedding_str = format!(
|
||||
"[{}]",
|
||||
result
|
||||
.embedding
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
|
||||
// Insert into face_identities
|
||||
let face_identities_table = schema::table_name("face_identities");
|
||||
let attrs_json =
|
||||
serde_json::to_string(&result.attributes).unwrap_or_else(|_| "{}".to_string());
|
||||
// Use public.vector type to work across schemas
|
||||
let vector_type = if schema::SCHEMA_PREFIX.as_str().is_empty() {
|
||||
"vector".to_string()
|
||||
} else {
|
||||
"public.vector".to_string()
|
||||
};
|
||||
let insert_query = format!(
|
||||
r#"
|
||||
INSERT INTO {} (face_id, name, embedding, attributes, metadata, is_active)
|
||||
VALUES ($1, $2, $3::{}, $4::jsonb, $5, TRUE)
|
||||
ON CONFLICT (face_id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
embedding = EXCLUDED.embedding,
|
||||
attributes = EXCLUDED.attributes,
|
||||
metadata = COALESCE(EXCLUDED.metadata, {}.metadata),
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
is_active = TRUE
|
||||
"#,
|
||||
face_identities_table, vector_type, face_identities_table
|
||||
);
|
||||
|
||||
match sqlx::query(&insert_query)
|
||||
.bind(&result.face_id)
|
||||
.bind(&name)
|
||||
.bind(&embedding_str)
|
||||
.bind(&attrs_json)
|
||||
.bind(serde_json::to_string(&metadata.unwrap_or(serde_json::json!({}))).unwrap())
|
||||
.execute(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"[FACE_REGISTRATION] Stored face '{}' (face_id={}) in DB",
|
||||
name,
|
||||
result.face_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[FACE_REGISTRATION] Failed to store face in DB: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(FaceRegistrationApiResponse {
|
||||
success: result.success,
|
||||
message: result.message.clone(),
|
||||
result: Some(result),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn search_faces(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Json(request): Json<FaceSearchRequest>,
|
||||
) -> Result<Json<FaceSearchResponse>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Convert embedding to PostgreSQL vector format
|
||||
let embedding_str = format!(
|
||||
"[{}]",
|
||||
request
|
||||
.embedding
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
|
||||
let similarity_threshold = request.similarity_threshold.unwrap_or(0.6);
|
||||
let limit = request.limit.unwrap_or(10);
|
||||
|
||||
// Search for similar faces
|
||||
let face_identities_table = schema::table_name("face_identities");
|
||||
let vector_type = if schema::SCHEMA_PREFIX.as_str().is_empty() {
|
||||
"vector".to_string()
|
||||
} else {
|
||||
"public.vector".to_string()
|
||||
};
|
||||
let query = format!(
|
||||
r#"
|
||||
SELECT
|
||||
face_id,
|
||||
name,
|
||||
1 - (embedding <=> $1::{}) as similarity,
|
||||
attributes,
|
||||
metadata
|
||||
FROM {}
|
||||
WHERE is_active = TRUE
|
||||
AND embedding IS NOT NULL
|
||||
AND 1 - (embedding <=> $1::{}) >= $2
|
||||
ORDER BY embedding <=> $1::{}
|
||||
LIMIT $3
|
||||
"#,
|
||||
vector_type, face_identities_table, vector_type, vector_type
|
||||
);
|
||||
|
||||
let results: Vec<FaceSearchResult> = match sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
Option<String>,
|
||||
f64,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
>(query.as_str())
|
||||
.bind(&embedding_str)
|
||||
.bind(similarity_threshold)
|
||||
.bind(limit)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(face_id, name, similarity, attributes, metadata)| FaceSearchResult {
|
||||
face_id,
|
||||
name,
|
||||
similarity,
|
||||
attributes,
|
||||
metadata,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to search faces: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(FaceSearchResponse {
|
||||
success: true,
|
||||
message: format!("Found {} similar faces", results.len()),
|
||||
results,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_faces(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Query(query): Query<FaceListQuery>,
|
||||
) -> Result<Json<FaceListResponse>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
let active_only = query.active_only.unwrap_or(true);
|
||||
|
||||
// Build query
|
||||
let mut where_clause = "WHERE 1=1".to_string();
|
||||
if active_only {
|
||||
where_clause.push_str(" AND is_active = TRUE");
|
||||
}
|
||||
|
||||
let face_identities_table = schema::table_name("face_identities");
|
||||
let count_query = format!(
|
||||
"SELECT COUNT(*) FROM {} {}",
|
||||
face_identities_table, where_clause
|
||||
);
|
||||
let list_query = format!(
|
||||
"SELECT face_id, name, created_at, updated_at, is_active, metadata FROM {} {} ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
face_identities_table, where_clause
|
||||
);
|
||||
|
||||
// Get total count
|
||||
let total: i64 = match sqlx::query_scalar(&count_query).fetch_one(db.pool()).await {
|
||||
Ok(count) => count,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to count faces: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Get face list
|
||||
let faces: Vec<FaceListItem> = match sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
Option<String>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
bool,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
>(&list_query)
|
||||
.bind(page_size as i32)
|
||||
.bind(offset)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(face_id, name, created_at, updated_at, is_active, metadata)| FaceListItem {
|
||||
face_id,
|
||||
name,
|
||||
created_at: created_at.to_rfc3339(),
|
||||
updated_at: updated_at.to_rfc3339(),
|
||||
is_active,
|
||||
metadata,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to list faces: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(FaceListResponse {
|
||||
success: true,
|
||||
message: format!("Found {} faces", total),
|
||||
faces,
|
||||
count: total,
|
||||
page,
|
||||
page_size,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_face_details(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path((file_uuid, face_id)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let face_identities_table = schema::table_name("face_identities");
|
||||
let query = format!(
|
||||
r#"
|
||||
SELECT
|
||||
face_id,
|
||||
name,
|
||||
embedding,
|
||||
attributes,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at,
|
||||
is_active
|
||||
FROM {}
|
||||
WHERE face_id = $1 AND file_uuid = $2
|
||||
"#,
|
||||
face_identities_table
|
||||
);
|
||||
|
||||
let face: Option<(
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
bool,
|
||||
)> = match sqlx::query_as(&query)
|
||||
.bind(&face_id)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(face) => face,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to fetch face details: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match face {
|
||||
Some((
|
||||
face_id,
|
||||
name,
|
||||
embedding,
|
||||
attributes,
|
||||
metadata,
|
||||
created_at,
|
||||
updated_at,
|
||||
is_active,
|
||||
)) => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"face_id": face_id,
|
||||
"name": name,
|
||||
"has_embedding": embedding.is_some(),
|
||||
"attributes": attributes,
|
||||
"metadata": metadata,
|
||||
"created_at": created_at.to_rfc3339(),
|
||||
"updated_at": updated_at.to_rfc3339(),
|
||||
"is_active": is_active
|
||||
});
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Face not found: {}", face_id),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_face(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path((file_uuid, face_id)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Soft delete by marking as inactive
|
||||
let face_identities_table = schema::table_name("face_identities");
|
||||
let query = format!(
|
||||
r#"
|
||||
UPDATE {}
|
||||
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE face_id = $1 AND file_uuid = $2 AND is_active = TRUE
|
||||
RETURNING face_id, name
|
||||
"#,
|
||||
face_identities_table
|
||||
);
|
||||
|
||||
let deleted: Option<(String, Option<String>)> = match sqlx::query_as(&query)
|
||||
.bind(&face_id)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to delete face: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match deleted {
|
||||
Some((deleted_id, name)) => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"message": format!("Face '{}' deleted successfully", name.clone().unwrap_or_else(|| deleted_id.clone())),
|
||||
"face_id": deleted_id.clone()
|
||||
});
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Face not found or already deleted: {}", face_id),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_recognition_results(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let query = r#"
|
||||
SELECT
|
||||
file_uuid,
|
||||
frame_count,
|
||||
fps,
|
||||
total_faces,
|
||||
recognized_faces,
|
||||
clusters_count,
|
||||
result_data,
|
||||
processing_time_secs,
|
||||
created_at
|
||||
FROM face_recognition_results
|
||||
WHERE file_uuid = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
let result: Option<(
|
||||
String,
|
||||
i64,
|
||||
f64,
|
||||
i32,
|
||||
i32,
|
||||
i32,
|
||||
serde_json::Value,
|
||||
Option<f64>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
)> = match sqlx::query_as(query)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to fetch recognition results: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Some((
|
||||
file_uuid,
|
||||
frame_count,
|
||||
fps,
|
||||
total_faces,
|
||||
recognized_faces,
|
||||
clusters_count,
|
||||
result_data,
|
||||
processing_time_secs,
|
||||
created_at,
|
||||
)) => {
|
||||
let response = serde_json::json!({
|
||||
"success": true,
|
||||
"file_uuid": file_uuid,
|
||||
"frame_count": frame_count,
|
||||
"fps": fps,
|
||||
"total_faces": total_faces,
|
||||
"recognized_faces": recognized_faces,
|
||||
"clusters_count": clusters_count,
|
||||
"result_data": result_data,
|
||||
"processing_time_secs": processing_time_secs,
|
||||
"created_at": created_at.to_rfc3339()
|
||||
});
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("No recognition results found for video: {}", file_uuid),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn store_recognition_results(
|
||||
db: &PostgresDb,
|
||||
file_uuid: &str,
|
||||
result: &FaceRecognitionResult,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let total_faces = result.frames.iter().map(|f| f.faces.len()).sum::<usize>();
|
||||
let recognized_faces = result
|
||||
.frames
|
||||
.iter()
|
||||
.flat_map(|f| &f.faces)
|
||||
.filter(|face| face.identity.is_some())
|
||||
.count();
|
||||
|
||||
let query = r#"
|
||||
INSERT INTO face_recognition_results (
|
||||
file_uuid,
|
||||
frame_count,
|
||||
fps,
|
||||
total_faces,
|
||||
recognized_faces,
|
||||
clusters_count,
|
||||
result_data
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (file_uuid) DO UPDATE SET
|
||||
frame_count = EXCLUDED.frame_count,
|
||||
fps = EXCLUDED.fps,
|
||||
total_faces = EXCLUDED.total_faces,
|
||||
recognized_faces = EXCLUDED.recognized_faces,
|
||||
clusters_count = EXCLUDED.clusters_count,
|
||||
result_data = EXCLUDED.result_data,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"#;
|
||||
|
||||
sqlx::query(query)
|
||||
.bind(file_uuid)
|
||||
.bind(result.frame_count as i64)
|
||||
.bind(result.fps)
|
||||
.bind(total_faces as i32)
|
||||
.bind(recognized_faces as i32)
|
||||
.bind(result.face_clusters.len() as i32)
|
||||
.bind(serde_json::to_value(result)?)
|
||||
.execute(db.pool())
|
||||
.await?;
|
||||
|
||||
// Store individual face detections
|
||||
for frame in &result.frames {
|
||||
for face in &frame.faces {
|
||||
if let Some(embedding) = &face.embedding {
|
||||
let embedding_str = format!(
|
||||
"[{}]",
|
||||
embedding
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
|
||||
let insert_query = r#"
|
||||
INSERT INTO face_detections (
|
||||
file_uuid,
|
||||
frame_number,
|
||||
timestamp_secs,
|
||||
face_id,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
confidence,
|
||||
embedding,
|
||||
attributes,
|
||||
identity_confidence,
|
||||
cluster_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11, $12, $13)
|
||||
ON CONFLICT (file_uuid, frame_number, x, y, width, height) DO UPDATE SET
|
||||
face_id = EXCLUDED.face_id,
|
||||
confidence = EXCLUDED.confidence,
|
||||
embedding = EXCLUDED.embedding,
|
||||
attributes = EXCLUDED.attributes,
|
||||
identity_confidence = EXCLUDED.identity_confidence,
|
||||
cluster_id = EXCLUDED.cluster_id
|
||||
"#;
|
||||
|
||||
let identity_confidence = face.identity.as_ref().map(|id| id.confidence as f64);
|
||||
let cluster_id = result
|
||||
.face_clusters
|
||||
.iter()
|
||||
.find(|c| {
|
||||
c.face_ids
|
||||
.contains(&face.face_id.clone().unwrap_or_default())
|
||||
})
|
||||
.map(|c| c.cluster_id.clone());
|
||||
|
||||
sqlx::query(insert_query)
|
||||
.bind(file_uuid)
|
||||
.bind(frame.frame as i64)
|
||||
.bind(frame.timestamp)
|
||||
.bind(face.face_id.as_deref())
|
||||
.bind(face.x)
|
||||
.bind(face.y)
|
||||
.bind(face.width)
|
||||
.bind(face.height)
|
||||
.bind(face.confidence as f64)
|
||||
.bind(&embedding_str)
|
||||
.bind(serde_json::to_value(&face.attributes)?)
|
||||
.bind(identity_confidence)
|
||||
.bind(cluster_id)
|
||||
.execute(db.pool())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store face clusters
|
||||
for cluster in &result.face_clusters {
|
||||
let centroid = &cluster.centroid;
|
||||
let centroid_str = format!(
|
||||
"[{}]",
|
||||
centroid
|
||||
.iter()
|
||||
.map(|v: &f32| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
|
||||
let cluster_query = r#"
|
||||
INSERT INTO face_clusters (
|
||||
cluster_id,
|
||||
file_uuid,
|
||||
centroid,
|
||||
size,
|
||||
representative_face_id,
|
||||
metadata
|
||||
) VALUES ($1, $2, $3::vector, $4, $5, $6)
|
||||
ON CONFLICT (cluster_id) DO UPDATE SET
|
||||
centroid = EXCLUDED.centroid,
|
||||
size = EXCLUDED.size,
|
||||
representative_face_id = EXCLUDED.representative_face_id,
|
||||
metadata = EXCLUDED.metadata
|
||||
"#;
|
||||
|
||||
sqlx::query(cluster_query)
|
||||
.bind(&cluster.cluster_id)
|
||||
.bind(file_uuid)
|
||||
.bind(¢roid_str)
|
||||
.bind(cluster.size as i32)
|
||||
.bind(cluster.representative_face_id.as_deref())
|
||||
.bind(&cluster.metadata)
|
||||
.execute(db.pool())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -12,31 +12,14 @@ use std::process::Command;
|
||||
use crate::core::db::{Database, PostgresDb};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterFromPersonRequest {
|
||||
pub file_uuid: String,
|
||||
pub person_id: String,
|
||||
pub identity_name: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterFromFaceRequest {
|
||||
pub struct CreateIdentityRequest {
|
||||
pub face_json_path: String,
|
||||
pub identity_name: String,
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegisterFromPersonResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub identity_id: i32,
|
||||
pub identity_name: String,
|
||||
pub person_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RegisterFromFaceResponse {
|
||||
pub struct CreateIdentityResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub identity_uuid: Option<String>,
|
||||
@@ -48,26 +31,17 @@ pub struct RegisterFromFaceResponse {
|
||||
|
||||
pub fn identity_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/identities/from-person", post(register_from_person))
|
||||
.route("/api/v1/identities/from-face", post(register_from_face))
|
||||
.route("/api/v1/identities", get(list_identities))
|
||||
.route("/api/v1/identity", post(create_identity))
|
||||
.route("/api/v1/faces/candidates", get(list_face_candidates))
|
||||
.route(
|
||||
"/api/v1/identities/:identity_id/faces",
|
||||
get(get_identity_faces),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/files/:file_uuid/faces/:face_id/thumbnail",
|
||||
get(get_face_thumbnail),
|
||||
)
|
||||
}
|
||||
|
||||
/// Register a Global Identity from face.json with multi-angle reference vectors.
|
||||
/// Calls select_face_reference_vectors_v2.py for automatic reference selection.
|
||||
async fn register_from_face(
|
||||
async fn create_identity(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Json(req): Json<RegisterFromFaceRequest>,
|
||||
) -> Result<Json<RegisterFromFaceResponse>, (StatusCode, String)> {
|
||||
Json(req): Json<CreateIdentityRequest>,
|
||||
) -> Result<Json<CreateIdentityResponse>, (StatusCode, String)> {
|
||||
let schema = req.schema.unwrap_or("dev".to_string());
|
||||
let python_path =
|
||||
std::env::var("MOMENTRY_PYTHON_PATH").unwrap_or("/opt/homebrew/bin/python3.11".to_string());
|
||||
@@ -141,7 +115,7 @@ async fn register_from_face(
|
||||
})?;
|
||||
|
||||
match row {
|
||||
Some((uuid, total, angles, quality)) => Ok(Json(RegisterFromFaceResponse {
|
||||
Some((uuid, total, angles, quality)) => Ok(Json(CreateIdentityResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Successfully registered identity '{}' with {} reference vectors",
|
||||
@@ -154,7 +128,7 @@ async fn register_from_face(
|
||||
angle_coverage: angles,
|
||||
quality_avg: quality,
|
||||
})),
|
||||
None => Ok(Json(RegisterFromFaceResponse {
|
||||
None => Ok(Json(CreateIdentityResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Identity '{}' registered, but details not found",
|
||||
@@ -169,175 +143,6 @@ async fn register_from_face(
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a Global Identity from a specific Person in a video.
|
||||
/// This creates/updates the Identity record, links the Person to the Identity,
|
||||
/// and updates the Person's name to match the Identity.
|
||||
async fn register_from_person(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Json(req): Json<RegisterFromPersonRequest>,
|
||||
) -> Result<Json<RegisterFromPersonResponse>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("DB error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mut tx = match db.pool().begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Tx error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Check if Person exists
|
||||
let person_query =
|
||||
"SELECT id, name FROM person_identities WHERE person_id = $1 AND file_uuid = $2";
|
||||
let person: Option<(i32, Option<String>)> = match sqlx::query_as(person_query)
|
||||
.bind(&req.person_id)
|
||||
.bind(&req.file_uuid)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Query error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let (person_db_id, _old_name) = match person {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!(
|
||||
"Person '{}' not found in video '{}'",
|
||||
req.person_id, req.file_uuid
|
||||
),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Check if Identity exists
|
||||
let identity_query = "SELECT id FROM identities WHERE name = $1";
|
||||
let identity_id: Option<i32> = match sqlx::query_scalar(identity_query)
|
||||
.bind(&req.identity_name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Query error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let final_identity_id = if let Some(id) = identity_id {
|
||||
id
|
||||
} else {
|
||||
// Create new Identity
|
||||
let meta_json = req.metadata.clone().unwrap_or(serde_json::json!({}));
|
||||
|
||||
let new_id: i32 = match sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO identities (name, embedding, metadata)
|
||||
VALUES ($1, NULLIF($2, '')::public.vector, $3)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(&req.identity_name)
|
||||
.bind("".to_string()) // No embedding for now via this API
|
||||
.bind(&meta_json)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Insert identity error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
new_id
|
||||
};
|
||||
|
||||
// 3. Create Binding
|
||||
// Columns: id, identity_id, identity_type, identity_value, confidence, metadata, created_at
|
||||
let binding_query = r#"
|
||||
INSERT INTO identity_bindings (identity_id, identity_type, identity_value, confidence, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT DO NOTHING
|
||||
"#;
|
||||
|
||||
match sqlx::query(binding_query)
|
||||
.bind(final_identity_id)
|
||||
.bind("person_id") // identity_type
|
||||
.bind(&req.person_id) // identity_value
|
||||
.bind(1.0) // confidence
|
||||
.bind(serde_json::to_string(&serde_json::json!({"auto_updated": true})).unwrap())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Binding error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Update Person Name
|
||||
let update_person = "UPDATE person_identities SET name = $1 WHERE id = $2";
|
||||
match sqlx::query(update_person)
|
||||
.bind(&req.identity_name)
|
||||
.bind(person_db_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Update person error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match tx.commit().await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Commit error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(RegisterFromPersonResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Successfully registered identity '{}' and linked to person '{}'",
|
||||
req.identity_name, req.person_id
|
||||
),
|
||||
identity_id: final_identity_id,
|
||||
identity_name: req.identity_name,
|
||||
person_id: req.person_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List all global identities
|
||||
async fn list_identities(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
@@ -409,21 +214,6 @@ pub struct ListIdentitiesQuery {
|
||||
pub page_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityResponse {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityListResponse {
|
||||
pub identities: Vec<IdentityResponse>,
|
||||
pub count: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FaceCandidatesQuery {
|
||||
pub file_uuid: Option<String>,
|
||||
@@ -438,8 +228,8 @@ pub struct FaceCandidate {
|
||||
pub id: i32,
|
||||
pub face_id: Option<String>,
|
||||
pub file_uuid: String,
|
||||
pub frame_number: i64,
|
||||
pub confidence: f64,
|
||||
pub frame_number: i32,
|
||||
pub confidence: f32,
|
||||
pub bbox: Option<serde_json::Value>,
|
||||
pub attributes: Option<serde_json::Value>,
|
||||
}
|
||||
@@ -452,29 +242,19 @@ pub struct FaceCandidatesResponse {
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IdentityFacesQuery {
|
||||
pub page: Option<usize>,
|
||||
pub page_size: Option<usize>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityFace {
|
||||
pub struct IdentityResponse {
|
||||
pub id: i32,
|
||||
pub face_id: Option<String>,
|
||||
pub file_uuid: String,
|
||||
pub frame_number: i64,
|
||||
pub confidence: f64,
|
||||
pub bbox: Option<serde_json::Value>,
|
||||
pub attributes: Option<serde_json::Value>,
|
||||
pub name: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityFacesResponse {
|
||||
pub identity_id: i32,
|
||||
pub faces: Vec<IdentityFace>,
|
||||
pub total: i64,
|
||||
pub struct IdentityListResponse {
|
||||
pub identities: Vec<IdentityResponse>,
|
||||
pub count: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
async fn list_face_candidates(
|
||||
@@ -538,7 +318,9 @@ async fn list_face_candidates(
|
||||
|
||||
let rows = if let Some(file_uuid) = &query.file_uuid {
|
||||
let sql = format!(
|
||||
"SELECT id, face_id, file_uuid, frame_number, confidence, bbox, attributes
|
||||
"SELECT id, face_id, file_uuid, frame_number, confidence,
|
||||
jsonb_build_object('x', x, 'y', y, 'width', width, 'height', height) as bbox,
|
||||
NULL::jsonb as attributes
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND confidence >= $1 AND file_uuid = $2
|
||||
ORDER BY confidence DESC
|
||||
@@ -551,8 +333,8 @@ async fn list_face_candidates(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
f64,
|
||||
i32,
|
||||
f32,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
@@ -574,7 +356,9 @@ async fn list_face_candidates(
|
||||
}
|
||||
} else {
|
||||
let sql = format!(
|
||||
"SELECT id, face_id, file_uuid, frame_number, confidence, bbox, attributes
|
||||
"SELECT id, face_id, file_uuid, frame_number, confidence,
|
||||
jsonb_build_object('x', x, 'y', y, 'width', width, 'height', height) as bbox,
|
||||
NULL::jsonb as attributes
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND confidence >= $1
|
||||
ORDER BY confidence DESC
|
||||
@@ -587,8 +371,8 @@ async fn list_face_candidates(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
f64,
|
||||
i32,
|
||||
f32,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
@@ -629,215 +413,3 @@ async fn list_face_candidates(
|
||||
page_size,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_identity_faces(
|
||||
axum::extract::Path(identity_id): axum::extract::Path<i32>,
|
||||
Query(query): Query<IdentityFacesQuery>,
|
||||
) -> Result<Json<IdentityFacesResponse>, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let page_size = std::cmp::min(query.page_size.unwrap_or(100), 1000);
|
||||
let offset = (query.page.unwrap_or(1) - 1) * page_size;
|
||||
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
let count_sql = format!("SELECT COUNT(*) FROM {} WHERE identity_id = $1", table);
|
||||
|
||||
let total: i64 = match sqlx::query_scalar(&count_sql)
|
||||
.bind(identity_id)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(count) => count,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Count error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id, face_id, file_uuid, frame_number, confidence, bbox, attributes
|
||||
FROM {}
|
||||
WHERE identity_id = $1
|
||||
ORDER BY confidence DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
table
|
||||
);
|
||||
|
||||
let rows = match sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
i32,
|
||||
Option<String>,
|
||||
String,
|
||||
i64,
|
||||
f64,
|
||||
Option<serde_json::Value>,
|
||||
Option<serde_json::Value>,
|
||||
),
|
||||
>(&sql)
|
||||
.bind(identity_id)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Query error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let faces: Vec<IdentityFace> = rows
|
||||
.into_iter()
|
||||
.map(|r| IdentityFace {
|
||||
id: r.0,
|
||||
face_id: r.1,
|
||||
file_uuid: r.2,
|
||||
frame_number: r.3,
|
||||
confidence: r.4,
|
||||
bbox: r.5,
|
||||
attributes: r.6,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(IdentityFacesResponse {
|
||||
identity_id,
|
||||
faces,
|
||||
total,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_face_thumbnail(
|
||||
Path((file_uuid, face_id)): Path<(String, i32)>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let db = match PostgresDb::init().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to connect to database: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let table_fd = crate::core::db::schema::table_name("face_detections");
|
||||
let table_v = crate::core::db::schema::table_name("videos");
|
||||
|
||||
let sql = format!(
|
||||
"SELECT fd.frame_number, fd.bbox, v.file_path, v.fps
|
||||
FROM {} fd
|
||||
JOIN {} v ON fd.file_uuid = v.uuid
|
||||
WHERE fd.id = $1 AND fd.file_uuid = $2",
|
||||
table_fd, table_v
|
||||
);
|
||||
|
||||
let row: Option<(i64, Option<serde_json::Value>, String, f64)> = match sqlx::query_as(&sql)
|
||||
.bind(face_id)
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Query error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let (frame_number, bbox_json, file_path, fps) = match row {
|
||||
Some(r) => r,
|
||||
None => return Err((StatusCode::NOT_FOUND, format!("Face {} not found", face_id))),
|
||||
};
|
||||
|
||||
let bbox: Bbox = match bbox_json {
|
||||
Some(json) => serde_json::from_value(json).unwrap_or(Bbox {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
None => Bbox {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
};
|
||||
|
||||
let timestamp = frame_number as f64 / fps;
|
||||
|
||||
let crop_filter = format!("crop={}:{}:{}:{}", bbox.width, bbox.height, bbox.x, bbox.y);
|
||||
|
||||
let output = match Command::new("ffmpeg")
|
||||
.args(&[
|
||||
"-ss",
|
||||
×tamp.to_string(),
|
||||
"-i",
|
||||
&file_path,
|
||||
"-vf",
|
||||
&crop_filter,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("ffmpeg error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("ffmpeg failed: {}", stderr),
|
||||
));
|
||||
}
|
||||
|
||||
let response = axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=3600")
|
||||
.body(Body::from(output.stdout))
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Response error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Bbox {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
}
|
||||
|
||||
@@ -531,17 +531,17 @@ async fn suggest_merge(
|
||||
i2.uuid as target_uuid,
|
||||
i1.name as source_name,
|
||||
i2.name as target_name,
|
||||
COUNT(DISTINCT fi1.file_uuid) as shared_files
|
||||
COUNT(DISTINCT fd1.file_uuid) as shared_files
|
||||
FROM identities i1
|
||||
JOIN identities i2 ON i1.id < i2.id
|
||||
LEFT JOIN file_identities fi1 ON fi1.identity_id = i1.id
|
||||
LEFT JOIN file_identities fi2 ON fi2.identity_id = i2.id AND fi1.file_uuid = fi2.file_uuid
|
||||
LEFT JOIN face_detections fd1 ON fd1.identity_id = i1.id
|
||||
LEFT JOIN face_detections fd2 ON fd2.identity_id = i2.id AND fd1.file_uuid = fd2.file_uuid
|
||||
WHERE i1.identity_type = 'people'
|
||||
AND i2.identity_type = 'people'
|
||||
AND i1.id != i2.id
|
||||
{}
|
||||
GROUP BY i1.uuid, i2.uuid, i1.name, i2.name
|
||||
HAVING COUNT(DISTINCT fi1.file_uuid) > 0
|
||||
HAVING COUNT(DISTINCT fd1.file_uuid) > 0
|
||||
ORDER BY shared_files DESC
|
||||
LIMIT 50
|
||||
"#,
|
||||
|
||||
@@ -13,13 +13,25 @@ use crate::core::db::ResourceRecord;
|
||||
pub fn identity_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/files", get(list_files))
|
||||
.route("/api/v1/files/:uuid", get(get_file_detail))
|
||||
.route("/api/v1/files/:uuid/identities", get(get_file_identities))
|
||||
.route("/api/v1/identities/:uuid", get(get_identity_detail))
|
||||
.route("/api/v1/identities/:uuid/files", get(get_identity_files))
|
||||
.route("/api/v1/identities/:uuid/chunks", get(get_identity_chunks))
|
||||
.route("/api/v1/resources/register", post(register_resource))
|
||||
.route("/api/v1/resources/heartbeat", post(heartbeat_resource))
|
||||
.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))
|
||||
}
|
||||
|
||||
@@ -124,11 +136,11 @@ pub struct FileDetailResponse {
|
||||
|
||||
async fn get_file_detail(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Result<Json<FileDetailResponse>, (StatusCode, String)> {
|
||||
let file = state
|
||||
.db
|
||||
.get_file_by_uuid(&uuid)
|
||||
.get_file_by_uuid(&file_uuid)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -141,7 +153,10 @@ async fn get_file_detail(
|
||||
metadata: f.probe_json,
|
||||
created_at: f.created_at,
|
||||
})),
|
||||
None => Err((StatusCode::NOT_FOUND, format!("File not found: {}", uuid))),
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("File not found: {}", file_uuid),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +184,7 @@ pub struct FileIdentityItem {
|
||||
|
||||
async fn get_file_identities(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
Path(file_uuid): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<FileIdentitiesResponse>, (StatusCode, String)> {
|
||||
let page = params.page.unwrap_or(1);
|
||||
@@ -178,7 +193,7 @@ async fn get_file_identities(
|
||||
|
||||
let records = state
|
||||
.db
|
||||
.get_file_identities(&uuid, page_size as i32, offset)
|
||||
.get_file_identities(&file_uuid, page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -198,7 +213,7 @@ async fn get_file_identities(
|
||||
|
||||
Ok(Json(FileIdentitiesResponse {
|
||||
success: true,
|
||||
file_uuid: uuid,
|
||||
file_uuid: file_uuid,
|
||||
total: data.len() as i64,
|
||||
page,
|
||||
page_size,
|
||||
@@ -222,10 +237,15 @@ pub struct IdentityDetailResponse {
|
||||
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
fn strip_uuid(u: &uuid::Uuid) -> String {
|
||||
u.to_string().replace('-', "")
|
||||
}
|
||||
|
||||
async fn get_identity_detail(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid_str): Path<String>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
) -> Result<Json<IdentityDetailResponse>, (StatusCode, String)> {
|
||||
let uuid_str = identity_uuid;
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
|
||||
@@ -267,6 +287,45 @@ pub struct IdentityFilesResponse {
|
||||
pub data: Vec<IdentityFileItem>,
|
||||
}
|
||||
|
||||
async fn delete_identity(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
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,
|
||||
@@ -282,9 +341,10 @@ pub struct IdentityFileItem {
|
||||
|
||||
async fn get_identity_files(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid_str): Path<String>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<IdentityFilesResponse>, (StatusCode, String)> {
|
||||
let uuid_str = identity_uuid;
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
|
||||
@@ -421,9 +481,10 @@ pub struct IdentityChunkItem {
|
||||
|
||||
async fn get_identity_chunks(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid_str): Path<String>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<IdentityChunksResponse>, (StatusCode, String)> {
|
||||
let uuid_str = identity_uuid;
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::db::{Database, PostgresDb};
|
||||
use crate::core::person_identity::{BindIdentityRequest, Identity, UnbindIdentityRequest};
|
||||
use crate::core::person_identity::{
|
||||
BindIdentityRequest, Identity, MergeIdentitiesRequest, UnbindIdentityRequest,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ApiResponse<T: Serialize> {
|
||||
@@ -56,323 +58,223 @@ pub async fn list_identities(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 綁定身份 (Face/Speaker -> Identity)
|
||||
/// V4.0 直接綁定:face_detections.identity_id = identities.id
|
||||
pub async fn bind_identity(
|
||||
Path(identity_uuid): Path<String>,
|
||||
Json(req): Json<BindIdentityRequest>,
|
||||
) -> Result<Json<ApiResponse<()>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = get_db().await?;
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
|
||||
let identity = if let Some(id_id) = req.identity_id {
|
||||
db.get_identity_by_id(id_id).await.ok().flatten()
|
||||
} else if let Some(name) = &req.name {
|
||||
db.get_or_create_identity(name).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let identity = match identity {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "Identity not found or name required" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let source = req.source.unwrap_or("manual".to_string());
|
||||
db.bind_identity(
|
||||
identity.id as i64,
|
||||
&req.binding_type,
|
||||
&req.binding_value,
|
||||
&source,
|
||||
1.0,
|
||||
)
|
||||
// Get identity_id from identity_uuid
|
||||
let identity_row: Option<(i32, String)> = sqlx::query_as(&format!(
|
||||
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
|
||||
id_table
|
||||
))
|
||||
.bind(&identity_uuid)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Bound {} '{}' to Identity '{}'",
|
||||
req.binding_type, req.binding_value, identity.name
|
||||
),
|
||||
data: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 解綁身份
|
||||
pub async fn unbind_identity(
|
||||
Json(req): Json<UnbindIdentityRequest>,
|
||||
) -> Result<Json<ApiResponse<()>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = get_db().await?;
|
||||
db.unbind_identity(&req.binding_type, &req.binding_value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!("Unbound {} '{}'", req.binding_type, req.binding_value),
|
||||
data: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 查詢機器 ID 對應的 Identity (人物)
|
||||
pub async fn get_identity_info(
|
||||
Path((binding_type, binding_value)): Path<(String, String)>,
|
||||
) -> Result<Json<ApiResponse<Identity>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = get_db().await?;
|
||||
let identity = db
|
||||
.get_identity_by_binding(&binding_type, &binding_value)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let identity = identity.ok_or_else(|| {
|
||||
let (identity_id, name) = identity_row.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "Identity not found" })),
|
||||
Json(serde_json::json!({"error": format!("Identity not found: {}", identity_uuid)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Direct UPDATE face_detections.identity_id
|
||||
let result = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.execute(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: "Identity info retrieved".to_string(),
|
||||
data: Some(identity),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 列出未綁定的信號 (待標註列表)
|
||||
pub async fn list_unbound_signals(
|
||||
Query(params): Query<ListSignalsParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<String>>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = get_db().await?;
|
||||
let signals = db
|
||||
.list_unbound_signals(¶ms.uuid, ¶ms.binding_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Found {} unbound {} signals",
|
||||
signals.len(),
|
||||
params.binding_type
|
||||
"Bound face {} of {} to {}",
|
||||
req.face_id, req.file_uuid, name
|
||||
),
|
||||
data: Some(signals),
|
||||
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 獲取特定信號 (Face ID 或 Speaker ID) 出現的所有 Chunk (時間軸)
|
||||
pub async fn get_signal_timeline(
|
||||
Path((uuid, binding_type, binding_value)): Path<(String, String, String)>,
|
||||
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = get_db().await?;
|
||||
let chunks = db
|
||||
.get_chunks_by_signal(&uuid, &binding_type, &binding_value)
|
||||
/// V4.0 直接解綁:SET face_detections.identity_id = NULL
|
||||
pub async fn unbind_identity(
|
||||
Json(req): Json<UnbindIdentityRequest>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.execute(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Found {} chunks for {} '{}'",
|
||||
chunks.len(),
|
||||
binding_type,
|
||||
binding_value
|
||||
),
|
||||
data: Some(chunks),
|
||||
message: format!("Unbound face {} from {}", req.face_id, req.file_uuid),
|
||||
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AVSuggestRequest {
|
||||
pub file_uuid: String,
|
||||
pub overlap_threshold: Option<f64>, // default 0.6
|
||||
}
|
||||
/// V4.0 合併:將 identity A 合併入 identity B,A 被刪除
|
||||
pub async fn merge_identities(
|
||||
Path(from_uuid): Path<String>,
|
||||
Json(req): Json<MergeIdentitiesRequest>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let face_table = crate::core::db::schema::table_name("face_detections");
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AVSuggestion {
|
||||
pub face_id: String,
|
||||
pub speaker_id: String,
|
||||
pub overlap_score: f64,
|
||||
pub face_talent_id: Option<i32>,
|
||||
pub speaker_talent_id: Option<i32>,
|
||||
}
|
||||
|
||||
/// Suggests Face-Speaker bindings based on temporal overlap
|
||||
pub async fn suggest_audio_visual_bindings(
|
||||
Json(req): Json<AVSuggestRequest>,
|
||||
) -> Result<Json<ApiResponse<Vec<AVSuggestion>>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = get_db().await?;
|
||||
let threshold = req.overlap_threshold.unwrap_or(0.6);
|
||||
|
||||
// 1. Get Face signals and their time ranges
|
||||
let face_signals = db
|
||||
.list_unbound_signals(&req.file_uuid, "face")
|
||||
let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("Face signals: {}", e) })),
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let speaker_signals = db
|
||||
.list_unbound_signals(&req.file_uuid, "speaker")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("Speaker signals: {}", e) })),
|
||||
)
|
||||
})?;
|
||||
// Get IDs for both identities
|
||||
let from_row: Option<(i32, String)> = sqlx::query_as(&format!(
|
||||
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
|
||||
id_table
|
||||
))
|
||||
.bind(&from_uuid)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
let (from_id, from_name) = from_row.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Source identity not found"})),
|
||||
))?;
|
||||
|
||||
let mut suggestions = Vec::new();
|
||||
let into_row: Option<(i32, String)> = sqlx::query_as(&format!(
|
||||
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
|
||||
id_table
|
||||
))
|
||||
.bind(&req.into_uuid)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
let (into_id, into_name) = into_row.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Target identity not found"})),
|
||||
))?;
|
||||
|
||||
for face_id in &face_signals {
|
||||
for speaker_id in &speaker_signals {
|
||||
// Calculate overlap
|
||||
// In a real implementation, we would query the exact timestamps from DB.
|
||||
// For now, we'll use a placeholder or simple heuristic if timestamps are available in signal timeline.
|
||||
// Let's assume we fetch timelines.
|
||||
// Transfer all face bindings from source → target
|
||||
let updated = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE identity_id = $2",
|
||||
face_table
|
||||
))
|
||||
.bind(into_id)
|
||||
.bind(from_id)
|
||||
.execute(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Placeholder: Calculate overlap by fetching timelines
|
||||
let face_timeline = db
|
||||
.get_chunks_by_signal(&req.file_uuid, "face", face_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let speaker_timeline = db
|
||||
.get_chunks_by_signal(&req.file_uuid, "speaker", speaker_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let keep = req.keep_history.unwrap_or(true);
|
||||
|
||||
// Simplified overlap calculation based on chunk count/ids (assuming chunk_id contains time info or we have ranges)
|
||||
// Since chunk IDs are generic, we rely on content JSON having 'start_time'
|
||||
let overlap = calculate_overlap(&face_timeline, &speaker_timeline);
|
||||
|
||||
if overlap >= threshold {
|
||||
// Check if they are already bound to the same identity
|
||||
let face_identity = db
|
||||
.get_identity_by_binding("face", face_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let speaker_identity = db
|
||||
.get_identity_by_binding("speaker", speaker_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// If both bound to different identities, don't suggest (conflict)
|
||||
if let (Some(fi), Some(si)) = (&face_identity, &speaker_identity) {
|
||||
if fi.id != si.id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.push(AVSuggestion {
|
||||
face_id: face_id.clone(),
|
||||
speaker_id: speaker_id.clone(),
|
||||
overlap_score: overlap,
|
||||
face_talent_id: face_identity.as_ref().map(|i| i.id as i32),
|
||||
speaker_talent_id: speaker_identity.as_ref().map(|i| i.id as i32),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.sort_by(|a, b| b.overlap_score.partial_cmp(&a.overlap_score).unwrap());
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!("Found {} AV suggestions", suggestions.len()),
|
||||
data: Some(suggestions),
|
||||
}))
|
||||
}
|
||||
|
||||
fn calculate_overlap(
|
||||
face_chunks: &[serde_json::Value],
|
||||
speaker_chunks: &[serde_json::Value],
|
||||
) -> f64 {
|
||||
// Simplified: Extract start/end times and calculate intersection over union
|
||||
// Assuming chunks have start_frame or start_time in content JSON
|
||||
// If content is raw string, we might need to parse it.
|
||||
// In our schema, content is JSONB.
|
||||
|
||||
let mut face_ranges: Vec<(f64, f64)> = Vec::new();
|
||||
for c in face_chunks {
|
||||
if let Some(content) = c.get("content") {
|
||||
if let (Some(start), Some(end)) = (
|
||||
content.get("start_time").and_then(|v| v.as_f64()),
|
||||
content.get("end_time").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
face_ranges.push((start, end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut speaker_ranges: Vec<(f64, f64)> = Vec::new();
|
||||
for c in speaker_chunks {
|
||||
if let Some(content) = c.get("content") {
|
||||
if let (Some(start), Some(end)) = (
|
||||
content.get("start_time").and_then(|v| v.as_f64()),
|
||||
content.get("end_time").and_then(|v| v.as_f64()),
|
||||
) {
|
||||
speaker_ranges.push((start, end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut overlap_duration = 0.0;
|
||||
for (fs, fe) in &face_ranges {
|
||||
for (ss, se) in &speaker_ranges {
|
||||
let start = fs.max(*ss);
|
||||
let end = fe.min(*se);
|
||||
if start < end {
|
||||
overlap_duration += end - start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return normalized overlap (0.0 to 1.0+), simple version: overlap / min_duration
|
||||
let min_duration = face_ranges
|
||||
.iter()
|
||||
.map(|(_, e)| e)
|
||||
.sum::<f64>()
|
||||
.min(speaker_ranges.iter().map(|(_, e)| e).sum::<f64>());
|
||||
|
||||
if min_duration > 0.0 {
|
||||
(overlap_duration / min_duration).min(1.0)
|
||||
if keep {
|
||||
// Mark as merged, keep record
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET status = 'merged', metadata = COALESCE(metadata, '{{}}'::jsonb) || jsonb_build_object('merged_into', $1) WHERE id = $2",
|
||||
id_table
|
||||
))
|
||||
.bind(&req.into_uuid).bind(from_id)
|
||||
.execute(&db).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))))?;
|
||||
} else {
|
||||
0.0
|
||||
// Delete source identity
|
||||
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table))
|
||||
.bind(from_id)
|
||||
.execute(&db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
"Merged '{}' into '{}' ({} faces transferred, {})",
|
||||
from_name,
|
||||
into_name,
|
||||
updated.rows_affected(),
|
||||
if keep {
|
||||
"history kept"
|
||||
} else {
|
||||
"source deleted"
|
||||
}
|
||||
),
|
||||
data: Some(serde_json::json!({"faces_transferred": updated.rows_affected()})),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Router Setup
|
||||
// ============================================================================
|
||||
// Router Setup
|
||||
// ============================================================================
|
||||
@@ -384,29 +286,15 @@ pub struct ListIdentitiesParams {
|
||||
pub offset: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListSignalsParams {
|
||||
pub uuid: String,
|
||||
pub binding_type: String, // "face" or "speaker"
|
||||
}
|
||||
|
||||
pub fn identity_binding_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/identities/bind", post(bind_identity))
|
||||
.route("/api/v1/identities/unbind", post(unbind_identity))
|
||||
.route("/api/v1/identity/:identity_uuid/bind", post(bind_identity))
|
||||
.route(
|
||||
"/api/v1/identity/:binding_type/:binding_value",
|
||||
get(get_identity_info),
|
||||
)
|
||||
// 信號發現 (Discovery)
|
||||
.route("/api/v1/signals/unbound", get(list_unbound_signals))
|
||||
// 信號時間軸 (Timeline)
|
||||
.route(
|
||||
"/api/v1/signals/:uuid/:binding_type/:binding_value/timeline",
|
||||
get(get_signal_timeline),
|
||||
"/api/v1/identity/:identity_uuid/unbind",
|
||||
post(unbind_identity),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/identities/suggest-av",
|
||||
post(suggest_audio_visual_bindings),
|
||||
"/api/v1/identity/:from_uuid/mergeinto",
|
||||
post(merge_identities),
|
||||
)
|
||||
}
|
||||
|
||||
402
src/api/media_api.rs
Normal file
402
src/api/media_api.rs
Normal file
@@ -0,0 +1,402 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::core::db::{schema, PostgresDb};
|
||||
|
||||
pub fn bbox_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/video/bbox",
|
||||
get(bbox_overlay_video),
|
||||
)
|
||||
.route("/api/v1/file/:file_uuid/video", get(stream_video))
|
||||
.route("/api/v1/file/:file_uuid/thumbnail", get(face_thumbnail))
|
||||
}
|
||||
|
||||
/// 5×7 bitmap font — each character 5 wide × 7 tall
|
||||
/// Encoding: col 0=0x10, col 1=0x08, col 2=0x04, col 3=0x02, col 4=0x01
|
||||
fn bitmap_char(c: char) -> [u8; 7] {
|
||||
match c.to_ascii_lowercase() {
|
||||
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
|
||||
'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
|
||||
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
|
||||
'3' => [0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E],
|
||||
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
|
||||
'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
|
||||
'6' => [0x0E, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x0E],
|
||||
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10],
|
||||
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
|
||||
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x11, 0x0E],
|
||||
'a' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
|
||||
'b' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
|
||||
'c' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
|
||||
'd' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
|
||||
'e' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
|
||||
'f' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
|
||||
't' => [0x04, 0x04, 0x1F, 0x04, 0x04, 0x04, 0x06],
|
||||
'_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
|
||||
' ' => [0x00; 7],
|
||||
_ => [0x00; 7],
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of one character in pixels (5 cols × 3px/dot = 15px)
|
||||
const CHAR_W: i32 = 5 * 3;
|
||||
/// Spacing between characters (px)
|
||||
const CHAR_GAP: i32 = 4;
|
||||
/// Total advance per character
|
||||
const CHAR_ADVANCE: i32 = CHAR_W + CHAR_GAP;
|
||||
|
||||
fn render_text(
|
||||
parts: &mut Vec<String>,
|
||||
text: &str,
|
||||
origin_x: i32,
|
||||
origin_y: i32,
|
||||
enable: Option<i32>,
|
||||
) -> i32 {
|
||||
let mut px = origin_x;
|
||||
for ch in text.chars() {
|
||||
let bm = bitmap_char(ch);
|
||||
for (row, bits) in bm.iter().enumerate() {
|
||||
for col in 0..5 {
|
||||
if bits & (1 << (4 - col)) != 0 {
|
||||
let x = px + col as i32 * 3;
|
||||
let y = origin_y + row as i32 * 3;
|
||||
if let Some(offset) = enable {
|
||||
parts.push(format!(
|
||||
"drawbox=x={}:y={}:w=3:h=3:color=white@1.0:t=fill:enable='eq(n,{})'",
|
||||
x, y, offset
|
||||
));
|
||||
} else {
|
||||
parts.push(format!(
|
||||
"drawbox=x={}:y={}:w=3:h=3:color=white@1.0:t=fill",
|
||||
x, y
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
px += CHAR_ADVANCE;
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct BboxParams {
|
||||
start: Option<i32>,
|
||||
end: Option<i32>,
|
||||
face_uuid: Option<String>,
|
||||
duration: Option<f64>,
|
||||
}
|
||||
|
||||
async fn bbox_overlay_video(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
Query(p): Query<BboxParams>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let videos_table = schema::table_name("videos");
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let (video_path,) = row.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let start_f = p.start.unwrap_or(0);
|
||||
let end_f = p.end.unwrap_or(i32::MAX);
|
||||
let face_fuid = p.face_uuid.as_deref().unwrap_or(&file_uuid);
|
||||
let duration = p.duration.unwrap_or(10.0);
|
||||
|
||||
// Get FPS
|
||||
let fps: f64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.unwrap_or(24.0);
|
||||
|
||||
let start_sec = start_f as f64 / fps;
|
||||
|
||||
// Get face bboxes
|
||||
let face_table = schema::table_name("face_detections");
|
||||
let rows: Vec<(i32, i32, i32, i32, i32, Option<i32>, Option<String>)> = sqlx::query_as(
|
||||
&format!("SELECT frame_number, x, y, width, height, trace_id, face_id FROM {} WHERE file_uuid = $1 AND frame_number BETWEEN $2 AND $3 ORDER BY frame_number", face_table)
|
||||
)
|
||||
.bind(face_fuid).bind(start_f).bind(end_f)
|
||||
.fetch_all(state.db.pool()).await
|
||||
.unwrap_or_else(|e| { tracing::error!("bbox query error: {}", e); vec![] });
|
||||
|
||||
// Build filters
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut is_first = true;
|
||||
for (frame, x, y, w, h, trace_id, _) in &rows {
|
||||
let text = format!("t{}", trace_id.unwrap_or(0));
|
||||
|
||||
if is_first {
|
||||
is_first = false;
|
||||
// Persistent bbox: thin pale red border
|
||||
parts.push(format!(
|
||||
"drawbox=x={}:y={}:w={}:h={}:color=red@0.3:thickness=4",
|
||||
x, y, w, h
|
||||
));
|
||||
// Always-on text: top-left of bbox with padding
|
||||
let tx = *x + 6;
|
||||
let ty = *y + 6;
|
||||
render_text(&mut parts, &text, tx, ty, None);
|
||||
} else {
|
||||
let offset = frame - start_f;
|
||||
// Per-frame bbox: thick bright red
|
||||
parts.push(format!(
|
||||
"drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=8:enable='eq(n,{})'",
|
||||
x, y, w, h, offset
|
||||
));
|
||||
// Per-frame text
|
||||
let tx = *x + 6;
|
||||
let ty = *y + 6;
|
||||
render_text(&mut parts, &text, tx, ty, Some(offset));
|
||||
}
|
||||
}
|
||||
|
||||
let vf = if parts.is_empty() {
|
||||
"null".to_string()
|
||||
} else {
|
||||
parts.join(",")
|
||||
};
|
||||
|
||||
let tmp = std::env::temp_dir().join(format!("bbox_{}.mp4", uuid::Uuid::new_v4()));
|
||||
let tmp_str = tmp.to_str().unwrap_or("").to_string();
|
||||
let status = std::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-ss",
|
||||
&start_sec.to_string(),
|
||||
"-i",
|
||||
&video_path,
|
||||
"-t",
|
||||
&duration.to_string(),
|
||||
"-vf",
|
||||
&vf,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-crf",
|
||||
"28",
|
||||
"-an",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-y",
|
||||
&tmp_str,
|
||||
])
|
||||
.status()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
if !status.success() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
let data = tokio::fs::read(&tmp)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Ok(Response::builder()
|
||||
.header(header::CONTENT_TYPE, "video/mp4")
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.body(Body::from(data))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
fn parse_range(range: &str, file_size: u64) -> (u64, u64) {
|
||||
let r = range.trim_start_matches("bytes=");
|
||||
let parts: Vec<&str> = r.split('-').collect();
|
||||
let start = parts[0].parse::<u64>().unwrap_or(0);
|
||||
let end = if parts.len() > 1 && !parts[1].is_empty() {
|
||||
parts[1].parse::<u64>().unwrap_or(file_size - 1)
|
||||
} else {
|
||||
file_size - 1
|
||||
};
|
||||
(start.min(file_size - 1), end.min(file_size - 1))
|
||||
}
|
||||
|
||||
async fn stream_video(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
let videos_table = schema::table_name("videos");
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let (file_path,) = row.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let src = std::path::PathBuf::from(&file_path);
|
||||
if !src.exists() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// Chunk extraction with start/end params
|
||||
if let (Some(s), Some(e)) = (params.get("start"), params.get("end")) {
|
||||
let start: f64 = s.parse().unwrap_or(0.0);
|
||||
let end: f64 = e.parse().unwrap_or(0.0);
|
||||
let dur = end - start;
|
||||
if dur <= 0.0 {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let tmp = std::env::temp_dir().join(format!("chunk_{}.mp4", uuid::Uuid::new_v4()));
|
||||
let tmp_str = tmp.to_str().unwrap_or("").to_string();
|
||||
let status = std::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-ss",
|
||||
&start.to_string(),
|
||||
"-i",
|
||||
&file_path,
|
||||
"-t",
|
||||
&dur.to_string(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-y",
|
||||
&tmp_str,
|
||||
])
|
||||
.status()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
if !status.success() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
let data = tokio::fs::read(&tmp)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Ok(Response::builder()
|
||||
.header(header::CONTENT_TYPE, "video/mp4")
|
||||
.header(header::CONTENT_LENGTH, data.len())
|
||||
.body(Body::from(data))
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
let file_size = src.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let content_type = "video/mp4";
|
||||
|
||||
let range_hdr = request
|
||||
.headers()
|
||||
.get(header::RANGE)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
if let Some(range_str) = range_hdr {
|
||||
let (start, end) = parse_range(range_str, file_size);
|
||||
let length = end - start + 1;
|
||||
let mut file = tokio::fs::File::open(&src)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
file.seek(std::io::SeekFrom::Start(start))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let take = file.take(length);
|
||||
let stream = tokio_util::io::ReaderStream::new(take);
|
||||
let body = Body::from_stream(stream);
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, end, file_size),
|
||||
)
|
||||
.header(header::CONTENT_LENGTH, length)
|
||||
.body(body)
|
||||
.unwrap())
|
||||
} else {
|
||||
let file = tokio::fs::File::open(&src)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let stream = tokio_util::io::ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
Ok(Response::builder()
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_LENGTH, file_size)
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.body(body)
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ThumbQuery {
|
||||
frame: i64,
|
||||
x: Option<i32>,
|
||||
y: Option<i32>,
|
||||
w: Option<i32>,
|
||||
h: Option<i32>,
|
||||
}
|
||||
|
||||
async fn face_thumbnail(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
Query(q): Query<ThumbQuery>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let videos_table = schema::table_name("videos");
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let (file_path,) = row.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let select = format!("select=eq(n\\,{})", q.frame);
|
||||
let vf = if let (Some(x), Some(y), Some(w), Some(h)) = (q.x, q.y, q.w, q.h) {
|
||||
format!("{},crop={}:{}:{}:{}", select, w, h, x, y)
|
||||
} else {
|
||||
select
|
||||
};
|
||||
|
||||
let output = std::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
&file_path,
|
||||
"-vf",
|
||||
&vf,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "image/jpeg")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(output.stdout))
|
||||
.unwrap())
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
pub mod agent_api;
|
||||
pub mod face_recognition;
|
||||
pub mod five_w1h_agent_api;
|
||||
pub mod identities;
|
||||
pub mod identity_agent_api;
|
||||
pub mod identity_api;
|
||||
pub mod identity_binding;
|
||||
pub mod media_api;
|
||||
pub mod middleware;
|
||||
pub mod n8n_search;
|
||||
pub mod person_identity;
|
||||
pub mod search;
|
||||
pub mod server;
|
||||
pub mod snapshot_api;
|
||||
pub mod trace_agent_api;
|
||||
pub mod universal_search;
|
||||
pub mod visual_chunk_search;
|
||||
pub mod who;
|
||||
|
||||
pub use server::start_server;
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
use crate::core::db::{Bm25Result, PostgresDb};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SmartSearchRequest {
|
||||
pub query: String,
|
||||
pub uuid: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SmartSearchResponse {
|
||||
pub query: String,
|
||||
pub parsed_dimensions: serde_json::Value,
|
||||
pub hits: Vec<serde_json::Value>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct LlmDimensionResponse {
|
||||
pub who: Option<String>,
|
||||
pub what: Option<String>,
|
||||
pub when: Option<String>,
|
||||
pub r#where: Option<String>,
|
||||
pub why: Option<String>,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
/// POST /api/v1/n8n/search/smart
|
||||
pub async fn n8n_search_smart(
|
||||
db: &PostgresDb,
|
||||
req: SmartSearchRequest,
|
||||
) -> Result<SmartSearchResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let limit = req.limit.unwrap_or(10);
|
||||
let file_uuid = req.uuid.clone();
|
||||
|
||||
// 1. Call LLM to extract 5W1H (Fallback to keywords if LLM fails)
|
||||
let dimensions = match parse_query_with_llm(&req.query).await {
|
||||
Some(dims) => dims,
|
||||
None => LlmDimensionResponse {
|
||||
who: None,
|
||||
what: None,
|
||||
when: None,
|
||||
r#where: None,
|
||||
why: None,
|
||||
keywords: extract_keywords(&req.query),
|
||||
},
|
||||
};
|
||||
|
||||
// Prepare search terms based on dimensions
|
||||
let keywords = dimensions.keywords.join(" ");
|
||||
|
||||
let semantic_query = [
|
||||
dimensions.who.clone(),
|
||||
dimensions.what.clone(),
|
||||
dimensions.r#where.clone(),
|
||||
dimensions.why.clone(),
|
||||
dimensions.when.clone(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
// 2. Multi-dimensional Search
|
||||
let mut hits: Vec<serde_json::Value> = Vec::new();
|
||||
let mut seen_chunk_ids: HashSet<String> = HashSet::new();
|
||||
|
||||
// Helper function
|
||||
fn add_hit(
|
||||
hits: &mut Vec<serde_json::Value>,
|
||||
seen_chunk_ids: &mut HashSet<String>,
|
||||
sr: Bm25Result,
|
||||
boost: f32,
|
||||
) {
|
||||
if seen_chunk_ids.insert(sr.chunk_id.clone()) {
|
||||
let score = sr.bm25_score * boost;
|
||||
let val = serde_json::json!({
|
||||
"id": sr.chunk_id,
|
||||
"vid": sr.uuid,
|
||||
"start": sr.start_time,
|
||||
"end": sr.end_time,
|
||||
"text": sr.text,
|
||||
"score": score,
|
||||
"chunk_type": sr.chunk_type
|
||||
});
|
||||
hits.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
// A. Keyword Search (BM25)
|
||||
if !keywords.is_empty() {
|
||||
if let Ok(results) = db.search_bm25(&keywords, file_uuid.as_deref(), limit).await {
|
||||
for sr in results {
|
||||
add_hit(&mut hits, &mut seen_chunk_ids, sr, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// B. Who Search (Person Matching)
|
||||
if let Some(who_query) = &dimensions.who {
|
||||
// 1. Search Person
|
||||
if let Ok(persons) = db.search_person_candidates(who_query, &file_uuid, 5).await {
|
||||
if !persons.is_empty() {
|
||||
let person_id = persons[0]
|
||||
.get("candidate_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
if let Some(pid) = person_id {
|
||||
// Heuristic: Search BM25 for the person's ID or Name
|
||||
let person_name = persons[0]
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(who_query);
|
||||
|
||||
// Re-run BM25 with person name to find specific chunks and boost them
|
||||
if let Ok(results) = db
|
||||
.search_bm25(person_name, file_uuid.as_deref(), limit)
|
||||
.await
|
||||
{
|
||||
for sr in results {
|
||||
let id = sr.chunk_id.clone();
|
||||
if seen_chunk_ids.insert(id) {
|
||||
let score = sr.bm25_score * 1.5;
|
||||
let val = serde_json::json!({
|
||||
"id": sr.chunk_id,
|
||||
"vid": sr.uuid,
|
||||
"start": sr.start_time,
|
||||
"end": sr.end_time,
|
||||
"text": sr.text,
|
||||
"score": score,
|
||||
"matched_person": pid
|
||||
});
|
||||
hits.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
hits.sort_by(|a, b| {
|
||||
let score_a = a.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let score_b = b.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
score_b.partial_cmp(&score_a).unwrap()
|
||||
});
|
||||
|
||||
// Limit
|
||||
hits.truncate(limit);
|
||||
let total = hits.len();
|
||||
|
||||
Ok(SmartSearchResponse {
|
||||
query: req.query,
|
||||
parsed_dimensions: serde_json::json!(dimensions),
|
||||
hits,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_keywords(query: &str) -> Vec<String> {
|
||||
// Simple keyword extraction: remove common stop words and punctuation
|
||||
let stop_words = [
|
||||
"who", "what", "where", "when", "why", "how", "is", "the", "a", "an", "and", "or", "of",
|
||||
"in", "to", "for", "with", "by", "on", "at", "from", "up", "about", "into", "over",
|
||||
"after",
|
||||
];
|
||||
|
||||
query
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { ' ' })
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.filter(|w| !stop_words.contains(w))
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn parse_query_with_llm(query: &str) -> Option<LlmDimensionResponse> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Test connectivity first
|
||||
if let Ok(resp) = client.get("http://127.0.0.1:8081/health").send().await {
|
||||
tracing::info!("LLM Health Check: {}", resp.status());
|
||||
} else {
|
||||
tracing::error!("LLM Server is unreachable at 127.0.0.1:8081");
|
||||
}
|
||||
|
||||
// We use the OpenAI-compatible endpoint provided by llama.cpp server (default port 8081)
|
||||
let prompt = format!(
|
||||
r#"Analyze the user query and extract the following dimensions into a JSON object.
|
||||
If a dimension is not present, use null.
|
||||
Dimensions: "who" (person/subject), "what" (action/event), "where" (location), "when" (time), "why" (reason/intent), "keywords" (array of specific keywords).
|
||||
|
||||
User Query: "{}"
|
||||
|
||||
Output ONLY the JSON object.
|
||||
"#,
|
||||
query
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": "gemma4",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"stream": false
|
||||
});
|
||||
|
||||
if let Ok(response) = client
|
||||
.post("http://127.0.0.1:8081/v1/chat/completions")
|
||||
.json(&payload)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
tracing::info!("LLM Response Status: {}", response.status());
|
||||
if let Ok(json_resp) = response.json::<serde_json::Value>().await {
|
||||
tracing::info!("LLM Response Body: {}", json_resp);
|
||||
if let Some(choices) = json_resp.get("choices").and_then(|v| v.as_array()) {
|
||||
if let Some(choice) = choices.get(0) {
|
||||
if let Some(content) = choice
|
||||
.get("message")
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
{
|
||||
if let Some(start) = content.find("{") {
|
||||
if let Some(end) = content.rfind("}") {
|
||||
let json_str = &content[start..=end];
|
||||
if let Ok(dims) =
|
||||
serde_json::from_str::<LlmDimensionResponse>(json_str)
|
||||
{
|
||||
tracing::info!("Parsed LLM Dimensions: {:?}", dims);
|
||||
return Some(dims);
|
||||
} else {
|
||||
tracing::warn!("Failed to parse LLM JSON: {}", json_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Failed to parse LLM response JSON");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("LLM request failed or timed out");
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -188,5 +188,5 @@ async fn get_ollama_embedding(
|
||||
// --- Router Setup ---
|
||||
|
||||
pub fn search_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new().route("/smart", post(smart_search))
|
||||
Router::new().route("/api/v1/search/smart", post(smart_search))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ use crate::core::text::tokenizer::tokenize_chinese_text;
|
||||
use crate::{Embedder, FileManager};
|
||||
|
||||
use super::agent_api;
|
||||
use super::face_recognition;
|
||||
use super::five_w1h_agent_api;
|
||||
use super::identities;
|
||||
use super::identity_api;
|
||||
@@ -2491,26 +2490,15 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
.route("/api/v1/files/register", post(register_file))
|
||||
.route("/api/v1/unregister", post(unregister))
|
||||
.route("/api/v1/files/scan", get(scan_files))
|
||||
.route("/api/v1/files/:file_uuid/probe", get(probe_by_uuid))
|
||||
.route("/api/v1/files/:file_uuid/process", post(trigger_processing))
|
||||
.route("/api/v1/assets/:uuid/status", get(get_asset_status))
|
||||
.route("/api/v1/jobs/:job_id", get(get_job_status))
|
||||
.route("/api/v1/rules/:rule/status", get(get_rule_status))
|
||||
.route("/api/v1/search/hybrid", post(hybrid_search))
|
||||
.route("/api/v1/search", post(search))
|
||||
.route("/api/v1/search/bm25", post(search_bm25))
|
||||
.route("/api/v1/lookup", get(lookup))
|
||||
.route("/api/v1/videos/:uuid", delete(delete_video))
|
||||
.route("/api/v1/videos/:uuid/details", get(video_details))
|
||||
.route("/api/v1/videos/:uuid/pre_chunks", get(list_pre_chunks))
|
||||
.route("/api/v1/file/:file_uuid/probe", get(probe_by_uuid))
|
||||
.route("/api/v1/file/:file_uuid/process", post(trigger_processing))
|
||||
.route("/api/v1/file/:file_uuid/chunks", get(list_pre_chunks))
|
||||
.route("/api/v1/progress/:uuid", get(get_progress))
|
||||
.route("/api/v1/jobs", get(list_jobs))
|
||||
.route("/api/v1/config/cache", post(cache_toggle))
|
||||
.merge(face_recognition::face_recognition_routes())
|
||||
// .merge(person_identity::person_identity_routes()) // V4.0: DISABLED (person_identities table removed)
|
||||
.merge(identity_binding::identity_binding_routes())
|
||||
.merge(identities::identity_routes())
|
||||
.merge(super::snapshot_api::snapshot_routes())
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.api_state.clone(),
|
||||
api_key_validation,
|
||||
@@ -2548,6 +2536,7 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
|
||||
.merge(agent_api::agent_routes()) // Phase 6 Routes
|
||||
.merge(super::identity_agent_api::identity_agent_routes()) // Phase 5 Routes
|
||||
.merge(five_w1h_agent_api::five_w1h_agent_routes()) // Phase 3 Routes (5W1H Agent)
|
||||
.merge(super::media_api::bbox_routes()) // Media: video/bbox/thumbnail
|
||||
.merge(search_routes()) // Smart search drill-down
|
||||
.merge(universal_search_routes()) // Universal / frames / persons search
|
||||
.merge(protected_routes)
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::processor::snapshot_agent::SnapshotAgent;
|
||||
use crate::core::storage::snapshot_manager::SnapshotManager;
|
||||
|
||||
pub fn snapshot_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/files/:uuid/snapshots",
|
||||
get(get_file_snapshots).post(generate_file_snapshots),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/files/:uuid/snapshots/status",
|
||||
get(get_file_snapshot_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/files/:uuid/snapshots/migrate",
|
||||
post(migrate_file_snapshots),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/files/:uuid/snapshots/teardown",
|
||||
post(teardown_file_snapshots),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/identities/:uuid/snapshots",
|
||||
get(get_identity_snapshots).post(generate_identity_snapshots),
|
||||
)
|
||||
}
|
||||
|
||||
// --- File Snapshot Endpoints ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileSnapshotsResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub tier: String,
|
||||
pub hits: u64,
|
||||
pub types: Vec<String>,
|
||||
}
|
||||
|
||||
async fn get_file_snapshots(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<FileSnapshotsResponse>, (StatusCode, String)> {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let manager = SnapshotManager::new(output_dir);
|
||||
|
||||
let hits = state
|
||||
.redis_cache
|
||||
.get_snapshot_hits(&uuid)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let tier = SnapshotManager::compute_tier(hits);
|
||||
let types = manager.list_snapshot_types(&uuid);
|
||||
|
||||
state.redis_cache.update_last_access(&uuid).await.ok();
|
||||
|
||||
Ok(Json(FileSnapshotsResponse {
|
||||
success: true,
|
||||
file_uuid: uuid,
|
||||
tier: tier.to_string(),
|
||||
hits,
|
||||
types,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SnapshotStatusResponse {
|
||||
pub success: bool,
|
||||
pub file_uuid: String,
|
||||
pub status: String,
|
||||
pub progress: Option<f32>,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
async fn get_file_snapshot_status(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<SnapshotStatusResponse>, (StatusCode, String)> {
|
||||
let status_json = state
|
||||
.redis_cache
|
||||
.get_snapshot_status(&uuid)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let status: String = status_json
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("cold")
|
||||
.to_string();
|
||||
|
||||
let progress: Option<f32> = status_json
|
||||
.get("progress")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as f32);
|
||||
|
||||
let hits = state
|
||||
.redis_cache
|
||||
.get_snapshot_hits(&uuid)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let tier = SnapshotManager::compute_tier(hits);
|
||||
|
||||
Ok(Json(SnapshotStatusResponse {
|
||||
success: true,
|
||||
file_uuid: uuid,
|
||||
status,
|
||||
progress,
|
||||
tier: tier.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GenerateSnapshotRequest {
|
||||
#[serde(rename = "type")]
|
||||
pub snapshot_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GenerateSnapshotResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
async fn generate_file_snapshots(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
Json(req): Json<GenerateSnapshotRequest>,
|
||||
) -> Result<Json<GenerateSnapshotResponse>, (StatusCode, String)> {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let manager = SnapshotManager::new(output_dir);
|
||||
let agent = SnapshotAgent::default();
|
||||
|
||||
manager
|
||||
.ensure_file_dirs(&uuid)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
state
|
||||
.redis_cache
|
||||
.set_snapshot_status(&uuid, "generating", Some(0.0))
|
||||
.await
|
||||
.map_err(|e: anyhow::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let snapshot_type = req.snapshot_type.as_deref().unwrap_or("faces");
|
||||
|
||||
tracing::info!(
|
||||
"Starting snapshot generation for file_uuid={}, type={}",
|
||||
uuid,
|
||||
snapshot_type
|
||||
);
|
||||
|
||||
match agent.generate_file_snapshots(&uuid, snapshot_type).await {
|
||||
Ok(_) => {
|
||||
state
|
||||
.redis_cache
|
||||
.set_snapshot_status(&uuid, "ready", Some(1.0))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::info!("Snapshot generation completed for file_uuid={}", uuid);
|
||||
Ok(Json(GenerateSnapshotResponse {
|
||||
success: true,
|
||||
message: format!("Snapshot generation completed for type: {}", snapshot_type),
|
||||
file_uuid: uuid,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
state
|
||||
.redis_cache
|
||||
.set_snapshot_status(&uuid, "failed", None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::error!("Snapshot generation failed for file_uuid={}: {}", uuid, e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Snapshot generation failed: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MigrateSnapshotRequest {
|
||||
pub parent_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MigrateSnapshotResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub file_uuid: String,
|
||||
pub migrated_types: Vec<String>,
|
||||
}
|
||||
|
||||
async fn migrate_file_snapshots(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
Json(req): Json<MigrateSnapshotRequest>,
|
||||
) -> Result<Json<MigrateSnapshotResponse>, (StatusCode, String)> {
|
||||
let agent = SnapshotAgent::default();
|
||||
|
||||
tracing::info!(
|
||||
"Starting snapshot migration from parent_uuid={} to file_uuid={}",
|
||||
req.parent_uuid,
|
||||
uuid
|
||||
);
|
||||
|
||||
match agent.migrate_snapshots(&uuid, &req.parent_uuid).await {
|
||||
Ok(migrated) => {
|
||||
state
|
||||
.redis_cache
|
||||
.set_migrate_hint(&uuid, &req.parent_uuid, migrated.len() as u64)
|
||||
.await
|
||||
.map_err(|e: anyhow::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(MigrateSnapshotResponse {
|
||||
success: true,
|
||||
message: format!("Migrated {} snapshot types", migrated.len()),
|
||||
file_uuid: uuid,
|
||||
migrated_types: migrated,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Snapshot migration failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Migration failed: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Identity Snapshot Endpoints ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentitySnapshotsResponse {
|
||||
pub success: bool,
|
||||
pub identity_uuid: String,
|
||||
pub has_reference: bool,
|
||||
pub face_count: usize,
|
||||
}
|
||||
|
||||
async fn get_identity_snapshots(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<IdentitySnapshotsResponse>, (StatusCode, String)> {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let manager = SnapshotManager::new(output_dir);
|
||||
|
||||
let identity_dir = manager.identity_snapshot_dir(&uuid);
|
||||
let has_reference = identity_dir.join("reference.jpg").exists();
|
||||
|
||||
let face_count = if identity_dir.join("faces").exists() {
|
||||
std::fs::read_dir(identity_dir.join("faces"))
|
||||
.map(|entries| entries.flatten().count())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(Json(IdentitySnapshotsResponse {
|
||||
success: true,
|
||||
identity_uuid: uuid,
|
||||
has_reference,
|
||||
face_count,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GenerateIdentitySnapshotResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub identity_uuid: String,
|
||||
}
|
||||
|
||||
async fn generate_identity_snapshots(
|
||||
State(_state): State<crate::api::server::AppState>,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<GenerateIdentitySnapshotResponse>, (StatusCode, String)> {
|
||||
let output_dir = crate::core::config::OUTPUT_DIR.as_str();
|
||||
let manager = SnapshotManager::new(output_dir);
|
||||
|
||||
manager
|
||||
.ensure_identity_dirs(&uuid)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::info!("Snapshot generation requested for identity_uuid={}", uuid);
|
||||
|
||||
Ok(Json(GenerateIdentitySnapshotResponse {
|
||||
success: true,
|
||||
message: "Identity snapshot directories created".to_string(),
|
||||
identity_uuid: uuid,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TeardownSnapshotResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub file_uuid: String,
|
||||
}
|
||||
|
||||
async fn teardown_file_snapshots(
|
||||
Path(uuid): Path<String>,
|
||||
) -> Result<Json<TeardownSnapshotResponse>, (StatusCode, String)> {
|
||||
let agent = SnapshotAgent::default();
|
||||
|
||||
tracing::info!("Manual teardown requested for file_uuid={}", uuid);
|
||||
|
||||
match agent.auto_tear_down(&uuid).await {
|
||||
Ok(_) => Ok(Json(TeardownSnapshotResponse {
|
||||
success: true,
|
||||
message: "Snapshot teardown completed".to_string(),
|
||||
file_uuid: uuid,
|
||||
})),
|
||||
Err(e) => {
|
||||
tracing::error!("Snapshot teardown failed for file_uuid={}: {}", uuid, e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Teardown failed: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/api/trace_agent_api.rs
Normal file
140
src/api/trace_agent_api.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::db::PostgresDb;
|
||||
|
||||
pub fn trace_agent_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new().route(
|
||||
"/api/v1/file/:file_uuid/face_trace/sortby",
|
||||
post(list_traces_sorted),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TracesRequest {
|
||||
min_faces: Option<i64>,
|
||||
sort_by: Option<String>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TraceInfo {
|
||||
trace_id: i32,
|
||||
face_count: i64,
|
||||
first_frame: i32,
|
||||
last_frame: i32,
|
||||
first_sec: f64,
|
||||
last_sec: f64,
|
||||
duration_sec: f64,
|
||||
avg_confidence: f64,
|
||||
sample_face_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TracesResponse {
|
||||
success: bool,
|
||||
file_uuid: String,
|
||||
total_traces: i64,
|
||||
total_faces: i64,
|
||||
traces: Vec<TraceInfo>,
|
||||
}
|
||||
|
||||
async fn list_traces_sorted(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
Json(req): Json<TracesRequest>,
|
||||
) -> Result<Json<TracesResponse>, (StatusCode, String)> {
|
||||
let min_faces = req.min_faces.unwrap_or(1);
|
||||
let sort = req.sort_by.as_deref().unwrap_or("first_appearance");
|
||||
let limit = req.limit.unwrap_or(500);
|
||||
|
||||
let order_clause = match sort {
|
||||
"face_count" => "face_count DESC",
|
||||
"duration" => "duration_sec DESC",
|
||||
_ => "first_frame ASC",
|
||||
};
|
||||
|
||||
// Get actual video FPS
|
||||
let fps: f64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(fps, 24.0) FROM dev.videos WHERE file_uuid = $1")
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.unwrap_or(24.0);
|
||||
|
||||
let query = format!(
|
||||
r#"SELECT tt.trace_id, tt.face_count, tt.first_frame, tt.last_frame,
|
||||
ROUND(tt.first_frame::numeric / {}, 1)::float8 AS first_sec,
|
||||
ROUND(tt.last_frame::numeric / {}, 1)::float8 AS last_sec,
|
||||
ROUND((tt.last_frame - tt.first_frame)::numeric / {}, 1)::float8 AS duration_sec,
|
||||
ROUND(tt.avg_confidence::numeric, 4)::float8 AS avg_confidence,
|
||||
fd.id::text AS sample_face_id
|
||||
FROM (
|
||||
SELECT trace_id,
|
||||
COUNT(*) AS face_count,
|
||||
MIN(frame_number) AS first_frame,
|
||||
MAX(frame_number) AS last_frame,
|
||||
AVG(confidence) AS avg_confidence
|
||||
FROM dev.face_detections
|
||||
WHERE file_uuid = $1 AND trace_id IS NOT NULL
|
||||
GROUP BY trace_id
|
||||
HAVING COUNT(*) >= $2
|
||||
ORDER BY {}
|
||||
LIMIT $3
|
||||
) tt
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM dev.face_detections
|
||||
WHERE trace_id = tt.trace_id AND file_uuid = $1
|
||||
ORDER BY confidence DESC LIMIT 1
|
||||
) fd ON true
|
||||
"#,
|
||||
fps, fps, fps, order_clause
|
||||
);
|
||||
|
||||
let rows: Vec<(i32, i64, i32, i32, f64, f64, f64, f64, Option<String>)> =
|
||||
sqlx::query_as(&query)
|
||||
.bind(&file_uuid)
|
||||
.bind(min_faces)
|
||||
.bind(limit)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let traces: Vec<TraceInfo> = rows
|
||||
.into_iter()
|
||||
.map(|(tid, fc, ff, lf, fs, ls, dur, conf, fid)| TraceInfo {
|
||||
trace_id: tid,
|
||||
face_count: fc,
|
||||
first_frame: ff,
|
||||
last_frame: lf,
|
||||
first_sec: fs,
|
||||
last_sec: ls,
|
||||
duration_sec: dur,
|
||||
avg_confidence: conf,
|
||||
sample_face_id: fid,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (total_traces, total_faces): (i64, i64) = sqlx::query_as(
|
||||
"SELECT COUNT(DISTINCT trace_id), COUNT(*) FROM dev.face_detections WHERE file_uuid = $1 AND trace_id IS NOT NULL"
|
||||
)
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(TracesResponse {
|
||||
success: true,
|
||||
file_uuid,
|
||||
total_traces,
|
||||
total_faces,
|
||||
traces,
|
||||
}))
|
||||
}
|
||||
@@ -93,7 +93,6 @@ pub fn universal_search_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/search/universal", post(universal_search))
|
||||
.route("/api/v1/search/frames", post(search_frames))
|
||||
.route("/api/v1/search/persons", get(search_persons))
|
||||
}
|
||||
|
||||
/// Unified search across all data types
|
||||
|
||||
145
src/api/who.rs
145
src/api/who.rs
@@ -1,145 +0,0 @@
|
||||
//! Who API - 身份識別與 ID 映射接口 (Video-Scoped)
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// --- Request / Response Structures ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WhoQuery {
|
||||
pub face_id: Option<String>,
|
||||
pub speaker_id: Option<String>,
|
||||
pub uuid: Option<String>,
|
||||
pub chunk_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WhoCandidatesRequest {
|
||||
pub query: String,
|
||||
pub file_uuid: Option<String>,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DefinePersonRequest {
|
||||
pub uuid: String,
|
||||
pub identity_id: Option<i32>,
|
||||
pub name: String,
|
||||
pub face_ids: Option<Vec<String>>,
|
||||
pub speaker_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WhoIdentity {
|
||||
pub identity_id: i32,
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub face_ids: Vec<String>,
|
||||
pub speaker_ids: Vec<String>,
|
||||
}
|
||||
|
||||
// --- API Handlers ---
|
||||
|
||||
/// GET /api/v1/who
|
||||
pub async fn get_who_identity(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
axum::extract::Query(query): axum::extract::Query<WhoQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = &state.db;
|
||||
|
||||
// Priority 1: Query by Chunk (UUID + Chunk ID)
|
||||
if let (Some(uuid), Some(chunk_id)) = (&query.uuid, &query.chunk_id) {
|
||||
let info = db
|
||||
.get_who_info_by_chunk(uuid, chunk_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
return Ok(Json(info));
|
||||
}
|
||||
|
||||
// Priority 2: List all for a specific UUID
|
||||
if let Some(uuid) = &query.uuid {
|
||||
// TODO: Implement list_all_persons(uuid)
|
||||
return Ok(Json(serde_json::json!({ "message": "List all pending" })));
|
||||
}
|
||||
|
||||
Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "Missing uuid" })),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/v1/who/candidates
|
||||
/// Search person_identities table for n8n workflow
|
||||
pub async fn get_who_candidates(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Json(req): Json<WhoCandidatesRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = &state.db;
|
||||
let limit = req.limit.unwrap_or(20);
|
||||
let query_str = format!("%{}%", req.query);
|
||||
|
||||
let results = db
|
||||
.search_person_candidates(&query_str, &req.file_uuid, limit)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Format for n8n
|
||||
let response = serde_json::json!({
|
||||
"query": req.query,
|
||||
"items": results,
|
||||
"total": results.len()
|
||||
});
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// POST /api/v1/who
|
||||
pub async fn define_person(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Json(req): Json<DefinePersonRequest>,
|
||||
) -> Result<Json<WhoIdentity>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = &state.db;
|
||||
|
||||
let identity = db
|
||||
.create_or_update_person(
|
||||
&req.uuid,
|
||||
req.identity_id,
|
||||
req.name.clone(),
|
||||
req.face_ids.unwrap_or_default(),
|
||||
req.speaker_ids.unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(identity))
|
||||
}
|
||||
|
||||
// --- Router Setup ---
|
||||
|
||||
pub fn who_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/who", get(get_who_identity).post(define_person))
|
||||
.route("/api/v1/who/candidates", post(get_who_candidates))
|
||||
}
|
||||
Reference in New Issue
Block a user