Files
momentry_core/src/api/identity_api.rs
M5Max128 3a33d00449 refactor: modularize server.rs into separate route modules
- Extract scan.rs, files.rs, types.rs, processing.rs, visual_chunk_search.rs
- Move AppState and AppConfig to types.rs
- Each module exposes pub fn xxx_routes() -> Router<AppState>
- server.rs reduced from 5005 to 118 lines (orchestrator only)
- All stubs filled with real implementations from git history
- Verify: cargo check, clippy, tests all pass
2026-05-21 16:38:49 +08:00

1225 lines
36 KiB
Rust

use axum::{
extract::{Multipart, Path, Query, State},
http::StatusCode,
response::{Html, Json},
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::core::db::ResourceRecord;
pub fn identity_routes() -> Router<crate::api::types::AppState> {
Router::new()
.route("/api/v1/files", get(list_files))
.route("/api/v1/file/:file_uuid", get(get_file_detail))
.route(
"/api/v1/file/:file_uuid/identities",
get(get_file_identities),
)
.route(
"/api/v1/identity/:identity_uuid",
get(get_identity_detail).delete(delete_identity),
)
.route(
"/api/v1/identity/:identity_uuid/files",
get(get_identity_files),
)
.route(
"/api/v1/identity/:identity_uuid/chunks",
get(get_identity_chunks),
)
.route(
"/api/v1/identity/:identity_uuid/faces",
get(get_identity_faces),
)
.route("/api/v1/resource/register", post(register_resource))
.route("/api/v1/resource/heartbeat", post(heartbeat_resource))
.route("/api/v1/resources", get(list_resources))
.route("/api/v1/identity/upload", post(upload_identity))
.route(
"/api/v1/identity/:identity_uuid/profile-image",
post(upload_profile_image).get(get_profile_image),
)
.route(
"/api/v1/identity/:identity_uuid/status",
get(get_identity_status),
)
.route(
"/api/v1/identity/:identity_uuid/json",
get(get_identity_json),
)
// Experiment: identity text search (non-polluting, separate endpoint)
.route("/api/v1/search/identity_text", get(search_identity_text))
.route("/api/v1/identities/search", get(search_identities_by_text))
}
// --- Files Endpoints ---
#[derive(Debug, Deserialize)]
pub struct FilesQuery {
page: Option<usize>,
page_size: Option<usize>,
uuid: Option<String>, // Add uuid filter
}
async fn list_files(
State(state): State<crate::api::types::AppState>,
Query(params): Query<FilesQuery>,
) -> Result<Json<FilesResponse>, (StatusCode, String)> {
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
// If UUID is provided, fetch that specific file and return it as a list item
if let Some(ref uuid) = params.uuid {
let video = state
.db
.get_video_by_uuid(uuid)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let data = if let Some(v) = video {
vec![FileItem {
file_uuid: v.file_uuid,
file_name: v.file_name,
file_path: v.file_path,
status: v.status.as_str().to_string(),
}]
} else {
vec![]
};
return Ok(Json(FilesResponse {
success: true,
total: data.len() as i64,
page,
page_size,
data,
}));
}
// Default: List files with pagination
let offset = ((page - 1) as i64) * (page_size as i64);
let records = state
.db
.list_videos(page_size as i32, offset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let data = records
.0
.into_iter()
.map(|r| FileItem {
file_uuid: r.file_uuid,
file_name: r.file_name,
file_path: r.file_path,
status: r.status.as_str().to_string(),
})
.collect();
let total = records.1;
Ok(Json(FilesResponse {
success: true,
total,
page,
page_size,
data,
}))
}
#[derive(Debug, Serialize)]
pub struct FilesResponse {
pub success: bool,
pub total: i64,
pub page: usize,
pub page_size: usize,
pub data: Vec<FileItem>,
}
#[derive(Debug, Serialize)]
pub struct FileItem {
pub file_uuid: String,
pub file_name: String,
pub file_path: String,
pub status: String,
}
#[derive(Debug, Serialize)]
pub struct FileDetailResponse {
pub success: bool,
pub file_uuid: String,
pub file_name: String,
pub file_path: String,
pub metadata: Option<serde_json::Value>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn get_file_detail(
State(state): State<crate::api::types::AppState>,
Path(file_uuid): Path<String>,
) -> Result<Json<FileDetailResponse>, (StatusCode, String)> {
let file = state
.db
.get_video_by_uuid(&file_uuid)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match file {
Some(f) => Ok(Json(FileDetailResponse {
success: true,
file_uuid: f.file_uuid,
file_name: f.file_name,
file_path: f.file_path,
metadata: f.probe_json,
created_at: chrono::DateTime::parse_from_rfc3339(&f.created_at)
.ok()
.map(|d| d.into()),
})),
None => Err((
StatusCode::NOT_FOUND,
format!("File not found: {}", file_uuid),
)),
}
}
#[derive(Debug, Serialize)]
pub struct FileIdentitiesResponse {
pub success: bool,
pub file_uuid: String,
pub fps: f64,
pub total: i64,
pub page: usize,
pub page_size: usize,
pub data: Vec<FileIdentityItem>,
}
#[derive(Debug, Serialize)]
pub struct FileIdentityItem {
pub identity_id: i32,
pub identity_uuid: Option<String>,
pub name: String,
pub metadata: serde_json::Value,
pub face_count: Option<i32>,
pub speaker_count: Option<i32>,
pub start_frame: Option<i32>,
pub end_frame: Option<i32>,
pub start_time: Option<f64>,
pub end_time: Option<f64>,
pub confidence: Option<f64>,
}
async fn get_file_identities(
State(state): State<crate::api::types::AppState>,
Path(file_uuid): Path<String>,
Query(params): Query<FilesQuery>,
) -> Result<Json<FileIdentitiesResponse>, (StatusCode, String)> {
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let offset = ((page - 1) as i64) * (page_size as i64);
let records = state
.db
.get_file_identities(&file_uuid, page_size as i32, offset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let fps = 25.0;
let data: Vec<FileIdentityItem> = records
.into_iter()
.map(|r| FileIdentityItem {
identity_id: r.identity_id,
identity_uuid: r.identity_uuid,
name: r.name,
metadata: r.metadata,
face_count: r.face_count,
speaker_count: r.speaker_count,
start_frame: r.start_frame,
end_frame: r.end_frame,
start_time: r.start_time,
end_time: r.end_time,
confidence: r.confidence,
})
.collect();
let total = match sqlx::query_scalar::<_, i64>(
&format!(
"SELECT COUNT(DISTINCT fd.identity_id) FROM {} fd WHERE fd.file_uuid = $1 AND fd.identity_id IS NOT NULL",
crate::core::db::schema::table_name("face_detections")
)
)
.bind(&file_uuid)
.fetch_one(state.db.pool())
.await
{
Ok(c) => c,
Err(_) => data.len() as i64,
};
Ok(Json(FileIdentitiesResponse {
success: true,
file_uuid: file_uuid,
fps,
total,
page,
page_size,
data,
}))
}
#[derive(Debug, Serialize)]
pub struct IdentityDetailResponse {
pub success: bool,
pub identity_uuid: String,
pub name: String,
pub identity_type: Option<String>,
pub source: Option<String>,
pub status: Option<String>,
pub metadata: serde_json::Value,
pub reference_data: serde_json::Value,
pub tmdb_id: Option<i32>,
pub tmdb_profile: Option<String>,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Serialize)]
pub struct IdentityStatusResponse {
pub success: bool,
pub identity_uuid: String,
pub name: String,
pub has_json: bool,
pub has_jpg: bool,
pub error: Option<String>,
}
fn strip_uuid(u: &uuid::Uuid) -> String {
u.to_string().replace('-', "")
}
async fn get_identity_detail(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
) -> Result<Json<IdentityDetailResponse>, (StatusCode, String)> {
let uuid_clean = identity_uuid.replace('-', "");
let identity = state
.db
.get_identity_by_uuid(&uuid_clean)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match identity {
Some(i) => Ok(Json(IdentityDetailResponse {
success: true,
identity_uuid: i.uuid.clone(),
name: i.name,
identity_type: i.identity_type,
source: i.source,
status: i.status,
metadata: i.metadata,
reference_data: i.reference_data,
tmdb_id: i.tmdb_id,
tmdb_profile: Some(format!(
"{}/identities/{}/profile.jpg",
crate::core::config::OUTPUT_DIR.as_str(),
i.uuid.replace('-', "")
)),
created_at: i.created_at,
updated_at: i.updated_at,
})),
None => Err((
StatusCode::NOT_FOUND,
format!("Identity not found: {}", uuid_clean),
)),
}
}
async fn get_identity_status(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
) -> Result<Json<IdentityStatusResponse>, (StatusCode, String)> {
let uuid_clean = identity_uuid.replace('-', "");
let identity = state
.db
.get_identity_by_uuid(&uuid_clean)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match identity {
Some(i) => {
// Check both UUID formats (with and without hyphens)
let dir_nohyphen = crate::core::identity::storage::identity_dir(&uuid_clean);
let uuid_hyphen = i.uuid.clone();
let dir_hyphen = crate::core::identity::storage::identity_dir(&uuid_hyphen);
let has_json = dir_nohyphen.join("identity.json").exists()
|| dir_hyphen.join("identity.json").exists();
let has_jpg = dir_nohyphen.join("profile.jpg").exists()
|| dir_hyphen.join("profile.jpg").exists();
Ok(Json(IdentityStatusResponse {
success: true,
identity_uuid: i.uuid.clone(),
name: i.name,
has_json,
has_jpg,
error: None,
}))
}
None => Err((
StatusCode::NOT_FOUND,
format!("Identity not found: {}", uuid_clean),
)),
}
}
#[derive(Debug, Serialize)]
pub struct IdentityFilesResponse {
pub success: bool,
pub identity_uuid: String,
pub total: i64,
pub page: usize,
pub page_size: usize,
pub data: Vec<IdentityFileItem>,
}
async fn delete_identity(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
) -> Result<StatusCode, StatusCode> {
let table = crate::core::db::schema::table_name("face_detections");
let id_table = crate::core::db::schema::table_name("identities");
// Get identity_id from identity_uuid
let row: Option<(i32,)> = sqlx::query_as(&format!(
"SELECT id FROM {} WHERE replace(uuid::text, '-', '') = $1",
id_table
))
.bind(&identity_uuid)
.fetch_optional(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let (identity_id,) = row.ok_or(StatusCode::NOT_FOUND)?;
// Unbind all faces
sqlx::query(&format!(
"UPDATE {} SET identity_id = NULL, identity_confidence = NULL WHERE identity_id = $1",
table
))
.bind(identity_id)
.execute(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Delete identity
sqlx::query(&format!("DELETE FROM {} WHERE id = $1", id_table))
.bind(identity_id)
.execute(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Serialize)]
pub struct IdentityFileItem {
pub file_uuid: String,
pub file_name: String,
pub file_path: String,
pub status: String,
pub face_count: Option<i32>,
pub speaker_count: Option<i32>,
pub first_appearance: Option<f64>,
pub last_appearance: Option<f64>,
pub confidence: Option<f64>,
}
async fn get_identity_files(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
Query(params): Query<FilesQuery>,
) -> Result<Json<IdentityFilesResponse>, (StatusCode, String)> {
let uuid = identity_uuid.replace('-', "");
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let offset = ((page - 1) as i64) * (page_size as i64);
let records = state
.db
.get_identity_files(&uuid, page_size as i32, offset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let data: Vec<IdentityFileItem> = records
.into_iter()
.map(|r| IdentityFileItem {
file_uuid: r.file_uuid,
file_name: r.file_name,
file_path: r.file_path,
status: r.status,
face_count: r.face_count,
speaker_count: r.speaker_count,
first_appearance: r.first_appearance,
last_appearance: r.last_appearance,
confidence: r.confidence,
})
.collect();
let total = match sqlx::query_scalar::<_, i64>(
&format!(
"SELECT COUNT(DISTINCT fd.file_uuid) FROM {} fd WHERE fd.identity_id = (SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1)",
crate::core::db::schema::table_name("face_detections"),
crate::core::db::schema::table_name("identities"),
)
)
.bind(&uuid)
.fetch_one(state.db.pool())
.await
{
Ok(c) => c,
Err(_) => data.len() as i64,
};
Ok(Json(IdentityFilesResponse {
success: true,
identity_uuid: uuid.to_string().replace('-', ""),
total,
page,
page_size,
data,
}))
}
#[derive(Debug, Serialize)]
pub struct IdentityFacesResponse {
pub success: bool,
pub identity_uuid: String,
pub total: i64,
pub page: usize,
pub page_size: usize,
pub data: Vec<IdentityFaceItem>,
}
#[derive(Debug, Serialize)]
pub struct IdentityFaceItem {
pub id: i64,
pub file_uuid: String,
pub frame_number: i64,
pub timestamp_secs: Option<f64>,
pub face_id: Option<String>,
pub bbox: BBox,
pub confidence: f64,
}
#[derive(Debug, Serialize)]
pub struct BBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
async fn get_identity_faces(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
Query(params): Query<FilesQuery>,
) -> Result<Json<IdentityFacesResponse>, (StatusCode, String)> {
let uuid = identity_uuid.replace('-', "");
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(50);
let offset = ((page - 1) as i64) * (page_size as i64);
let records = state
.db
.get_identity_faces(&uuid, page_size as i32, offset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let data: Vec<IdentityFaceItem> = records
.into_iter()
.map(|r| IdentityFaceItem {
id: r.id,
file_uuid: r.file_uuid,
frame_number: r.frame_number,
timestamp_secs: r.timestamp_secs,
face_id: r.face_id,
bbox: BBox {
x: r.x,
y: r.y,
width: r.width,
height: r.height,
},
confidence: r.confidence,
})
.collect();
let total = match sqlx::query_scalar::<_, i64>(
&format!(
"SELECT COUNT(*) FROM {} fd WHERE fd.identity_id = (SELECT id FROM {} WHERE REPLACE(uuid::text, '-', '') = $1)",
crate::core::db::schema::table_name("face_detections"),
crate::core::db::schema::table_name("identities"),
)
)
.bind(&uuid)
.fetch_one(state.db.pool())
.await
{
Ok(c) => c,
Err(_) => data.len() as i64,
};
Ok(Json(IdentityFacesResponse {
success: true,
identity_uuid: uuid.to_string().replace('-', ""),
total,
page,
page_size,
data,
}))
}
#[derive(Debug, Serialize)]
pub struct IdentityChunksResponse {
pub success: bool,
pub identity_uuid: String,
pub total: i64,
pub page: usize,
pub page_size: usize,
pub data: Vec<IdentityChunkItem>,
}
#[derive(Debug, Serialize)]
pub struct IdentityChunkItem {
pub id: i64,
pub file_uuid: String,
pub chunk_id: String,
pub chunk_type: String,
pub start_frame: i64,
pub end_frame: i64,
pub fps: f64,
pub start_time: Option<f64>,
pub end_time: Option<f64>,
pub text_content: Option<String>,
}
async fn get_identity_chunks(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
Query(params): Query<FilesQuery>,
) -> Result<Json<IdentityChunksResponse>, (StatusCode, String)> {
let uuid = identity_uuid.replace('-', "");
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let offset = ((page - 1) as i64) * (page_size as i64);
let records = state
.db
.get_identity_chunks(&uuid, page_size as i32, offset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let data: Vec<IdentityChunkItem> = records
.into_iter()
.map(|r| IdentityChunkItem {
id: r.id as i64,
file_uuid: r.file_uuid,
chunk_id: r.chunk_id,
chunk_type: r.chunk_type,
start_frame: r.start_frame,
end_frame: r.end_frame,
fps: r.fps,
start_time: r.start_time,
end_time: r.end_time,
text_content: r.text_content,
})
.collect();
Ok(Json(IdentityChunksResponse {
success: true,
identity_uuid: uuid.to_string().replace('-', ""),
total: data.len() as i64,
page,
page_size,
data,
}))
}
// --- Resource Registry Endpoints (Phase 5) ---
#[derive(Debug, Deserialize)]
pub struct RegisterResourceRequest {
pub resource_id: String,
pub resource_type: String,
pub category: String,
pub capabilities: Option<serde_json::Value>,
pub config: Option<serde_json::Value>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct ResourceResponse {
pub success: bool,
pub message: String,
pub data: Option<ResourceItem>,
}
#[derive(Debug, Serialize)]
pub struct ResourceItem {
pub resource_id: String,
pub resource_type: String,
pub category: String,
pub capabilities: Option<serde_json::Value>,
pub status: String,
pub last_heartbeat: Option<chrono::DateTime<chrono::Utc>>,
}
async fn register_resource(
State(state): State<crate::api::types::AppState>,
Json(req): Json<RegisterResourceRequest>,
) -> Result<Json<ResourceResponse>, (StatusCode, String)> {
let resource = ResourceRecord {
resource_id: req.resource_id.clone(),
resource_type: req.resource_type.clone(),
category: req.category.clone(),
capabilities: req.capabilities,
config: req.config,
metadata: req.metadata,
status: "online".to_string(),
last_heartbeat: None,
created_at: None,
};
state
.db
.register_resource(resource)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(ResourceResponse {
success: true,
message: "Resource registered successfully".to_string(),
data: None, // We could return the full record, but simplified for now
}))
}
#[derive(Debug, Deserialize)]
pub struct HeartbeatRequest {
pub resource_id: String,
pub status: Option<String>,
}
async fn heartbeat_resource(
State(state): State<crate::api::types::AppState>,
Json(req): Json<HeartbeatRequest>,
) -> Result<Json<ResourceResponse>, (StatusCode, String)> {
let status = req.status.unwrap_or("online".to_string());
state
.db
.heartbeat_resource(&req.resource_id, &status)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(ResourceResponse {
success: true,
message: "Heartbeat received".to_string(),
data: None,
}))
}
async fn list_resources(
State(state): State<crate::api::types::AppState>,
) -> Result<Json<ResourceResponse>, (StatusCode, String)> {
let records = state
.db
.list_resources()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let data: Vec<ResourceItem> = records
.into_iter()
.map(|r| ResourceItem {
resource_id: r.resource_id,
resource_type: r.resource_type,
category: r.category,
capabilities: r.capabilities,
status: r.status,
last_heartbeat: r.last_heartbeat,
})
.collect();
Ok(Json(ResourceResponse {
success: true,
message: "Resources listed".to_string(),
data: None,
}))
}
// ── Identity Upload ──────────────────────────────────────────
#[derive(Debug, Serialize)]
struct IdentityUploadResponse {
success: bool,
identity_uuid: String,
name: String,
message: String,
}
async fn upload_identity(
State(state): State<crate::api::types::AppState>,
Json(payload): Json<crate::core::identity::storage::IdentityFile>,
) -> Result<Json<IdentityUploadResponse>, (StatusCode, Json<serde_json::Value>)> {
let parsed = uuid::Uuid::parse_str(&payload.identity_uuid)
.map_err(|_| (StatusCode::BAD_REQUEST, Json(serde_json::json!({
"success": false, "message": format!("Invalid identity_uuid: {}", payload.identity_uuid)
}))))?;
// Upsert into identities table
let identities_table = crate::core::db::schema::table_name("identities");
let metadata_json = serde_json::to_value(&payload.metadata).unwrap_or_default();
let result = sqlx::query_as::<_, (String,)>(&format!(
"INSERT INTO {} (uuid, name, identity_type, source, status, tmdb_id, tmdb_profile, metadata) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
ON CONFLICT (name) DO UPDATE SET \
source = EXCLUDED.source, status = EXCLUDED.status, \
tmdb_id = EXCLUDED.tmdb_id, tmdb_profile = EXCLUDED.tmdb_profile, \
metadata = EXCLUDED.metadata \
RETURNING uuid::text", identities_table
))
.bind(parsed)
.bind(&payload.name)
.bind(&payload.identity_type)
.bind(&payload.source)
.bind(&payload.status)
.bind(payload.tmdb_id)
.bind(&payload.tmdb_profile)
.bind(&metadata_json)
.fetch_optional(state.db.pool())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"success": false, "message": format!("DB error: {}", e)
}))))?;
let uuid_str = match result {
Some((u,)) => crate::core::identity::storage::update_index(&u, &payload.name)
.and(Ok(u))
.unwrap_or_else(|_| payload.identity_uuid.clone()),
None => payload.identity_uuid.clone(),
};
// Write identity.json to filesystem (strip hyphens from UUID for directory name)
let mut file_payload = payload.clone();
file_payload.identity_uuid = file_payload.identity_uuid.replace('-', "");
if let Err(e) = crate::core::identity::storage::write_identity_file(&file_payload) {
tracing::warn!("[identity-upload] Failed to write identity.json: {}", e);
}
Ok(Json(IdentityUploadResponse {
success: true,
identity_uuid: uuid_str.replace('-', ""),
name: file_payload.name,
message: "Identity uploaded successfully".to_string(),
}))
}
// ── Profile Image Upload ────────────────────────────────────
#[derive(Debug, Serialize)]
struct ProfileImageResponse {
success: bool,
identity_uuid: String,
path: String,
message: String,
}
async fn upload_profile_image(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
mut multipart: Multipart,
) -> Result<Json<ProfileImageResponse>, (StatusCode, Json<serde_json::Value>)> {
let uuid_clean = identity_uuid.replace('-', "");
// Verify identity exists
if state
.db
.get_identity_by_uuid(&uuid_clean)
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"success": false, "message": "DB error"})),
)
})?
.is_none()
{
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"success": false, "message": "Identity not found"
})),
));
}
// Process multipart upload
let mut image_data: Option<Vec<u8>> = None;
let mut ext: &str = "jpg";
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name == "image" {
let content_type = field.content_type().unwrap_or("image/jpeg").to_string();
ext = match content_type.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"success": false, "message": "Unsupported image type. Use JPEG or PNG."
})),
))
}
};
image_data = Some(field.bytes().await.map_err(|_| {
(StatusCode::BAD_REQUEST, Json(serde_json::json!({"success": false, "message": "Failed to read image data"})))
})?.to_vec());
}
}
let data = image_data.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"success": false, "message": "No image field found. Use field name 'image'."
})),
)
})?;
// Write image file
let dir = crate::core::identity::storage::identity_dir(&uuid_clean);
std::fs::create_dir_all(&dir).map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to create dir: {}", e)})))
})?;
let file_name = format!("profile.{}", ext);
let file_path = dir.join(&file_name);
std::fs::write(&file_path, &data).map_err(|e| {
(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,
path: file_path.to_string_lossy().to_string(),
message: format!("Profile image saved: {}", file_name),
}))
}
async fn get_profile_image(
Path(identity_uuid): Path<String>,
) -> Result<(StatusCode, [(String, String); 1], Vec<u8>), StatusCode> {
let uuid_clean = identity_uuid.replace('-', "");
let dir = crate::core::identity::storage::identity_dir(&uuid_clean);
for ext in &["jpg", "png"] {
let path = dir.join(format!("profile.{}", ext));
if path.exists() {
let data = std::fs::read(&path).map_err(|_| StatusCode::NOT_FOUND)?;
let content_type = if *ext == "png" {
"image/png"
} else {
"image/jpeg"
};
return Ok((
StatusCode::OK,
[("content-type".to_string(), content_type.to_string())],
data,
));
}
}
Err(StatusCode::NOT_FOUND)
}
async fn get_identity_json(
State(state): State<crate::api::types::AppState>,
Path(identity_uuid): Path<String>,
) -> Result<(StatusCode, [(String, String); 1], Vec<u8>), StatusCode> {
let clean = identity_uuid.replace('-', "");
let with_hyphens = if clean.len() == 32 {
format!(
"{}-{}-{}-{}-{}",
&clean[0..8],
&clean[8..12],
&clean[12..16],
&clean[16..20],
&clean[20..32]
)
} else {
identity_uuid.clone()
};
// 1. Try file system first
for u in [&clean, &identity_uuid, &with_hyphens] {
let p = crate::core::identity::storage::identity_file_path(u);
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,
));
}
}
// 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);
}
// 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,
));
}
Err(StatusCode::NOT_FOUND)
}
// ── Experiment: Identity Text Search ──────────────────────────
// Separate endpoints — do not modify existing API behavior.
#[derive(Debug, Deserialize)]
struct IdentityTextQuery {
uuid: String,
q: String,
limit: Option<i64>,
page: Option<usize>,
page_size: Option<usize>,
}
#[derive(Debug, Serialize)]
struct IdentityTextHit {
file_uuid: String,
chunk_id: String,
start_time: f64,
end_time: f64,
text_content: Option<String>,
identity_id: Option<i32>,
identity_name: Option<String>,
identity_source: Option<String>,
trace_id: Option<i32>,
}
#[derive(Debug, Serialize)]
struct IdentityTextResponse {
success: bool,
total: i64,
page: usize,
page_size: usize,
limit: usize,
results: Vec<IdentityTextHit>,
}
/// Path A: Search chunk text → associated identities
async fn search_identity_text(
State(state): State<crate::api::types::AppState>,
Query(params): Query<IdentityTextQuery>,
) -> Result<Json<IdentityTextResponse>, StatusCode> {
use crate::core::db::schema;
let chunk_table = schema::table_name("chunk");
let fd_table = schema::table_name("face_detections");
let id_table = schema::table_name("identities");
let like_q = format!("%{}%", params.q.replace('%', "%%"));
let limit = params.limit.unwrap_or(50).min(100);
let query = format!(
r#"SELECT c.file_uuid, c.chunk_id, c.start_time, c.end_time, c.text_content,
fd.identity_id, i.name AS identity_name, i.source AS identity_source,
fd.trace_id
FROM {} c
LEFT JOIN {} fd ON fd.file_uuid = c.file_uuid
AND fd.frame_number BETWEEN c.start_frame AND c.end_frame
AND fd.identity_id IS NOT NULL
LEFT JOIN {} i ON i.id = fd.identity_id
WHERE c.file_uuid = $1 AND LOWER(c.text_content) LIKE LOWER($2)
ORDER BY c.start_time
LIMIT $3"#,
chunk_table, fd_table, id_table
);
let rows = sqlx::query_as::<
_,
(
String,
String,
f64,
f64,
Option<String>,
Option<i32>,
Option<String>,
Option<String>,
Option<i32>,
),
>(&query)
.bind(&params.uuid)
.bind(&like_q)
.bind(limit)
.fetch_all(state.db.pool())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let results: Vec<IdentityTextHit> = rows
.into_iter()
.map(
|(fu, cid, st, et, txt, iid, iname, isrc, tid)| IdentityTextHit {
file_uuid: fu,
chunk_id: cid,
start_time: st,
end_time: et,
text_content: txt,
identity_id: iid,
identity_name: iname,
identity_source: isrc,
trace_id: tid,
},
)
.collect();
let total = results.len() as i64;
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(total as usize).max(1);
let start = (page - 1) * page_size;
let paged: Vec<IdentityTextHit> = results.into_iter().skip(start).take(page_size).collect();
let limit = params.limit.unwrap_or(50) as usize;
Ok(Json(IdentityTextResponse {
success: true,
total,
page,
page_size,
limit,
results: paged,
}))
}
#[derive(Debug, Deserialize)]
struct IdentitySearchQuery {
q: String,
uuid: Option<String>,
limit: Option<i64>,
}
#[derive(Debug, Serialize)]
struct IdentitySearchHit {
identity_id: i32,
name: String,
source: Option<String>,
tmdb_id: Option<i32>,
file_uuid: String,
trace_id: Option<i32>,
chunk_id: String,
start_time: f64,
text_content: Option<String>,
}
#[derive(Debug, Serialize)]
struct IdentitySearchResponse {
success: bool,
total: i64,
results: Vec<IdentitySearchHit>,
}
/// Path B: Search identity name → associated chunk text
async fn search_identities_by_text(
State(state): State<crate::api::types::AppState>,
Query(params): Query<IdentitySearchQuery>,
) -> Result<Json<IdentitySearchResponse>, StatusCode> {
use crate::core::db::schema;
let id_table = schema::table_name("identities");
let fd_table = schema::table_name("face_detections");
let chunk_table = schema::table_name("chunk");
let like_q = format!("%{}%", params.q.replace('%', "%%"));
let limit = params.limit.unwrap_or(50).min(100);
let query = format!(
r#"SELECT i.id::int, i.name, i.source, i.tmdb_id,
fd.file_uuid, fd.trace_id,
c.chunk_id, c.start_time, c.text_content
FROM {} i
JOIN {} fd ON fd.identity_id = i.id
JOIN {} c ON c.file_uuid = fd.file_uuid
AND c.start_time <= fd.frame_number / COALESCE(c.fps, 25.0)
AND c.end_time >= fd.frame_number / COALESCE(c.fps, 25.0)
WHERE i.name ILIKE $1
AND ($2::text IS NULL OR fd.file_uuid = $2)
ORDER BY i.name, c.start_time
LIMIT $3"#,
id_table, fd_table, chunk_table
);
let rows = sqlx::query_as::<
_,
(
i32,
String,
Option<String>,
Option<i32>,
String,
Option<i32>,
String,
f64,
Option<String>,
),
>(&query)
.bind(&like_q)
.bind(&params.uuid)
.bind(limit)
.fetch_all(state.db.pool())
.await
.map_err(|e| {
tracing::error!("[identities/search] Query failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let results: Vec<IdentitySearchHit> = rows
.into_iter()
.map(
|(iid, name, src, tid, fu, trace_id, cid, st, txt)| IdentitySearchHit {
identity_id: iid,
name,
source: src,
tmdb_id: tid,
file_uuid: fu,
trace_id,
chunk_id: cid,
start_time: st,
text_content: txt,
},
)
.collect();
let total = results.len() as i64;
Ok(Json(IdentitySearchResponse {
success: true,
total,
results,
}))
}