diff --git a/src/api/identity_api.rs b/src/api/identity_api.rs index 4be6cf6..9805259 100644 --- a/src/api/identity_api.rs +++ b/src/api/identity_api.rs @@ -766,6 +766,11 @@ async fn upload_profile_image( (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to write file: {}", e)}))) })?; + // Sync identity JSON to reflect new profile image + let pool = state.db.pool().clone(); + let uuid_clone = uuid_clean.clone(); + let _ = crate::core::identity::storage::save_identity_file_by_pool(&pool, &uuid_clone).await; + Ok(Json(ProfileImageResponse { success: true, identity_uuid: uuid_clean, @@ -815,45 +820,24 @@ async fn get_identity_json( } } - // 2. Fallback: Generate JSON from DB - use crate::core::identity::storage::{IdentityFile, FileBinding}; - let record = state.db.get_identity_by_uuid(&clean).await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; + // 2. Lazy Sync: If file missing, generate from DB and save + if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &clean).await { + tracing::warn!("[identity-json] Lazy sync failed for {}: {}", clean, e); + return Err(StatusCode::NOT_FOUND); + } - let id = record.id as i64; - let bindings: Vec = { - let fd_table = crate::core::db::schema::table_name("face_detections"); - let rows = sqlx::query_as::<_, (String, Vec, i64)>( - &format!("SELECT fd.file_uuid, COALESCE(array_agg(DISTINCT fd.trace_id) FILTER (WHERE fd.trace_id IS NOT NULL), '{{}}'::int[]), COUNT(*)::bigint FROM {} fd WHERE fd.identity_id = $1 GROUP BY fd.file_uuid ORDER BY fd.file_uuid", fd_table) - ).bind(id).fetch_all(state.db.pool()).await.unwrap_or_default(); - rows.into_iter().map(|(fu, tids, cnt)| FileBinding { - file_uuid: fu, trace_ids: tids, face_count: cnt, - }).collect() - }; + // 3. Read the newly generated file + let p = crate::core::identity::storage::identity_file_path(&clean); + if p.exists() { + let data = std::fs::read(&p).map_err(|_| StatusCode::NOT_FOUND)?; + return Ok(( + StatusCode::OK, + [("content-type".to_string(), "application/json".to_string())], + data, + )); + } - let fmt_time = |dt: Option>| -> String { - dt.map(|d| d.to_rfc3339()).unwrap_or_else(|| chrono::Utc::now().to_rfc3339()) - }; - - let file = IdentityFile { - version: 1, - identity_uuid: record.uuid.clone(), - name: record.name.clone(), - identity_type: record.identity_type.clone(), - source: record.source.clone(), - status: record.status.clone(), - tmdb_id: record.tmdb_id, - tmdb_profile: record.tmdb_profile.clone(), - metadata: record.metadata.clone(), - file_bindings: bindings, - created_at: fmt_time(record.created_at), - updated_at: fmt_time(record.updated_at), - }; - - let json = serde_json::to_string_pretty(&file).unwrap_or_default(); - let bytes = json.into_bytes(); - Ok((StatusCode::OK, [("content-type".to_string(), "application/json".to_string())], bytes)) + Err(StatusCode::NOT_FOUND) } // ── Experiment: Identity Text Search ────────────────────────── diff --git a/src/api/identity_binding.rs b/src/api/identity_binding.rs index e1a54e2..7b85a9f 100644 --- a/src/api/identity_binding.rs +++ b/src/api/identity_binding.rs @@ -115,10 +115,9 @@ pub async fn bind_identity( })?; let uuid_clean = identity_uuid.replace('-', ""); - if let Ok(ref db) = PostgresDb::init().await { - if let Err(e) = crate::core::identity::storage::save_identity_file(db, &uuid_clean).await { - tracing::warn!("[bind] Failed to save identity file for {}: {}", uuid_clean, e); - } + // Sync identity JSON file + if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &uuid_clean).await { + tracing::warn!("[bind] Failed to sync identity file for {}: {}", uuid_clean, e); } Ok(Json(ApiResponse { @@ -136,6 +135,7 @@ pub async fn unbind_identity( Json(req): Json, ) -> Result>, (StatusCode, Json)> { let table = crate::core::db::schema::table_name("face_detections"); + let id_table = crate::core::db::schema::table_name("identities"); let db = sqlx::PgPool::connect(&crate::core::config::DATABASE_URL) .await @@ -146,6 +146,22 @@ pub async fn unbind_identity( ) })?; + // Find the identity_id before unbinding to sync it later + let identity_id: Option = sqlx::query_scalar(&format!( + "SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2 AND identity_id IS NOT NULL", + table + )) + .bind(&req.file_uuid) + .bind(&req.face_id) + .fetch_optional(&db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e.to_string()})), + ) + })?; + let result = sqlx::query(&format!( "UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2", table @@ -161,6 +177,24 @@ pub async fn unbind_identity( ) })?; + // Sync the identity JSON if we found an identity + if let Some(id) = identity_id { + let uuid: Option = sqlx::query_scalar(&format!( + "SELECT uuid::text FROM {} WHERE id = $1", + id_table + )) + .bind(id) + .fetch_optional(&db) + .await + .ok() + .flatten(); + if let Some(identity_uuid) = uuid { + if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &identity_uuid).await { + tracing::warn!("[unbind] Failed to sync identity file for {}: {}", identity_uuid, e); + } + } + } + Ok(Json(ApiResponse { success: true, message: format!("Unbound face {} from {}", req.face_id, req.file_uuid), @@ -263,6 +297,18 @@ pub async fn merge_identities( })?; } + // Sync target identity JSON + let into_uuid_clean = req.into_uuid.replace('-', ""); + if let Err(e) = crate::core::identity::storage::save_identity_file_by_pool(&db, &into_uuid_clean).await { + tracing::warn!("[merge] Failed to sync target identity file for {}: {}", into_uuid_clean, e); + } + + // Delete source identity JSON if not keeping history + if !keep { + let from_uuid_clean = identity_uuid.replace('-', ""); + let _ = crate::core::identity::storage::delete_identity_file(&from_uuid_clean); + } + Ok(Json(ApiResponse { success: true, message: format!( diff --git a/src/api/tmdb_api.rs b/src/api/tmdb_api.rs index 586bf02..f876344 100644 --- a/src/api/tmdb_api.rs +++ b/src/api/tmdb_api.rs @@ -197,18 +197,41 @@ async fn tmdb_probe_handler( } match tmdb::probe::probe_from_cache(&state.db, &file_uuid).await { - Ok(result) => Ok(Json(TmdbProbeResponse { - success: true, - file_uuid, - tmdb_id: Some(result.tmdb_id), - movie_title: Some(result.title), - cast_count: Some(result.cast_count), - identities_created: Some(result.identities_created), - message: format!( - "Created/updated {} identities for movie ID {}", - result.identities_created, result.tmdb_id - ), - })), + Ok(result) => { + // Sync identity JSON files for newly created/updated identities + let pool = state.db.pool().clone(); + let file_uuid_clone = file_uuid.clone(); + tokio::spawn(async move { + // Query identities linked to this file + let fi_table = crate::core::db::schema::table_name("file_identities"); + let query = format!( + "SELECT i.uuid::text FROM {} fi JOIN {} i ON fi.identity_id = i.id WHERE fi.file_uuid = $1", + fi_table, crate::core::db::schema::table_name("identities") + ); + if let Ok(rows) = sqlx::query_scalar::<_, String>(&query) + .bind(&file_uuid_clone) + .fetch_all(&pool) + .await + { + for uuid in rows { + let _ = crate::core::identity::storage::save_identity_file_by_pool(&pool, &uuid).await; + } + } + }); + + Ok(Json(TmdbProbeResponse { + success: true, + file_uuid, + tmdb_id: Some(result.tmdb_id), + movie_title: Some(result.title), + cast_count: Some(result.cast_count), + identities_created: Some(result.identities_created), + message: format!( + "Created/updated {} identities for movie ID {}", + result.identities_created, result.tmdb_id + ), + })) + } Err(e) => { let msg = e.to_string(); if msg.contains("not found") { diff --git a/src/core/identity/storage.rs b/src/core/identity/storage.rs index f50e7e7..8937a6a 100644 --- a/src/core/identity/storage.rs +++ b/src/core/identity/storage.rs @@ -18,6 +18,9 @@ pub struct IdentityFile { pub status: Option, pub tmdb_id: Option, pub tmdb_profile: Option, + /// Local profile image filename (e.g., "profile.jpg") if uploaded by user. + /// Overrides tmdb_profile if present. + pub local_profile: Option, pub metadata: serde_json::Value, pub file_bindings: Vec, pub created_at: String, @@ -187,7 +190,7 @@ pub async fn save_identity_file_by_pool(pool: &sqlx::PgPool, uuid: &str) -> Resu let clean = uuid.replace('-', ""); let record = sqlx::query_as::<_, crate::core::db::IdentityDetailRecord>( &format!( - "SELECT id, uuid::text, name, identity_type, source, status, metadata, reference_data, \ + "SELECT id, uuid::text, COALESCE(real_name, actor_name, name) AS name, identity_type, source, status, metadata, reference_data, \ NULL::real[] as voice_embedding, NULL::real[] as identity_embedding, \ face_embedding::real[] as face_embedding, \ tmdb_id, tmdb_profile, created_at::timestamptz as created_at, NULL::timestamptz as updated_at \ @@ -222,6 +225,14 @@ pub async fn save_identity_file_by_pool(pool: &sqlx::PgPool, uuid: &str) -> Resu }) .collect(); + // Check for local profile image + let profile_path = identity_dir(&clean).join("profile.jpg"); + let local_profile = if profile_path.exists() { + Some("profile.jpg".to_string()) + } else { + None + }; + let fmt_time = |dt: Option>| -> String { dt.map(|d| d.to_rfc3339()) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()) @@ -236,6 +247,7 @@ pub async fn save_identity_file_by_pool(pool: &sqlx::PgPool, uuid: &str) -> Resu status: record.status, tmdb_id: record.tmdb_id, tmdb_profile: record.tmdb_profile, + local_profile, metadata: record.metadata, file_bindings, created_at: fmt_time(record.created_at), @@ -349,6 +361,15 @@ pub async fn save_identity_file(db: &PostgresDb, uuid: &str) -> Result<()> { }) .collect(); + // Check for local profile image + let clean = uuid.replace('-', ""); + let profile_path = identity_dir(&clean).join("profile.jpg"); + let local_profile = if profile_path.exists() { + Some("profile.jpg".to_string()) + } else { + None + }; + let fmt_time = |dt: Option>| -> String { dt.map(|d| d.to_rfc3339()) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()) @@ -363,6 +384,7 @@ pub async fn save_identity_file(db: &PostgresDb, uuid: &str) -> Result<()> { status: record.status, tmdb_id: record.tmdb_id, tmdb_profile: record.tmdb_profile, + local_profile, metadata: record.metadata, file_bindings, created_at: fmt_time(record.created_at),