feat: trace-level matching, health watcher/worker status, timezone config
This commit is contained in:
@@ -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(¶ms.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(¶ms.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(¶ms.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(¶ms.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,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user