update: pipeline, search, clip, embedding fixes

This commit is contained in:
Accusys
2026-05-17 19:46:35 +08:00
parent eec2eea880
commit 3164a65554
36 changed files with 4313 additions and 4061 deletions
+203 -43
View File
@@ -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)]