fix: API endpoints for file_uuid filtering of pending identities
- get_file_identities: UNION face_detections + file_identities - list_identities: add file_bindings from file_identities table - Add back /api/v1/traces/unassigned route - Total count query now includes file_identities Frontend can now: - Filter pending identities by file_uuid - Filter pending faces (unassigned traces) by file_uuid
This commit is contained in:
@@ -34,6 +34,7 @@ pub fn identity_routes() -> Router<crate::api::types::AppState> {
|
||||
.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/traces/unassigned", get(list_unassigned_traces))
|
||||
}
|
||||
|
||||
/// Register a Global Identity from face.json with multi-angle reference vectors.
|
||||
@@ -180,11 +181,24 @@ async fn list_identities(
|
||||
})?;
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id::int, uuid, name, metadata, status, starred FROM {} WHERE status IS NULL OR status != 'merged' ORDER BY id DESC LIMIT $1 OFFSET $2",
|
||||
id_table
|
||||
r#"SELECT i.id::int, i.uuid, i.name, i.metadata, i.status, i.starred,
|
||||
COALESCE(
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'file_uuid', fi.file_uuid,
|
||||
'confidence', fi.confidence,
|
||||
'source', fi.metadata->>'source'
|
||||
) ORDER BY fi.created_at DESC),
|
||||
'[]'::jsonb
|
||||
) as file_bindings
|
||||
FROM {} i
|
||||
LEFT JOIN {} fi ON i.id = fi.identity_id
|
||||
WHERE i.status IS NULL OR i.status != 'merged'
|
||||
GROUP BY i.id, i.uuid, i.name, i.metadata, i.status, i.starred
|
||||
ORDER BY i.id DESC LIMIT $1 OFFSET $2"#,
|
||||
id_table, crate::core::db::schema::table_name("file_identities")
|
||||
);
|
||||
|
||||
let rows: Vec<(i32, uuid::Uuid, String, Option<serde_json::Value>, Option<String>, Option<bool>)> = match sqlx::query_as(&sql)
|
||||
let rows: Vec<(i32, uuid::Uuid, String, Option<serde_json::Value>, Option<String>, Option<bool>, serde_json::Value)> = match sqlx::query_as(&sql)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset)
|
||||
.fetch_all(db.pool())
|
||||
@@ -202,6 +216,10 @@ let sql = format!(
|
||||
let identities: Vec<IdentityResponse> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let file_bindings: Vec<FileBinding> = r.6.as_array()
|
||||
.map(|arr| arr.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect())
|
||||
.unwrap_or_default();
|
||||
let file_uuids: Vec<String> = file_bindings.iter().map(|fb| fb.file_uuid.clone()).collect();
|
||||
IdentityResponse {
|
||||
id: r.0,
|
||||
identity_uuid: r.1.to_string().replace('-', ""),
|
||||
@@ -209,7 +227,8 @@ let sql = format!(
|
||||
metadata: r.3,
|
||||
status: r.4,
|
||||
starred: r.5.unwrap_or(false),
|
||||
file_uuids: vec![], // Removed N+1 query
|
||||
file_uuids,
|
||||
file_bindings: Some(file_bindings),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -280,6 +299,13 @@ pub struct FaceCandidatesResponse {
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct FileBinding {
|
||||
pub file_uuid: String,
|
||||
pub confidence: f64,
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IdentityResponse {
|
||||
pub id: i32,
|
||||
@@ -289,6 +315,7 @@ pub struct IdentityResponse {
|
||||
pub status: Option<String>,
|
||||
pub starred: bool,
|
||||
pub file_uuids: Vec<String>,
|
||||
pub file_bindings: Option<Vec<FileBinding>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -458,3 +485,173 @@ async fn list_face_candidates(
|
||||
page_size,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UnassignedTracesQuery {
|
||||
pub file_uuid: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
pub page_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UnassignedTrace {
|
||||
pub trace_id: i32,
|
||||
pub file_uuid: String,
|
||||
pub frame_count: i64,
|
||||
pub start_frame: i64,
|
||||
pub end_frame: i64,
|
||||
pub best_face_id: i32,
|
||||
pub best_face_frame: i64,
|
||||
pub best_face_confidence: f64,
|
||||
pub best_face_bbox: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UnassignedTracesResponse {
|
||||
pub traces: Vec<UnassignedTrace>,
|
||||
pub total: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
/// List unassigned traces (identity_id IS NULL, grouped by trace_id)
|
||||
async fn list_unassigned_traces(
|
||||
Query(query): Query<UnassignedTracesQuery>,
|
||||
) -> Result<Json<UnassignedTracesResponse>, (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 = std::cmp::min(query.page_size.unwrap_or(20), 100);
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
let table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
let total: i64 = if let Some(file_uuid) = &query.file_uuid {
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id IS NULL AND trace_id IS NOT NULL AND file_uuid = $1",
|
||||
table
|
||||
);
|
||||
sqlx::query_scalar(&count_sql)
|
||||
.bind(file_uuid)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Count error: {}", e)))?
|
||||
} else {
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id IS NULL AND trace_id IS NOT NULL",
|
||||
table
|
||||
);
|
||||
sqlx::query_scalar(&count_sql)
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Count error: {}", e)))?
|
||||
};
|
||||
|
||||
let sql = if let Some(file_uuid) = &query.file_uuid {
|
||||
format!(
|
||||
"WITH trace_agg AS (
|
||||
SELECT trace_id, file_uuid,
|
||||
COUNT(*) as frame_count,
|
||||
MIN(frame_number) as start_frame,
|
||||
MAX(frame_number) as end_frame
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND trace_id IS NOT NULL AND file_uuid = $1
|
||||
GROUP BY trace_id, file_uuid
|
||||
),
|
||||
best_face AS (
|
||||
SELECT DISTINCT ON (fd.trace_id, fd.file_uuid)
|
||||
fd.trace_id, fd.file_uuid, fd.id as best_face_id,
|
||||
fd.frame_number as best_face_frame,
|
||||
fd.confidence as best_face_confidence,
|
||||
jsonb_build_object('x', fd.x, 'y', fd.y, 'width', fd.width, 'height', fd.height) as best_face_bbox
|
||||
FROM {} fd
|
||||
WHERE fd.identity_id IS NULL AND fd.trace_id IS NOT NULL AND fd.file_uuid = $1
|
||||
ORDER BY fd.trace_id, fd.file_uuid, fd.confidence DESC
|
||||
)
|
||||
SELECT ta.trace_id, ta.file_uuid, ta.frame_count, ta.start_frame, ta.end_frame,
|
||||
bf.best_face_id, bf.best_face_frame, bf.best_face_confidence, bf.best_face_bbox
|
||||
FROM trace_agg ta
|
||||
JOIN best_face bf ON ta.trace_id = bf.trace_id AND ta.file_uuid = bf.file_uuid
|
||||
ORDER BY ta.frame_count DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
table, table
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"WITH trace_agg AS (
|
||||
SELECT trace_id, file_uuid,
|
||||
COUNT(*) as frame_count,
|
||||
MIN(frame_number) as start_frame,
|
||||
MAX(frame_number) as end_frame
|
||||
FROM {}
|
||||
WHERE identity_id IS NULL AND trace_id IS NOT NULL
|
||||
GROUP BY trace_id, file_uuid
|
||||
),
|
||||
best_face AS (
|
||||
SELECT DISTINCT ON (fd.trace_id, fd.file_uuid)
|
||||
fd.trace_id, fd.file_uuid, fd.id as best_face_id,
|
||||
fd.frame_number as best_face_frame,
|
||||
fd.confidence as best_face_confidence,
|
||||
jsonb_build_object('x', fd.x, 'y', fd.y, 'width', fd.width, 'height', fd.height) as best_face_bbox
|
||||
FROM {} fd
|
||||
WHERE fd.identity_id IS NULL AND fd.trace_id IS NOT NULL
|
||||
ORDER BY fd.trace_id, fd.file_uuid, fd.confidence DESC
|
||||
)
|
||||
SELECT ta.trace_id, ta.file_uuid, ta.frame_count, ta.start_frame, ta.end_frame,
|
||||
bf.best_face_id, bf.best_face_frame, bf.best_face_confidence, bf.best_face_bbox
|
||||
FROM trace_agg ta
|
||||
JOIN best_face bf ON ta.trace_id = bf.trace_id AND ta.file_uuid = bf.file_uuid
|
||||
ORDER BY ta.frame_count DESC
|
||||
LIMIT $1 OFFSET $2",
|
||||
table, table
|
||||
)
|
||||
};
|
||||
|
||||
let rows: Vec<(i32, String, i64, i64, i64, i32, i64, f64, Option<serde_json::Value>)> =
|
||||
if let Some(file_uuid) = &query.file_uuid {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(file_uuid)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {}", e)))?
|
||||
} else {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {}", e)))?
|
||||
};
|
||||
|
||||
let traces: Vec<UnassignedTrace> = rows
|
||||
.into_iter()
|
||||
.map(|r| UnassignedTrace {
|
||||
trace_id: r.0,
|
||||
file_uuid: r.1,
|
||||
frame_count: r.2,
|
||||
start_frame: r.3,
|
||||
end_frame: r.4,
|
||||
best_face_id: r.5,
|
||||
best_face_frame: r.6,
|
||||
best_face_confidence: r.7,
|
||||
best_face_bbox: r.8,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(UnassignedTracesResponse {
|
||||
traces,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -266,10 +266,16 @@ async fn get_file_identities(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let fi_table = crate::core::db::schema::table_name("file_identities");
|
||||
let total = match sqlx::query_scalar::<_, i64>(
|
||||
&format!(
|
||||
"SELECT COUNT(DISTINCT fd.identity_id) FROM {} fd WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL",
|
||||
crate::core::db::schema::table_name("face_detections")
|
||||
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"),
|
||||
fi_table
|
||||
)
|
||||
)
|
||||
.bind(&file_uuid)
|
||||
|
||||
@@ -3836,26 +3836,50 @@ sqlx::query(
|
||||
let id_table = schema::table_name("identities");
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let video_table = schema::table_name("videos");
|
||||
let fi_table = schema::table_name("file_identities");
|
||||
use sqlx::Row;
|
||||
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT i.id, i.uuid::text, i.name, i.metadata, \
|
||||
COUNT(fd.id)::int4 as face_count, 0::int4 as speaker_count, \
|
||||
MIN(fd.frame_number)::int4 as start_frame, MAX(fd.frame_number)::int4 as end_frame, \
|
||||
MIN(fd.frame_number::float8 / NULLIF(v.fps, 0)) as start_time, \
|
||||
MAX(fd.frame_number::float8 / NULLIF(v.fps, 0)) as end_time, \
|
||||
AVG(fd.confidence)::float8 as confidence \
|
||||
FROM {} fd JOIN {} i ON i.id = fd.identity_id \
|
||||
JOIN {} v ON v.file_uuid = fd.file_uuid \
|
||||
WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL \
|
||||
GROUP BY i.id, i.uuid, i.name, i.metadata \
|
||||
ORDER BY face_count DESC LIMIT $2 OFFSET $3",
|
||||
fd_table, id_table, video_table
|
||||
r#"WITH face_matched AS (
|
||||
SELECT i.id, i.uuid::text, i.name, i.metadata, i.status, i.source,
|
||||
COUNT(fd.id)::int4 as face_count, 0::int4 as speaker_count,
|
||||
MIN(fd.frame_number)::int4 as start_frame, MAX(fd.frame_number)::int4 as end_frame,
|
||||
MIN(fd.frame_number::float8 / NULLIF(v.fps, 0)) as start_time,
|
||||
MAX(fd.frame_number::float8 / NULLIF(v.fps, 0)) as end_time,
|
||||
AVG(fd.confidence)::float8 as confidence
|
||||
FROM {} fd JOIN {} i ON i.id = fd.identity_id
|
||||
JOIN {} v ON v.file_uuid = fd.file_uuid
|
||||
WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL
|
||||
GROUP BY i.id, i.uuid, i.name, i.metadata, i.status, i.source
|
||||
),
|
||||
file_linked AS (
|
||||
SELECT i.id, i.uuid::text, i.name, i.metadata, i.status, i.source,
|
||||
0::int4 as face_count, 0::int4 as speaker_count,
|
||||
NULL::int4 as start_frame, NULL::int4 as end_frame,
|
||||
NULL::float8 as start_time, NULL::float8 as end_time,
|
||||
fi.confidence::float8 as confidence
|
||||
FROM {} fi JOIN {} i ON i.id = fi.identity_id
|
||||
WHERE fi.file_uuid = $1
|
||||
),
|
||||
combined AS (
|
||||
SELECT * FROM face_matched
|
||||
UNION
|
||||
SELECT * FROM file_linked WHERE id NOT IN (SELECT id FROM face_matched)
|
||||
)
|
||||
SELECT id, uuid, name, metadata, status, source,
|
||||
face_count, speaker_count, start_frame, end_frame,
|
||||
start_time, end_time, confidence
|
||||
FROM combined
|
||||
ORDER BY face_count DESC, name ASC
|
||||
LIMIT $2 OFFSET $3"#,
|
||||
fd_table, id_table, video_table, fi_table, id_table
|
||||
))
|
||||
.bind(uuid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| super::FileIdentityRecord {
|
||||
|
||||
Reference in New Issue
Block a user