fix: ASRX duplication, TKG edges, trace ingest, and add pipeline progress publishing
- ASRX handler no longer stores duplicate 'asr' pre_chunks - Pre_chunks storage made idempotent (delete-before-insert) - Rule 1 + trace_ingest changed to query 'asrx' not 'asr' - Trace chunks removed (dynamic from TKG/Qdrant) - TKG scroll_face_points fixed: trace_id >= 1 (not == 1) - TKG AsrxSegmentEntry: start/end -> start_time/end_time (match ASRX JSON) - Unregister error handling: log instead of silent discard - Add publish_pipeline_progress calls at each pipeline stage (processors, rule1, face_trace, identity_agent, TKG, rule2, completion)
This commit is contained in:
+214
-253
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::core::db::ResourceRecord;
|
||||
use crate::core::db::{QdrantDb, ResourceRecord};
|
||||
|
||||
pub fn identity_routes() -> Router<crate::api::types::AppState> {
|
||||
Router::new()
|
||||
@@ -269,12 +269,7 @@ async fn get_file_identities(
|
||||
let fi_table = crate::core::db::schema::table_name("file_identities");
|
||||
let total = match sqlx::query_scalar::<_, i64>(
|
||||
&format!(
|
||||
r#"SELECT COUNT(DISTINCT identity_id) FROM (
|
||||
SELECT identity_id FROM {} WHERE file_uuid = $1 AND identity_id IS NOT NULL
|
||||
UNION
|
||||
SELECT identity_id FROM {} WHERE file_uuid = $1
|
||||
) combined"#,
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
r#"SELECT COUNT(DISTINCT identity_id) FROM {} WHERE file_uuid = $1 AND identity_id IS NOT NULL"#,
|
||||
fi_table
|
||||
)
|
||||
)
|
||||
@@ -419,7 +414,6 @@ async fn delete_identity(
|
||||
Extension(auth): Extension<crate::api::middleware::UserAuth>,
|
||||
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");
|
||||
let history_table = crate::core::db::schema::table_name("identity_history");
|
||||
|
||||
@@ -440,15 +434,27 @@ async fn delete_identity(
|
||||
// Delete identity file from disk
|
||||
let _ = crate::core::identity::storage::delete_identity_file(&uuid_clean);
|
||||
|
||||
// Capture unbound faces before unbinding
|
||||
let unbound_faces: Vec<(String, Option<String>, Option<i32>)> = sqlx::query_as(&format!(
|
||||
"SELECT file_uuid, face_id, trace_id FROM {} WHERE identity_id = $1",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
// Capture unbound faces from Qdrant _faces before unbinding
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let qdrant = QdrantDb::new();
|
||||
let face_filter = json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 1000).await.unwrap_or_default();
|
||||
|
||||
let unbound_faces: Vec<(String, Option<String>, Option<i32>)> = points.iter()
|
||||
.filter_map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let file_uuid = payload["file_uuid"].as_str()?.to_string();
|
||||
let face_id = payload.get("face_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let trace_id = payload["trace_id"].as_i64().map(|t| t as i32);
|
||||
Some((file_uuid, face_id, trace_id))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let face_list: Vec<serde_json::Value> = unbound_faces
|
||||
.into_iter()
|
||||
@@ -494,15 +500,17 @@ async fn delete_identity(
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
|
||||
// Unbind all faces
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE identity_id = $1",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
// Unbind all faces in Qdrant _faces
|
||||
let qdrant = QdrantDb::new();
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": serde_json::Value::Null});
|
||||
let _ = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await;
|
||||
|
||||
// Delete identity
|
||||
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table))
|
||||
@@ -572,17 +580,21 @@ async fn get_identity_files(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = match sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(DISTINCT fd.file_uuid) FROM {} fd WHERE fd.identity_id = $1",
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
))
|
||||
.bind(identity_id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => data.len() as i64,
|
||||
};
|
||||
// Get total from Qdrant _faces
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let qdrant = QdrantDb::new();
|
||||
let face_filter = json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 1000).await.unwrap_or_default();
|
||||
let unique_files: std::collections::HashSet<String> = points.iter()
|
||||
.filter_map(|p| p["payload"]["file_uuid"].as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
let total = unique_files.len() as i64;
|
||||
|
||||
Ok(Json(IdentityFilesResponse {
|
||||
success: true,
|
||||
@@ -673,17 +685,14 @@ async fn get_identity_faces(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = match sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(*) FROM {} fd WHERE fd.identity_id = $1",
|
||||
crate::core::db::schema::table_name("face_detections"),
|
||||
))
|
||||
.bind(identity_id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => data.len() as i64,
|
||||
};
|
||||
let qdrant2 = QdrantDb::new();
|
||||
let face_filter2 = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let points2 = qdrant2.scroll_all_points("_faces", face_filter2, 2000).await.unwrap_or_default();
|
||||
let total = points2.len() as i64;
|
||||
|
||||
Ok(Json(IdentityFacesResponse {
|
||||
success: true,
|
||||
@@ -759,151 +768,114 @@ async fn get_file_faces(
|
||||
let page_size = params.page_size.unwrap_or(50);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
let fd_table = crate::core::db::schema::table_name("face_detections");
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
let st_table = crate::core::db::schema::table_name("strangers");
|
||||
let video_table = crate::core::db::schema::table_name("videos");
|
||||
|
||||
// Build WHERE clauses
|
||||
let mut where_clauses = vec![format!(
|
||||
"fd.file_uuid = '{}'",
|
||||
file_uuid.replace('\'', "''")
|
||||
)];
|
||||
// Get fps
|
||||
let fps: f64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(fps, 25.0) FROM {} WHERE file_uuid = $1",
|
||||
video_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.unwrap_or(25.0);
|
||||
|
||||
// Get face points from Qdrant _faces
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
|
||||
let qdrant = QdrantDb::new();
|
||||
let mut filter_conditions = vec![
|
||||
json!({"key": "file_uuid", "match": {"value": file_uuid}})
|
||||
];
|
||||
|
||||
if let Some(ref binding) = params.binding {
|
||||
match binding.as_str() {
|
||||
"identity" => {
|
||||
where_clauses.push(format!("fd.identity_id IN (SELECT id FROM {})", id_table));
|
||||
filter_conditions.push(json!({"key": "identity_id", "exists": true}));
|
||||
}
|
||||
"stranger" => {
|
||||
where_clauses.push("fd.stranger_id IS NOT NULL".to_string());
|
||||
}
|
||||
"dangling" => {
|
||||
where_clauses.push(format!(
|
||||
"fd.identity_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM {} WHERE id = fd.identity_id)",
|
||||
id_table
|
||||
));
|
||||
filter_conditions.push(json!({"key": "stranger_id", "exists": true}));
|
||||
}
|
||||
"unbound" => {
|
||||
where_clauses.push("fd.identity_id IS NULL AND fd.stranger_id IS NULL".to_string());
|
||||
filter_conditions.push(json!({"key": "identity_id", "match": {"value": null}}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tid) = params.trace_id {
|
||||
where_clauses.push(format!("fd.trace_id = {}", tid));
|
||||
}
|
||||
if let Some(mc) = params.min_confidence {
|
||||
where_clauses.push(format!("fd.confidence >= {}", mc));
|
||||
}
|
||||
if let Some(sf) = params.start_frame {
|
||||
where_clauses.push(format!("fd.frame_number >= {}", sf));
|
||||
}
|
||||
if let Some(ef) = params.end_frame {
|
||||
where_clauses.push(format!("fd.frame_number <= {}", ef));
|
||||
filter_conditions.push(json!({"key": "trace_id", "match": {"value": tid}}));
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
let face_filter = json!({"must": filter_conditions});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 2000).await.unwrap_or_default();
|
||||
|
||||
let select_sql = format!(
|
||||
"SELECT fd.id::bigint as id, fd.file_uuid, \
|
||||
fd.frame_number::bigint as frame_number, \
|
||||
(fd.frame_number::float8 / NULLIF(v.fps, 0)) as timestamp_secs, \
|
||||
fd.face_id, fd.trace_id, \
|
||||
fd.x::float8 as x, fd.y::float8 as y, \
|
||||
fd.width::float8 as width, fd.height::float8 as height, \
|
||||
fd.confidence::float8 as confidence, \
|
||||
fd.identity_id, fd.stranger_id, \
|
||||
i.uuid::text as identity_uuid, i.name as identity_name, \
|
||||
s.metadata as stranger_metadata \
|
||||
FROM {} fd \
|
||||
JOIN {} v ON v.file_uuid = fd.file_uuid \
|
||||
LEFT JOIN {} i ON i.id = fd.identity_id \
|
||||
LEFT JOIN {} s ON s.id = fd.stranger_id \
|
||||
WHERE {} \
|
||||
ORDER BY fd.frame_number, fd.trace_id \
|
||||
LIMIT {} OFFSET {}",
|
||||
fd_table, video_table, id_table, st_table, where_sql, page_size as i64, offset
|
||||
);
|
||||
// Apply additional filters in Rust
|
||||
let filtered: Vec<_> = points.into_iter().filter(|p| {
|
||||
let payload = &p["payload"];
|
||||
let confidence = payload["confidence"].as_f64().unwrap_or(0.0);
|
||||
let frame = payload["frame"].as_i64().unwrap_or(0);
|
||||
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM {} fd \
|
||||
WHERE {}",
|
||||
fd_table, where_sql
|
||||
);
|
||||
if let Some(mc) = params.min_confidence {
|
||||
if confidence < mc { return false; }
|
||||
}
|
||||
if let Some(sf) = params.start_frame {
|
||||
if frame < sf { return false; }
|
||||
}
|
||||
if let Some(ef) = params.end_frame {
|
||||
if frame > ef { return false; }
|
||||
}
|
||||
true
|
||||
}).collect();
|
||||
|
||||
use sqlx::Row;
|
||||
let rows = sqlx::query(&select_sql)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let total = filtered.len() as i64;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
// Apply pagination
|
||||
let paged: Vec<_> = filtered.into_iter().skip(offset as usize).take(page_size as usize).collect();
|
||||
|
||||
let data: Vec<FileFaceItem> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let identity_id: Option<i32> = r.get("identity_id");
|
||||
let identity_uuid: Option<String> = r.get("identity_uuid");
|
||||
let identity_name: Option<String> = r.get("identity_name");
|
||||
let stranger_id: Option<i32> = r.get("stranger_id");
|
||||
// Build response items
|
||||
let mut data = Vec::new();
|
||||
for point in &paged {
|
||||
let payload = &point["payload"];
|
||||
let bbox = &payload["bbox"];
|
||||
let frame = payload["frame"].as_i64().unwrap_or(0);
|
||||
let confidence = payload["confidence"].as_f64().unwrap_or(0.0);
|
||||
|
||||
let binding = if let (Some(iid), Some(iuuid), Some(iname)) =
|
||||
(identity_id, identity_uuid, identity_name)
|
||||
{
|
||||
FaceBinding::Identity {
|
||||
identity_id: iid,
|
||||
identity_uuid: iuuid,
|
||||
identity_name: iname,
|
||||
}
|
||||
} else if let Some(sid) = stranger_id {
|
||||
FaceBinding::Stranger {
|
||||
stranger_id: sid,
|
||||
metadata: r
|
||||
.get::<Option<serde_json::Value>, _>("stranger_metadata")
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
}
|
||||
} else if let Some(iid) = identity_id {
|
||||
FaceBinding::Dangling {
|
||||
old_identity_id: iid,
|
||||
}
|
||||
} else {
|
||||
FaceBinding::Unbound
|
||||
};
|
||||
|
||||
FileFaceItem {
|
||||
id: r.get("id"),
|
||||
file_uuid: r.get("file_uuid"),
|
||||
frame_number: r.get("frame_number"),
|
||||
timestamp_secs: r.get("timestamp_secs"),
|
||||
face_id: r.get("face_id"),
|
||||
trace_id: r.get("trace_id"),
|
||||
bbox: BBox {
|
||||
x: r.get("x"),
|
||||
y: r.get("y"),
|
||||
width: r.get("width"),
|
||||
height: r.get("height"),
|
||||
},
|
||||
confidence: r.get("confidence"),
|
||||
binding,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let item = FileFaceItem {
|
||||
id: 0,
|
||||
file_uuid: file_uuid.clone(),
|
||||
frame_number: frame,
|
||||
timestamp_secs: Some(frame as f64 / fps),
|
||||
face_id: payload.get("face_id").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
trace_id: payload["trace_id"].as_i64().map(|t| t as i32),
|
||||
bbox: BBox {
|
||||
x: bbox["x"].as_f64().unwrap_or(0.0),
|
||||
y: bbox["y"].as_f64().unwrap_or(0.0),
|
||||
width: bbox["width"].as_f64().unwrap_or(0.0),
|
||||
height: bbox["height"].as_f64().unwrap_or(0.0),
|
||||
},
|
||||
confidence,
|
||||
binding: FaceBinding::Unbound,
|
||||
};
|
||||
data.push(item);
|
||||
}
|
||||
|
||||
Ok(Json(FileFacesResponse {
|
||||
success: true,
|
||||
file_uuid,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
page: page as usize,
|
||||
page_size: page_size as usize,
|
||||
data,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- List Face Candidates ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityChunksResponse {
|
||||
pub success: bool,
|
||||
@@ -1305,76 +1277,62 @@ async fn set_profile_from_face(
|
||||
Json(req): Json<SetProfileFromFaceRequest>,
|
||||
) -> Result<Json<ProfileImageResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
use crate::core::db::schema;
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
use crate::core::db::qdrant_db::QdrantDb;
|
||||
use serde_json::json;
|
||||
let videos_table = schema::table_name("videos");
|
||||
|
||||
let uuid_clean = identity_uuid.replace('-', "");
|
||||
|
||||
let (face_identifier, use_trace, use_frame) = match (&req.face_id, req.id, req.trace_id) {
|
||||
(Some(fid), _, _) => (fid.clone(), false, None),
|
||||
(None, Some(id), _) => (id.to_string(), false, None),
|
||||
(None, None, Some(trace_id)) => (trace_id.to_string(), true, req.frame_number),
|
||||
(Some(fid), _, _) => (fid.clone(), None, None),
|
||||
(None, Some(id), _) => (id.to_string(), None, None),
|
||||
(None, None, Some(trace_id)) => (trace_id.to_string(), Some(trace_id), req.frame_number),
|
||||
(None, None, None) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"success": false, "message": "Either face_id, id, or trace_id is required"})),
|
||||
Json(
|
||||
serde_json::json!({"success": false, "message": "Either face_id, id, or trace_id is required"}),
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let row: Option<(i64, i32, i32, i32, i32, f64)> = if use_trace {
|
||||
// Get face data from Qdrant _faces
|
||||
let qdrant = QdrantDb::new();
|
||||
let row: Option<(i64, i32, i32, i32, i32, f64)> = if let Some(trace_id) = use_trace {
|
||||
let mut filter_conds = vec![
|
||||
json!({"key": "file_uuid", "match": {"value": req.file_uuid}}),
|
||||
json!({"key": "trace_id", "match": {"value": trace_id}})
|
||||
];
|
||||
if let Some(frame) = use_frame {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND frame_number = $3 LIMIT 1",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(use_trace)
|
||||
.bind(frame as i32)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND trace_id = $2 ORDER BY confidence DESC LIMIT 1",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(use_trace)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
filter_conds.push(json!({"key": "frame", "match": {"value": frame}}));
|
||||
}
|
||||
let face_filter = json!({"must": filter_conds});
|
||||
let points = qdrant.scroll_all_points("_faces", face_filter, 10).await.unwrap_or_default();
|
||||
points.first().map(|p| {
|
||||
let payload = &p["payload"];
|
||||
let bbox = &payload["bbox"];
|
||||
(
|
||||
payload["frame"].as_i64().unwrap_or(0),
|
||||
bbox["x"].as_f64().unwrap_or(0.0) as i32,
|
||||
bbox["y"].as_f64().unwrap_or(0.0) as i32,
|
||||
bbox["width"].as_f64().unwrap_or(0.0) as i32,
|
||||
bbox["height"].as_f64().unwrap_or(0.0) as i32,
|
||||
payload["confidence"].as_f64().unwrap_or(0.0),
|
||||
)
|
||||
})
|
||||
} else if req.id.is_some() {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
// id lookup not supported in Qdrant - skip
|
||||
None
|
||||
} else {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("DB error: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
// face_id lookup not supported in Qdrant - skip
|
||||
None
|
||||
};
|
||||
|
||||
let (frame_number, x, y, width, height, confidence) = row.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"success": false, "message": "Face not found"})),
|
||||
)
|
||||
})?;
|
||||
let (frame_number, x, y, w, h, confidence) = row.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"success": false, "message": "Face not found"})),
|
||||
))?;
|
||||
|
||||
let video_row: Option<(String, Option<i32>, Option<i32>)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path, width, height FROM {} WHERE file_uuid = $1",
|
||||
@@ -1400,7 +1358,7 @@ async fn set_profile_from_face(
|
||||
let vw = video_width.unwrap_or(1920);
|
||||
let vh = video_height.unwrap_or(1080);
|
||||
|
||||
crate::core::thumbnail::validator::validate_crop(x, y, width, height, vw, vh).map_err(|e| {
|
||||
crate::core::thumbnail::validator::validate_crop(x, y, w, h, vw, vh).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"success": false, "message": format!("Crop validation failed: {}", e)})),
|
||||
@@ -1408,7 +1366,7 @@ async fn set_profile_from_face(
|
||||
})?;
|
||||
|
||||
let select = format!("select=eq(n\\,{})", frame_number);
|
||||
let vf = format!("{},crop={}:{}:{}:{}", select, width, height, x, y);
|
||||
let vf = format!("{},crop={}:{}:{}:{}", select, w, h, x, y);
|
||||
|
||||
let output = Command::new("ffmpeg")
|
||||
.args([
|
||||
@@ -1465,7 +1423,10 @@ async fn set_profile_from_face(
|
||||
success: true,
|
||||
identity_uuid: uuid_clean,
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
message: format!("Profile image set from face {} (frame {}, confidence {:.2})", face_identifier, frame_number, confidence),
|
||||
message: format!(
|
||||
"Profile image set from face {} (frame {}, confidence {:.2})",
|
||||
face_identifier, frame_number, confidence
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1567,21 +1528,20 @@ async fn search_identity_text(
|
||||
) -> Result<Json<IdentityTextResponse>, StatusCode> {
|
||||
use crate::core::db::schema;
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let id_table = schema::table_name("identities");
|
||||
let ib_table = schema::table_name("identity_bindings");
|
||||
let like_q = format!("%{}%", params.q.replace('%', "%%"));
|
||||
let limit = params.limit.unwrap_or(50).min(100);
|
||||
|
||||
let sd_table = schema::table_name("speaker_detections");
|
||||
let query = format!(
|
||||
r#"SELECT c.file_uuid, c.chunk_id, c.start_time, c.end_time, c.text_content,
|
||||
fd.identity_id, i.name AS identity_name, i.source AS identity_source,
|
||||
fd.trace_id
|
||||
i.id AS identity_id, i.name AS identity_name, i.source AS identity_source,
|
||||
(c.metadata->>'trace_id')::int AS trace_id
|
||||
FROM {} c
|
||||
LEFT JOIN {} fd ON fd.file_uuid = c.file_uuid
|
||||
AND fd.frame_number BETWEEN c.start_frame AND c.end_frame
|
||||
AND fd.identity_id IS NOT NULL
|
||||
LEFT JOIN {} i ON i.id = fd.identity_id
|
||||
LEFT JOIN {} ib ON ib.identity_value = c.metadata->>'trace_id'
|
||||
AND ib.identity_type = 'trace'
|
||||
LEFT JOIN {} i ON i.id = ib.identity_id
|
||||
WHERE ($1::text IS NULL OR c.file_uuid = $1) AND (LOWER(c.text_content) LIKE LOWER($2) OR LOWER(c.content::text) LIKE LOWER($2))
|
||||
|
||||
UNION ALL
|
||||
@@ -1597,7 +1557,7 @@ async fn search_identity_text(
|
||||
|
||||
ORDER BY 3
|
||||
LIMIT $3"#,
|
||||
chunk_table, fd_table, id_table, sd_table, id_table, chunk_table
|
||||
chunk_table, ib_table, id_table, sd_table, id_table, chunk_table
|
||||
);
|
||||
|
||||
let rows = sqlx::query_as::<
|
||||
@@ -1696,7 +1656,6 @@ async fn search_identities_by_text(
|
||||
) -> Result<Json<IdentitySearchResponse>, StatusCode> {
|
||||
use crate::core::db::schema;
|
||||
let id_table = schema::table_name("identities");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let chunk_table = schema::table_name("chunk");
|
||||
let like_q = format!("%{}%", params.q.replace('%', "%%"));
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
@@ -1710,26 +1669,26 @@ async fn search_identities_by_text(
|
||||
|
||||
let sd_table = schema::table_name("speaker_detections");
|
||||
let ib_table = schema::table_name("identity_bindings");
|
||||
let fi_table = schema::table_name("file_identities");
|
||||
let query = format!(
|
||||
r#"WITH matched AS (
|
||||
SELECT i.id::int, i.name, i.source, i.tmdb_id,
|
||||
fd.file_uuid, fd.trace_id,
|
||||
c.file_uuid, (c.metadata->>'trace_id')::int AS trace_id,
|
||||
c.chunk_id, c.start_frame, c.end_frame, c.fps,
|
||||
c.start_time, c.end_time, c.text_content
|
||||
FROM {} i
|
||||
JOIN {} fi ON fi.identity_id = i.id
|
||||
JOIN {} ib ON ib.identity_id = i.id AND ib.identity_type = 'trace'
|
||||
JOIN {} fd ON fd.trace_id = ib.identity_value::int
|
||||
JOIN {} c ON c.file_uuid = fd.file_uuid
|
||||
AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0)
|
||||
AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0)
|
||||
JOIN {} c ON c.file_uuid = fi.file_uuid
|
||||
AND c.metadata->>'trace_id' = ib.identity_value
|
||||
WHERE (i.name ILIKE $1
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(i.metadata->'aliases') AS a
|
||||
WHERE a->>'name' ILIKE $1
|
||||
))
|
||||
AND ($2::text IS NULL OR fd.file_uuid = $2)
|
||||
AND ($2::text IS NULL OR c.file_uuid = $2)
|
||||
|
||||
UNION ALL
|
||||
UNION ALL
|
||||
|
||||
SELECT i.id::int, i.name, i.source, i.tmdb_id,
|
||||
sd.file_uuid, NULL::int AS trace_id,
|
||||
@@ -1755,7 +1714,7 @@ SELECT *, COUNT(*) OVER() AS total_count
|
||||
FROM deduped
|
||||
ORDER BY name, start_time
|
||||
LIMIT $3 OFFSET $4"#,
|
||||
id_table, ib_table, fd_table, chunk_table, id_table, sd_table, chunk_table
|
||||
id_table, fi_table, ib_table, chunk_table, id_table, sd_table, chunk_table
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&query)
|
||||
@@ -2093,7 +2052,6 @@ async fn undo_identity(
|
||||
|
||||
let table = crate::core::db::schema::table_name("identities");
|
||||
let history_table = crate::core::db::schema::table_name("identity_history");
|
||||
let face_table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
// Try normal identity lookup
|
||||
let identity_row: Option<(i32,)> = sqlx::query_as(&format!(
|
||||
@@ -2174,22 +2132,23 @@ async fn undo_identity(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Re-bind faces
|
||||
// Re-bind faces via Qdrant _faces
|
||||
if let Some(faces) = snapshot.get("unbound_faces").and_then(|v| v.as_array()) {
|
||||
let qdrant = QdrantDb::new();
|
||||
for face in faces {
|
||||
let file_uuid = face.get("file_uuid").and_then(|v| v.as_str());
|
||||
let face_id = face.get("face_id").and_then(|v| v.as_str());
|
||||
let trace_id = face.get("trace_id").and_then(|v| v.as_i64());
|
||||
if let (Some(fu), Some(fid)) = (file_uuid, face_id) {
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
|
||||
face_table
|
||||
))
|
||||
.bind(new_id)
|
||||
.bind(fu)
|
||||
.bind(fid)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
if let (Some(fu), Some(tid)) = (file_uuid, trace_id) {
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "file_uuid", "match": {"value": fu}},
|
||||
{"key": "trace_id", "match": {"value": tid}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": new_id});
|
||||
let _ = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2377,7 +2336,6 @@ async fn redo_identity(
|
||||
|
||||
let table = crate::core::db::schema::table_name("identities");
|
||||
let history_table = crate::core::db::schema::table_name("identity_history");
|
||||
let face_table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
// Get identity_id
|
||||
let identity_id: i32 = sqlx::query_scalar(&format!(
|
||||
@@ -2417,14 +2375,17 @@ async fn redo_identity(
|
||||
// ── Delete redo: re-delete the identity ──
|
||||
let _ = crate::core::identity::storage::delete_identity_file(&uuid_clean);
|
||||
|
||||
// Unbind all faces
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE identity_id = $1",
|
||||
face_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
// Unbind all faces in Qdrant _faces
|
||||
let qdrant = QdrantDb::new();
|
||||
let filter = serde_json::json!({
|
||||
"must": [
|
||||
{"key": "identity_id", "match": {"value": identity_id}}
|
||||
]
|
||||
});
|
||||
let payload = serde_json::json!({"identity_id": serde_json::Value::Null});
|
||||
let _ = qdrant
|
||||
.update_payload_by_filter("_faces", filter, payload)
|
||||
.await;
|
||||
|
||||
// Delete identity
|
||||
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", table))
|
||||
|
||||
Reference in New Issue
Block a user