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

@@ -57,17 +57,12 @@ async fn translate_text(
"temperature": 0.1
});
let response = client
.post(llm_url)
.json(&body)
.send()
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to call LLM: {}", e),
)
})?;
let response = client.post(llm_url).json(&body).send().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to call LLM: {}", e),
)
})?;
let llm_resp: serde_json::Value = response.json().await.map_err(|e| {
(

View File

@@ -97,17 +97,25 @@ struct SceneSummaryResult {
fn llm_base_url() -> String {
let v = std::env::var("MOMENTRY_LLM_URL");
if v.is_ok() { return v.unwrap(); }
if v.is_ok() {
return v.unwrap();
}
let v = std::env::var("MOMENTRY_LLM_SUMMARY_URL");
if v.is_ok() { return v.unwrap(); }
if v.is_ok() {
return v.unwrap();
}
"http://localhost:8082/v1/chat/completions".to_string()
}
fn llm_model() -> String {
let v = std::env::var("MOMENTRY_LLM_MODEL");
if v.is_ok() { return v.unwrap(); }
if v.is_ok() {
return v.unwrap();
}
let v = std::env::var("MOMENTRY_LLM_SUMMARY_MODEL");
if v.is_ok() { return v.unwrap(); }
if v.is_ok() {
return v.unwrap();
}
"google_gemma-4-26B-A4B-it-Q5_K_M.gguf".to_string()
}
@@ -115,7 +123,7 @@ fn llm_model() -> String {
async fn fetch_cut_scenes(db: &PostgresDb, file_uuid: &str) -> anyhow::Result<Vec<CutScene>> {
let table = schema::table_name("chunk");
sqlx::query_as::<_, (String, i64, i64, f64, f64, f64, serde_json::Value, serde_json::Value, Option<String>)>(&format!(
sqlx::query_as::<_, (String, i64, i64, f64, Option<f64>, Option<f64>, serde_json::Value, Option<serde_json::Value>, Option<String>)>(&format!(
r#"SELECT chunk_id, start_frame, end_frame, fps, start_time, end_time, content, metadata, summary_text
FROM {} WHERE file_uuid = $1 AND chunk_type = 'cut' ORDER BY start_frame"#, table
))
@@ -123,7 +131,8 @@ async fn fetch_cut_scenes(db: &PostgresDb, file_uuid: &str) -> anyhow::Result<Ve
.fetch_all(db.pool()).await?
.into_iter().map(|r| Ok(CutScene {
chunk_id: r.0, start_frame: r.1, end_frame: r.2,
fps: r.3, start_time: r.4, end_time: r.5, content: r.6, metadata: r.7, summary_text: r.8,
fps: r.3, start_time: r.4.unwrap_or(0.0), end_time: r.5.unwrap_or(0.0),
content: r.6, metadata: r.7.unwrap_or(serde_json::json!({})), summary_text: r.8,
})).collect()
}
@@ -133,7 +142,7 @@ async fn fetch_sentences_in_scene(
cut: &CutScene,
) -> anyhow::Result<Vec<SentenceChunk>> {
let table = schema::table_name("chunk");
sqlx::query_as::<_, (String, String, f64, f64, i64, i64, serde_json::Value)>(&format!(
sqlx::query_as::<_, (String, String, Option<f64>, Option<f64>, i64, i64, serde_json::Value)>(&format!(
r#"SELECT chunk_id, COALESCE(text_content,''), start_time, end_time, start_frame, end_frame, content
FROM {} WHERE file_uuid = $1 AND chunk_type = 'sentence'
AND start_time >= $2 AND end_time <= $3 ORDER BY start_time"#, table
@@ -141,7 +150,7 @@ async fn fetch_sentences_in_scene(
.bind(file_uuid).bind(cut.start_time).bind(cut.end_time)
.fetch_all(db.pool()).await?
.into_iter().map(|r| Ok(SentenceChunk {
chunk_id: r.0, text: r.1, start_time: r.2, end_time: r.3,
chunk_id: r.0, text: r.1, start_time: r.2.unwrap_or(0.0), end_time: r.3.unwrap_or(0.0),
start_frame: r.4, end_frame: r.5, content: r.6,
})).collect()
}
@@ -540,10 +549,7 @@ async fn analyze_5w1h(
if let Some(ref t) = cut.summary_text {
if t.len() > 20 {
processed += 1;
prev_context.push(format!(
"Scene (t={:.0}s): {}",
cut.start_time, t
));
prev_context.push(format!("Scene (t={:.0}s): {}", cut.start_time, t));
continue;
}
}
@@ -621,10 +627,7 @@ async fn batch_analyze_5w1h(
if let Some(ref t) = cut.summary_text {
if t.len() > 20 {
processed += 1;
prev_context.push(format!(
"Scene (t={:.0}s): {}",
cut.start_time, t
));
prev_context.push(format!("Scene (t={:.0}s): {}", cut.start_time, t));
continue;
}
}
@@ -713,10 +716,7 @@ pub async fn run_5w1h_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Result<
if let Some(ref t) = cut.summary_text {
if t.len() > 20 {
processed += 1;
prev_context.push(format!(
"Scene (t={:.0}s): {}",
cut.start_time, t
));
prev_context.push(format!("Scene (t={:.0}s): {}", cut.start_time, t));
continue;
}
}
@@ -764,38 +764,44 @@ pub async fn run_5w1h_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Result<
qdrant.init_collection(768).await?;
let chunk_table = schema::table_name("chunk");
let rows = sqlx::query_as::<_, (String, String, String, f64, f64)>(
&format!("SELECT chunk_id, chunk_type, text_content, start_time, end_time \
let rows = sqlx::query_as::<_, (String, String, String, i64, i64, f64, f64)>(&format!(
"SELECT chunk_id, chunk_type, text_content, start_frame, end_frame, start_time, end_time \
FROM {} WHERE file_uuid = $1 AND chunk_type = 'sentence' AND embedding IS NULL \
AND (text_content IS NOT NULL AND text_content != '') ORDER BY id", chunk_table),
)
AND (text_content IS NOT NULL AND text_content != '') ORDER BY id",
chunk_table
))
.bind(file_uuid)
.fetch_all(db.pool())
.await?;
let total_vec = rows.len();
let mut stored = 0usize;
for (chunk_id, _ctype, text, start_time, end_time) in &rows {
for (chunk_id, _ctype, text, start_frame, end_frame, start_time, end_time) in &rows {
let text = text.trim();
if text.is_empty() || text.len() < 5 {
continue;
}
match embedder.embed_document(text).await {
Ok(vector) => {
if let Err(e) = sqlx::query(
&format!("UPDATE {} SET embedding = $1::vector WHERE chunk_id = $2 AND file_uuid = $3", chunk_table)
)
if let Err(e) = sqlx::query(&format!(
"UPDATE {} SET embedding = $1::vector WHERE chunk_id = $2 AND file_uuid = $3",
chunk_table
))
.bind(&vector as &[f32])
.bind(chunk_id)
.bind(file_uuid)
.execute(db.pool()).await {
.execute(db.pool())
.await
{
tracing::error!("[Vectorize] PG failed for {}: {}", chunk_id, e);
continue;
}
let payload = VectorPayload {
uuid: file_uuid.to_string(),
file_uuid: file_uuid.to_string(),
chunk_id: chunk_id.clone(),
chunk_type: "sentence".to_string(),
start_frame: *start_frame,
end_frame: *end_frame,
start_time: *start_time,
end_time: *end_time,
text: Some(text.to_string()),

View File

@@ -93,16 +93,15 @@ async fn create_identity(
})?;
let id_table = crate::core::db::schema::table_name("identities");
let name_col = if id_table.starts_with("dev.") { "name" } else { "real_name" };
let query = format!(
"SELECT uuid, reference_data->'total_references' as total,
reference_data->'angles_covered' as angles,
reference_data->'quality_avg' as quality
FROM {}
WHERE {} = $1
WHERE name = $1
ORDER BY created_at DESC
LIMIT 1",
id_table, name_col
id_table
);
let row: Option<(String, Option<i32>, Option<Vec<String>>, Option<f64>)> =
@@ -168,11 +167,19 @@ async fn list_identities(
let id_table = crate::core::db::schema::table_name("identities");
let total: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {}", id_table))
.fetch_one(db.pool()).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Count error: {}", e)))?;
.fetch_one(db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Count error: {}", e),
)
})?;
let name_col = if id_table.starts_with("dev.") { "name" } else { "real_name" };
let sql = format!("SELECT id::int, uuid, {} AS name, metadata FROM {} ORDER BY id DESC LIMIT $1 OFFSET $2", name_col, id_table);
let sql = format!(
"SELECT id::int, uuid, name, metadata FROM {} ORDER BY id DESC LIMIT $1 OFFSET $2",
id_table
);
let rows: Vec<(i32, uuid::Uuid, String, Option<serde_json::Value>)> = match sqlx::query_as(&sql)
.bind(page_size as i64)
@@ -200,12 +207,25 @@ async fn list_identities(
.collect();
let identities_table = crate::core::db::schema::table_name("identities");
let total_identities: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {}", identities_table))
.fetch_one(db.pool()).await.unwrap_or(0);
let tmdb_identities: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {} WHERE source = 'tmdb'", identities_table))
.fetch_one(db.pool()).await.unwrap_or(0);
let auto_identities: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {} WHERE source = 'auto'", identities_table))
.fetch_one(db.pool()).await.unwrap_or(0);
let total_identities: i64 =
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {}", identities_table))
.fetch_one(db.pool())
.await
.unwrap_or(0);
let tmdb_identities: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE source = 'tmdb'",
identities_table
))
.fetch_one(db.pool())
.await
.unwrap_or(0);
let auto_identities: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE source = 'auto'",
identities_table
))
.fetch_one(db.pool())
.await
.unwrap_or(0);
Ok(Json(IdentityListResponse {
identities,

View File

@@ -15,8 +15,14 @@ use crate::core::db::PostgresDb;
pub fn identity_agent_routes() -> Router<AppState> {
Router::new()
.route("/api/v1/agents/identity/match-from-photo", post(match_from_photo))
.route("/api/v1/agents/identity/match-from-trace", post(match_from_trace))
.route(
"/api/v1/agents/identity/match-from-photo",
post(match_from_photo),
)
.route(
"/api/v1/agents/identity/match-from-trace",
post(match_from_trace),
)
}
#[derive(Debug, Serialize)]
@@ -73,13 +79,21 @@ async fn match_from_photo(
let uuid_clean = identity_uuid.replace('-', "");
if uuid_clean.is_empty() || file_uuid.is_empty() {
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
"success": false, "message": "identity_uuid and file_uuid are required"
}))));
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"success": false, "message": "identity_uuid and file_uuid are required"
})),
));
}
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'."
})),
)
})?;
// 1. Save uploaded image to temp
let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR")
@@ -88,11 +102,17 @@ async fn match_from_photo(
.unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string());
let temp_dir = std::env::temp_dir().join("momentry_match_face");
std::fs::create_dir_all(&temp_dir).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": format!("Failed to create temp dir: {}", e)})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": format!("Failed to create temp dir: {}", e)})),
)
})?;
let temp_img = temp_dir.join(format!("{}.jpg", uuid_clean));
std::fs::write(&temp_img, &data).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": format!("Failed to save temp image: {}", e)})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": format!("Failed to save temp image: {}", e)})),
)
})?;
// 2. Extract face embedding via Python script
@@ -103,79 +123,109 @@ async fn match_from_photo(
.output()
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": format!("Failed to run extractor: {}", e)})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": format!("Failed to run extractor: {}", e)})),
)
})?;
let _ = std::fs::remove_file(&temp_img);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
"success": false, "message": format!("Face extraction failed: {}", stderr)
}))));
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"success": false, "message": format!("Face extraction failed: {}", stderr)
})),
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let extract_result: serde_json::Value = serde_json::from_str(&stdout).map_err(|_| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": "Failed to parse extractor output"})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": "Failed to parse extractor output"})),
)
})?;
let embedding: Vec<f64> = serde_json::from_value(
extract_result.get("embedding")
.ok_or_else(|| (StatusCode::BAD_REQUEST, Json(serde_json::json!({"message": "No embedding in extractor output"}))))?
.clone()
).map_err(|_| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": "Invalid embedding format"})))
extract_result
.get("embedding")
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"message": "No embedding in extractor output"})),
)
})?
.clone(),
)
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": "Invalid embedding format"})),
)
})?;
let embedding_f32: Vec<f32> = embedding.into_iter().map(|v| v as f32).collect();
// 3. Look up identity internal ID
let id_table = schema::table_name("identities");
let identity_id_row: Option<(i32,)> = sqlx::query_as(
&format!("SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table)
)
let identity_id_row: Option<(i32,)> = sqlx::query_as(&format!(
"SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": format!("DB error: {}", e)})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": format!("DB error: {}", e)})),
)
})?;
let identity_id = match identity_id_row {
Some((id,)) => id,
None => return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({
"success": false, "message": "Identity not found"
})))),
None => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"success": false, "message": "Identity not found"
})),
))
}
};
// 4. Find best matching trace (highest similarity, no threshold)
let fd_table = schema::table_name("face_detections");
let best_match: Option<(i32, i32, f64)> = sqlx::query_as(
&format!(
r#"SELECT id, trace_id,
let best_match: Option<(i32, i32, f64)> = sqlx::query_as(&format!(
r#"SELECT id, trace_id,
1 - (embedding::vector <=> $1::vector) as similarity
FROM {}
WHERE file_uuid = $2 AND embedding IS NOT NULL
ORDER BY embedding::vector <=> $1::vector
LIMIT 1"#,
fd_table
)
)
fd_table
))
.bind(&embedding_f32)
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": format!("Search failed: {}", e)})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": format!("Search failed: {}", e)})),
)
})?;
// 5. Update best match face_detection
let mut traces_matched: Vec<i32> = Vec::new();
if let Some((fb_id, fb_trace, fb_sim)) = best_match {
let _ = sqlx::query(
&format!("UPDATE {} SET identity_id = $1 WHERE id = $2", fd_table)
)
let _ = sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE id = $2",
fd_table
))
.bind(identity_id)
.bind(fb_id)
.execute(state.db.pool())
@@ -191,7 +241,10 @@ async fn match_from_photo(
file_uuid,
matches: 1,
traces_matched,
message: format!("Best trace: trace_id={}, similarity={:.4}", fb_trace, fb_sim),
message: format!(
"Best trace: trace_id={}, similarity={:.4}",
fb_trace, fb_sim
),
}))
} else {
Ok(Json(MatchFromPhotoResponse {
@@ -221,26 +274,30 @@ async fn match_from_trace(
// 1. Get 3 best face embeddings from this trace at different angles
// Divide trace frame range into 3 segments, pick best face from each
let fd_table = schema::table_name("face_detections");
let all_faces: Vec<(Vec<f32>, i64)> = sqlx::query_as::<_, (Vec<f32>, i64)>(
&format!(
"SELECT embedding, frame_number FROM {} \
let all_faces: Vec<(Vec<f32>, i64)> = sqlx::query_as::<_, (Vec<f32>, i64)>(&format!(
"SELECT embedding, frame_number FROM {} \
WHERE file_uuid = $1 AND trace_id = $2 AND embedding IS NOT NULL \
ORDER BY frame_number ASC",
fd_table
)
)
fd_table
))
.bind(&req.file_uuid)
.bind(req.trace_id)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": format!("DB error: {}", e)})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": format!("DB error: {}", e)})),
)
})?;
if all_faces.is_empty() {
return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({
"success": false, "message": "No embedding found for this trace"
}))));
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"success": false, "message": "No embedding found for this trace"
})),
));
}
// Pick 3 samples: divide frame range into 3 segments, use face with largest area per segment
@@ -254,14 +311,12 @@ async fn match_from_trace(
let mut query_embeddings: Vec<Vec<f32>> = Vec::new();
// Get width*height info if available (not all pipelines store it)
let face_sizes: Vec<(i64, i32)> = sqlx::query_as::<_, (i64, i32)>(
&format!(
"SELECT frame_number, COALESCE(width, 0) * COALESCE(height, 0) AS area \
let face_sizes: Vec<(i64, i32)> = sqlx::query_as::<_, (i64, i32)>(&format!(
"SELECT frame_number, COALESCE(width, 0) * COALESCE(height, 0) AS area \
FROM {} WHERE file_uuid = $1 AND trace_id = $2 AND embedding IS NOT NULL \
ORDER BY frame_number ASC",
fd_table
)
)
fd_table
))
.bind(&req.file_uuid)
.bind(req.trace_id)
.fetch_all(state.db.pool())
@@ -296,9 +351,8 @@ async fn match_from_trace(
let mut seen_trace_ids = std::collections::HashSet::new();
for qemb in &query_embeddings {
let top = sqlx::query_as::<_, (i32, i32, f64)>(
&format!(
r#"SELECT id, trace_id,
let top = sqlx::query_as::<_, (i32, i32, f64)>(&format!(
r#"SELECT id, trace_id,
1 - (embedding::vector <=> $1::vector) as similarity
FROM {}
WHERE file_uuid = $2
@@ -306,16 +360,18 @@ async fn match_from_trace(
AND embedding IS NOT NULL
ORDER BY embedding::vector <=> $1::vector
LIMIT 1"#,
fd_table
)
)
fd_table
))
.bind(qemb)
.bind(&req.file_uuid)
.bind(req.trace_id)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": format!("Search failed: {}", e)})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": format!("Search failed: {}", e)})),
)
})?;
if let Some((cface_id, c_trace_id, c_sim)) = top {
@@ -327,35 +383,49 @@ async fn match_from_trace(
// 3. Look up identity internal ID
let id_table = schema::table_name("identities");
let identity_id_row: Option<(i32,)> = sqlx::query_as(
&format!("SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1", id_table)
)
let identity_id_row: Option<(i32,)> = sqlx::query_as(&format!(
"SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1",
id_table
))
.bind(&uuid_clean)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"message": format!("DB error: {}", e)})))
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"message": format!("DB error: {}", e)})),
)
})?;
let identity_id = match identity_id_row {
Some((id,)) => id,
None => return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({
"success": false, "message": "Identity not found"
})))),
None => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"success": false, "message": "Identity not found"
})),
))
}
};
// 4. Update matched face_detections
let mut traces_matched: Vec<i32> = Vec::new();
for (id, trace_id, _similarity) in &validated {
if let Err(e) = sqlx::query(
&format!("UPDATE {} SET identity_id = $1 WHERE id = $2", fd_table)
)
if let Err(e) = sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE id = $2",
fd_table
))
.bind(identity_id)
.bind(id)
.execute(state.db.pool())
.await
{
tracing::warn!("[match-from-trace] Failed to update face_detection {}: {}", id, e);
tracing::warn!(
"[match-from-trace] Failed to update face_detection {}: {}",
id,
e
);
} else {
if !traces_matched.contains(trace_id) {
traces_matched.push(*trace_id);
@@ -364,9 +434,10 @@ async fn match_from_trace(
}
// 5. Also bind the source trace itself
let _ = sqlx::query(
&format!("UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = $3", fd_table)
)
let _ = sqlx::query(&format!(
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = $3",
fd_table
))
.bind(identity_id)
.bind(&req.file_uuid)
.bind(req.trace_id)
@@ -388,7 +459,10 @@ async fn match_from_trace(
file_uuid: req.file_uuid,
matches: match_count,
traces_matched,
message: format!("Matched {} faces ({} unique traces)", match_count, trace_count),
message: format!(
"Matched {} faces ({} unique traces)",
match_count, trace_count
),
}))
}
@@ -461,7 +535,10 @@ fn analyze_person_speaker_overlap(
}
// Check if persons co-occur in time (frame proximity)
let overlap = person.frames.iter().any(|f| other_person.frames.contains(f));
let overlap = person
.frames
.iter()
.any(|f| other_person.frames.contains(f));
if overlap {
matched_persons.push(other_person.person_id.clone());
visited_persons.insert(other_person.person_id.clone());
@@ -474,9 +551,10 @@ fn analyze_person_speaker_overlap(
person.frames.iter().max().copied().unwrap_or(0) as f64,
);
for speaker in speakers {
let has_overlap = speaker.segments.iter().any(|(start, end)| {
*start <= person_time_range.1 && *end >= person_time_range.0
});
let has_overlap = speaker
.segments
.iter()
.any(|(start, end)| *start <= person_time_range.1 && *end >= person_time_range.0);
if has_overlap {
if !matched_speakers.contains(&speaker.speaker_id) {
matched_speakers.push(speaker.speaker_id.clone());
@@ -563,11 +641,12 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
// Step 2: 載入所有 face_detections含 frame_number按 trace_id 分組
let fd_table = schema::table_name("face_detections");
let fd_rows = sqlx::query_as::<_, (i32, i32, Vec<f32>)>(
&format!("SELECT trace_id, frame_number, embedding FROM {} \
let fd_rows = sqlx::query_as::<_, (i32, i32, Vec<f32>)>(&format!(
"SELECT trace_id, frame_number, embedding FROM {} \
WHERE file_uuid=$1 AND trace_id IS NOT NULL AND embedding IS NOT NULL \
ORDER BY trace_id, frame_number", fd_table),
)
ORDER BY trace_id, frame_number",
fd_table
))
.bind(file_uuid)
.fetch_all(pool)
.await?;
@@ -647,16 +726,18 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
let fd_table = schema::table_name("face_detections");
let mut updated = 0usize;
for (tid, name) in &matched {
let id_opt = sqlx::query_scalar::<_, Option<i32>>(
&format!("SELECT id FROM {} WHERE name=$1 AND source='tmdb'", identities_table),
)
let id_opt = sqlx::query_scalar::<_, Option<i32>>(&format!(
"SELECT id FROM {} WHERE name=$1 AND source='tmdb'",
identities_table
))
.bind(name)
.fetch_optional(pool)
.await?;
if let Some(identity_id) = id_opt {
let _ = sqlx::query(
&format!("UPDATE {} SET identity_id=$1 WHERE file_uuid=$2 AND trace_id=$3", fd_table),
)
let _ = sqlx::query(&format!(
"UPDATE {} SET identity_id=$1 WHERE file_uuid=$2 AND trace_id=$3",
fd_table
))
.bind(identity_id)
.bind(file_uuid)
.bind(tid)
@@ -726,32 +807,32 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
// Step 6: 未匹配的 trace 設 stranger_id = trace_id
// trace_id 在同一個 file 內是 sequential integer直接複用為 stranger_id
let stranger_update = sqlx::query(
&format!(
"UPDATE {} SET stranger_id = trace_id \
let stranger_update = sqlx::query(&format!(
"UPDATE {} SET stranger_id = trace_id \
WHERE file_uuid = $1 AND trace_id IS NOT NULL AND identity_id IS NULL \
AND (stranger_id IS NULL OR stranger_id != trace_id)",
fd_table
)
)
fd_table
))
.bind(file_uuid)
.execute(pool)
.await?;
let stranger_count = stranger_update.rows_affected();
// Step 7: Save identity files for all affected identities
let affected = sqlx::query_scalar::<_, uuid::Uuid>(
&format!("SELECT DISTINCT i.uuid FROM {} i \
let affected = sqlx::query_scalar::<_, uuid::Uuid>(&format!(
"SELECT DISTINCT i.uuid FROM {} i \
JOIN {} fd ON fd.identity_id = i.id \
WHERE fd.file_uuid=$1 AND fd.identity_id IS NOT NULL", identities_table, fd_table)
)
WHERE fd.file_uuid=$1 AND fd.identity_id IS NOT NULL",
identities_table, fd_table
))
.bind(file_uuid)
.fetch_all(pool)
.await
.unwrap_or_default();
for uuid in &affected {
let us = uuid.to_string().replace('-', "");
if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(pool, &us).await {
if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(pool, &us).await
{
tracing::warn!("[FaceMatch] Failed to save identity file {}: {}", us, e);
}
}
@@ -773,13 +854,15 @@ async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::
pub async fn bind_speakers(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Result<usize> {
// Load face traces with identity_id and frame numbers
let fd_table = schema::table_name("face_detections");
let traces = sqlx::query_as::<_, (i32, Vec<i32>)>(
&format!("SELECT trace_id, array_agg(frame_number ORDER BY frame_number) \
let traces = sqlx::query_as::<_, (i32, Vec<i32>)>(&format!(
"SELECT trace_id, array_agg(frame_number ORDER BY frame_number) \
FROM {} WHERE file_uuid=$1 AND trace_id IS NOT NULL AND identity_id IS NOT NULL \
GROUP BY trace_id", fd_table)
)
GROUP BY trace_id",
fd_table
))
.bind(file_uuid)
.fetch_all(pool).await?;
.fetch_all(pool)
.await?;
if traces.is_empty() {
tracing::info!("[SpeakerBind] No face traces with identities");
@@ -945,9 +1028,8 @@ pub async fn run_identity_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Res
let speakers = extract_speakers_from_asrx_data(&asrx_data);
let identities = analyze_person_speaker_overlap(&persons, &speakers);
let uuid_short = &file_uuid[..8.min(file_uuid.len())];
for (idx, id_result) in identities.iter().enumerate() {
let identity_name = format!("stranger_{}_{}", uuid_short, idx);
let identity_name = format!("stranger_{}", idx);
let metadata = serde_json::json!({
"source": "identity_agent",
"trace_ids": id_result.person_ids,

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,
}))
}

View File

@@ -1,5 +1,5 @@
use axum::{
extract::{Path, Query},
extract::{Path, Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
@@ -77,7 +77,7 @@ pub async fn bind_identity(
// Get identity_id from identity_uuid
let identity_row: Option<(i64, String)> = sqlx::query_as(&format!(
"SELECT id, COALESCE(real_name, actor_name) AS name FROM {} WHERE uuid = $1::uuid",
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
id_table
))
.bind(&identity_uuid)
@@ -116,8 +116,14 @@ pub async fn bind_identity(
let uuid_clean = identity_uuid.replace('-', "");
// Sync identity JSON file
if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &uuid_clean).await {
tracing::warn!("[bind] Failed to sync identity file for {}: {}", uuid_clean, e);
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(&db, &uuid_clean).await
{
tracing::warn!(
"[bind] Failed to sync identity file for {}: {}",
uuid_clean,
e
);
}
Ok(Json(ApiResponse {
@@ -189,8 +195,15 @@ pub async fn unbind_identity(
.ok()
.flatten();
if let Some(identity_uuid) = uuid {
if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &identity_uuid).await {
tracing::warn!("[unbind] Failed to sync identity file for {}: {}", identity_uuid, e);
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(&db, &identity_uuid)
.await
{
tracing::warn!(
"[unbind] Failed to sync identity file for {}: {}",
identity_uuid,
e
);
}
}
}
@@ -221,7 +234,7 @@ pub async fn merge_identities(
// Get IDs for both identities
let from_row: Option<(i64, String)> = sqlx::query_as(&format!(
"SELECT id, COALESCE(real_name, actor_name) AS name FROM {} WHERE uuid = $1::uuid",
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
id_table
))
.bind(&identity_uuid)
@@ -239,7 +252,7 @@ pub async fn merge_identities(
))?;
let into_row: Option<(i64, String)> = sqlx::query_as(&format!(
"SELECT id, COALESCE(real_name, actor_name) AS name FROM {} WHERE uuid = $1::uuid",
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
id_table
))
.bind(&req.into_uuid)
@@ -299,8 +312,14 @@ pub async fn merge_identities(
// Sync target identity JSON
let into_uuid_clean = req.into_uuid.replace('-', "");
if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &into_uuid_clean).await {
tracing::warn!("[merge] Failed to sync target identity file for {}: {}", into_uuid_clean, e);
if let Err(e) =
crate::core::identity::storage::save_identity_file_by_pool(&db, &into_uuid_clean).await
{
tracing::warn!(
"[merge] Failed to sync target identity file for {}: {}",
into_uuid_clean,
e
);
}
// Delete source identity JSON if not keeping history
@@ -339,6 +358,106 @@ pub struct ListIdentitiesParams {
pub offset: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct IdentityTraceInfo {
pub file_uuid: String,
pub trace_id: i32,
pub frame_count: i64,
pub first_frame: i32,
pub last_frame: i32,
pub first_sec: f64,
pub last_sec: f64,
pub avg_confidence: f64,
}
#[derive(Debug, Serialize)]
pub struct IdentityTracesResponse {
pub success: bool,
pub identity_uuid: String,
pub name: String,
pub total_traces: usize,
pub total_faces: i64,
pub traces: Vec<IdentityTraceInfo>,
}
pub async fn get_identity_traces(
State(state): State<crate::api::server::AppState>,
Path(identity_uuid): Path<String>,
) -> Result<Json<IdentityTracesResponse>, (StatusCode, String)> {
let id_table = crate::core::db::schema::table_name("identities");
let fd_table = crate::core::db::schema::table_name("face_detections");
// Get identity name
let identity: Option<(i32, String)> = sqlx::query_as(&format!(
"SELECT id, name FROM {} WHERE uuid = $1::uuid",
id_table
))
.bind(&identity_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (identity_id, name) =
identity.ok_or((StatusCode::NOT_FOUND, "Identity not found".to_string()))?;
// Get all traces for this identity across all files
let rows: Vec<(String, i32, i64, i32, i32, f64, f64, f64)> = sqlx::query_as(&format!(
r#"SELECT fd.file_uuid::text, fd.trace_id,
COUNT(*)::bigint AS frame_count,
MIN(fd.frame_number)::int AS first_frame,
MAX(fd.frame_number)::int AS last_frame,
ROUND(MIN(fd.frame_number)::numeric / 25.0, 1)::float8 AS first_sec,
ROUND(MAX(fd.frame_number)::numeric / 25.0, 1)::float8 AS last_sec,
ROUND(AVG(fd.confidence)::numeric, 4)::float8 AS avg_confidence
FROM {} fd
WHERE fd.identity_id = $1
GROUP BY fd.file_uuid, fd.trace_id
ORDER BY fd.file_uuid, fd.trace_id"#,
fd_table
))
.bind(identity_id)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total_traces = rows.len();
let total_faces: i64 = rows.iter().map(|r| r.2).sum();
let traces: Vec<IdentityTraceInfo> = rows
.into_iter()
.map(
|(
file_uuid,
trace_id,
frame_count,
first_frame,
last_frame,
first_sec,
last_sec,
avg_confidence,
)| IdentityTraceInfo {
file_uuid,
trace_id,
frame_count,
first_frame,
last_frame,
first_sec,
last_sec,
avg_confidence,
},
)
.collect();
Ok(Json(IdentityTracesResponse {
success: true,
identity_uuid,
name,
total_traces,
total_faces,
traces,
}))
}
pub fn identity_binding_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route("/api/v1/identity/:identity_uuid/bind", post(bind_identity))
@@ -350,4 +469,8 @@ pub fn identity_binding_routes() -> Router<crate::api::server::AppState> {
"/api/v1/identity/:identity_uuid/mergeinto",
post(merge_identities),
)
.route(
"/api/v1/identity/:identity_uuid/traces",
get(get_identity_traces),
)
}

View File

@@ -14,8 +14,16 @@ use crate::core::db::{schema, PostgresDb};
/// Shared video query params: mode=normal|debug, audio=on|off
fn parse_video_params(params: &std::collections::HashMap<String, String>) -> (String, String) {
let mode = params.get("mode").map(|s| s.as_str()).unwrap_or("normal").to_string();
let audio = params.get("audio").map(|s| s.as_str()).unwrap_or("on").to_string();
let mode = params
.get("mode")
.map(|s| s.as_str())
.unwrap_or("normal")
.to_string();
let audio = params
.get("audio")
.map(|s| s.as_str())
.unwrap_or("on")
.to_string();
(mode, audio)
}
@@ -142,9 +150,12 @@ struct BboxParams {
/// Priority: start_frame/end_frame > start/end > start_time/end_time.
/// If only time is given, convert via fps.
fn resolve_frame_range(
start_frame: Option<i32>, end_frame: Option<i32>,
start: Option<i32>, end: Option<i32>,
start_time: Option<f64>, end_time: Option<f64>,
start_frame: Option<i32>,
end_frame: Option<i32>,
start: Option<i32>,
end: Option<i32>,
start_time: Option<f64>,
end_time: Option<f64>,
fps: f64,
) -> (i32, i32) {
if let (Some(sf), Some(ef)) = (start_frame.or(start), end_frame.or(end)) {
@@ -186,7 +197,15 @@ async fn bbox_overlay_video(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.unwrap_or(24.0);
let (start_f, end_f) = resolve_frame_range(p.start_frame, p.end_frame, p.start, p.end, p.start_time, p.end_time, fps);
let (start_f, end_f) = resolve_frame_range(
p.start_frame,
p.end_frame,
p.start,
p.end,
p.start_time,
p.end_time,
fps,
);
let start_sec = start_f as f64 / fps;
@@ -228,13 +247,26 @@ async fn bbox_overlay_video(
let dur = duration.to_string();
let mut bbox_args = vec!["-ss", &ss, "-i", &video_path, "-t", &dur];
if vf != "null" {
bbox_args.extend_from_slice(&["-vf", &vf, "-c:v", "libx264", "-preset", "ultrafast", "-crf", "28"]);
bbox_args.extend_from_slice(&[
"-vf",
&vf,
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"28",
]);
} else {
bbox_args.extend_from_slice(&["-c", "copy"]);
}
if bbox_audio == "off" { bbox_args.push("-an"); }
if bbox_audio == "off" {
bbox_args.push("-an");
}
bbox_args.extend_from_slice(&["-movflags", "+faststart", "-y", &tmp_str]);
let status = ffmpeg_cmd().args(&bbox_args).status()
let status = ffmpeg_cmd()
.args(&bbox_args)
.status()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !status.success() {
let _ = std::fs::remove_file(&tmp);
@@ -315,14 +347,20 @@ async fn trace_video(
let sk = seek.to_string();
let du = duration.to_string();
let mut cmd_args = vec!["-ss", &sk, "-i", &video_path, "-t", &du, "-c", "copy"];
if audio == "off" { cmd_args.push("-an"); }
if audio == "off" {
cmd_args.push("-an");
}
cmd_args.extend_from_slice(&["-y", &tmp_str]);
let result = ffmpeg_cmd().args(&cmd_args).output()
let result = ffmpeg_cmd()
.args(&cmd_args)
.output()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !result.status.success() {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
let data = tokio::fs::read(&tmp).await.map_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")
@@ -345,8 +383,11 @@ async fn trace_video(
ORDER BY fd.trace_id, fd.frame_number",
face_table, identities_table
))
.bind(&file_uuid).bind(start_fn).bind(end_fn)
.fetch_all(state.db.pool()).await
.bind(&file_uuid)
.bind(start_fn)
.bind(end_fn)
.fetch_all(state.db.pool())
.await
.unwrap_or_default();
// Group frames by trace_id, compute start_frame per trace; collect bbox per frame
@@ -359,7 +400,9 @@ async fn trace_video(
if let Some(name) = name_opt {
trace_identity.entry(*tid).or_insert_with(|| name.clone());
} else {
trace_identity.entry(*tid).or_insert_with(|| format!("Stranger_{:03}", tid));
trace_identity
.entry(*tid)
.or_insert_with(|| format!("Stranger_{:03}", tid));
}
}
@@ -374,7 +417,8 @@ async fn trace_video(
.unwrap_or_else(|| "-".to_string());
// Sort traces for consistent ordering
let mut sorted_traces: Vec<(i32, &Vec<i32>)> = trace_frames.iter().map(|(k, v)| (*k, v)).collect();
let mut sorted_traces: Vec<(i32, &Vec<i32>)> =
trace_frames.iter().map(|(k, v)| (*k, v)).collect();
sorted_traces.sort_by_key(|(tid, _)| *tid);
let frame_offset = first_frame as i64 - (padding * fps) as i64;
@@ -389,10 +433,12 @@ async fn trace_video(
"drawtext=text='Frame %{{n}} %{{pts}}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=12"
));
parts.push(format!(
"drawtext=text='Cut\\: {}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=56", cut_label
"drawtext=text='Cut\\: {}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=56",
cut_label
));
parts.push(format!(
"drawtext=text='{}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=100", file_uuid
"drawtext=text='{}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=100",
file_uuid
));
// Per-trace entries: show trace_id, start_frame, identity name
@@ -400,11 +446,18 @@ async fn trace_video(
let mut y_pos = 144;
for (tid, frames) in &sorted_traces {
let start = frames.iter().min().unwrap_or(&first_frame);
let identity = trace_identity.get(tid).map(|s| s.as_str()).unwrap_or("unknown");
let identity = trace_identity
.get(tid)
.map(|s| s.as_str())
.unwrap_or("unknown");
let label = format!("Trace {}\\: start={} {}", tid, start, identity);
// Continuous range (interpolated): visible from first to last frame
let enable = format!("between(n,{},{})", frames[0] as i64 - frame_offset, frames[frames.len() - 1] as i64 - frame_offset);
let enable = format!(
"between(n,{},{})",
frames[0] as i64 - frame_offset,
frames[frames.len() - 1] as i64 - frame_offset
);
parts.push(format!(
"drawtext=text='{}':fontsize=24:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y={}:enable='{}'",
@@ -415,7 +468,11 @@ async fn trace_video(
// Bounding boxes: interpolated (thickness=1) + actual (thickness=4) with trace_id label
for (tid, frames) in &sorted_traces {
let range_enable = format!("between(n,{},{})", frames[0] as i64 - frame_offset, frames[frames.len() - 1] as i64 - frame_offset);
let range_enable = format!(
"between(n,{},{})",
frames[0] as i64 - frame_offset,
frames[frames.len() - 1] as i64 - frame_offset
);
// Interpolated bbox at first known position across the whole trace range
if let Some((x, y, w, h)) = bbox_per_frame.get(&(*tid, frames[0])) {
parts.push(format!(
@@ -448,23 +505,45 @@ async fn trace_video(
let tmp_str = tmp.to_str().unwrap_or("").to_string();
let sk = seek.to_string();
let du = duration.to_string();
let mut debug_args = vec!["-ss", &sk, "-i", &video_path, "-t", &du,
"-/filter_complex", &filter_path,
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "28"];
if audio == "on" { debug_args.extend_from_slice(&["-c:a", "aac"]); }
let mut debug_args = vec![
"-ss",
&sk,
"-i",
&video_path,
"-t",
&du,
"-/filter_complex",
&filter_path,
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-crf",
"28",
];
if audio == "on" {
debug_args.extend_from_slice(&["-c:a", "aac"]);
}
debug_args.extend_from_slice(&["-movflags", "+faststart", "-y", &tmp_str]);
let result = ffmpeg_cmd().args(&debug_args).output()
let result = ffmpeg_cmd()
.args(&debug_args)
.output()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
let _ = std::fs::write("/tmp/ffmpeg_last_error.txt", stderr.as_bytes());
tracing::error!("ffmpeg failed ({} bytes), see /tmp/ffmpeg_last_error.txt", stderr.len());
tracing::error!(
"ffmpeg failed ({} bytes), see /tmp/ffmpeg_last_error.txt",
stderr.len()
);
let _ = std::fs::remove_file(&filter_file);
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 data = tokio::fs::read(&tmp)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let _ = std::fs::remove_file(&filter_file);
let _ = std::fs::remove_file(&tmp);
Ok(Response::builder()
@@ -503,19 +582,27 @@ async fn stream_video(
// Chunk extraction with dual time/frame params
let start_time_param = params.get("start_time").and_then(|v| v.parse::<f64>().ok());
let end_time_param = params.get("end_time").and_then(|v| v.parse::<f64>().ok());
let start_frame_param = params.get("start_frame").and_then(|v| v.parse::<f64>().ok());
let start_frame_param = params
.get("start_frame")
.and_then(|v| v.parse::<f64>().ok());
let end_frame_param = params.get("end_frame").and_then(|v| v.parse::<f64>().ok());
let start_legacy = params.get("start").and_then(|v| v.parse::<f64>().ok());
let end_legacy = params.get("end").and_then(|v| v.parse::<f64>().ok());
let has_range = start_frame_param.is_some() || start_time_param.is_some() || start_legacy.is_some();
let has_range =
start_frame_param.is_some() || start_time_param.is_some() || start_legacy.is_some();
if has_range {
let (start_sec, dur) = if let (Some(sf), Some(ef)) = (start_frame_param, end_frame_param) {
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);
"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);
(sf / _fps, (ef - sf) / _fps)
} else if let (Some(st), Some(et)) = (start_time_param, end_time_param) {
(st, et - st)
@@ -533,15 +620,21 @@ async fn stream_video(
let ss = start_sec.to_string();
let d = dur.to_string();
let mut chunk_args = vec!["-ss", &ss, "-i", &file_path, "-t", &d, "-c", "copy"];
if audio == "off" { chunk_args.push("-an"); }
if audio == "off" {
chunk_args.push("-an");
}
chunk_args.extend_from_slice(&["-movflags", "+faststart", "-y", &tmp_str]);
let status = ffmpeg_cmd().args(&chunk_args).status()
let status = ffmpeg_cmd()
.args(&chunk_args)
.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 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")
@@ -704,7 +797,7 @@ async fn video_clip(
let frame_count = ((e - s) * fps) as i64;
cmd.args(["-vframes", &frame_count.to_string()]);
} else {
cmd.args(["-to", &e.to_string()]);
cmd.args(["-t", &(e - s).to_string()]);
}
if mode == "debug" {
let debug_text = if let (Some(sf), Some(ef)) = (q.start_frame, q.end_frame) {
@@ -717,8 +810,20 @@ async fn video_clip(
if audio == "off" {
cmd.args(["-an"]);
}
cmd.args(["-c:v", "libx264", "-c:a", "aac", "-f", "mpegts", "-"]);
let output = cmd.output().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
cmd.args([
"-c:v",
"libx264",
"-c:a",
"aac",
"-movflags",
"frag_keyframe+empty_moov",
"-f",
"mp4",
"-",
]);
let output = cmd
.output()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !output.status.success() {
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}

View File

@@ -88,16 +88,10 @@ fn hex_val(c: u8) -> Option<u8> {
}
fn extract_api_key(headers: &HeaderMap, uri: &axum::http::Uri) -> Result<String, StatusCode> {
if let Some(key) = headers
.get("X-API-Key")
.and_then(|v| v.to_str().ok())
{
if let Some(key) = headers.get("X-API-Key").and_then(|v| v.to_str().ok()) {
return Ok(key.to_string());
}
if let Some(auth) = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
{
if let Some(auth) = headers.get("Authorization").and_then(|v| v.to_str().ok()) {
// Check if it's a JWT (starts with eyJ)
let trimmed = auth.strip_prefix("Bearer ").unwrap_or(auth);
if !jwt::is_jwt(trimmed) {
@@ -129,7 +123,11 @@ pub async fn unified_auth(
// Priority 1: Cookie session (Portal)
let cookies = extract_cookies(headers);
if let Some(sid) = cookies.iter().find(|(k, _)| k == "session_id").map(|(_, v)| v.clone()) {
if let Some(sid) = cookies
.iter()
.find(|(k, _)| k == "session_id")
.map(|(_, v)| v.clone())
{
match state.db.get_session_by_id(&sid).await {
Ok(Some((_id, user_id, api_key_id, _expires_at))) => {
let key_hash = hash_key(&api_key_id);
@@ -162,15 +160,17 @@ pub async fn unified_auth(
}
// Priority 2: JWT (Authorization: Bearer <eyJ...>)
if let Some(auth_header) = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
{
if let Some(auth_header) = headers.get("Authorization").and_then(|v| v.to_str().ok()) {
if let Some(token) = auth_header.strip_prefix("Bearer ") {
if jwt::is_jwt(token) {
match jwt::verify_jwt(token) {
Ok(claims) => {
if !state.db.is_jwt_blacklisted(&claims.jti).await.unwrap_or(false) {
if !state
.db
.is_jwt_blacklisted(&claims.jti)
.await
.unwrap_or(false)
{
let exp = chrono::DateTime::from_timestamp(claims.exp as i64, 0);
let user_id: i32 = claims.sub.parse().unwrap_or(0);
let auth = UserAuth {

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::api::server::AppState;
use crate::core::config;
use crate::core::db::PostgresDb;
use crate::core::db::{PostgresDb, QdrantDb};
use crate::core::tmdb;
#[derive(Debug, Serialize)]
@@ -64,10 +64,44 @@ struct FileUuidParam {
file_uuid: String,
}
#[derive(Debug, Deserialize)]
struct TmdbFetchRequest {
file_uuid: String,
}
#[derive(Debug, Serialize)]
struct TmdbFetchMemberResult {
name: String,
character: Option<String>,
aliases: Vec<String>,
metadata: serde_json::Value,
status: String,
has_json: bool,
has_jpg: bool,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct TmdbFetchResponse {
success: bool,
movie_title: Option<String>,
tmdb_id: Option<u64>,
results: Vec<TmdbFetchMemberResult>,
summary: serde_json::Value,
}
pub fn tmdb_routes() -> Router<AppState> {
Router::new()
.route("/api/v1/agents/tmdb/prefetch", post(tmdb_prefetch))
.route("/api/v1/file/:file_uuid/tmdb-probe", post(tmdb_probe_handler))
.route(
"/api/v1/file/:file_uuid/tmdb-probe",
post(tmdb_probe_handler),
)
.route("/api/v1/tmdb/fetch", post(tmdb_fetch))
.route(
"/api/v1/agents/tmdb/match/:file_uuid",
post(tmdb_match_handler),
)
.route("/api/v1/resource/tmdb", get(tmdb_resource_status))
.route("/api/v1/resource/tmdb/check", post(tmdb_resource_check))
}
@@ -79,9 +113,10 @@ async fn tmdb_prefetch(
let file_uuid = req.file_uuid;
// Verify file exists in DB
let file_exists: bool = sqlx::query_scalar(
&format!("SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1", crate::core::db::schema::table_name("videos"))
)
let file_exists: bool = sqlx::query_scalar(&format!(
"SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
@@ -182,18 +217,22 @@ async fn tmdb_probe_handler(
let file_uuid = params.file_uuid;
// Verify file exists
let file_exists: bool = sqlx::query_scalar(
&format!("SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1", crate::core::db::schema::table_name("videos"))
)
let file_exists: bool = sqlx::query_scalar(&format!(
"SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.unwrap_or(false);
if !file_exists {
return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({
"error": "Video not found", "file_uuid": file_uuid
}))));
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Video not found", "file_uuid": file_uuid
})),
));
}
match tmdb::probe::probe_from_cache(&state.db, &file_uuid).await {
@@ -214,7 +253,10 @@ async fn tmdb_probe_handler(
.await
{
for uuid in rows {
let _ = crate::core::identity::storage::save_identity_file_by_pool(&pool, &uuid).await;
let _ = crate::core::identity::storage::save_identity_file_by_pool(
&pool, &uuid,
)
.await;
}
}
});
@@ -245,24 +287,26 @@ async fn tmdb_probe_handler(
message: "No TMDb cache found. Run tmdb-prefetch first.".to_string(),
}))
} else {
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"error": msg, "file_uuid": file_uuid
}))))
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": msg, "file_uuid": file_uuid
})),
))
}
}
}
}
async fn tmdb_resource_status(
State(state): State<AppState>,
) -> Json<TmdbResourceResponse> {
async fn tmdb_resource_status(State(state): State<AppState>) -> Json<TmdbResourceResponse> {
let status = tmdb::status::quick_status();
let identities_seeded = tmdb::status::count_tmdb_identities(state.db.pool())
.await
.unwrap_or(0);
let identities_with_embedding = tmdb::status::count_tmdb_identities_with_embedding(state.db.pool())
.await
.unwrap_or(0);
let identities_with_embedding =
tmdb::status::count_tmdb_identities_with_embedding(state.db.pool())
.await
.unwrap_or(0);
let cache_files = tmdb::status::count_cache_files();
Json(TmdbResourceResponse {
@@ -303,3 +347,383 @@ async fn tmdb_resource_check() -> Json<TmdbCheckResponse> {
status,
})
}
async fn tmdb_fetch(
State(state): State<AppState>,
Json(req): Json<TmdbFetchRequest>,
) -> Result<Json<TmdbFetchResponse>, (StatusCode, Json<serde_json::Value>)> {
let file_uuid = req.file_uuid;
let filename: Option<String> = sqlx::query_scalar(&format!(
"SELECT file_name FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
})?
.flatten();
let filename = filename.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "File not found"})),
)
})?;
// Run probe to create identities
match tmdb::probe::probe_movie(&state.db, &filename, &file_uuid).await {
Ok(Some(probe_result)) => {
let mut member_results = Vec::new();
// Read the cache to get cast list with names and profile URLs
if let Ok(cache) = tmdb::cache::read_tmdb_cache(&file_uuid) {
for member in &cache.cast {
let name = member.name.clone();
let character = if member.character.is_empty() {
None
} else {
Some(member.character.clone())
};
let aliases = member.also_known_as.clone();
let profile_url = member
.profile_path
.as_ref()
.map(|p| format!("https://image.tmdb.org/t/p/w185{}", p));
let metadata = serde_json::json!({
"tmdb_id": member.id,
"name": member.name,
"character": member.character,
"aliases": member.also_known_as,
"profile_path": member.profile_path,
"order": member.order,
"biography": member.biography,
"birthday": member.birthday,
"place_of_birth": member.place_of_birth,
"imdb_id": member.imdb_id,
"known_for_department": member.known_for_department,
"popularity": member.popularity,
"deathday": member.deathday,
"gender": member.gender,
"homepage": member.homepage,
});
let identity_row = sqlx::query_as::<_, (i32, uuid::Uuid)>(&format!(
"SELECT id, uuid FROM {} WHERE name = $1 AND source = 'tmdb' LIMIT 1",
crate::core::db::schema::table_name("identities")
))
.bind(&name)
.fetch_optional(state.db.pool())
.await;
match identity_row {
Ok(Some((identity_id, uuid))) => {
let clean = uuid.to_string().replace('-', "");
let dir = crate::core::identity::storage::identity_dir(&clean);
std::fs::create_dir_all(&dir).ok();
let json_result = crate::core::identity::storage::save_identity_file(
&state.db, &clean,
)
.await;
let has_json = json_result.is_ok();
let has_jpg = if let Some(url) = &profile_url {
let jpg_path = dir.join("profile.jpg");
if jpg_path.exists() {
true
} else if let Ok(resp) = reqwest::get(url).await {
if let Ok(bytes) = resp.bytes().await {
std::fs::write(&jpg_path, &bytes).is_ok()
} else {
false
}
} else {
false
}
} else {
false
};
// Push face_embedding to Qdrant if available
let face_collection = format!(
"{}_faces",
crate::core::config::REDIS_KEY_PREFIX
.as_str()
.trim_end_matches(':')
);
let emb_row: Option<(Vec<f32>,)> = sqlx::query_as(
&format!(
"SELECT face_embedding::real[] FROM {} WHERE uuid = $1 AND face_embedding IS NOT NULL",
crate::core::db::schema::table_name("identities")
)
)
.bind(&uuid)
.fetch_optional(state.db.pool())
.await
.unwrap_or(None);
if let Some((embedding,)) = emb_row {
let qdrant = QdrantDb::new();
qdrant.ensure_collection(&face_collection, 512).await.ok();
let _ = qdrant
.upsert_vector_to_collection(
&face_collection,
identity_id as u64,
&embedding,
Some(serde_json::json!({
"identity_id": identity_id,
"name": name,
"source": "tmdb",
})),
)
.await;
}
let status = if has_json && has_jpg {
"success"
} else {
"partial"
};
let error = if !has_json {
Some(format!("{:?}", json_result.err()))
} else if !has_jpg {
Some("profile download failed".to_string())
} else {
None
};
member_results.push(TmdbFetchMemberResult {
name: name.clone(),
character: character.clone(),
aliases: aliases.clone(),
metadata: metadata.clone(),
status: status.to_string(),
has_json,
has_jpg,
error,
});
}
Ok(None) => {
member_results.push(TmdbFetchMemberResult {
name: name.clone(),
character: character.clone(),
aliases: aliases.clone(),
metadata: metadata.clone(),
status: "skipped".to_string(),
has_json: false,
has_jpg: false,
error: None,
});
}
Err(e) => {
member_results.push(TmdbFetchMemberResult {
name: name.clone(),
character: character.clone(),
aliases: aliases.clone(),
metadata: metadata.clone(),
status: "error".to_string(),
has_json: false,
has_jpg: false,
error: Some(format!("DB error: {}", e)),
});
}
}
}
}
let total = member_results.len();
let success_count = member_results
.iter()
.filter(|r| r.status == "success")
.count();
let json_count = member_results.iter().filter(|r| r.has_json).count();
let jpg_count = member_results.iter().filter(|r| r.has_jpg).count();
Ok(Json(TmdbFetchResponse {
success: true,
movie_title: Some(probe_result.title),
tmdb_id: Some(probe_result.tmdb_id),
results: member_results,
summary: serde_json::json!({
"total": total,
"success": success_count,
"with_json": json_count,
"with_jpg": jpg_count,
}),
}))
}
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "No movie found for this filename"
})),
)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": e.to_string()
})),
)),
}
}
#[derive(Debug, Serialize)]
struct TmdbMatchResponse {
success: bool,
file_uuid: String,
bindings_created: usize,
tmdb_identities_available: usize,
message: String,
}
async fn tmdb_match_handler(
Path(params): Path<FileUuidParam>,
State(state): State<AppState>,
) -> Result<Json<TmdbMatchResponse>, (StatusCode, Json<serde_json::Value>)> {
let file_uuid = params.file_uuid;
// Verify file exists
let file_exists: bool = sqlx::query_scalar(&format!(
"SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
.unwrap_or(false);
if !file_exists {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Video not found", "file_uuid": file_uuid
})),
));
}
// Get all TMDb identities with face_embedding
let tmdb_rows = sqlx::query_as::<_, (i32, String, Vec<f32>)>(
&format!(
"SELECT id, name, face_embedding::real[] FROM {} WHERE source='tmdb' AND face_embedding IS NOT NULL",
crate::core::db::schema::table_name("identities")
)
)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
})?;
if tmdb_rows.is_empty() {
return Ok(Json(TmdbMatchResponse {
success: true,
file_uuid,
bindings_created: 0,
tmdb_identities_available: 0,
message: "No TMDb identities with face embeddings".to_string(),
}));
}
let face_collection = format!(
"{}_faces",
crate::core::config::REDIS_KEY_PREFIX
.as_str()
.trim_end_matches(':')
);
let qdrant = QdrantDb::new();
let _ = qdrant.ensure_collection(&face_collection, 512).await;
let trace_collection = format!(
"{}_traces",
crate::core::config::REDIS_KEY_PREFIX
.as_str()
.trim_end_matches(':')
);
let _ = qdrant.ensure_collection(&trace_collection, 512).await;
// Sync trace embeddings (idempotent)
if let Err(e) = crate::core::db::qdrant_db::sync_trace_embeddings(&file_uuid).await {
tracing::error!("[TKG-MATCH] Trace sync failed: {}", e);
}
let mut total_bindings = 0usize;
for (tmdb_id, tmdb_name, tmdb_embedding) in &tmdb_rows {
// Search Qdrant trace collection with this TMDb embedding
let results = match qdrant
.search_face_collection(
&trace_collection,
tmdb_embedding,
100,
"source",
"tmdb",
Some(&file_uuid),
)
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("[TKG-MATCH] Qdrant search failed for {}: {}", tmdb_name, e);
continue;
}
};
// Filter results by threshold and file_uuid
let filtered: Vec<_> = results
.into_iter()
.filter(|(score, payload)| {
*score >= 0.50
&& payload.get("file_uuid").and_then(|v| v.as_str()) == Some(&file_uuid)
})
.collect();
if filtered.is_empty() {
continue;
}
// Bind matched traces directly
let mut bound_count = 0usize;
for (_score, payload) in &filtered {
if let Some(tid) = payload.get("trace_id").and_then(|v| v.as_i64()) {
let r = sqlx::query(&format!(
"UPDATE {} SET identity_id=$1 WHERE file_uuid=$2 AND trace_id=$3",
crate::core::db::schema::table_name("face_detections")
))
.bind(tmdb_id)
.bind(&file_uuid)
.bind(tid as i32)
.execute(state.db.pool())
.await;
if let Ok(result) = r {
bound_count += result.rows_affected() as usize;
}
}
}
if bound_count > 0 {
tracing::info!(
"[TKG-MATCH] {}: bound {} traces to TMDb identity {}",
tmdb_name,
bound_count,
tmdb_id
);
}
total_bindings += bound_count;
}
Ok(Json(TmdbMatchResponse {
success: true,
file_uuid,
bindings_created: total_bindings,
tmdb_identities_available: tmdb_rows.len(),
message: format!("{} traces matched to TMDb identities", total_bindings),
}))
}

View File

@@ -11,10 +11,7 @@ use crate::core::db::PostgresDb;
pub fn trace_agent_routes() -> Router<crate::api::server::AppState> {
Router::new()
.route(
"/api/v1/file/:file_uuid/traces",
post(list_traces_sorted),
)
.route("/api/v1/file/:file_uuid/traces", post(list_traces_sorted))
.route(
"/api/v1/file/:file_uuid/trace/:trace_id/faces",
get(list_trace_faces),
@@ -78,14 +75,15 @@ async fn list_traces_sorted(
_ => "start_frame ASC",
};
let fps: f64 =
sqlx::query_scalar(&format!("SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")))
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(24.0);
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.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!(
"SELECT tt.*, fd.id AS sample_face_id FROM (
@@ -113,17 +111,16 @@ async fn list_traces_sorted(
crate::core::db::schema::table_name("face_detections"),
);
let rows: Vec<(i32, i64, i32, i32, f64, f64, Option<i32>)> =
sqlx::query_as(&query)
.bind(&file_uuid)
.bind(min_faces)
.bind(effective_limit)
.bind(db_offset)
.bind(min_confidence)
.bind(max_confidence)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let rows: Vec<(i32, i64, i32, i32, f64, f64, Option<i32>)> = sqlx::query_as(&query)
.bind(&file_uuid)
.bind(min_faces)
.bind(effective_limit)
.bind(db_offset)
.bind(min_confidence)
.bind(max_confidence)
.fetch_all(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let traces: Vec<TraceInfo> = rows
.into_iter()
@@ -220,19 +217,20 @@ async fn list_trace_faces(
};
let interpolate = q.interpolate.unwrap_or(false);
let fps: f64 =
sqlx::query_scalar(&format!("SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")))
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(24.0);
let fps: f64 = sqlx::query_scalar(&format!(
"SELECT COALESCE(fps, 24.0) FROM {} WHERE file_uuid = $1",
crate::core::db::schema::table_name("videos")
))
.bind(&file_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(24.0);
let total_detected: i64 = sqlx::query_scalar(
&format!("SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
crate::core::db::schema::table_name("face_detections"))
)
let total_detected: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND trace_id = $2",
crate::core::db::schema::table_name("face_detections")
))
.bind(&file_uuid)
.bind(trace_id)
.fetch_one(state.db.pool())
@@ -247,12 +245,12 @@ async fn list_trace_faces(
Option<i32>,
Option<i32>,
f32,
)> = sqlx::query_as(
&format!("SELECT id, frame_number::int, x, y, width, height, confidence::float4 \
)> = sqlx::query_as(&format!(
"SELECT id, frame_number::int, x, y, width, height, confidence::float4 \
FROM {} WHERE file_uuid = $1 AND trace_id = $2 \
ORDER BY frame_number ASC LIMIT $3 OFFSET $4",
crate::core::db::schema::table_name("face_detections"))
)
crate::core::db::schema::table_name("face_detections")
))
.bind(&file_uuid)
.bind(trace_id)
.bind(limit)

View File

@@ -88,9 +88,9 @@ pub enum SearchResult {
},
#[serde(rename = "person")]
Person {
person_id: String,
identity_id: i32,
identity_uuid: String,
name: Option<String>,
speaker_id: Option<String>,
appearance_count: i32,
score: f64,
first_appearance_time: Option<f64>,
@@ -168,7 +168,7 @@ pub async fn universal_search(
results.retain(|r| match r {
SearchResult::Chunk { chunk_id, .. } => seen_chunks.insert(chunk_id.clone()),
SearchResult::Frame { frame_number, .. } => seen_frames.insert(*frame_number),
SearchResult::Person { person_id, .. } => seen_persons.insert(person_id.clone()),
SearchResult::Person { identity_id, .. } => seen_persons.insert(*identity_id),
});
}
@@ -251,9 +251,9 @@ pub async fn search_persons(
let limit = query.limit.unwrap_or(20);
let persons = search_persons_by_query(
&db,
&query.file_uuid,
&query.query,
query.min_appearances,
query.max_age,
limit,
)
.await
@@ -305,7 +305,6 @@ pub struct PersonSearchQuery {
pub file_uuid: String,
pub query: Option<String>,
pub min_appearances: Option<i32>,
pub max_age: Option<i32>, // New filter for "children"
pub limit: Option<usize>,
}
@@ -317,13 +316,9 @@ pub struct PersonSearchResponse {
#[derive(Debug, Serialize)]
pub struct PersonResult {
pub person_id: String,
pub identity_id: i32,
pub identity_uuid: String,
pub name: Option<String>,
pub character_name: Option<String>,
pub aliases: Option<Vec<String>>,
pub age: Option<i32>,
pub gender: Option<String>,
pub speaker_id: Option<String>,
pub appearance_count: i32,
pub first_appearance_time: Option<f64>,
pub last_appearance_time: Option<f64>,
@@ -594,43 +589,37 @@ async fn search_persons_internal(
db: &PostgresDb,
req: &UniversalSearchRequest,
) -> Result<Vec<SearchResult>, anyhow::Error> {
let table = "person_identities";
let uuid = match &req.file_uuid {
Some(u) => u.replace('\'', "''"),
None => return Err(anyhow::anyhow!("file_uuid is required for person search")),
};
let id_table = schema::table_name("identities");
let fd_table = schema::table_name("face_detections");
let mut sql = format!(
"SELECT person_id, name, speaker_id, appearance_count, first_appearance_time, last_appearance_time FROM {} WHERE 1=1",
table
"SELECT i.id, i.uuid::text, i.name, COUNT(fd.id) AS appearance_count, \
MIN(fd.timestamp_secs) AS first_time, MAX(fd.timestamp_secs) AS last_time \
FROM {} i JOIN {} fd ON fd.identity_id = i.id \
WHERE fd.file_uuid = '{}'",
id_table, fd_table, uuid
);
if !req.query.is_empty() {
sql.push_str(&format!(
" AND (name ILIKE '%{}%' OR person_id ILIKE '%{}%' OR speaker_id ILIKE '%{}%')",
req.query, req.query, req.query
));
}
if let Some(ref filters) = req.filters {
if let Some(ref speaker_id) = filters.speaker_id {
sql.push_str(&format!(" AND speaker_id = '{}'", speaker_id));
}
if let Some(ref person_id) = filters.person_id {
sql.push_str(&format!(" AND person_id = '{}'", person_id));
}
let q = req.query.replace('\'', "''");
sql.push_str(&format!(" AND i.name ILIKE '%{}%'", q));
}
sql.push_str(" GROUP BY i.id, i.uuid, i.name");
sql.push_str(" ORDER BY appearance_count DESC");
sql.push_str(&format!(" LIMIT {}", req.page_size.unwrap_or(20)));
let rows: Vec<(
String,
Option<String>,
Option<String>,
i32,
Option<f64>,
Option<f64>,
)> = sqlx::query_as(&sql).fetch_all(db.pool()).await?;
let rows: Vec<(i32, String, Option<String>, i64, Option<f64>, Option<f64>)> =
sqlx::query_as(&sql).fetch_all(db.pool()).await?;
let results: Vec<SearchResult> = rows
.into_iter()
.map(
|(person_id, name, speaker_id, appearance_count, first_time, last_time)| {
|(identity_id, identity_uuid, name, appearance_count, first_time, last_time)| {
let score = if !req.query.is_empty()
&& name.as_ref().map_or(false, |n| {
n.to_lowercase().contains(&req.query.to_lowercase())
@@ -641,10 +630,10 @@ async fn search_persons_internal(
};
SearchResult::Person {
person_id,
identity_id,
identity_uuid,
name,
speaker_id,
appearance_count,
appearance_count: appearance_count as i32,
score,
first_appearance_time: first_time,
last_appearance_time: last_time,
@@ -739,82 +728,49 @@ async fn search_frames_internal_v2(
async fn search_persons_by_query(
db: &PostgresDb,
file_uuid: &str,
query: &Option<String>,
min_appearances: Option<i32>,
max_age: Option<i32>,
limit: usize,
) -> Result<Vec<PersonResult>, anyhow::Error> {
let table = "person_identities";
let id_table = schema::table_name("identities");
let fd_table = schema::table_name("face_detections");
let mut sql = format!(
"SELECT person_id, name, character_name, aliases, age, gender, speaker_id, appearance_count, first_appearance_time, last_appearance_time FROM {} WHERE 1=1",
table
"SELECT i.id, i.uuid::text, i.name, COUNT(fd.id) AS appearance_count, \
MIN(fd.timestamp_secs) AS first_time, MAX(fd.timestamp_secs) AS last_time \
FROM {} i JOIN {} fd ON fd.identity_id = i.id \
WHERE fd.file_uuid = '{}'",
id_table,
fd_table,
file_uuid.replace('\'', "''")
);
if let Some(ref q) = query {
// Search name, character_name, aliases (cast to text), person_id, speaker_id
sql.push_str(&format!(
" AND (name ILIKE '%{}%' OR character_name ILIKE '%{}%' OR aliases::text ILIKE '%{}%' OR person_id ILIKE '%{}%' OR speaker_id ILIKE '%{}%')",
q, q, q, q, q
));
if let Some(q) = query {
let safe = q.replace('\'', "''");
sql.push_str(&format!(" AND i.name ILIKE '%{}%'", safe));
}
sql.push_str(" GROUP BY i.id, i.uuid, i.name");
if let Some(min) = min_appearances {
sql.push_str(&format!(" AND appearance_count >= {}", min));
}
if let Some(max_a) = max_age {
// Strictly filter for age <= max_age.
// Note: This excludes entries with NULL age.
sql.push_str(&format!(" AND age <= {}", max_a));
sql.push_str(&format!(" HAVING COUNT(fd.id) >= {}", min));
}
sql.push_str(" ORDER BY appearance_count DESC");
sql.push_str(&format!(" LIMIT {}", limit));
let rows: Vec<(
String,
Option<String>,
Option<String>,
Option<serde_json::Value>,
Option<i32>,
Option<String>,
Option<String>,
i32,
Option<f64>,
Option<f64>,
)> = sqlx::query_as(&sql).fetch_all(db.pool()).await?;
let rows: Vec<(i32, String, Option<String>, i64, Option<f64>, Option<f64>)> =
sqlx::query_as(&sql).fetch_all(db.pool()).await?;
let results: Vec<PersonResult> = rows
.into_iter()
.map(
|(
person_id,
name,
character_name,
aliases_json,
age,
gender,
speaker_id,
appearance_count,
first_time,
last_time,
)| {
let aliases = aliases_json.and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|val| val.as_str().map(String::from))
.collect()
})
});
|(identity_id, identity_uuid, name, appearance_count, first_time, last_time)| {
PersonResult {
person_id,
identity_id,
identity_uuid,
name,
character_name,
aliases,
age,
gender,
speaker_id,
appearance_count,
appearance_count: appearance_count as i32,
first_appearance_time: first_time,
last_appearance_time: last_time,
}

View File

@@ -392,8 +392,14 @@ pub async fn get_visual_chunk_statistics(
uuid.replace('\'', "''")
);
let row: (i64, Option<f64>, Option<f64>, Option<f64>, Option<i64>, Option<f64>) =
sqlx::query_as(&sql).fetch_one(db.pool()).await?;
let row: (
i64,
Option<f64>,
Option<f64>,
Option<f64>,
Option<i64>,
Option<f64>,
) = sqlx::query_as(&sql).fetch_one(db.pool()).await?;
let mut stats = HashMap::new();
stats.insert("total_chunks".to_string(), Value::from(row.0));