feat: trace-level matching, health watcher/worker status, timezone config

This commit is contained in:
Accusys
2026-05-21 01:08:30 +08:00
parent 8ede4be159
commit bebaa743ed
60 changed files with 6110 additions and 1586 deletions

View File

@@ -38,8 +38,18 @@ pub fn identity_routes() -> Router<crate::api::server::AppState> {
.route("/api/v1/resource/heartbeat", post(heartbeat_resource))
.route("/api/v1/resources", get(list_resources))
.route("/api/v1/identity/upload", post(upload_identity))
.route("/api/v1/identity/:identity_uuid/profile-image", post(upload_profile_image).get(get_profile_image))
.route("/api/v1/identity/:identity_uuid/json", get(get_identity_json))
.route(
"/api/v1/identity/:identity_uuid/profile-image",
post(upload_profile_image).get(get_profile_image),
)
.route(
"/api/v1/identity/:identity_uuid/status",
get(get_identity_status),
)
.route(
"/api/v1/identity/:identity_uuid/json",
get(get_identity_json),
)
// Experiment: identity text search (non-polluting, separate endpoint)
.route("/api/v1/search/identity_text", get(search_identity_text))
.route("/api/v1/identities/search", get(search_identities_by_text))
@@ -98,9 +108,10 @@ async fn list_files(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let data = records.0
let data = records
.0
.into_iter()
.map(|r| FileItem {
.map(|r| FileItem {
file_uuid: r.file_uuid,
file_name: r.file_name,
file_path: r.file_path,
@@ -163,7 +174,9 @@ async fn get_file_detail(
file_name: f.file_name,
file_path: f.file_path,
metadata: f.probe_json,
created_at: chrono::DateTime::parse_from_rfc3339(&f.created_at).ok().map(|d| d.into()),
created_at: chrono::DateTime::parse_from_rfc3339(&f.created_at)
.ok()
.map(|d| d.into()),
})),
None => Err((
StatusCode::NOT_FOUND,
@@ -214,13 +227,42 @@ async fn get_file_identities(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let fps = 25.0;
let data: Vec<FileIdentityItem> = Vec::new();
let data: Vec<FileIdentityItem> = records
.into_iter()
.map(|r| FileIdentityItem {
identity_id: r.identity_id,
identity_uuid: r.identity_uuid,
name: r.name,
metadata: r.metadata,
face_count: r.face_count,
speaker_count: r.speaker_count,
start_frame: r.start_frame,
end_frame: r.end_frame,
start_time: r.start_time,
end_time: r.end_time,
confidence: r.confidence,
})
.collect();
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")
)
)
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
{
Ok(c) => c,
Err(_) => data.len() as i64,
};
Ok(Json(FileIdentitiesResponse {
success: true,
file_uuid: file_uuid,
fps,
total: data.len() as i64,
total,
page,
page_size,
data,
@@ -243,6 +285,16 @@ pub struct IdentityDetailResponse {
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize)]
pub struct IdentityStatusResponse {
pub success: bool,
pub identity_uuid: String,
pub name: String,
pub has_json: bool,
pub has_jpg: bool,
pub error: Option<String>,
}
fn strip_uuid(u: &uuid::Uuid) -> String {
u.to_string().replace('-', "")
}
@@ -270,7 +322,11 @@ async fn get_identity_detail(
metadata: i.metadata,
reference_data: i.reference_data,
tmdb_id: i.tmdb_id,
tmdb_profile: Some(format!("{}/identities/{}/profile.jpg", crate::core::config::OUTPUT_DIR.as_str(), i.uuid.replace('-', ""))),
tmdb_profile: Some(format!(
"{}/identities/{}/profile.jpg",
crate::core::config::OUTPUT_DIR.as_str(),
i.uuid.replace('-', "")
)),
created_at: i.created_at,
updated_at: i.updated_at,
})),
@@ -281,6 +337,44 @@ async fn get_identity_detail(
}
}
async fn get_identity_status(
State(state): State<crate::api::server::AppState>,
Path(identity_uuid): Path<String>,
) -> Result<Json<IdentityStatusResponse>, (StatusCode, String)> {
let uuid_clean = identity_uuid.replace('-', "");
let identity = state
.db
.get_identity_by_uuid(&uuid_clean)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match identity {
Some(i) => {
// Check both UUID formats (with and without hyphens)
let dir_nohyphen = crate::core::identity::storage::identity_dir(&uuid_clean);
let uuid_hyphen = i.uuid.clone();
let dir_hyphen = crate::core::identity::storage::identity_dir(&uuid_hyphen);
let has_json = dir_nohyphen.join("identity.json").exists()
|| dir_hyphen.join("identity.json").exists();
let has_jpg = dir_nohyphen.join("profile.jpg").exists()
|| dir_hyphen.join("profile.jpg").exists();
Ok(Json(IdentityStatusResponse {
success: true,
identity_uuid: i.uuid.clone(),
name: i.name,
has_json,
has_jpg,
error: None,
}))
}
None => Err((
StatusCode::NOT_FOUND,
format!("Identity not found: {}", uuid_clean),
)),
}
}
#[derive(Debug, Serialize)]
pub struct IdentityFilesResponse {
pub success: bool,
@@ -375,10 +469,25 @@ 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 = (SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1)",
crate::core::db::schema::table_name("face_detections"),
crate::core::db::schema::table_name("identities"),
)
)
.bind(&uuid)
.fetch_one(state.db.pool())
.await
{
Ok(c) => c,
Err(_) => data.len() as i64,
};
Ok(Json(IdentityFilesResponse {
success: true,
identity_uuid: uuid.to_string().replace('-', ""),
total: data.len() as i64,
total,
page,
page_size,
data,
@@ -449,10 +558,25 @@ async fn get_identity_faces(
})
.collect();
let total = match sqlx::query_scalar::<_, i64>(
&format!(
"SELECT COUNT(*) FROM {} fd WHERE fd.identity_id = (SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1)",
crate::core::db::schema::table_name("face_detections"),
crate::core::db::schema::table_name("identities"),
)
)
.bind(&uuid)
.fetch_one(state.db.pool())
.await
{
Ok(c) => c,
Err(_) => data.len() as i64,
};
Ok(Json(IdentityFacesResponse {
success: true,
identity_uuid: uuid.to_string().replace('-', ""),
total: data.len() as i64,
total,
page,
page_size,
data,
@@ -721,12 +845,24 @@ async fn upload_profile_image(
let uuid_clean = identity_uuid.replace('-', "");
// Verify identity exists
if state.db.get_identity_by_uuid(&uuid_clean).await.map_err(|_| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": "DB error"})))
})?.is_none() {
return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({
"success": false, "message": "Identity not found"
}))));
if state
.db
.get_identity_by_uuid(&uuid_clean)
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"success": false, "message": "DB error"})),
)
})?
.is_none()
{
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"success": false, "message": "Identity not found"
})),
));
}
// Process multipart upload
@@ -740,9 +876,14 @@ async fn upload_profile_image(
ext = match content_type.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
_ => return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
"success": false, "message": "Unsupported image type. Use JPEG or PNG."
})))),
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"success": false, "message": "Unsupported image type. Use JPEG or PNG."
})),
))
}
};
image_data = Some(field.bytes().await.map_err(|_| {
(StatusCode::BAD_REQUEST, Json(serde_json::json!({"success": false, "message": "Failed to read image data"})))
@@ -750,9 +891,14 @@ async fn upload_profile_image(
}
}
let data = image_data.ok_or_else(|| (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"success": false, "message": "No image field found. Use field name 'image'."
}))))?;
let data = image_data.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"success": false, "message": "No image field found. Use field name 'image'."
})),
)
})?;
// Write image file
let dir = crate::core::identity::storage::identity_dir(&uuid_clean);
@@ -789,8 +935,16 @@ async fn get_profile_image(
let path = dir.join(format!("profile.{}", ext));
if path.exists() {
let data = std::fs::read(&path).map_err(|_| StatusCode::NOT_FOUND)?;
let content_type = if *ext == "png" { "image/png" } else { "image/jpeg" };
return Ok((StatusCode::OK, [("content-type".to_string(), content_type.to_string())], data));
let content_type = if *ext == "png" {
"image/png"
} else {
"image/jpeg"
};
return Ok((
StatusCode::OK,
[("content-type".to_string(), content_type.to_string())],
data,
));
}
}
Err(StatusCode::NOT_FOUND)
@@ -802,7 +956,14 @@ async fn get_identity_json(
) -> Result<(StatusCode, [(String, String); 1], Vec<u8>), StatusCode> {
let clean = identity_uuid.replace('-', "");
let with_hyphens = if clean.len() == 32 {
format!("{}-{}-{}-{}-{}", &clean[0..8], &clean[8..12], &clean[12..16], &clean[16..20], &clean[20..32])
format!(
"{}-{}-{}-{}-{}",
&clean[0..8],
&clean[8..12],
&clean[12..16],
&clean[16..20],
&clean[20..32]
)
} else {
identity_uuid.clone()
};
@@ -821,7 +982,9 @@ async fn get_identity_json(
}
// 2. Lazy Sync: If file missing, generate from DB and save
if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &clean).await {
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &clean).await
{
tracing::warn!("[identity-json] Lazy sync failed for {}: {}", clean, e);
return Err(StatusCode::NOT_FOUND);
}
@@ -858,7 +1021,7 @@ struct IdentityTextHit {
chunk_id: String,
start_time: f64,
end_time: f64,
text_content: String,
text_content: Option<String>,
identity_id: Option<i32>,
identity_name: Option<String>,
identity_source: Option<String>,
@@ -889,7 +1052,7 @@ async fn search_identity_text(
let query = format!(
r#"SELECT c.file_uuid, c.chunk_id, c.start_time, c.end_time, c.text_content,
fd.identity_id, CASE WHEN id_table LIKE 'dev.%' THEN i.name ELSE i.real_name END AS identity_name, i.source AS identity_source,
fd.identity_id, i.name AS identity_name, i.source AS identity_source,
fd.trace_id
FROM {} c
LEFT JOIN {} fd ON fd.file_uuid = c.file_uuid
@@ -902,18 +1065,42 @@ async fn search_identity_text(
chunk_table, fd_table, id_table
);
let rows = sqlx::query_as::<_, (String, String, f64, f64, String, Option<i32>, Option<String>, Option<String>, Option<i32>)>(&query)
.bind(&params.uuid).bind(&like_q).bind(limit)
.fetch_all(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let rows = sqlx::query_as::<
_,
(
String,
String,
f64,
f64,
Option<String>,
Option<i32>,
Option<String>,
Option<String>,
Option<i32>,
),
>(&query)
.bind(&params.uuid)
.bind(&like_q)
.bind(limit)
.fetch_all(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let results: Vec<IdentityTextHit> = rows
.into_iter()
.map(|(fu, cid, st, et, txt, iid, iname, isrc, tid)| IdentityTextHit {
file_uuid: fu, chunk_id: cid, start_time: st, end_time: et, text_content: txt,
identity_id: iid, identity_name: iname, identity_source: isrc, trace_id: tid,
})
.map(
|(fu, cid, st, et, txt, iid, iname, isrc, tid)| IdentityTextHit {
file_uuid: fu,
chunk_id: cid,
start_time: st,
end_time: et,
text_content: txt,
identity_id: iid,
identity_name: iname,
identity_source: isrc,
trace_id: tid,
},
)
.collect();
let total = results.len() as i64;
@@ -922,7 +1109,14 @@ async fn search_identity_text(
let start = (page - 1) * page_size;
let paged: Vec<IdentityTextHit> = results.into_iter().skip(start).take(page_size).collect();
let limit = params.limit.unwrap_or(50) as usize;
Ok(Json(IdentityTextResponse { success: true, total, page, page_size, limit, results: paged }))
Ok(Json(IdentityTextResponse {
success: true,
total,
page,
page_size,
limit,
results: paged,
}))
}
#[derive(Debug, Deserialize)]
@@ -942,7 +1136,7 @@ struct IdentitySearchHit {
trace_id: Option<i32>,
chunk_id: String,
start_time: f64,
text_content: String,
text_content: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -965,7 +1159,7 @@ async fn search_identities_by_text(
let limit = params.limit.unwrap_or(50).min(100);
let query = format!(
r#"SELECT i.id::int, COALESCE(i.real_name, i.actor_name, i.name) AS name, i.source, i.tmdb_id,
r#"SELECT i.id::int, i.name, i.source, i.tmdb_id,
fd.file_uuid, fd.trace_id,
c.chunk_id, c.start_time, c.text_content
FROM {} i
@@ -973,30 +1167,58 @@ async fn search_identities_by_text(
JOIN {} c ON c.file_uuid = fd.file_uuid
AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0)
AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0)
WHERE COALESCE(i.real_name, i.actor_name, i.name) ILIKE $1
WHERE i.name ILIKE $1
AND ($2::text IS NULL OR fd.file_uuid = $2)
ORDER BY COALESCE(i.real_name, i.actor_name, i.name), c.start_time
ORDER BY i.name, c.start_time
LIMIT $3"#,
id_table, fd_table, chunk_table
);
let rows = sqlx::query_as::<_, (i32, String, Option<String>, Option<i32>, String, Option<i32>, String, f64, String)>(&query)
.bind(&like_q).bind(&params.uuid).bind(limit)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
tracing::error!("[identities/search] Query failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let rows = sqlx::query_as::<
_,
(
i32,
String,
Option<String>,
Option<i32>,
String,
Option<i32>,
String,
f64,
Option<String>,
),
>(&query)
.bind(&like_q)
.bind(&params.uuid)
.bind(limit)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
tracing::error!("[identities/search] Query failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let results: Vec<IdentitySearchHit> = rows
.into_iter()
.map(|(iid, name, src, tid, fu, trace_id, cid, st, txt)| IdentitySearchHit {
identity_id: iid, name, source: src, tmdb_id: tid,
file_uuid: fu, trace_id, chunk_id: cid, start_time: st, text_content: txt,
})
.map(
|(iid, name, src, tid, fu, trace_id, cid, st, txt)| IdentitySearchHit {
identity_id: iid,
name,
source: src,
tmdb_id: tid,
file_uuid: fu,
trace_id,
chunk_id: cid,
start_time: st,
text_content: txt,
},
)
.collect();
let total = results.len() as i64;
Ok(Json(IdentitySearchResponse { success: true, total, results }))
Ok(Json(IdentitySearchResponse {
success: true,
total,
results,
}))
}