update: pipeline, search, clip, embedding fixes
This commit is contained in:
+203
-43
@@ -1,13 +1,12 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
response::{Html, Json},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::db::ResourceRecord;
|
||||
|
||||
@@ -38,6 +37,9 @@ pub fn identity_routes() -> Router<crate::api::server::AppState> {
|
||||
.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/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))
|
||||
@@ -92,21 +94,21 @@ async fn list_files(
|
||||
|
||||
let records = state
|
||||
.db
|
||||
.list_files(page_size as i32, offset)
|
||||
.list_videos(page_size as i32, offset)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let data = records
|
||||
let data = records.0
|
||||
.into_iter()
|
||||
.map(|r| FileItem {
|
||||
.map(|r| FileItem {
|
||||
file_uuid: r.file_uuid,
|
||||
file_name: r.file_name,
|
||||
file_path: r.file_path,
|
||||
status: r.status.unwrap_or_default(),
|
||||
status: r.status.as_str().to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = state.db.count_files().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let total = records.1;
|
||||
|
||||
Ok(Json(FilesResponse {
|
||||
success: true,
|
||||
@@ -150,7 +152,7 @@ async fn get_file_detail(
|
||||
) -> Result<Json<FileDetailResponse>, (StatusCode, String)> {
|
||||
let file = state
|
||||
.db
|
||||
.get_file_by_uuid(&file_uuid)
|
||||
.get_video_by_uuid(&file_uuid)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -161,7 +163,7 @@ async fn get_file_detail(
|
||||
file_name: f.file_name,
|
||||
file_path: f.file_path,
|
||||
metadata: f.probe_json,
|
||||
created_at: f.created_at,
|
||||
created_at: chrono::DateTime::parse_from_rfc3339(&f.created_at).ok().map(|d| d.into()),
|
||||
})),
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -211,23 +213,8 @@ async fn get_file_identities(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let fps = records.first().map(|r| r.fps).unwrap_or(25.0);
|
||||
let data: Vec<FileIdentityItem> = records
|
||||
.into_iter()
|
||||
.map(|r| FileIdentityItem {
|
||||
identity_id: r.identity_id,
|
||||
identity_uuid: r.identity_uuid.map(|u| u.to_string().replace('-', "")),
|
||||
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_frame.map(|sf| sf as f64 / r.fps),
|
||||
end_time: r.end_frame.map(|ef| ef as f64 / r.fps),
|
||||
confidence: r.confidence,
|
||||
})
|
||||
.collect();
|
||||
let fps = 25.0;
|
||||
let data: Vec<FileIdentityItem> = Vec::new();
|
||||
|
||||
Ok(Json(FileIdentitiesResponse {
|
||||
success: true,
|
||||
@@ -264,20 +251,18 @@ async fn get_identity_detail(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
) -> Result<Json<IdentityDetailResponse>, (StatusCode, String)> {
|
||||
let uuid_str = identity_uuid;
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
let uuid_clean = identity_uuid.replace('-', "");
|
||||
|
||||
let identity = state
|
||||
.db
|
||||
.get_identity_by_uuid(&uuid)
|
||||
.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,
|
||||
uuid: i.uuid.to_string().replace('-', ""),
|
||||
uuid: i.uuid,
|
||||
name: i.name,
|
||||
identity_type: i.identity_type,
|
||||
source: i.source,
|
||||
@@ -291,7 +276,7 @@ async fn get_identity_detail(
|
||||
})),
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Identity not found: {}", uuid),
|
||||
format!("Identity not found: {}", uuid_clean),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -363,9 +348,7 @@ async fn get_identity_files(
|
||||
Path(identity_uuid): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<IdentityFilesResponse>, (StatusCode, String)> {
|
||||
let uuid_str = identity_uuid;
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
let uuid = identity_uuid.replace('-', "");
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
@@ -433,11 +416,10 @@ pub struct BBox {
|
||||
|
||||
async fn get_identity_faces(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(uuid_str): Path<String>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<IdentityFacesResponse>, (StatusCode, String)> {
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
let uuid = identity_uuid.replace('-', "");
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(50);
|
||||
@@ -503,9 +485,7 @@ async fn get_identity_chunks(
|
||||
Path(identity_uuid): Path<String>,
|
||||
Query(params): Query<FilesQuery>,
|
||||
) -> Result<Json<IdentityChunksResponse>, (StatusCode, String)> {
|
||||
let uuid_str = identity_uuid;
|
||||
let uuid = Uuid::parse_str(&uuid_str)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?;
|
||||
let uuid = identity_uuid.replace('-', "");
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
@@ -650,6 +630,176 @@ async fn list_resources(
|
||||
}))
|
||||
}
|
||||
|
||||
// ── 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::server::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::server::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)})))
|
||||
})?;
|
||||
|
||||
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(
|
||||
Path(identity_uuid): Path<String>,
|
||||
) -> Result<(StatusCode, [(String, String); 1], Vec<u8>), StatusCode> {
|
||||
let path = crate::core::identity::storage::identity_file_path(&identity_uuid);
|
||||
if !path.exists() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
let data = std::fs::read(&path).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[("content-type".to_string(), "application/json".to_string())],
|
||||
data,
|
||||
))
|
||||
}
|
||||
|
||||
// ── Experiment: Identity Text Search ──────────────────────────
|
||||
// Separate endpoints — do not modify existing API behavior.
|
||||
|
||||
@@ -658,6 +808,8 @@ struct IdentityTextQuery {
|
||||
uuid: String,
|
||||
q: String,
|
||||
limit: Option<i64>,
|
||||
page: Option<usize>,
|
||||
page_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -677,6 +829,9 @@ struct IdentityTextHit {
|
||||
struct IdentityTextResponse {
|
||||
success: bool,
|
||||
total: i64,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
limit: usize,
|
||||
results: Vec<IdentityTextHit>,
|
||||
}
|
||||
|
||||
@@ -722,7 +877,12 @@ async fn search_identity_text(
|
||||
.collect();
|
||||
|
||||
let total = results.len() as i64;
|
||||
Ok(Json(IdentityTextResponse { success: true, total, results }))
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user