feat: update core API, database layer, and worker modules

- 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)
This commit is contained in:
Warren
2026-04-30 15:07:02 +08:00
parent 8f2208dd63
commit 2b23d1cfbd
148 changed files with 8553 additions and 48637 deletions

View File

@@ -10,7 +10,6 @@ use axum::{
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::core::db::schema;
use crate::core::db::{Database, PostgresDb};
use crate::core::person_identity::{
ChunkPersonInfo, CreatePersonIdentityRequest, PersonIdentity, PersonIdentityResponse,
@@ -20,7 +19,7 @@ use crate::core::person_identity::{
#[derive(Debug, Deserialize)]
pub struct IdentifyPersonsRequest {
pub video_uuid: String,
pub file_uuid: String,
pub auto_match: Option<bool>,
pub match_threshold: Option<f64>,
}
@@ -39,19 +38,19 @@ pub struct FaceListQuery {
#[derive(Debug, Deserialize)]
pub struct VideoUuidQuery {
pub video_uuid: String,
pub file_uuid: String,
}
#[derive(Debug, Deserialize)]
pub struct PersonTimelineQuery {
pub video_uuid: String,
pub file_uuid: String,
}
#[derive(Debug, Deserialize)]
pub struct FaceThumbnailQuery {
pub video_uuid: String,
pub file_uuid: String,
#[serde(default)]
pub index: Option<usize>, // Which face detection to use (default: 0)
pub index: Option<usize>,
}
// Structs for parsing face_clustered.json
@@ -85,7 +84,7 @@ pub struct ChunkPersonsResponse {
#[derive(Debug, Deserialize)]
pub struct MergePersonsRequest {
pub video_uuid: String,
pub file_uuid: String,
pub target_person_id: String,
pub source_person_ids: Vec<String>,
}
@@ -115,7 +114,7 @@ pub struct MergeHistoryResponse {
#[derive(Debug, Deserialize)]
pub struct PersonListQuery {
pub video_uuid: String,
pub file_uuid: String,
pub page: Option<usize>,
pub page_size: Option<usize>,
pub min_appearances: Option<i32>,
@@ -124,13 +123,13 @@ pub struct PersonListQuery {
#[derive(Debug, Deserialize)]
pub struct AutoIdentifyRequest {
pub video_uuid: String,
pub file_uuid: String,
pub min_speaker_confidence: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct SimilarPersonsQuery {
pub video_uuid: String,
pub file_uuid: String,
pub threshold: Option<f64>,
pub limit: Option<i32>,
}
@@ -210,7 +209,7 @@ pub struct AutoIdentifyResponse {
}
pub struct AggregateBySpeakerRequest {
pub video_uuid: String,
pub file_uuid: String,
pub auto_merge: bool, // If true, automatically merge duplicates
}
@@ -311,14 +310,14 @@ async fn identify_persons(
tracing::info!(
"[PERSON_IDENTITY] Identifying persons for video: {}",
request.video_uuid
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.video_uuid, threshold).await {
let matches = match auto_match_face_speaker(&db, &request.file_uuid, threshold).await {
Ok(m) => m,
Err(e) => {
return Err((
@@ -333,7 +332,7 @@ async fn identify_persons(
let person = match create_person_identity(
&db,
CreatePersonIdentityRequest {
video_uuid: request.video_uuid.clone(),
file_uuid: request.file_uuid.clone(),
face_identity_id: None,
speaker_id: Some(match_result.speaker_id.clone()),
name: None,
@@ -372,7 +371,7 @@ async fn identify_persons(
#[derive(Debug, Deserialize)]
pub struct PersonDetailQuery {
pub video_uuid: String,
pub file_uuid: String,
}
#[derive(Debug, Deserialize)]
@@ -381,7 +380,7 @@ pub struct SearchIdentitiesRequest {
pub speaker_id: Option<String>,
pub gender: Option<String>,
pub min_appearances: Option<i32>,
pub video_uuid: Option<String>, // Optional: search only in specific video
pub file_uuid: Option<String>, // Optional: search only in specific video
pub limit: Option<usize>,
}
@@ -404,7 +403,7 @@ async fn search_identities(
let mut sql = format!(
r#"
SELECT
id, person_id, face_identity_id, speaker_id, video_uuid,
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
@@ -435,8 +434,8 @@ async fn search_identities(
conditions.push(format!("appearance_count >= {}", min_count));
}
if let Some(vid) = &req.video_uuid {
conditions.push(format!("video_uuid = '{}'", vid.replace('\'', "''")));
if let Some(vid) = &req.file_uuid {
conditions.push(format!("file_uuid = '{}'", vid.replace('\'', "''")));
}
if !conditions.is_empty() {
@@ -482,7 +481,7 @@ async fn search_identities(
person_id,
face_id,
speaker_id,
video_uuid,
file_uuid,
name,
original_name,
character_name,
@@ -498,7 +497,7 @@ async fn search_identities(
"id": id,
"person_id": person_id,
"face_identity_id": face_id,
"video_uuid": video_uuid,
"file_uuid": file_uuid,
"profile": {
"name": name,
"original_name": original_name,
@@ -579,12 +578,12 @@ async fn get_identity_videos(
// Each video has its own local person_id, speaker_id, and character_name
let videos_query = r#"
SELECT
pi.video_uuid, v.file_name, v.file_path,
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.video_uuid = v.uuid
LEFT JOIN videos v ON pi.file_uuid = v.uuid
WHERE pi.face_identity_id = $1
ORDER BY pi.last_appearance_time DESC
"#;
@@ -599,7 +598,7 @@ async fn get_identity_videos(
.map(|row| {
use sqlx::Row;
serde_json::json!({
"video_uuid": row.get::<String, _>("video_uuid"),
"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"),
@@ -659,12 +658,12 @@ async fn get_identity_faces(
// Fetch distinct face detections for this identity
let sql = r#"
SELECT
fd.id as detection_id, fd.video_uuid, fd.frame_number,
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.video_uuid = pi.video_uuid
LEFT JOIN videos v ON fd.video_uuid = v.uuid
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
@@ -682,7 +681,7 @@ async fn get_identity_faces(
use sqlx::Row;
serde_json::json!({
"detection_id": row.get::<i32, _>("detection_id"),
"video_uuid": row.get::<String, _>("video_uuid"),
"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"),
@@ -715,7 +714,7 @@ async fn get_identity_faces(
/// List all faces in a video (both registered and unregistered)
async fn get_video_faces(
State(_state): State<crate::api::server::AppState>,
Path(video_uuid): Path<String>,
Path(file_uuid): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let db = match PostgresDb::init().await {
Ok(db) => db,
@@ -735,13 +734,13 @@ async fn get_video_faces(
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.video_uuid = fc.video_uuid
WHERE fc.video_uuid = $1
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(&video_uuid)
.bind(&file_uuid)
.fetch_all(db.pool())
.await
{
@@ -785,7 +784,7 @@ async fn get_video_faces(
Ok(Json(serde_json::json!({
"success": true,
"video_uuid": video_uuid,
"file_uuid": file_uuid,
"total_faces": total_faces,
"registered_count": registered_count,
"unregistered_count": total_faces - registered_count,
@@ -799,7 +798,7 @@ async fn register_identity(
Path(person_id): Path<String>,
Query(query): Query<VideoUuidQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let video_uuid = query.video_uuid;
let file_uuid = query.file_uuid;
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
@@ -813,11 +812,11 @@ async fn register_identity(
// 1. Fetch person info
let person_query = r#"
SELECT id, name, face_identity_id FROM '"{}'
WHERE person_id = $1 AND video_uuid = $2
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(&video_uuid)
.bind(&file_uuid)
.fetch_optional(db.pool())
.await
{
@@ -835,7 +834,7 @@ async fn register_identity(
None => {
return Err((
StatusCode::NOT_FOUND,
format!("Person '{}' not found in video '{}'", person_id, video_uuid),
format!("Person '{}' not found in video '{}'", person_id, file_uuid),
))
}
};
@@ -851,11 +850,11 @@ async fn register_identity(
// 2. Get cluster centroid or detections embedding
let cluster_query = r#"
SELECT centroid FROM face_clusters WHERE cluster_id = $1 AND video_uuid = $2
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(&video_uuid)
.bind(&file_uuid)
.fetch_optional(db.pool())
.await
.ok()
@@ -923,7 +922,7 @@ async fn get_person_details(
Path(person_id): Path<String>,
Query(query): Query<PersonDetailQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let video_uuid = query.video_uuid;
let file_uuid = query.file_uuid;
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
@@ -941,7 +940,7 @@ async fn get_person_details(
first_appearance_time, last_appearance_time,
is_confirmed, created_at, updated_at
FROM '"{}'
WHERE person_id = $1 AND video_uuid = $2
WHERE person_id = $1 AND file_uuid = $2
"#;
let person: Option<(
@@ -959,7 +958,7 @@ async fn get_person_details(
chrono::DateTime<chrono::Utc>,
)> = match sqlx::query_as(query)
.bind(&person_id)
.bind(&video_uuid)
.bind(&file_uuid)
.fetch_optional(db.pool())
.await
{
@@ -1001,7 +1000,7 @@ async fn get_person_details(
#[derive(Debug, Deserialize)]
pub struct UpdatePersonQuery {
pub video_uuid: String,
pub file_uuid: String,
}
async fn update_person_identity(
@@ -1010,7 +1009,7 @@ async fn update_person_identity(
Query(query): Query<UpdatePersonQuery>,
Json(request): Json<UpdatePersonIdentityRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let video_uuid = query.video_uuid;
let file_uuid = query.file_uuid;
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
@@ -1083,10 +1082,10 @@ async fn get_person_timeline(
}
};
let name_query = "SELECT name FROM person_identities WHERE person_id = $1 AND video_uuid = $2";
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.video_uuid)
.bind(&query.file_uuid)
.fetch_optional(db.pool())
.await
{
@@ -1102,13 +1101,13 @@ async fn get_person_timeline(
let timeline_query = r#"
SELECT start_time, end_time, duration, confidence
FROM person_appearances
WHERE person_id = $1 AND video_uuid = $2
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.video_uuid)
.bind(&query.file_uuid)
.fetch_all(db.pool())
.await
{
@@ -1139,13 +1138,13 @@ async fn get_person_timeline(
MAX(end_time) as last_appearance,
AVG(confidence) as average_confidence
FROM person_appearances
WHERE person_id = $1 AND video_uuid = $2
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.video_uuid)
.bind(&query.file_uuid)
.fetch_one(db.pool())
.await
{
@@ -1179,7 +1178,7 @@ async fn get_person_appearances(
Path(person_id): Path<String>,
Query(query): Query<PersonDetailQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let video_uuid = query.video_uuid;
let file_uuid = query.file_uuid;
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
@@ -1192,18 +1191,18 @@ async fn get_person_appearances(
let query = r#"
SELECT
person_id, video_uuid, start_time, end_time, duration,
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 video_uuid = $2
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(&video_uuid)
.bind(&file_uuid)
.fetch_all(db.pool())
.await
{
@@ -1213,7 +1212,7 @@ async fn get_person_appearances(
use sqlx::Row;
serde_json::json!({
"person_id": row.get::<String, _>("person_id"),
"video_uuid": row.get::<String, _>("video_uuid"),
"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"),
@@ -1240,7 +1239,7 @@ async fn get_person_appearances(
#[derive(Debug, Deserialize)]
pub struct ChunkPersonsQuery {
pub video_uuid: String,
pub file_uuid: String,
}
async fn get_chunk_persons(
@@ -1248,7 +1247,7 @@ async fn get_chunk_persons(
Path(chunk_id): Path<String>,
Query(query): Query<ChunkPersonsQuery>,
) -> Result<Json<ChunkPersonsResponse>, (StatusCode, String)> {
let video_uuid = &query.video_uuid;
let file_uuid = &query.file_uuid;
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
@@ -1290,7 +1289,7 @@ async fn get_chunk_persons(
}
};
let (video_uuid, start_time, end_time, _metadata) = chunk;
let (file_uuid, start_time, end_time, _metadata) = chunk;
let persons_query = r#"
SELECT
@@ -1300,7 +1299,7 @@ async fn get_chunk_persons(
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.video_uuid = $1
WHERE pa.file_uuid = $1
AND pa.start_time < $3
AND pa.end_time > $2
ORDER BY overlap_duration DESC
@@ -1308,7 +1307,7 @@ async fn get_chunk_persons(
let persons: Vec<ChunkPersonInfo> =
match sqlx::query_as::<_, (String, Option<String>, f64, f64)>(persons_query)
.bind(&video_uuid)
.bind(&file_uuid)
.bind(start_time)
.bind(end_time)
.fetch_all(db.pool())
@@ -1349,15 +1348,15 @@ async fn get_person_thumbnail(
// 1. Locate the face_clustered.json file
let json_path = format!(
"output/{}/{}_face_clustered.json",
query.video_uuid, query.video_uuid
query.file_uuid, query.file_uuid
);
let json_path2 = format!(
"output/{}/{}.face_clustered.json",
query.video_uuid, query.video_uuid
query.file_uuid, query.file_uuid
);
// Fallback path if the naming convention is slightly different
let fallback_path = format!("output/{}/face_clustered.json", query.video_uuid);
let fallback_path = format!("output/{}/face_clustered.json", query.file_uuid);
let path = if std::path::Path::new(&json_path).exists() {
json_path
@@ -1370,7 +1369,7 @@ async fn get_person_thumbnail(
StatusCode::NOT_FOUND,
format!(
"Face data not found for video: {}. Tried: {}, {}, {}",
query.video_uuid, json_path, json_path2, fallback_path
query.file_uuid, json_path, json_path2, fallback_path
),
));
};
@@ -1418,7 +1417,7 @@ async fn get_person_thumbnail(
let (timestamp, face) = detections[index];
// 3. Locate the video file
let video_path = format!("output/{}/{}.mp4", query.video_uuid, query.video_uuid);
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,
@@ -1489,12 +1488,12 @@ async fn create_person_identity(
let query = r#"
INSERT INTO person_identities (
person_id, video_uuid, face_identity_id, speaker_id,
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,
video_uuid, confidence, name, metadata,
file_uuid, confidence, name, metadata,
first_appearance_time, last_appearance_time,
total_appearance_duration, appearance_count,
created_at, updated_at, is_confirmed
@@ -1502,11 +1501,11 @@ async fn create_person_identity(
let person: PersonIdentity = sqlx::query_as(query)
.bind(&person_id)
.bind(&request.video_uuid)
.bind(&request.file_uuid)
.bind(&request.face_identity_id)
.bind(&request.speaker_id)
.bind(&request.name)
.bind(&request.metadata.unwrap_or(serde_json::json!({})))
.bind(serde_json::to_string(&request.metadata.unwrap_or(serde_json::json!({}))).unwrap())
.bind(0.0)
.fetch_one(db.pool())
.await?;
@@ -1516,13 +1515,13 @@ async fn create_person_identity(
async fn auto_match_face_speaker(
db: &PostgresDb,
video_uuid: &str,
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(video_uuid)
.bind(file_uuid)
.bind(threshold)
.fetch_all(db.pool())
.await?;
@@ -1548,9 +1547,9 @@ async fn auto_identify_persons(
// 1. Load face_clustered.json
let clustered_path = format!(
"output/{}/{}.face_clustered.json",
request.video_uuid, request.video_uuid
request.file_uuid, request.file_uuid
);
let fallback_path = format!("output/{}/face_clustered.json", request.video_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() {
@@ -1560,7 +1559,7 @@ async fn auto_identify_persons(
StatusCode::NOT_FOUND,
format!(
"face_clustered.json not found for video: {}",
request.video_uuid
request.file_uuid
),
));
};
@@ -1615,7 +1614,7 @@ async fn auto_identify_persons(
// 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.video_uuid)
.bind(&request.file_uuid)
.fetch_all(db.pool())
.await
{
@@ -1631,7 +1630,7 @@ async fn auto_identify_persons(
// 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.video_uuid)
.bind(&request.file_uuid)
.fetch_all(db.pool())
.await
{
@@ -1801,7 +1800,7 @@ async fn list_persons(
}
};
let video_uuid = query.video_uuid.replace("'", "''");
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);
@@ -1811,25 +1810,25 @@ async fn list_persons(
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 video_uuid = '{}' AND speaker_id IS NOT NULL AND appearance_count >= $1 ORDER BY appearance_count DESC LIMIT $2 OFFSET $3", video_uuid),
format!("SELECT COUNT(*) FROM person_identities WHERE video_uuid = '{}' AND speaker_id IS NOT NULL AND appearance_count >= $1", video_uuid),
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 video_uuid = '{}' AND speaker_id IS NOT NULL ORDER BY appearance_count DESC LIMIT $1 OFFSET $2", video_uuid),
format!("SELECT COUNT(*) FROM person_identities WHERE video_uuid = '{}' AND speaker_id IS NOT NULL", video_uuid),
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 video_uuid = '{}' AND appearance_count >= $1 ORDER BY appearance_count DESC LIMIT $2 OFFSET $3", video_uuid),
format!("SELECT COUNT(*) FROM person_identities WHERE video_uuid = '{}' AND appearance_count >= $1", video_uuid),
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 video_uuid = '{}' ORDER BY appearance_count DESC LIMIT $1 OFFSET $2", video_uuid),
format!("SELECT COUNT(*) FROM person_identities WHERE video_uuid = '{}'", video_uuid),
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),
)
}
};
@@ -1925,7 +1924,7 @@ async fn merge_persons(
State(_state): State<crate::api::server::AppState>,
Json(request): Json<MergePersonsRequest>,
) -> Result<Json<MergePersonsResponse>, (StatusCode, String)> {
let video_uuid = &request.video_uuid;
let file_uuid = &request.file_uuid;
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
@@ -2150,7 +2149,7 @@ async fn undo_merge(
State(_state): State<crate::api::server::AppState>,
Json(request): Json<UndoMergeRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// video_uuid is validated through merge_history lookup
// file_uuid is validated through merge_history lookup
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
@@ -2285,7 +2284,10 @@ async fn undo_merge(
.bind(source_duration)
.bind(source_first)
.bind(source_last)
.bind(&serde_json::json!({"restored_from_merge": _merge_id}))
.bind(
serde_json::to_string(&serde_json::json!({"restored_from_merge": _merge_id}))
.unwrap(),
)
.execute(&mut *tx)
.await
{
@@ -2333,14 +2335,14 @@ async fn undo_merge(
/// Get merge history
#[derive(Debug, Deserialize)]
pub struct MergeHistoryQuery {
pub video_uuid: String,
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 _video_uuid = &query.video_uuid;
let _file_uuid = &query.file_uuid;
let db = match PostgresDb::init().await {
Ok(db) => db,
Err(e) => {
@@ -2412,16 +2414,16 @@ async fn get_similar_persons(
}
};
let video_uuid = query.video_uuid;
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 video_uuid = $2";
"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(&video_uuid)
.bind(&file_uuid)
.fetch_optional(db.pool())
.await
{
@@ -2437,7 +2439,7 @@ async fn get_similar_persons(
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 video_uuid = $3 ORDER BY appearance_count DESC LIMIT $4";
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>,
@@ -2448,7 +2450,7 @@ async fn get_similar_persons(
)> = match sqlx::query_as(similar_query)
.bind(&sid)
.bind(&person_id)
.bind(&video_uuid)
.bind(&file_uuid)
.bind(limit)
.fetch_all(db.pool())
.await
@@ -2509,13 +2511,13 @@ async fn get_person_suggestions(
// 1. Naming suggestions: Persons with NULL name but high appearance count.
// 2. Merge suggestions: Persons sharing the same speaker_id.
let video_uuid = req.video_uuid;
let file_uuid = req.file_uuid;
// Naming suggestions
let naming_query = "SELECT person_id, name, speaker_id, appearance_count FROM person_identities WHERE video_uuid = $1 AND (name IS NULL OR name = person_id) AND appearance_count > 50 ORDER BY appearance_count DESC LIMIT 10";
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(&video_uuid)
.bind(&file_uuid)
.fetch_all(db.pool())
.await
{
@@ -2538,9 +2540,9 @@ async fn get_person_suggestions(
}
// Merge suggestions (Speaker overlap)
let merge_query = "SELECT person_id, speaker_id, appearance_count FROM person_identities WHERE video_uuid = $1 AND speaker_id IS NOT NULL ORDER BY speaker_id, appearance_count DESC";
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(&video_uuid)
.bind(&file_uuid)
.fetch_all(db.pool())
.await
{
@@ -2644,14 +2646,14 @@ async fn confirm_person_suggestion(
/// Request to unbind speaker from person
#[derive(Debug, Deserialize)]
pub struct UnbindSpeakerRequest {
pub video_uuid: String,
pub file_uuid: String,
pub reason: Option<String>,
}
/// Request to reassign speaker to person
#[derive(Debug, Deserialize)]
pub struct ReassignSpeakerRequest {
pub video_uuid: String,
pub file_uuid: String,
pub speaker_id: String,
pub reason: Option<String>,
}
@@ -2659,7 +2661,7 @@ pub struct ReassignSpeakerRequest {
/// Request to remove a specific appearance
#[derive(Debug, Deserialize)]
pub struct RemoveAppearanceRequest {
pub video_uuid: String,
pub file_uuid: String,
pub appearance_id: i32,
pub reason: Option<String>,
}
@@ -2667,7 +2669,7 @@ pub struct RemoveAppearanceRequest {
/// Request to reassign appearance to another person
#[derive(Debug, Deserialize)]
pub struct ReassignAppearanceRequest {
pub video_uuid: String,
pub file_uuid: String,
pub appearance_id: i32,
pub target_person_id: String,
pub reason: Option<String>,
@@ -2676,7 +2678,7 @@ pub struct ReassignAppearanceRequest {
/// Request to split a person into two
#[derive(Debug, Deserialize)]
pub struct SplitPersonRequest {
pub video_uuid: String,
pub file_uuid: String,
pub new_person_id: String,
pub appearance_ids_to_move: Vec<i32>,
pub new_person_name: Option<String>,