- Remove unused imports (n8n_search, universal_search, Client, Arc, etc.) - Update API endpoints for identity, face recognition, search - Fix postgres_db.rs search_videos parent_uuid column - Add snapshot API and identity agent API - Clean up backup files (.bak, .bak2)
3284 lines
102 KiB
Rust
3284 lines
102 KiB
Rust
use axum::{
|
|
body::Body,
|
|
extract::{Path, Query, State},
|
|
http::{header, StatusCode},
|
|
response::IntoResponse,
|
|
response::Json,
|
|
routing::{get, patch, post},
|
|
Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::core::db::{Database, PostgresDb};
|
|
use crate::core::person_identity::{
|
|
ChunkPersonInfo, CreatePersonIdentityRequest, PersonIdentity, PersonIdentityResponse,
|
|
PersonMatch, PersonStatistics, PersonTimelineEntry, PersonTimelineResponse,
|
|
UpdatePersonIdentityRequest,
|
|
};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct IdentifyPersonsRequest {
|
|
pub file_uuid: String,
|
|
pub auto_match: Option<bool>,
|
|
pub match_threshold: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct IdentifyPersonsResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub persons: Vec<PersonIdentityResponse>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FaceListQuery {
|
|
pub limit: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct VideoUuidQuery {
|
|
pub file_uuid: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonTimelineQuery {
|
|
pub file_uuid: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FaceThumbnailQuery {
|
|
pub file_uuid: String,
|
|
#[serde(default)]
|
|
pub index: Option<usize>,
|
|
}
|
|
|
|
// Structs for parsing face_clustered.json
|
|
#[derive(Debug, Deserialize)]
|
|
struct FaceDetection {
|
|
#[serde(default)]
|
|
person_id: Option<String>,
|
|
x: i32,
|
|
y: i32,
|
|
width: i32,
|
|
height: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FaceFrame {
|
|
timestamp: f64,
|
|
faces: Vec<FaceDetection>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FaceClusteredData {
|
|
frames: Vec<FaceFrame>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ChunkPersonsResponse {
|
|
pub success: bool,
|
|
pub chunk_id: String,
|
|
pub persons: Vec<ChunkPersonInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct MergePersonsRequest {
|
|
pub file_uuid: String,
|
|
pub target_person_id: String,
|
|
pub source_person_ids: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UndoMergeRequest {
|
|
pub merge_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MergeHistoryEntry {
|
|
pub merge_id: String,
|
|
pub target_person_id: String,
|
|
pub source_person_ids: Vec<String>,
|
|
pub original_target_stats: serde_json::Value,
|
|
pub original_source_stats: serde_json::Value,
|
|
pub merged_at: String,
|
|
pub is_undone: bool,
|
|
pub undone_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MergeHistoryResponse {
|
|
pub success: bool,
|
|
pub history: Vec<MergeHistoryEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonListQuery {
|
|
pub file_uuid: String,
|
|
pub page: Option<usize>,
|
|
pub page_size: Option<usize>,
|
|
pub min_appearances: Option<i32>,
|
|
pub has_speaker: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AutoIdentifyRequest {
|
|
pub file_uuid: String,
|
|
pub min_speaker_confidence: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SimilarPersonsQuery {
|
|
pub file_uuid: String,
|
|
pub threshold: Option<f64>,
|
|
pub limit: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct NamingSuggestion {
|
|
pub person_id: String,
|
|
pub current_name: Option<String>,
|
|
pub suggested_name: String,
|
|
pub confidence: f64,
|
|
pub sources: Vec<SuggestionSource>,
|
|
pub action: String, // "auto_apply" or "needs_review"
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SuggestionSource {
|
|
pub r#type: String, // "speaker_match", "talent_db", "ocr_context", "face_similarity"
|
|
pub detail: String,
|
|
pub weight: f64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MergeSuggestion {
|
|
pub person_id: String,
|
|
pub merge_with: Vec<String>,
|
|
pub confidence: f64,
|
|
pub reasons: Vec<String>,
|
|
pub action: String, // "auto_apply" or "needs_review"
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SuggestionsResponse {
|
|
pub success: bool,
|
|
pub naming_suggestions: Vec<NamingSuggestion>,
|
|
pub merge_suggestions: Vec<MergeSuggestion>,
|
|
pub total_naming: usize,
|
|
pub total_merge: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PersonSummary {
|
|
pub person_id: String,
|
|
pub name: Option<String>,
|
|
pub speaker_id: Option<String>,
|
|
pub appearance_count: i32,
|
|
pub total_appearance_duration: f64,
|
|
pub first_appearance_time: Option<f64>,
|
|
pub last_appearance_time: Option<f64>,
|
|
pub is_confirmed: bool,
|
|
pub speaker_confidence: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PersonListResponse {
|
|
pub success: bool,
|
|
pub persons: Vec<PersonSummary>,
|
|
pub count: i64,
|
|
pub page: usize,
|
|
pub page_size: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MergePersonsResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub target_person_id: String,
|
|
pub merge_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct AutoIdentifyResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub total_persons: i32,
|
|
pub matched_speakers: i32,
|
|
pub persons: Vec<PersonSummary>,
|
|
}
|
|
|
|
pub struct AggregateBySpeakerRequest {
|
|
pub file_uuid: String,
|
|
pub auto_merge: bool, // If true, automatically merge duplicates
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct AggregationGroup {
|
|
pub speaker_id: String,
|
|
pub primary_person_id: String,
|
|
pub merged_person_ids: Vec<String>,
|
|
pub total_appearances: i32,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct AggregateResponse {
|
|
pub success: bool,
|
|
pub groups: Vec<AggregationGroup>,
|
|
pub total_groups: usize,
|
|
pub merged_count: usize,
|
|
}
|
|
|
|
pub fn person_identity_routes() -> Router<crate::api::server::AppState> {
|
|
Router::new()
|
|
.route("/api/v1/people/identify", post(identify_persons))
|
|
.route("/api/v1/people/auto-identify", post(auto_identify_persons))
|
|
.route("/api/v1/people/suggest", post(get_person_suggestions))
|
|
.route("/api/v1/people/list", get(list_persons))
|
|
.route("/api/v1/people/merge", post(merge_persons))
|
|
.route("/api/v1/people/merge/undo", post(undo_merge))
|
|
.route("/api/v1/people/merge/history", get(get_merge_history))
|
|
.route(
|
|
"/api/v1/people/:person_id/unbind-speaker",
|
|
post(unbind_speaker),
|
|
)
|
|
.route(
|
|
"/api/v1/people/:person_id/reassign-speaker",
|
|
post(reassign_speaker),
|
|
)
|
|
.route(
|
|
"/api/v1/people/:person_id/remove-appearance",
|
|
post(remove_appearance),
|
|
)
|
|
.route(
|
|
"/api/v1/people/:person_id/reassign-appearance",
|
|
post(reassign_appearance),
|
|
)
|
|
.route("/api/v1/people/:person_id/split", post(split_person))
|
|
.route(
|
|
"/api/v1/people/:person_id/similar",
|
|
get(get_similar_persons),
|
|
)
|
|
.route(
|
|
"/api/v1/people/:person_id/confirm",
|
|
patch(confirm_person_suggestion),
|
|
)
|
|
.route("/api/v1/people/:person_id", get(get_person_details))
|
|
.route("/api/v1/people/:person_id", patch(update_person_identity))
|
|
.route(
|
|
"/api/v1/people/:person_id/timeline",
|
|
get(get_person_timeline),
|
|
)
|
|
.route(
|
|
"/api/v1/people/:person_id/appearances",
|
|
get(get_person_appearances),
|
|
)
|
|
.route(
|
|
"/api/v1/people/:person_id/thumbnail",
|
|
get(get_person_thumbnail),
|
|
)
|
|
.route("/api/v1/chunks/:chunk_id/people", get(get_chunk_persons))
|
|
.route(
|
|
"/api/v1/people/:person_id/register",
|
|
post(register_identity),
|
|
)
|
|
.route(
|
|
"/api/v1/identities/:identity_id/videos",
|
|
get(get_identity_videos),
|
|
)
|
|
.route(
|
|
"/api/v1/identities/:identity_id/faces",
|
|
get(get_identity_faces),
|
|
)
|
|
.route("/api/v1/identities/search", post(search_identities))
|
|
.route("/api/v1/videos/:uuid/faces", get(get_video_faces))
|
|
}
|
|
|
|
async fn identify_persons(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Json(request): Json<IdentifyPersonsRequest>,
|
|
) -> Result<Json<IdentifyPersonsResponse>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
tracing::info!(
|
|
"[PERSON_IDENTITY] Identifying persons for video: {}",
|
|
request.file_uuid
|
|
);
|
|
|
|
let auto_match = request.auto_match.unwrap_or(true);
|
|
let threshold = request.match_threshold.unwrap_or(0.5);
|
|
|
|
if auto_match {
|
|
let matches = match auto_match_face_speaker(&db, &request.file_uuid, threshold).await {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to auto-match: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let mut persons = Vec::new();
|
|
for match_result in matches {
|
|
let person = match create_person_identity(
|
|
&db,
|
|
CreatePersonIdentityRequest {
|
|
file_uuid: request.file_uuid.clone(),
|
|
face_identity_id: None,
|
|
speaker_id: Some(match_result.speaker_id.clone()),
|
|
name: None,
|
|
metadata: Some(serde_json::json!({
|
|
"auto_matched": true,
|
|
"confidence": match_result.confidence,
|
|
"match_count": match_result.match_count
|
|
})),
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
tracing::warn!("Failed to create person identity: {}", e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
persons.push(PersonIdentityResponse::from(person));
|
|
}
|
|
|
|
Ok(Json(IdentifyPersonsResponse {
|
|
success: true,
|
|
message: format!("Identified {} persons", persons.len()),
|
|
persons,
|
|
}))
|
|
} else {
|
|
Ok(Json(IdentifyPersonsResponse {
|
|
success: true,
|
|
message: "Auto-match disabled, no persons identified".to_string(),
|
|
persons: vec![],
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PersonDetailQuery {
|
|
pub file_uuid: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SearchIdentitiesRequest {
|
|
pub query: Option<String>, // Search name, character_name, original_name
|
|
pub speaker_id: Option<String>,
|
|
pub gender: Option<String>,
|
|
pub min_appearances: Option<i32>,
|
|
pub file_uuid: Option<String>, // Optional: search only in specific video
|
|
pub limit: Option<usize>,
|
|
}
|
|
|
|
async fn search_identities(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Json(req): Json<SearchIdentitiesRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Base query: Select all relevant columns from person_identities
|
|
let table_name = crate::core::db::schema::table_name("person_identities");
|
|
let mut sql = format!(
|
|
r#"
|
|
SELECT
|
|
id, person_id, face_identity_id, speaker_id, file_uuid,
|
|
name, original_name, character_name, gender, age,
|
|
appearance_count, total_appearance_duration,
|
|
first_appearance_time, last_appearance_time, is_confirmed
|
|
FROM {}
|
|
WHERE name IS NOT NULL AND name != ''
|
|
"#,
|
|
table_name
|
|
);
|
|
|
|
let mut conditions = Vec::new();
|
|
|
|
if let Some(q) = &req.query {
|
|
conditions.push(format!(
|
|
"(name ILIKE '%{}%' OR character_name ILIKE '%{}%' OR original_name ILIKE '%{}%' OR speaker_id ILIKE '%{}%')",
|
|
q.replace('\'', "''"), q.replace('\'', "''"), q.replace('\'', "''"), q.replace('\'', "''")
|
|
));
|
|
}
|
|
|
|
if let Some(sid) = &req.speaker_id {
|
|
conditions.push(format!("speaker_id = '{}'", sid.replace('\'', "''")));
|
|
}
|
|
|
|
if let Some(g) = &req.gender {
|
|
conditions.push(format!("gender ILIKE '{}'", g.replace('\'', "''")));
|
|
}
|
|
|
|
if let Some(min_count) = req.min_appearances {
|
|
conditions.push(format!("appearance_count >= {}", min_count));
|
|
}
|
|
|
|
if let Some(vid) = &req.file_uuid {
|
|
conditions.push(format!("file_uuid = '{}'", vid.replace('\'', "''")));
|
|
}
|
|
|
|
if !conditions.is_empty() {
|
|
sql.push_str(" AND ");
|
|
sql.push_str(&conditions.join(" AND "));
|
|
}
|
|
|
|
sql.push_str(" ORDER BY appearance_count DESC");
|
|
sql.push_str(&format!(" LIMIT {}", req.limit.unwrap_or(50)));
|
|
|
|
// Execute query
|
|
let rows: Vec<(
|
|
i32,
|
|
String,
|
|
Option<i32>,
|
|
Option<String>,
|
|
String,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<i32>,
|
|
i32,
|
|
f64,
|
|
Option<f64>,
|
|
Option<f64>,
|
|
bool,
|
|
)> = match sqlx::query_as(&sql).fetch_all(db.pool()).await {
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to search identities: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let identities: Vec<serde_json::Value> = rows
|
|
.into_iter()
|
|
.map(
|
|
|(
|
|
id,
|
|
person_id,
|
|
face_id,
|
|
speaker_id,
|
|
file_uuid,
|
|
name,
|
|
original_name,
|
|
character_name,
|
|
gender,
|
|
age,
|
|
appearance_count,
|
|
duration,
|
|
first_time,
|
|
last_time,
|
|
is_confirmed,
|
|
)| {
|
|
serde_json::json!({
|
|
"id": id,
|
|
"person_id": person_id,
|
|
"face_identity_id": face_id,
|
|
"file_uuid": file_uuid,
|
|
"profile": {
|
|
"name": name,
|
|
"original_name": original_name,
|
|
"character_name": character_name,
|
|
"gender": gender,
|
|
"age": age,
|
|
"speaker_id": speaker_id
|
|
},
|
|
"stats": {
|
|
"appearance_count": appearance_count,
|
|
"total_duration": duration,
|
|
"first_appearance": first_time,
|
|
"last_appearance": last_time
|
|
},
|
|
"is_confirmed": is_confirmed
|
|
})
|
|
},
|
|
)
|
|
.collect();
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"total": identities.len(),
|
|
"identities": identities
|
|
})))
|
|
}
|
|
|
|
async fn get_identity_videos(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(identity_id): Path<i32>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Fetch identity info (global name, original_name, aliases, etc.)
|
|
let identity_query = r#"
|
|
SELECT DISTINCT ON (face_identity_id)
|
|
name, original_name, aliases, character_name, gender, age, speaker_id
|
|
FROM '"{}'
|
|
WHERE face_identity_id = $1
|
|
ORDER BY face_identity_id, is_confirmed DESC, updated_at DESC
|
|
LIMIT 1
|
|
"#;
|
|
|
|
let identity_info: Option<(
|
|
Option<String>, // name
|
|
Option<String>, // original_name
|
|
Option<serde_json::Value>, // aliases
|
|
Option<String>, // character_name
|
|
Option<String>, // gender
|
|
Option<i32>, // age
|
|
Option<String>, // speaker_id
|
|
)> = match sqlx::query_as(identity_query)
|
|
.bind(identity_id)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(info) => info,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch identity info: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let (name, original_name, aliases, character_name, gender, age, speaker_id) =
|
|
identity_info.unwrap_or((None, None, None, None, None, None, None));
|
|
|
|
// Fetch all videos associated with this identity
|
|
// Each video has its own local person_id, speaker_id, and character_name
|
|
let videos_query = r#"
|
|
SELECT
|
|
pi.file_uuid, v.file_name, v.file_path,
|
|
pi.person_id, pi.speaker_id, pi.character_name,
|
|
pi.appearance_count,
|
|
pi.total_appearance_duration, pi.first_appearance_time, pi.last_appearance_time
|
|
FROM '"{}' pi
|
|
LEFT JOIN videos v ON pi.file_uuid = v.uuid
|
|
WHERE pi.face_identity_id = $1
|
|
ORDER BY pi.last_appearance_time DESC
|
|
"#;
|
|
|
|
let videos: Vec<serde_json::Value> = match sqlx::query(videos_query)
|
|
.bind(identity_id)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(rows) => rows
|
|
.iter()
|
|
.map(|row| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"file_uuid": row.get::<String, _>("file_uuid"),
|
|
"file_name": row.get::<Option<String>, _>("file_name"),
|
|
"file_path": row.get::<Option<String>, _>("file_path"),
|
|
"person_id": row.get::<String, _>("person_id"),
|
|
"speaker_id": row.get::<Option<String>, _>("speaker_id"),
|
|
"character_name": row.get::<Option<String>, _>("character_name"),
|
|
"appearance_count": row.get::<Option<i32>, _>("appearance_count"),
|
|
"total_duration": row.get::<Option<f64>, _>("total_appearance_duration"),
|
|
"first_appearance": row.get::<Option<f64>, _>("first_appearance_time"),
|
|
"last_appearance": row.get::<Option<f64>, _>("last_appearance_time")
|
|
})
|
|
})
|
|
.collect(),
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch videos: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"identity_id": identity_id,
|
|
"profile": {
|
|
"name": name,
|
|
"original_name": original_name,
|
|
"aliases": aliases,
|
|
"character_name": character_name,
|
|
"gender": gender,
|
|
"age": age,
|
|
"speaker_id": speaker_id
|
|
},
|
|
"total_videos": videos.len(),
|
|
"videos": videos
|
|
})))
|
|
}
|
|
|
|
/// List all face thumbnails for a global identity
|
|
async fn get_identity_faces(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(identity_id): Path<i32>,
|
|
Query(query): Query<FaceListQuery>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Limit defaults to 20, max 100
|
|
let limit = std::cmp::min(query.limit.unwrap_or(20), 100);
|
|
|
|
// Fetch distinct face detections for this identity
|
|
let sql = r#"
|
|
SELECT
|
|
fd.id as detection_id, fd.file_uuid, fd.frame_number,
|
|
fd.timestamp_secs, fd.x, fd.y, fd.width, fd.height,
|
|
fd.cluster_id, v.file_name
|
|
FROM face_detections fd
|
|
JOIN person_identities pi ON fd.file_uuid = pi.file_uuid
|
|
LEFT JOIN videos v ON fd.file_uuid = v.uuid
|
|
WHERE pi.face_identity_id = $1
|
|
ORDER BY fd.timestamp_secs DESC
|
|
LIMIT $2
|
|
"#;
|
|
|
|
let faces: Vec<serde_json::Value> = match sqlx::query(sql)
|
|
.bind(identity_id)
|
|
.bind(limit as i64)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(rows) => rows
|
|
.iter()
|
|
.map(|row| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"detection_id": row.get::<i32, _>("detection_id"),
|
|
"file_uuid": row.get::<String, _>("file_uuid"),
|
|
"file_name": row.get::<Option<String>, _>("file_name"),
|
|
"frame_number": row.get::<i64, _>("frame_number"),
|
|
"timestamp": row.get::<f64, _>("timestamp_secs"),
|
|
"cluster_id": row.get::<Option<String>, _>("cluster_id"),
|
|
"bbox": {
|
|
"x": row.get::<i32, _>("x"),
|
|
"y": row.get::<i32, _>("y"),
|
|
"width": row.get::<i32, _>("width"),
|
|
"height": row.get::<i32, _>("height")
|
|
}
|
|
})
|
|
})
|
|
.collect(),
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch faces: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"identity_id": identity_id,
|
|
"total": faces.len(),
|
|
"faces": faces
|
|
})))
|
|
}
|
|
|
|
/// List all faces in a video (both registered and unregistered)
|
|
async fn get_video_faces(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(file_uuid): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 1. Fetch all face clusters in this video
|
|
let clusters_query = r#"
|
|
SELECT
|
|
fc.cluster_id, fc.size, fc.representative_face_id,
|
|
fc.metadata,
|
|
pi.person_id, pi.face_identity_id, pi.name, pi.is_confirmed
|
|
FROM face_clusters fc
|
|
LEFT JOIN person_identities pi ON fc.cluster_id = pi.person_id
|
|
AND pi.file_uuid = fc.file_uuid
|
|
WHERE fc.file_uuid = $1
|
|
ORDER BY fc.size DESC
|
|
"#;
|
|
|
|
let clusters: Vec<serde_json::Value> = match sqlx::query(clusters_query)
|
|
.bind(&file_uuid)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(rows) => rows
|
|
.iter()
|
|
.map(|row| {
|
|
use sqlx::Row;
|
|
let is_registered: bool = row.get::<Option<String>, _>("person_id").is_some();
|
|
serde_json::json!({
|
|
"cluster_id": row.get::<String, _>("cluster_id"),
|
|
"face_count": row.get::<i32, _>("size"),
|
|
"representative_face_id": row.get::<Option<String>, _>("representative_face_id"),
|
|
"status": if is_registered { "registered" } else { "unregistered" },
|
|
"identity": if is_registered {
|
|
serde_json::json!({
|
|
"person_id": row.get::<String, _>("person_id"),
|
|
"face_identity_id": row.get::<Option<i32>, _>("face_identity_id"),
|
|
"name": row.get::<Option<String>, _>("name"),
|
|
"is_confirmed": row.get::<bool, _>("is_confirmed")
|
|
})
|
|
} else {
|
|
serde_json::json!(null)
|
|
}
|
|
})
|
|
})
|
|
.collect(),
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch clusters: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 2. Fetch global stats
|
|
let total_faces = clusters.len();
|
|
let registered_count = clusters
|
|
.iter()
|
|
.filter(|c| c["status"] == "registered")
|
|
.count();
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"file_uuid": file_uuid,
|
|
"total_faces": total_faces,
|
|
"registered_count": registered_count,
|
|
"unregistered_count": total_faces - registered_count,
|
|
"clusters": clusters
|
|
})))
|
|
}
|
|
|
|
/// Register a local person as a global identity
|
|
async fn register_identity(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Query(query): Query<VideoUuidQuery>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let file_uuid = query.file_uuid;
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 1. Fetch person info
|
|
let person_query = r#"
|
|
SELECT id, name, face_identity_id FROM '"{}'
|
|
WHERE person_id = $1 AND file_uuid = $2
|
|
"#;
|
|
let person: Option<(i32, Option<String>, Option<i32>)> = match sqlx::query_as(person_query)
|
|
.bind(&person_id)
|
|
.bind(&file_uuid)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch person: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let (person_db_id, name, current_identity_id) = match person {
|
|
Some(p) => p,
|
|
None => {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Person '{}' not found in video '{}'", person_id, file_uuid),
|
|
))
|
|
}
|
|
};
|
|
|
|
if let Some(fid) = current_identity_id {
|
|
return Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": "Already registered",
|
|
"face_identity_id": fid,
|
|
"name": name
|
|
})));
|
|
}
|
|
|
|
// 2. Get cluster centroid or detections embedding
|
|
let cluster_query = r#"
|
|
SELECT centroid FROM face_clusters WHERE cluster_id = $1 AND file_uuid = $2
|
|
"#;
|
|
let centroid: Option<Vec<f32>> = sqlx::query_scalar(cluster_query)
|
|
.bind(&person_id)
|
|
.bind(&file_uuid)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
let embedding_val: Option<&Vec<f32>> = centroid.as_ref();
|
|
|
|
// 3. Insert into global face_identities
|
|
// Generate a unique face_id
|
|
let global_face_id = format!("identity_{}", uuid::Uuid::new_v4().simple());
|
|
let final_name = name.unwrap_or_else(|| person_id.clone());
|
|
|
|
let insert_query = r#"
|
|
INSERT INTO face_identities (face_id, name, embedding)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id
|
|
"#;
|
|
|
|
let new_identity_id: i32 = match sqlx::query_scalar(insert_query)
|
|
.bind(&global_face_id)
|
|
.bind(&final_name)
|
|
.bind(¢roid)
|
|
.fetch_one(db.pool())
|
|
.await
|
|
{
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to create global identity: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 4. Update person_identities with the new identity ID
|
|
let update_query = r#"
|
|
UPDATE person_identities SET face_identity_id = $1
|
|
WHERE id = $2
|
|
"#;
|
|
|
|
if let Err(e) = sqlx::query(update_query)
|
|
.bind(new_identity_id)
|
|
.bind(person_db_id)
|
|
.execute(db.pool())
|
|
.await
|
|
{
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to link person to identity: {}", e),
|
|
));
|
|
}
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": "Successfully registered as global identity",
|
|
"person_id": person_id,
|
|
"name": final_name,
|
|
"face_identity_id": new_identity_id,
|
|
"global_face_id": global_face_id
|
|
})))
|
|
}
|
|
|
|
async fn get_person_details(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Query(query): Query<PersonDetailQuery>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let file_uuid = query.file_uuid;
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let query = r#"
|
|
SELECT
|
|
person_id, name, face_identity_id, speaker_id,
|
|
confidence, appearance_count, total_appearance_duration,
|
|
first_appearance_time, last_appearance_time,
|
|
is_confirmed, created_at, updated_at
|
|
FROM '"{}'
|
|
WHERE person_id = $1 AND file_uuid = $2
|
|
"#;
|
|
|
|
let person: Option<(
|
|
String,
|
|
Option<String>,
|
|
Option<i32>,
|
|
Option<String>,
|
|
f64,
|
|
i32,
|
|
f64,
|
|
Option<f64>,
|
|
Option<f64>,
|
|
bool,
|
|
chrono::DateTime<chrono::Utc>,
|
|
chrono::DateTime<chrono::Utc>,
|
|
)> = match sqlx::query_as(query)
|
|
.bind(&person_id)
|
|
.bind(&file_uuid)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(person) => person,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch person: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
match person {
|
|
Some(person_data) => {
|
|
let response = serde_json::json!({
|
|
"success": true,
|
|
"person_id": person_data.0,
|
|
"name": person_data.1,
|
|
"face_identity_id": person_data.2,
|
|
"speaker_id": person_data.3,
|
|
"confidence": person_data.4,
|
|
"appearance_count": person_data.5,
|
|
"total_appearance_duration": person_data.6,
|
|
"first_appearance_time": person_data.7,
|
|
"last_appearance_time": person_data.8,
|
|
"is_confirmed": person_data.9,
|
|
"created_at": person_data.10.to_rfc3339(),
|
|
"updated_at": person_data.11.to_rfc3339()
|
|
});
|
|
|
|
Ok(Json(response))
|
|
}
|
|
None => Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Person not found: {}", person_id),
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdatePersonQuery {
|
|
pub file_uuid: String,
|
|
}
|
|
|
|
async fn update_person_identity(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Query(query): Query<UpdatePersonQuery>,
|
|
Json(request): Json<UpdatePersonIdentityRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let file_uuid = query.file_uuid;
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
tracing::info!("[PERSON_IDENTITY] Updating person: {}", person_id);
|
|
|
|
let query = r#"
|
|
UPDATE person_identities
|
|
SET
|
|
name = COALESCE($2, name),
|
|
metadata = COALESCE($3, metadata),
|
|
is_confirmed = COALESCE($4, is_confirmed),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE person_id = $1
|
|
RETURNING person_id, name
|
|
"#;
|
|
|
|
let updated: Option<(String, Option<String>)> = match sqlx::query_as(query)
|
|
.bind(&person_id)
|
|
.bind(&request.name)
|
|
.bind(&request.metadata)
|
|
.bind(&request.is_confirmed)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to update person: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
match updated {
|
|
Some((id, name)) => {
|
|
let response = serde_json::json!({
|
|
"success": true,
|
|
"message": format!("Person '{}' updated successfully", name.unwrap_or_else(|| id.clone())),
|
|
"person_id": id
|
|
});
|
|
|
|
Ok(Json(response))
|
|
}
|
|
None => Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Person not found: {}", person_id),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn get_person_timeline(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Query(query): Query<PersonTimelineQuery>,
|
|
) -> Result<Json<PersonTimelineResponse>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let name_query = "SELECT name FROM person_identities WHERE person_id = $1 AND file_uuid = $2";
|
|
let name: Option<String> = match sqlx::query_scalar(name_query)
|
|
.bind(&person_id)
|
|
.bind(&query.file_uuid)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(name) => name,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch person name: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let timeline_query = r#"
|
|
SELECT start_time, end_time, duration, confidence
|
|
FROM person_appearances
|
|
WHERE person_id = $1 AND file_uuid = $2
|
|
ORDER BY start_time ASC
|
|
"#;
|
|
|
|
let timeline: Vec<(f64, f64, f64, f64)> = match sqlx::query_as(timeline_query)
|
|
.bind(&person_id)
|
|
.bind(&query.file_uuid)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(timeline) => timeline,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch timeline: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let timeline: Vec<PersonTimelineEntry> = timeline
|
|
.into_iter()
|
|
.map(|(start, end, duration, confidence)| PersonTimelineEntry {
|
|
start_time: start,
|
|
end_time: end,
|
|
duration,
|
|
confidence,
|
|
})
|
|
.collect();
|
|
|
|
let stats_query = r#"
|
|
SELECT
|
|
COUNT(*) as total_appearances,
|
|
SUM(duration) as total_duration,
|
|
MIN(start_time) as first_appearance,
|
|
MAX(end_time) as last_appearance,
|
|
AVG(confidence) as average_confidence
|
|
FROM person_appearances
|
|
WHERE person_id = $1 AND file_uuid = $2
|
|
"#;
|
|
|
|
let stats: (i64, Option<f64>, Option<f64>, Option<f64>, Option<f64>) =
|
|
match sqlx::query_as(stats_query)
|
|
.bind(&person_id)
|
|
.bind(&query.file_uuid)
|
|
.fetch_one(db.pool())
|
|
.await
|
|
{
|
|
Ok(stats) => stats,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch statistics: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let statistics = PersonStatistics {
|
|
total_appearances: stats.0 as i32,
|
|
total_duration: stats.1.unwrap_or(0.0),
|
|
first_appearance: stats.2,
|
|
last_appearance: stats.3,
|
|
average_confidence: stats.4.unwrap_or(0.0),
|
|
};
|
|
|
|
Ok(Json(PersonTimelineResponse {
|
|
person_id,
|
|
name,
|
|
timeline,
|
|
statistics,
|
|
}))
|
|
}
|
|
|
|
async fn get_person_appearances(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Query(query): Query<PersonDetailQuery>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let file_uuid = query.file_uuid;
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let query = r#"
|
|
SELECT
|
|
person_id, file_uuid, start_time, end_time, duration,
|
|
face_detection_id, asrx_segment_start, asrx_segment_end,
|
|
confidence, created_at
|
|
FROM person_appearances
|
|
WHERE person_id = $1 AND file_uuid = $2
|
|
ORDER BY start_time DESC
|
|
LIMIT 100
|
|
"#;
|
|
|
|
let appearances: Vec<serde_json::Value> = match sqlx::query(query)
|
|
.bind(&person_id)
|
|
.bind(&file_uuid)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(rows) => {
|
|
rows.iter()
|
|
.map(|row| {
|
|
use sqlx::Row;
|
|
serde_json::json!({
|
|
"person_id": row.get::<String, _>("person_id"),
|
|
"file_uuid": row.get::<String, _>("file_uuid"),
|
|
"start_time": row.get::<f64, _>("start_time"),
|
|
"end_time": row.get::<f64, _>("end_time"),
|
|
"duration": row.get::<f64, _>("duration"),
|
|
"confidence": row.get::<f64, _>("confidence"),
|
|
"created_at": row.get::<chrono::DateTime<chrono::Utc>, _>("created_at").to_rfc3339()
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch appearances: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"person_id": person_id,
|
|
"appearances": appearances
|
|
})))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ChunkPersonsQuery {
|
|
pub file_uuid: String,
|
|
}
|
|
|
|
async fn get_chunk_persons(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(chunk_id): Path<String>,
|
|
Query(query): Query<ChunkPersonsQuery>,
|
|
) -> Result<Json<ChunkPersonsResponse>, (StatusCode, String)> {
|
|
let file_uuid = &query.file_uuid;
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to connect to database: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let chunk_query = r#"
|
|
SELECT uuid, start_time, end_time, metadata
|
|
FROM chunks
|
|
WHERE chunk_id = $1
|
|
"#;
|
|
|
|
let chunk: Option<(String, f64, f64, Option<serde_json::Value>)> =
|
|
match sqlx::query_as(chunk_query)
|
|
.bind(&chunk_id)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(chunk) => chunk,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch chunk: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let chunk = match chunk {
|
|
Some(c) => c,
|
|
None => {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Chunk not found: {}", chunk_id),
|
|
))
|
|
}
|
|
};
|
|
|
|
let (file_uuid, start_time, end_time, _metadata) = chunk;
|
|
|
|
let persons_query = r#"
|
|
SELECT
|
|
pi.person_id,
|
|
pi.name,
|
|
pa.confidence,
|
|
LEAST(pa.end_time, $3) - GREATEST(pa.start_time, $2) as overlap_duration
|
|
FROM person_appearances pa
|
|
JOIN person_identities pi ON pa.person_id = pi.person_id
|
|
WHERE pa.file_uuid = $1
|
|
AND pa.start_time < $3
|
|
AND pa.end_time > $2
|
|
ORDER BY overlap_duration DESC
|
|
"#;
|
|
|
|
let persons: Vec<ChunkPersonInfo> =
|
|
match sqlx::query_as::<_, (String, Option<String>, f64, f64)>(persons_query)
|
|
.bind(&file_uuid)
|
|
.bind(start_time)
|
|
.bind(end_time)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(rows) => rows
|
|
.into_iter()
|
|
.map(
|
|
|(person_id, name, confidence, overlap_duration)| ChunkPersonInfo {
|
|
person_id,
|
|
name,
|
|
confidence,
|
|
overlap_duration,
|
|
},
|
|
)
|
|
.collect(),
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Failed to fetch persons: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(ChunkPersonsResponse {
|
|
success: true,
|
|
chunk_id,
|
|
persons,
|
|
}))
|
|
}
|
|
|
|
/// Extracts a face thumbnail for a given person from the video
|
|
async fn get_person_thumbnail(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Query(query): Query<FaceThumbnailQuery>,
|
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
|
// 1. Locate the face_clustered.json file
|
|
let json_path = format!(
|
|
"output/{}/{}_face_clustered.json",
|
|
query.file_uuid, query.file_uuid
|
|
);
|
|
let json_path2 = format!(
|
|
"output/{}/{}.face_clustered.json",
|
|
query.file_uuid, query.file_uuid
|
|
);
|
|
|
|
// Fallback path if the naming convention is slightly different
|
|
let fallback_path = format!("output/{}/face_clustered.json", query.file_uuid);
|
|
|
|
let path = if std::path::Path::new(&json_path).exists() {
|
|
json_path
|
|
} else if std::path::Path::new(&json_path2).exists() {
|
|
json_path2
|
|
} else if std::path::Path::new(&fallback_path).exists() {
|
|
fallback_path
|
|
} else {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!(
|
|
"Face data not found for video: {}. Tried: {}, {}, {}",
|
|
query.file_uuid, json_path, json_path2, fallback_path
|
|
),
|
|
));
|
|
};
|
|
|
|
// 2. Parse the JSON to find the person's face
|
|
let content = match tokio::fs::read_to_string(&path).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Read error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let data: FaceClusteredData = match serde_json::from_str(&content) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Parse error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let mut detections = Vec::new();
|
|
for frame in &data.frames {
|
|
for face in &frame.faces {
|
|
if let Some(pid) = &face.person_id {
|
|
if pid == &person_id {
|
|
detections.push((frame.timestamp, face));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if detections.is_empty() {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("No detections found for person: {}", person_id),
|
|
));
|
|
}
|
|
|
|
let index = query.index.unwrap_or(0).min(detections.len() - 1);
|
|
let (timestamp, face) = detections[index];
|
|
|
|
// 3. Locate the video file
|
|
let video_path = format!("output/{}/{}.mp4", query.file_uuid, query.file_uuid);
|
|
if !std::path::Path::new(&video_path).exists() {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Video file not found: {}", video_path),
|
|
));
|
|
}
|
|
|
|
// 4. Use ffmpeg to extract and crop the face
|
|
// ffmpeg -ss {timestamp} -i {video} -vf "crop=w:h:x:y" -frames:v 1 -f image2pipe -vcodec mjpeg -
|
|
let crop_filter = format!("crop={}:{}:{}:{}", face.width, face.height, face.x, face.y);
|
|
|
|
let output = match tokio::process::Command::new("ffmpeg")
|
|
.args(&[
|
|
"-ss",
|
|
×tamp.to_string(),
|
|
"-i",
|
|
&video_path,
|
|
"-vf",
|
|
&crop_filter,
|
|
"-frames:v",
|
|
"1",
|
|
"-f",
|
|
"image2pipe",
|
|
"-vcodec",
|
|
"mjpeg",
|
|
"-",
|
|
])
|
|
.output()
|
|
.await
|
|
{
|
|
Ok(o) => o,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("ffmpeg error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("ffmpeg failed: {}", stderr),
|
|
));
|
|
}
|
|
|
|
// 5. Return the image
|
|
let response = axum::response::Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "image/jpeg")
|
|
.body(Body::from(output.stdout))
|
|
.map_err(|e| {
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Response error: {}", e),
|
|
)
|
|
})?;
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
async fn create_person_identity(
|
|
db: &PostgresDb,
|
|
request: CreatePersonIdentityRequest,
|
|
) -> Result<PersonIdentity, anyhow::Error> {
|
|
let person_id = format!("person_{}", Uuid::new_v4());
|
|
|
|
let query = r#"
|
|
INSERT INTO person_identities (
|
|
person_id, file_uuid, face_identity_id, speaker_id,
|
|
name, metadata, confidence
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING
|
|
id, person_id, face_identity_id, speaker_id,
|
|
file_uuid, confidence, name, metadata,
|
|
first_appearance_time, last_appearance_time,
|
|
total_appearance_duration, appearance_count,
|
|
created_at, updated_at, is_confirmed
|
|
"#;
|
|
|
|
let person: PersonIdentity = sqlx::query_as(query)
|
|
.bind(&person_id)
|
|
.bind(&request.file_uuid)
|
|
.bind(&request.face_identity_id)
|
|
.bind(&request.speaker_id)
|
|
.bind(&request.name)
|
|
.bind(serde_json::to_string(&request.metadata.unwrap_or(serde_json::json!({}))).unwrap())
|
|
.bind(0.0)
|
|
.fetch_one(db.pool())
|
|
.await?;
|
|
|
|
Ok(person)
|
|
}
|
|
|
|
async fn auto_match_face_speaker(
|
|
db: &PostgresDb,
|
|
file_uuid: &str,
|
|
threshold: f64,
|
|
) -> Result<Vec<PersonMatch>, anyhow::Error> {
|
|
let query = "SELECT * FROM auto_match_face_speaker($1, $2)";
|
|
|
|
let matches: Vec<PersonMatch> = sqlx::query_as(query)
|
|
.bind(file_uuid)
|
|
.bind(threshold)
|
|
.fetch_all(db.pool())
|
|
.await?;
|
|
|
|
Ok(matches)
|
|
}
|
|
|
|
/// Auto-identify persons from face_clustered.json + ASRX speaker data
|
|
async fn auto_identify_persons(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Json(request): Json<AutoIdentifyRequest>,
|
|
) -> Result<Json<AutoIdentifyResponse>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 1. Load face_clustered.json
|
|
let clustered_path = format!(
|
|
"output/{}/{}.face_clustered.json",
|
|
request.file_uuid, request.file_uuid
|
|
);
|
|
let fallback_path = format!("output/{}/face_clustered.json", request.file_uuid);
|
|
let path = if std::path::Path::new(&clustered_path).exists() {
|
|
clustered_path
|
|
} else if std::path::Path::new(&fallback_path).exists() {
|
|
fallback_path
|
|
} else {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!(
|
|
"face_clustered.json not found for video: {}",
|
|
request.file_uuid
|
|
),
|
|
));
|
|
};
|
|
|
|
let content = match tokio::fs::read_to_string(&path).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Read error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let clustered: FaceClusteredData = match serde_json::from_str(&content) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Parse error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 2. Build person stats from face_clustered.json
|
|
use std::collections::HashMap;
|
|
#[derive(Default)]
|
|
struct PersonStat {
|
|
frame_count: i32,
|
|
first_time: Option<f64>,
|
|
last_time: Option<f64>,
|
|
timestamps: Vec<f64>,
|
|
}
|
|
|
|
let mut person_stats: HashMap<String, PersonStat> = HashMap::new();
|
|
for frame in &clustered.frames {
|
|
for face in &frame.faces {
|
|
if let Some(ref pid) = face.person_id {
|
|
let stat = person_stats.entry(pid.clone()).or_default();
|
|
stat.frame_count += 1;
|
|
stat.timestamps.push(frame.timestamp);
|
|
if stat.first_time.is_none() || Some(frame.timestamp) < stat.first_time {
|
|
stat.first_time = Some(frame.timestamp);
|
|
}
|
|
if stat.last_time.is_none() || Some(frame.timestamp) > stat.last_time {
|
|
stat.last_time = Some(frame.timestamp);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Load ASRX from chunks
|
|
let asrx_query = "SELECT chunk_id, content::text FROM chunks WHERE uuid = $1 AND chunk_type = 'trace' AND chunk_id LIKE 'trace_asrx_%'";
|
|
let asrx_chunks: Vec<(String, String)> = match sqlx::query_as(asrx_query)
|
|
.bind(&request.file_uuid)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("ASRX query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Also check sentence chunks for speaker_id
|
|
let sentence_query = "SELECT content::text FROM chunks WHERE uuid = $1 AND chunk_type = 'sentence' AND content ? 'speaker_id'";
|
|
let sentence_chunks: Vec<String> = match sqlx::query_scalar(sentence_query)
|
|
.bind(&request.file_uuid)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(rows) => rows,
|
|
Err(_) => vec![],
|
|
};
|
|
|
|
// 4. Match speakers to persons by time overlap
|
|
let mut person_speaker_votes: HashMap<String, HashMap<String, f64>> = HashMap::new();
|
|
|
|
// Check ASRX trace chunks
|
|
for (_, content_text) in &asrx_chunks {
|
|
if let Ok(content) = serde_json::from_str::<serde_json::Value>(content_text) {
|
|
if let (Some(speaker_id), Some(start)) = (
|
|
content.get("speaker_id").and_then(|v| v.as_str()),
|
|
content.get("timestamp").and_then(|v| v.as_f64()),
|
|
) {
|
|
let end = start + 5.0; // Approximate 5s segments
|
|
for (pid, stat) in &person_stats {
|
|
for ts in &stat.timestamps {
|
|
if *ts >= start && *ts <= end {
|
|
person_speaker_votes
|
|
.entry(pid.clone())
|
|
.or_default()
|
|
.entry(speaker_id.to_string())
|
|
.and_modify(|v| *v += 1.0)
|
|
.or_insert(1.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check sentence chunks for speaker_id
|
|
for content_text in &sentence_chunks {
|
|
if let Ok(content) = serde_json::from_str::<serde_json::Value>(content_text) {
|
|
if let (Some(_speaker_id), Some(_text)) = (
|
|
content.get("speaker_id").and_then(|v| v.as_str()),
|
|
content.get("text").and_then(|v| v.as_str()),
|
|
) {
|
|
// Timestamps not directly available in sentence chunks for ASRX matching
|
|
// Rely on ASRX trace chunks for precise matching
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Insert/update person_identities
|
|
let min_conf = request.min_speaker_confidence.unwrap_or(0.0);
|
|
let mut matched_count = 0;
|
|
let mut persons_result = Vec::new();
|
|
|
|
// Sort by frame count descending
|
|
let mut sorted_persons: Vec<_> = person_stats.into_iter().collect();
|
|
sorted_persons.sort_by(|a, b| b.1.frame_count.cmp(&a.1.frame_count));
|
|
|
|
for (pid, stat) in sorted_persons {
|
|
let speaker_info = person_speaker_votes.get(&pid);
|
|
let (speaker_id, confidence) = if let Some(votes) = speaker_info {
|
|
let total: f64 = votes.values().sum();
|
|
if total > 0.0 {
|
|
let (best_speaker, best_votes) = votes
|
|
.iter()
|
|
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
|
.unwrap();
|
|
let conf = best_votes / total;
|
|
if conf >= min_conf {
|
|
(Some(best_speaker.clone()), Some(conf))
|
|
} else {
|
|
(None, None)
|
|
}
|
|
} else {
|
|
(None, None)
|
|
}
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
if speaker_id.is_some() {
|
|
matched_count += 1;
|
|
}
|
|
|
|
// Upsert into person_identities
|
|
let upsert_query = r#"
|
|
INSERT INTO person_identities (person_id, name, speaker_id, first_appearance_time, last_appearance_time, appearance_count, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (person_id) DO UPDATE SET
|
|
speaker_id = COALESCE(EXCLUDED.speaker_id, person_identities.speaker_id),
|
|
first_appearance_time = EXCLUDED.first_appearance_time,
|
|
last_appearance_time = EXCLUDED.last_appearance_time,
|
|
appearance_count = EXCLUDED.appearance_count,
|
|
metadata = COALESCE(EXCLUDED.metadata, person_identities.metadata),
|
|
updated_at = NOW()
|
|
RETURNING person_id, name, speaker_id, appearance_count, total_appearance_duration,
|
|
first_appearance_time, last_appearance_time, is_confirmed
|
|
"#;
|
|
|
|
let metadata = if let Some(conf) = confidence {
|
|
serde_json::json!({"auto_identified": true, "speaker_confidence": conf})
|
|
} else {
|
|
serde_json::json!({"auto_identified": true})
|
|
};
|
|
|
|
let result: Result<
|
|
Option<(
|
|
String,
|
|
Option<String>,
|
|
Option<String>,
|
|
i32,
|
|
f64,
|
|
Option<f64>,
|
|
Option<f64>,
|
|
bool,
|
|
)>,
|
|
_,
|
|
> = sqlx::query_as(upsert_query)
|
|
.bind(&pid)
|
|
.bind(&pid) // Use cluster label as initial name
|
|
.bind(&speaker_id)
|
|
.bind(stat.first_time)
|
|
.bind(stat.last_time)
|
|
.bind(stat.frame_count)
|
|
.bind(&metadata)
|
|
.fetch_optional(db.pool())
|
|
.await;
|
|
|
|
if let Ok(Some(row)) = result {
|
|
persons_result.push(PersonSummary {
|
|
person_id: row.0,
|
|
name: row.1,
|
|
speaker_id: row.2,
|
|
appearance_count: row.3,
|
|
total_appearance_duration: row.4,
|
|
first_appearance_time: row.5,
|
|
last_appearance_time: row.6,
|
|
is_confirmed: row.7,
|
|
speaker_confidence: confidence,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(Json(AutoIdentifyResponse {
|
|
success: true,
|
|
message: format!(
|
|
"Identified {} persons, {} matched to speakers",
|
|
persons_result.len(),
|
|
matched_count
|
|
),
|
|
total_persons: persons_result.len() as i32,
|
|
matched_speakers: matched_count,
|
|
persons: persons_result,
|
|
}))
|
|
}
|
|
|
|
/// List all persons with optional filters
|
|
async fn list_persons(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Query(query): Query<PersonListQuery>,
|
|
) -> Result<Json<PersonListResponse>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let file_uuid = query.file_uuid.replace("'", "''");
|
|
let page = query.page.unwrap_or(1);
|
|
let page_size = query.page_size.unwrap_or(20);
|
|
let offset = ((page - 1) as i64) * (page_size as i64);
|
|
let min_appearances = query.min_appearances.unwrap_or(0);
|
|
let has_speaker = query.has_speaker.unwrap_or(false);
|
|
|
|
let (sql, count_sql) = if has_speaker {
|
|
if min_appearances > 0 {
|
|
(
|
|
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE file_uuid = '{}' AND speaker_id IS NOT NULL AND appearance_count >= $1 ORDER BY appearance_count DESC LIMIT $2 OFFSET $3", file_uuid),
|
|
format!("SELECT COUNT(*) FROM person_identities WHERE file_uuid = '{}' AND speaker_id IS NOT NULL AND appearance_count >= $1", file_uuid),
|
|
)
|
|
} else {
|
|
(
|
|
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE file_uuid = '{}' AND speaker_id IS NOT NULL ORDER BY appearance_count DESC LIMIT $1 OFFSET $2", file_uuid),
|
|
format!("SELECT COUNT(*) FROM person_identities WHERE file_uuid = '{}' AND speaker_id IS NOT NULL", file_uuid),
|
|
)
|
|
}
|
|
} else {
|
|
if min_appearances > 0 {
|
|
(
|
|
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE file_uuid = '{}' AND appearance_count >= $1 ORDER BY appearance_count DESC LIMIT $2 OFFSET $3", file_uuid),
|
|
format!("SELECT COUNT(*) FROM person_identities WHERE file_uuid = '{}' AND appearance_count >= $1", file_uuid),
|
|
)
|
|
} else {
|
|
(
|
|
format!("SELECT person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, is_confirmed, metadata::text FROM person_identities WHERE file_uuid = '{}' ORDER BY appearance_count DESC LIMIT $1 OFFSET $2", file_uuid),
|
|
format!("SELECT COUNT(*) FROM person_identities WHERE file_uuid = '{}'", file_uuid),
|
|
)
|
|
}
|
|
};
|
|
|
|
let total: i64 = if min_appearances > 0 {
|
|
sqlx::query_scalar(&count_sql)
|
|
.bind(min_appearances)
|
|
.fetch_one(db.pool())
|
|
.await
|
|
.unwrap_or(0)
|
|
} else {
|
|
sqlx::query_scalar(&count_sql)
|
|
.fetch_one(db.pool())
|
|
.await
|
|
.unwrap_or(0)
|
|
};
|
|
|
|
let rows: Vec<(
|
|
String,
|
|
Option<String>,
|
|
Option<String>,
|
|
i32,
|
|
f64,
|
|
Option<f64>,
|
|
Option<f64>,
|
|
bool,
|
|
Option<String>,
|
|
)> = if has_speaker && min_appearances > 0 {
|
|
sqlx::query_as(&sql)
|
|
.bind(min_appearances)
|
|
.bind(page_size as i64)
|
|
.bind(offset)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
.unwrap_or_default()
|
|
} else if has_speaker {
|
|
sqlx::query_as(&sql)
|
|
.bind(page_size as i64)
|
|
.bind(offset)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
.unwrap_or_default()
|
|
} else if min_appearances > 0 {
|
|
sqlx::query_as(&sql)
|
|
.bind(min_appearances)
|
|
.bind(page_size as i64)
|
|
.bind(offset)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
.unwrap_or_default()
|
|
} else {
|
|
sqlx::query_as(&sql)
|
|
.bind(page_size as i64)
|
|
.bind(offset)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
.unwrap_or_default()
|
|
};
|
|
|
|
let persons: Vec<PersonSummary> = rows
|
|
.into_iter()
|
|
.map(|r| {
|
|
let speaker_confidence = r.8.as_ref().and_then(|m| {
|
|
serde_json::from_str::<serde_json::Value>(m)
|
|
.ok()
|
|
.and_then(|v| v.get("speaker_confidence").and_then(|v| v.as_f64()))
|
|
});
|
|
PersonSummary {
|
|
person_id: r.0,
|
|
name: r.1,
|
|
speaker_id: r.2,
|
|
appearance_count: r.3,
|
|
total_appearance_duration: r.4,
|
|
first_appearance_time: r.5,
|
|
last_appearance_time: r.6,
|
|
is_confirmed: r.7,
|
|
speaker_confidence,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(PersonListResponse {
|
|
success: true,
|
|
persons,
|
|
count: total,
|
|
page,
|
|
page_size,
|
|
}))
|
|
}
|
|
|
|
/// Merge duplicate persons into one
|
|
async fn merge_persons(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Json(request): Json<MergePersonsRequest>,
|
|
) -> Result<Json<MergePersonsResponse>, (StatusCode, String)> {
|
|
let file_uuid = &request.file_uuid;
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
if request.source_person_ids.is_empty() {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
"source_person_ids cannot be empty".into(),
|
|
));
|
|
}
|
|
|
|
let mut tx = match db.pool().begin().await {
|
|
Ok(tx) => tx,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Tx error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 0. Save original stats for undo capability
|
|
let orig_target_query = "SELECT appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time FROM person_identities WHERE person_id = $1";
|
|
let orig_target: Option<(i32, f64, Option<f64>, Option<f64>)> =
|
|
match sqlx::query_as(orig_target_query)
|
|
.bind(&request.target_person_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Target query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
let orig_target = match orig_target {
|
|
Some(t) => t,
|
|
None => {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Target person not found: {}", request.target_person_id),
|
|
))
|
|
}
|
|
};
|
|
let orig_target_stats = serde_json::json!({
|
|
"appearance_count": orig_target.0,
|
|
"total_appearance_duration": orig_target.1,
|
|
"first_appearance_time": orig_target.2,
|
|
"last_appearance_time": orig_target.3,
|
|
});
|
|
|
|
let orig_sources_query = "SELECT person_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time FROM person_identities WHERE person_id = ANY($1)";
|
|
let orig_sources: Vec<(String, i32, f64, Option<f64>, Option<f64>)> =
|
|
match sqlx::query_as(orig_sources_query)
|
|
.bind(&request.source_person_ids)
|
|
.fetch_all(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Source query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
let orig_source_stats: Vec<serde_json::Value> = orig_sources
|
|
.into_iter()
|
|
.map(|(pid, count, dur, first, last)| {
|
|
serde_json::json!({
|
|
"person_id": pid,
|
|
"appearance_count": count,
|
|
"total_appearance_duration": dur,
|
|
"first_appearance_time": first,
|
|
"last_appearance_time": last,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Generate merge_id
|
|
let merge_id = Uuid::new_v4().to_string();
|
|
|
|
// A. Calculate sum of stats from sources
|
|
let stats_query = r#"
|
|
SELECT
|
|
COALESCE(SUM(appearance_count), 0)::integer as count,
|
|
COALESCE(SUM(total_appearance_duration), 0.0)::double precision as duration
|
|
FROM '"{}'
|
|
WHERE person_id = ANY($1)
|
|
"#;
|
|
let (add_count, add_duration): (i32, f64) = match sqlx::query_as(stats_query)
|
|
.bind(&request.source_person_ids)
|
|
.fetch_one(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Stats error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// B. Transfer person_appearances
|
|
let move_query = "UPDATE person_appearances SET person_id = $1 WHERE person_id = ANY($2)";
|
|
match sqlx::query(move_query)
|
|
.bind(&request.target_person_id)
|
|
.bind(&request.source_person_ids)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Move error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// C. Delete source person_identities
|
|
let delete_query = "DELETE FROM person_identities WHERE person_id = ANY($1)";
|
|
match sqlx::query(delete_query)
|
|
.bind(&request.source_person_ids)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Delete error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// D. Update target stats
|
|
let update_query = r#"
|
|
UPDATE person_identities
|
|
SET
|
|
appearance_count = appearance_count + $1,
|
|
total_appearance_duration = total_appearance_duration + $2,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE person_id = $3
|
|
"#;
|
|
match sqlx::query(update_query)
|
|
.bind(add_count)
|
|
.bind(add_duration)
|
|
.bind(&request.target_person_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Update error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// E. Record merge history for undo capability
|
|
let history_query = r#"
|
|
INSERT INTO merge_history (merge_id, target_person_id, source_person_ids, original_target_stats, original_source_stats)
|
|
VALUES ($1::uuid, $2, $3, $4::jsonb, $5::jsonb)
|
|
"#;
|
|
let source_ids_json: Vec<String> = request.source_person_ids.clone();
|
|
let target_stats_json =
|
|
serde_json::to_string(&orig_target_stats).unwrap_or_else(|_| "{}".to_string());
|
|
let source_stats_json =
|
|
serde_json::to_string(&orig_source_stats).unwrap_or_else(|_| "[]".to_string());
|
|
match sqlx::query(history_query)
|
|
.bind(&merge_id)
|
|
.bind(&request.target_person_id)
|
|
.bind(&source_ids_json)
|
|
.bind(&target_stats_json)
|
|
.bind(&source_stats_json)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
tracing::warn!("[MERGE] Failed to record merge history: {}", e);
|
|
// Don't fail the merge if history recording fails
|
|
}
|
|
};
|
|
|
|
// F. Commit
|
|
match tx.commit().await {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Commit error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(MergePersonsResponse {
|
|
success: true,
|
|
message: format!(
|
|
"Merged {} persons into {}",
|
|
request.source_person_ids.len(),
|
|
request.target_person_id
|
|
),
|
|
target_person_id: request.target_person_id,
|
|
merge_id,
|
|
}))
|
|
}
|
|
|
|
/// Undo a previous merge
|
|
async fn undo_merge(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Json(request): Json<UndoMergeRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
// file_uuid is validated through merge_history lookup
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 1. Get merge history
|
|
let history_query = "SELECT id, merge_id::text, target_person_id, source_person_ids, original_target_stats::text, original_source_stats::text, is_undone FROM merge_history WHERE merge_id = $1::uuid";
|
|
let history: Option<(i32, String, String, Vec<String>, String, String, bool)> =
|
|
match sqlx::query_as(history_query)
|
|
.bind(&request.merge_id)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("History query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let (history_id, _merge_id, target_id, source_ids, tgt_stats_str, src_stats_str, is_undone) =
|
|
match history {
|
|
Some(h) => h,
|
|
None => {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Merge history not found: {}", request.merge_id),
|
|
))
|
|
}
|
|
};
|
|
|
|
let orig_target_stats: serde_json::Value =
|
|
serde_json::from_str(&tgt_stats_str).unwrap_or(serde_json::json!({}));
|
|
let orig_source_stats: serde_json::Value =
|
|
serde_json::from_str(&src_stats_str).unwrap_or(serde_json::json!({}));
|
|
|
|
if is_undone {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
"This merge has already been undone".into(),
|
|
));
|
|
}
|
|
|
|
let mut tx = match db.pool().begin().await {
|
|
Ok(tx) => tx,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Tx error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 2. Restore target stats
|
|
let target_count = orig_target_stats
|
|
.get("appearance_count")
|
|
.and_then(|v| v.as_i64())
|
|
.unwrap_or(0) as i32;
|
|
let target_duration = orig_target_stats
|
|
.get("total_appearance_duration")
|
|
.and_then(|v| v.as_f64())
|
|
.unwrap_or(0.0);
|
|
let target_first = orig_target_stats
|
|
.get("first_appearance_time")
|
|
.and_then(|v| v.as_f64());
|
|
let target_last = orig_target_stats
|
|
.get("last_appearance_time")
|
|
.and_then(|v| v.as_f64());
|
|
|
|
let restore_target_query = "UPDATE person_identities SET appearance_count = $1, total_appearance_duration = $2, first_appearance_time = $3, last_appearance_time = $4, updated_at = CURRENT_TIMESTAMP WHERE person_id = $5";
|
|
match sqlx::query(restore_target_query)
|
|
.bind(target_count)
|
|
.bind(target_duration)
|
|
.bind(target_first)
|
|
.bind(target_last)
|
|
.bind(&target_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Restore target error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// 3. Recreate source person_identities
|
|
let empty_arr = vec![];
|
|
let source_stats_arr = orig_source_stats.as_array().unwrap_or(&empty_arr);
|
|
for source_stat in source_stats_arr {
|
|
let source_id = source_stat
|
|
.get("person_id")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
let source_count = source_stat
|
|
.get("appearance_count")
|
|
.and_then(|v| v.as_i64())
|
|
.unwrap_or(0) as i32;
|
|
let source_duration = source_stat
|
|
.get("total_appearance_duration")
|
|
.and_then(|v| v.as_f64())
|
|
.unwrap_or(0.0);
|
|
let source_first = source_stat
|
|
.get("first_appearance_time")
|
|
.and_then(|v| v.as_f64());
|
|
let source_last = source_stat
|
|
.get("last_appearance_time")
|
|
.and_then(|v| v.as_f64());
|
|
|
|
let restore_source_query = r#"
|
|
INSERT INTO person_identities (person_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, metadata, is_confirmed)
|
|
VALUES ($1, $2, $3, $4, $5, $6, FALSE)
|
|
ON CONFLICT (person_id) DO UPDATE SET
|
|
appearance_count = EXCLUDED.appearance_count,
|
|
total_appearance_duration = EXCLUDED.total_appearance_duration,
|
|
first_appearance_time = EXCLUDED.first_appearance_time,
|
|
last_appearance_time = EXCLUDED.last_appearance_time,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
"#;
|
|
match sqlx::query(restore_source_query)
|
|
.bind(source_id)
|
|
.bind(source_count)
|
|
.bind(source_duration)
|
|
.bind(source_first)
|
|
.bind(source_last)
|
|
.bind(
|
|
serde_json::to_string(&serde_json::json!({"restored_from_merge": _merge_id}))
|
|
.unwrap(),
|
|
)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => tracing::warn!("[UNDO] Failed to restore {}: {}", source_id, e),
|
|
};
|
|
}
|
|
|
|
// 4. Mark merge history as undone
|
|
let mark_undone_query =
|
|
"UPDATE merge_history SET is_undone = TRUE, undone_at = CURRENT_TIMESTAMP WHERE id = $1";
|
|
match sqlx::query(mark_undone_query)
|
|
.bind(history_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Mark undone error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
match tx.commit().await {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Commit error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": format!("Undo merge completed. Restored {} source persons", source_ids.len()),
|
|
"merge_id": _merge_id,
|
|
"target_person_id": target_id,
|
|
"restored_persons": source_ids
|
|
})))
|
|
}
|
|
|
|
/// Get merge history
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct MergeHistoryQuery {
|
|
pub file_uuid: String,
|
|
}
|
|
|
|
async fn get_merge_history(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Query(query): Query<MergeHistoryQuery>,
|
|
) -> Result<Json<MergeHistoryResponse>, (StatusCode, String)> {
|
|
let _file_uuid = &query.file_uuid;
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let query = "SELECT merge_id::text, target_person_id, source_person_ids, original_target_stats::text, original_source_stats::text, merged_at, is_undone, undone_at FROM merge_history ORDER BY merged_at DESC LIMIT 50";
|
|
let rows: Vec<(
|
|
String,
|
|
String,
|
|
Vec<String>,
|
|
String,
|
|
String,
|
|
chrono::DateTime<chrono::Utc>,
|
|
bool,
|
|
Option<chrono::DateTime<chrono::Utc>>,
|
|
)> = match sqlx::query_as(query).fetch_all(db.pool()).await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("History query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let history: Vec<MergeHistoryEntry> = rows
|
|
.into_iter()
|
|
.map(
|
|
|(merge_id, target, sources, tgt_stats, src_stats, merged_at, is_undone, undone_at)| {
|
|
MergeHistoryEntry {
|
|
merge_id,
|
|
target_person_id: target,
|
|
source_person_ids: sources,
|
|
original_target_stats: serde_json::from_str(&tgt_stats)
|
|
.unwrap_or(serde_json::json!({})),
|
|
original_source_stats: serde_json::from_str(&src_stats)
|
|
.unwrap_or(serde_json::json!({})),
|
|
merged_at: merged_at.to_rfc3339(),
|
|
is_undone,
|
|
undone_at: undone_at.map(|t| t.to_rfc3339()),
|
|
}
|
|
},
|
|
)
|
|
.collect();
|
|
|
|
Ok(Json(MergeHistoryResponse {
|
|
success: true,
|
|
history,
|
|
}))
|
|
}
|
|
|
|
async fn get_similar_persons(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Query(query): Query<SimilarPersonsQuery>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let file_uuid = query.file_uuid;
|
|
let threshold = query.threshold.unwrap_or(0.5);
|
|
let limit = query.limit.unwrap_or(10);
|
|
|
|
// Find the speaker_id of the requested person
|
|
let get_speaker_query =
|
|
"SELECT speaker_id FROM person_identities WHERE person_id = $1 AND file_uuid = $2";
|
|
let current_speaker_id: Option<String> = match sqlx::query_scalar(get_speaker_query)
|
|
.bind(&person_id)
|
|
.bind(&file_uuid)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let results = match current_speaker_id {
|
|
Some(sid) => {
|
|
// Find others with same speaker_id
|
|
let similar_query = "SELECT person_id, name, speaker_id, appearance_count, first_appearance_time, last_appearance_time FROM person_identities WHERE speaker_id = $1 AND person_id != $2 AND file_uuid = $3 ORDER BY appearance_count DESC LIMIT $4";
|
|
let rows: Vec<(
|
|
String,
|
|
Option<String>,
|
|
Option<String>,
|
|
i32,
|
|
Option<f64>,
|
|
Option<f64>,
|
|
)> = match sqlx::query_as(similar_query)
|
|
.bind(&sid)
|
|
.bind(&person_id)
|
|
.bind(&file_uuid)
|
|
.bind(limit)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let results: Vec<serde_json::Value> = rows
|
|
.into_iter()
|
|
.map(|r| {
|
|
serde_json::json!({
|
|
"person_id": r.0,
|
|
"name": r.1,
|
|
"speaker_id": r.2,
|
|
"appearance_count": r.3,
|
|
"similarity": 0.9 // Static high similarity since they share speaker_id
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
results
|
|
}
|
|
None => {
|
|
vec![]
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"person_id": person_id,
|
|
"similar_persons": results,
|
|
"threshold": threshold
|
|
})))
|
|
}
|
|
|
|
/// Confirm an AI suggestion (auto-apply naming)
|
|
async fn get_person_suggestions(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Json(req): Json<AutoIdentifyRequest>,
|
|
) -> Result<Json<SuggestionsResponse>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Simple suggestion logic:
|
|
// 1. Naming suggestions: Persons with NULL name but high appearance count.
|
|
// 2. Merge suggestions: Persons sharing the same speaker_id.
|
|
|
|
let file_uuid = req.file_uuid;
|
|
|
|
// Naming suggestions
|
|
let naming_query = "SELECT person_id, name, speaker_id, appearance_count FROM person_identities WHERE file_uuid = $1 AND (name IS NULL OR name = person_id) AND appearance_count > 50 ORDER BY appearance_count DESC LIMIT 10";
|
|
let naming_rows: Vec<(String, Option<String>, Option<String>, i32)> =
|
|
match sqlx::query_as(naming_query)
|
|
.bind(&file_uuid)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(_) => vec![],
|
|
};
|
|
|
|
let mut naming_suggestions = Vec::new();
|
|
for (pid, name, speaker_id, count) in naming_rows {
|
|
naming_suggestions.push(NamingSuggestion {
|
|
person_id: pid,
|
|
current_name: name.clone(),
|
|
suggested_name: speaker_id
|
|
.clone()
|
|
.unwrap_or_else(|| "Unknown Character".to_string()),
|
|
confidence: 0.5,
|
|
sources: vec![],
|
|
action: "needs_review".to_string(),
|
|
});
|
|
}
|
|
|
|
// Merge suggestions (Speaker overlap)
|
|
let merge_query = "SELECT person_id, speaker_id, appearance_count FROM person_identities WHERE file_uuid = $1 AND speaker_id IS NOT NULL ORDER BY speaker_id, appearance_count DESC";
|
|
let merge_rows: Vec<(String, String, i32)> = match sqlx::query_as(merge_query)
|
|
.bind(&file_uuid)
|
|
.fetch_all(db.pool())
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(_) => vec![],
|
|
};
|
|
|
|
use std::collections::HashMap;
|
|
let mut speaker_map: HashMap<String, Vec<(String, i32)>> = HashMap::new();
|
|
for (pid, sid, count) in merge_rows {
|
|
speaker_map.entry(sid).or_default().push((pid, count));
|
|
}
|
|
|
|
let mut merge_suggestions = Vec::new();
|
|
for (sid, mut persons) in speaker_map {
|
|
if persons.len() > 1 {
|
|
persons.sort_by(|a, b| b.1.cmp(&a.1));
|
|
let target = persons[0].0.clone();
|
|
let sources: Vec<String> = persons[1..].iter().map(|p| p.0.clone()).collect();
|
|
merge_suggestions.push(MergeSuggestion {
|
|
person_id: target,
|
|
merge_with: sources,
|
|
confidence: 0.7,
|
|
reasons: vec![format!("All share speaker_id: {}", sid)],
|
|
action: "auto_apply".to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
let total_naming = naming_suggestions.len();
|
|
let total_merge = merge_suggestions.len();
|
|
|
|
Ok(Json(SuggestionsResponse {
|
|
success: true,
|
|
naming_suggestions,
|
|
merge_suggestions,
|
|
total_naming,
|
|
total_merge,
|
|
}))
|
|
}
|
|
|
|
async fn confirm_person_suggestion(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Json(request): Json<UpdatePersonIdentityRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let query = r#"
|
|
UPDATE person_identities
|
|
SET
|
|
name = COALESCE($2, name),
|
|
metadata = COALESCE($3, metadata),
|
|
is_confirmed = true,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE person_id = $1
|
|
RETURNING person_id, name
|
|
"#;
|
|
|
|
let updated: Option<(String, Option<String>)> = match sqlx::query_as(query)
|
|
.bind(&person_id)
|
|
.bind(&request.name)
|
|
.bind(&request.metadata)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Update error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
match updated {
|
|
Some((id, name)) => Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": format!("Person '{}' confirmed", name.unwrap_or_else(|| id.clone())),
|
|
"person_id": id
|
|
}))),
|
|
None => Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Person not found: {}", person_id),
|
|
)),
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Correction APIs - For fixing incorrect person bindings
|
|
// ============================================================
|
|
|
|
/// Request to unbind speaker from person
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UnbindSpeakerRequest {
|
|
pub file_uuid: String,
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
/// Request to reassign speaker to person
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ReassignSpeakerRequest {
|
|
pub file_uuid: String,
|
|
pub speaker_id: String,
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
/// Request to remove a specific appearance
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct RemoveAppearanceRequest {
|
|
pub file_uuid: String,
|
|
pub appearance_id: i32,
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
/// Request to reassign appearance to another person
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct ReassignAppearanceRequest {
|
|
pub file_uuid: String,
|
|
pub appearance_id: i32,
|
|
pub target_person_id: String,
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
/// Request to split a person into two
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SplitPersonRequest {
|
|
pub file_uuid: String,
|
|
pub new_person_id: String,
|
|
pub appearance_ids_to_move: Vec<i32>,
|
|
pub new_person_name: Option<String>,
|
|
pub reason: Option<String>,
|
|
}
|
|
|
|
/// Unbind speaker from person
|
|
async fn unbind_speaker(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Json(request): Json<UnbindSpeakerRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let update_query = r#"
|
|
UPDATE person_identities
|
|
SET speaker_id = NULL, updated_at = CURRENT_TIMESTAMP,
|
|
metadata = jsonb_set(
|
|
COALESCE(metadata, '{}'::jsonb),
|
|
'{speaker_unbound}',
|
|
'true'::jsonb
|
|
)
|
|
WHERE person_id = $1
|
|
RETURNING person_id
|
|
"#;
|
|
|
|
let updated: Option<String> = match sqlx::query_scalar(update_query)
|
|
.bind(&person_id)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Unbind error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
match updated {
|
|
Some(id) => Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": format!("Speaker unbound from person '{}'", id),
|
|
"person_id": id,
|
|
"reason": request.reason.unwrap_or_default()
|
|
}))),
|
|
None => Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Person not found: {}", person_id),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Reassign speaker to person
|
|
async fn reassign_speaker(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Json(request): Json<ReassignSpeakerRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let update_query = r#"
|
|
UPDATE person_identities
|
|
SET speaker_id = $2, updated_at = CURRENT_TIMESTAMP,
|
|
metadata = jsonb_set(
|
|
COALESCE(metadata, '{}'::jsonb),
|
|
'{speaker_reassigned}',
|
|
'true'::jsonb
|
|
)
|
|
WHERE person_id = $1
|
|
RETURNING person_id
|
|
"#;
|
|
|
|
let updated: Option<String> = match sqlx::query_scalar(update_query)
|
|
.bind(&person_id)
|
|
.bind(&request.speaker_id)
|
|
.fetch_optional(db.pool())
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Reassign error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
match updated {
|
|
Some(id) => Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": format!("Speaker '{}' assigned to person '{}'", request.speaker_id, id),
|
|
"person_id": id,
|
|
"new_speaker_id": request.speaker_id,
|
|
"reason": request.reason.unwrap_or_default()
|
|
}))),
|
|
None => Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Person not found: {}", person_id),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Remove a specific appearance
|
|
async fn remove_appearance(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Json(request): Json<RemoveAppearanceRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let mut tx = match db.pool().begin().await {
|
|
Ok(tx) => tx,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Tx error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Get appearance info before deleting
|
|
let app_query =
|
|
"SELECT duration, person_id FROM person_appearances WHERE id = $1 AND person_id = $2";
|
|
let app_info: Option<(f64, String)> = match sqlx::query_as(app_query)
|
|
.bind(request.appearance_id)
|
|
.bind(&person_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Appearance query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let (duration, actual_person_id) = match app_info {
|
|
Some(info) => info,
|
|
None => {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Appearance not found: {}", request.appearance_id),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Delete appearance
|
|
let delete_query = "DELETE FROM person_appearances WHERE id = $1 AND person_id = $2";
|
|
match sqlx::query(delete_query)
|
|
.bind(request.appearance_id)
|
|
.bind(&person_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Delete error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Update person stats
|
|
let update_stats_query = r#"
|
|
UPDATE person_identities
|
|
SET appearance_count = appearance_count - 1,
|
|
total_appearance_duration = GREATEST(0, total_appearance_duration - $1),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE person_id = $2
|
|
"#;
|
|
match sqlx::query(update_stats_query)
|
|
.bind(duration)
|
|
.bind(&actual_person_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Stats update error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
match tx.commit().await {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Commit error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": format!("Appearance {} removed from person '{}'", request.appearance_id, person_id),
|
|
"appearance_id": request.appearance_id,
|
|
"person_id": person_id,
|
|
"removed_duration": duration,
|
|
"reason": request.reason.unwrap_or_default()
|
|
})))
|
|
}
|
|
|
|
/// Reassign appearance to another person
|
|
async fn reassign_appearance(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Json(request): Json<ReassignAppearanceRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let mut tx = match db.pool().begin().await {
|
|
Ok(tx) => tx,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Tx error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Get appearance info
|
|
let app_query =
|
|
"SELECT id, duration, person_id FROM person_appearances WHERE id = $1 AND person_id = $2";
|
|
let app_info: Option<(i32, f64, String)> = match sqlx::query_as(app_query)
|
|
.bind(request.appearance_id)
|
|
.bind(&person_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Appearance query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let (app_id, duration, _old_person_id) = match app_info {
|
|
Some(info) => info,
|
|
None => {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Appearance not found: {}", request.appearance_id),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Reassign to new person
|
|
let update_query = "UPDATE person_appearances SET person_id = $1 WHERE id = $2";
|
|
match sqlx::query(update_query)
|
|
.bind(&request.target_person_id)
|
|
.bind(app_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Reassign error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Update old person stats (decrement)
|
|
let update_old_query = r#"
|
|
UPDATE person_identities
|
|
SET appearance_count = GREATEST(0, appearance_count - 1),
|
|
total_appearance_duration = GREATEST(0, total_appearance_duration - $1),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE person_id = $2
|
|
"#;
|
|
match sqlx::query(update_old_query)
|
|
.bind(duration)
|
|
.bind(&person_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Old person stats error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Update new person stats (increment)
|
|
let update_new_query = r#"
|
|
UPDATE person_identities
|
|
SET appearance_count = appearance_count + 1,
|
|
total_appearance_duration = total_appearance_duration + $1,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE person_id = $2
|
|
"#;
|
|
match sqlx::query(update_new_query)
|
|
.bind(duration)
|
|
.bind(&request.target_person_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("New person stats error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
match tx.commit().await {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Commit error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": format!("Appearance {} reassigned from '{}' to '{}'", request.appearance_id, person_id, request.target_person_id),
|
|
"appearance_id": request.appearance_id,
|
|
"from_person_id": person_id,
|
|
"to_person_id": request.target_person_id,
|
|
"reason": request.reason.unwrap_or_default()
|
|
})))
|
|
}
|
|
|
|
/// Split a person into two
|
|
async fn split_person(
|
|
State(_state): State<crate::api::server::AppState>,
|
|
Path(person_id): Path<String>,
|
|
Json(request): Json<SplitPersonRequest>,
|
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
let db = match PostgresDb::init().await {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("DB error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let mut tx = match db.pool().begin().await {
|
|
Ok(tx) => tx,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Tx error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Get original person info
|
|
let orig_query = "SELECT speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, metadata FROM person_identities WHERE person_id = $1";
|
|
let orig_info: Option<(
|
|
Option<String>,
|
|
i32,
|
|
f64,
|
|
Option<f64>,
|
|
Option<f64>,
|
|
Option<serde_json::Value>,
|
|
)> = match sqlx::query_as(orig_query)
|
|
.bind(&person_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Person query error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
let (speaker_id, orig_count, orig_duration, orig_first, orig_last, orig_metadata) =
|
|
match orig_info {
|
|
Some(info) => info,
|
|
None => {
|
|
return Err((
|
|
StatusCode::NOT_FOUND,
|
|
format!("Person not found: {}", person_id),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Create new person
|
|
let new_count = request.appearance_ids_to_move.len();
|
|
let create_query = r#"
|
|
INSERT INTO person_identities (person_id, name, speaker_id, appearance_count, total_appearance_duration, first_appearance_time, last_appearance_time, metadata, is_confirmed)
|
|
VALUES ($1, $2, $3, $4, 0, NULL, NULL, $5, FALSE)
|
|
ON CONFLICT (person_id) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
speaker_id = EXCLUDED.speaker_id,
|
|
metadata = EXCLUDED.metadata
|
|
RETURNING person_id
|
|
"#;
|
|
let new_name = request
|
|
.new_person_name
|
|
.unwrap_or_else(|| format!("{}-split", request.new_person_id));
|
|
let mut new_metadata = orig_metadata.unwrap_or(serde_json::json!({}));
|
|
new_metadata["split_from"] = serde_json::json!(person_id);
|
|
|
|
match sqlx::query(create_query)
|
|
.bind(&request.new_person_id)
|
|
.bind(new_name)
|
|
.bind(&speaker_id)
|
|
.bind(new_count as i32)
|
|
.bind(&new_metadata)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Create person error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Move appearances to new person and calculate new stats
|
|
let mut new_duration: f64 = 0.0;
|
|
let mut new_first: Option<f64> = None;
|
|
let mut new_last: Option<f64> = None;
|
|
|
|
for app_id in &request.appearance_ids_to_move {
|
|
let app_query = "SELECT duration, start_time, end_time FROM person_appearances WHERE id = $1 AND person_id = $2";
|
|
let app_info: Option<(f64, f64, f64)> = match sqlx::query_as(app_query)
|
|
.bind(app_id)
|
|
.bind(&person_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!("[SPLIT] Failed to get appearance {}: {}", app_id, e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if let Some((dur, start, end)) = app_info {
|
|
// Update appearance to new person
|
|
let update_app_query = "UPDATE person_appearances SET person_id = $1 WHERE id = $2";
|
|
match sqlx::query(update_app_query)
|
|
.bind(&request.new_person_id)
|
|
.bind(app_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
tracing::warn!("[SPLIT] Failed to update appearance {}: {}", app_id, e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
new_duration += dur;
|
|
if new_first.is_none() || Some(start) < new_first {
|
|
new_first = Some(start);
|
|
}
|
|
if new_last.is_none() || Some(end) > new_last {
|
|
new_last = Some(end);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update new person stats
|
|
let update_new_query = r#"
|
|
UPDATE person_identities
|
|
SET total_appearance_duration = $1,
|
|
first_appearance_time = $2,
|
|
last_appearance_time = $3,
|
|
appearance_count = $4,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE person_id = $5
|
|
"#;
|
|
match sqlx::query(update_new_query)
|
|
.bind(new_duration)
|
|
.bind(new_first)
|
|
.bind(new_last)
|
|
.bind(new_count as i32)
|
|
.bind(&request.new_person_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Update new person error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Update original person stats (decrement)
|
|
let update_orig_query = r#"
|
|
UPDATE person_identities
|
|
SET appearance_count = GREATEST(0, appearance_count - $1),
|
|
total_appearance_duration = GREATEST(0, total_appearance_duration - $2),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE person_id = $3
|
|
"#;
|
|
match sqlx::query(update_orig_query)
|
|
.bind(new_count as i32)
|
|
.bind(new_duration)
|
|
.bind(&person_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Update original person error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
// Update original person's first/last appearance times
|
|
let recalc_query = r#"
|
|
UPDATE person_identities
|
|
SET first_appearance_time = (SELECT MIN(start_time) FROM person_appearances WHERE person_id = $1),
|
|
last_appearance_time = (SELECT MAX(end_time) FROM person_appearances WHERE person_id = $1)
|
|
WHERE person_id = $1
|
|
"#;
|
|
match sqlx::query(recalc_query)
|
|
.bind(&person_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
{
|
|
Ok(_) => {}
|
|
Err(e) => tracing::warn!("[SPLIT] Failed to recalc original person times: {}", e),
|
|
};
|
|
|
|
match tx.commit().await {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
return Err((
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Commit error: {}", e),
|
|
))
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"success": true,
|
|
"message": format!("Person '{}' split into '{}' with {} appearances moved", person_id, request.new_person_id, new_count),
|
|
"original_person_id": person_id,
|
|
"new_person_id": request.new_person_id,
|
|
"appearances_moved": new_count,
|
|
"new_person_duration": new_duration,
|
|
"new_person_first": new_first,
|
|
"new_person_last": new_last,
|
|
"reason": request.reason.unwrap_or_default()
|
|
})))
|
|
}
|