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