update: pipeline, search, clip, embedding fixes
This commit is contained in:
@@ -41,22 +41,24 @@ async fn translate_text(
|
||||
req.target_language, req.text
|
||||
);
|
||||
|
||||
// Call Ollama API
|
||||
// Call Gemma4 via llama.cpp (port 8082, OpenAI-compatible API)
|
||||
let client = Client::new();
|
||||
let ollama_url = "http://localhost:11434/api/generate";
|
||||
|
||||
// Using qwen3:latest which is available locally
|
||||
let model = "qwen3:latest".to_string();
|
||||
let llm_url = "http://localhost:8082/v1/chat/completions";
|
||||
let model = "google_gemma-4-26B-A4B-it-Q5_K_M.gguf".to_string();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"system": system_prompt,
|
||||
"stream": false
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"stream": false,
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.1
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(ollama_url)
|
||||
.post(llm_url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -67,15 +69,19 @@ async fn translate_text(
|
||||
)
|
||||
})?;
|
||||
|
||||
let ollama_resp: serde_json::Value = response.json().await.map_err(|e| {
|
||||
let llm_resp: serde_json::Value = response.json().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to parse LLM response: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let translated_text = ollama_resp
|
||||
.get("response")
|
||||
let translated_text = llm_resp
|
||||
.get("choices")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|c| c.first())
|
||||
.and_then(|c| c.get("message"))
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Translation failed")
|
||||
.to_string();
|
||||
|
||||
@@ -96,13 +96,19 @@ struct SceneSummaryResult {
|
||||
// ── LLM Endpoint ──
|
||||
|
||||
fn llm_base_url() -> String {
|
||||
std::env::var("MOMENTRY_LLM_SUMMARY_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8081/v1/chat/completions".to_string())
|
||||
let v = std::env::var("MOMENTRY_LLM_URL");
|
||||
if v.is_ok() { return v.unwrap(); }
|
||||
let v = std::env::var("MOMENTRY_LLM_SUMMARY_URL");
|
||||
if v.is_ok() { return v.unwrap(); }
|
||||
"http://localhost:8082/v1/chat/completions".to_string()
|
||||
}
|
||||
|
||||
fn llm_model() -> String {
|
||||
std::env::var("MOMENTRY_LLM_SUMMARY_MODEL")
|
||||
.unwrap_or_else(|_| "gemma-4-31B-it-Q5_K_M.gguf".to_string())
|
||||
let v = std::env::var("MOMENTRY_LLM_MODEL");
|
||||
if v.is_ok() { return v.unwrap(); }
|
||||
let v = std::env::var("MOMENTRY_LLM_SUMMARY_MODEL");
|
||||
if v.is_ok() { return v.unwrap(); }
|
||||
"google_gemma-4-26B-A4B-it-Q5_K_M.gguf".to_string()
|
||||
}
|
||||
|
||||
// ── Data Fetching ──
|
||||
|
||||
@@ -162,21 +162,15 @@ async fn list_identities(
|
||||
let page_size = query.page_size.unwrap_or(20);
|
||||
let offset = ((page - 1) as i64) * (page_size as i64);
|
||||
|
||||
// 獲取總數
|
||||
let count_sql = "SELECT COUNT(*) FROM identities";
|
||||
let total: i64 = match sqlx::query_scalar(count_sql).fetch_one(db.pool()).await {
|
||||
Ok(count) => count,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Count error: {}", e),
|
||||
))
|
||||
}
|
||||
};
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
|
||||
let sql = "SELECT id, uuid, name, metadata FROM identities ORDER BY id DESC LIMIT $1 OFFSET $2";
|
||||
let total: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {}", id_table))
|
||||
.fetch_one(db.pool()).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Count error: {}", e)))?;
|
||||
|
||||
let rows: Vec<(i32, uuid::Uuid, String, Option<serde_json::Value>)> = match sqlx::query_as(sql)
|
||||
let sql = format!("SELECT id, uuid, name, metadata FROM {} ORDER BY id DESC LIMIT $1 OFFSET $2", id_table);
|
||||
|
||||
let rows: Vec<(i32, uuid::Uuid, String, Option<serde_json::Value>)> = match sqlx::query_as(&sql)
|
||||
.bind(page_size as i64)
|
||||
.bind(offset)
|
||||
.fetch_all(db.pool())
|
||||
@@ -201,11 +195,22 @@ async fn list_identities(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let identities_table = crate::core::db::schema::table_name("identities");
|
||||
let total_identities: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {}", identities_table))
|
||||
.fetch_one(db.pool()).await.unwrap_or(0);
|
||||
let tmdb_identities: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {} WHERE source = 'tmdb'", identities_table))
|
||||
.fetch_one(db.pool()).await.unwrap_or(0);
|
||||
let auto_identities: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {} WHERE source = 'auto'", identities_table))
|
||||
.fetch_one(db.pool()).await.unwrap_or(0);
|
||||
|
||||
Ok(Json(IdentityListResponse {
|
||||
identities,
|
||||
count: total,
|
||||
page,
|
||||
page_size,
|
||||
total_identities,
|
||||
tmdb_identities,
|
||||
auto_identities,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -257,6 +262,9 @@ pub struct IdentityListResponse {
|
||||
pub count: i64,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub total_identities: i64,
|
||||
pub tmdb_identities: i64,
|
||||
pub auto_identities: i64,
|
||||
}
|
||||
|
||||
async fn list_face_candidates(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)]
|
||||
|
||||
@@ -114,6 +114,13 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!(
|
||||
|
||||
@@ -51,6 +51,7 @@ pub fn bbox_routes() -> Router<crate::api::server::AppState> {
|
||||
)
|
||||
.route("/api/v1/file/:file_uuid/video", get(stream_video))
|
||||
.route("/api/v1/file/:file_uuid/thumbnail", get(face_thumbnail))
|
||||
.route("/api/v1/file/:file_uuid/clip", get(video_clip))
|
||||
}
|
||||
|
||||
/// 5×7 bitmap font — each character 5 wide × 7 tall
|
||||
@@ -198,35 +199,18 @@ async fn bbox_overlay_video(
|
||||
.fetch_all(state.db.pool()).await
|
||||
.unwrap_or_else(|e| { tracing::error!("bbox query error: {}", e); vec![] });
|
||||
|
||||
// Build filters
|
||||
// Build filters — each bbox enabled only on its frame
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut is_first = true;
|
||||
for (frame, x, y, w, h, trace_id, _) in &rows {
|
||||
let text = format!("t{}", trace_id.unwrap_or(0));
|
||||
|
||||
if is_first {
|
||||
is_first = false;
|
||||
// Persistent bbox: thin pale red border
|
||||
parts.push(format!(
|
||||
"drawbox=x={}:y={}:w={}:h={}:color=red@0.3:thickness=4",
|
||||
x, y, w, h
|
||||
));
|
||||
// Always-on text: top-left of bbox with padding
|
||||
let tx = *x + 6;
|
||||
let ty = *y + 6;
|
||||
render_text(&mut parts, &text, tx, ty, None);
|
||||
} else {
|
||||
let offset = frame - start_f;
|
||||
// Per-frame bbox: thick bright red
|
||||
parts.push(format!(
|
||||
"drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=8:enable='eq(n,{})'",
|
||||
x, y, w, h, offset
|
||||
));
|
||||
// Per-frame text
|
||||
let tx = *x + 6;
|
||||
let ty = *y + 6;
|
||||
render_text(&mut parts, &text, tx, ty, Some(offset));
|
||||
}
|
||||
let offset = frame - start_f;
|
||||
parts.push(format!(
|
||||
"drawbox=x={}:y={}:w={}:h={}:color=red@0.8:thickness=4:enable='eq(n,{})'",
|
||||
x, y, w, h, offset
|
||||
));
|
||||
let tx = *x + 6;
|
||||
let ty = *y + 6;
|
||||
render_text(&mut parts, &text, tx, ty, Some(offset));
|
||||
}
|
||||
|
||||
let bbox_mode = p.mode.as_deref().unwrap_or("normal");
|
||||
@@ -671,3 +655,78 @@ async fn face_thumbnail(
|
||||
.body(Body::from(output.stdout))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ClipQuery {
|
||||
start_frame: Option<i64>,
|
||||
end_frame: Option<i64>,
|
||||
start_time: Option<f64>,
|
||||
end_time: Option<f64>,
|
||||
fps: Option<f64>,
|
||||
mode: Option<String>,
|
||||
audio: Option<String>,
|
||||
}
|
||||
|
||||
async fn video_clip(
|
||||
State(state): State<crate::api::server::AppState>,
|
||||
Path(file_uuid): Path<String>,
|
||||
Query(q): Query<ClipQuery>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let videos_table = schema::table_name("videos");
|
||||
let row: Option<(String, f64)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path, COALESCE(fps, 30.0) FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let (file_path, db_fps) = row.ok_or(StatusCode::NOT_FOUND)?;
|
||||
let fps = q.fps.unwrap_or(db_fps);
|
||||
|
||||
let (s, e) = if let (Some(sf), Some(ef)) = (q.start_frame, q.end_frame) {
|
||||
(sf as f64 / fps, ef as f64 / fps)
|
||||
} else if let (Some(st), Some(et)) = (q.start_time, q.end_time) {
|
||||
(st, et)
|
||||
} else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
if e <= s {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let mode = q.mode.as_deref().unwrap_or("normal").to_string();
|
||||
let audio = q.audio.as_deref().unwrap_or("on");
|
||||
|
||||
let mut cmd = ffmpeg_cmd();
|
||||
cmd.args(["-ss", &s.to_string(), "-i", &file_path]);
|
||||
if q.start_frame.is_some() {
|
||||
let frame_count = ((e - s) * fps) as i64;
|
||||
cmd.args(["-vframes", &frame_count.to_string()]);
|
||||
} else {
|
||||
cmd.args(["-to", &e.to_string()]);
|
||||
}
|
||||
if mode == "debug" {
|
||||
let debug_text = if let (Some(sf), Some(ef)) = (q.start_frame, q.end_frame) {
|
||||
format!("drawtext=text='Frame %{{n}} FRAMES {}-{}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=10", sf, ef)
|
||||
} else {
|
||||
"drawtext=text='Frame %{n} CLIP':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=10:y=10".to_string()
|
||||
};
|
||||
cmd.args(["-vf", &debug_text]);
|
||||
}
|
||||
if audio == "off" {
|
||||
cmd.args(["-an"]);
|
||||
}
|
||||
cmd.args(["-c:v", "libx264", "-c:a", "aac", "-f", "mpegts", "-"]);
|
||||
let output = cmd.output().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
if !output.status.success() {
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "video/mp2t")
|
||||
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
||||
.body(Body::from(output.stdout))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
@@ -7,13 +7,25 @@ use axum::{
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::core::auth::jwt;
|
||||
use crate::core::db::postgres_db::ApiKeyRecord;
|
||||
use crate::core::db::PostgresDb;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApiKeyAuth {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthSource {
|
||||
Session,
|
||||
Jwt,
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserAuth {
|
||||
pub user_id: i32,
|
||||
pub role: String,
|
||||
pub source: AuthSource,
|
||||
pub key_id: String,
|
||||
pub record: ApiKeyRecord,
|
||||
pub jwt_jti: Option<String>,
|
||||
pub jwt_exp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -21,143 +33,27 @@ pub struct ApiState {
|
||||
pub db: Arc<PostgresDb>,
|
||||
}
|
||||
|
||||
const PUBLIC_PATHS: &[&str] = &[
|
||||
"/api/v1/faces/", // Thumbnail paths (partial match)
|
||||
];
|
||||
|
||||
fn is_public_path(path: &str) -> bool {
|
||||
PUBLIC_PATHS.iter().any(|prefix| path.starts_with(prefix)) && path.ends_with("/thumbnail")
|
||||
pub fn extract_cookies(headers: &HeaderMap) -> Vec<(String, String)> {
|
||||
let cookie_header = match headers.get("cookie").and_then(|v| v.to_str().ok()) {
|
||||
Some(c) => c,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
cookie_header
|
||||
.split(';')
|
||||
.filter_map(|pair| {
|
||||
let mut parts = pair.trim().splitn(2, '=');
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(k), Some(v)) => Some((k.to_lowercase(), v.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn api_key_validation(
|
||||
State(state): State<ApiState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = request.uri().path();
|
||||
tracing::info!("[MIDDLEWARE] Starting API key validation");
|
||||
tracing::info!("[MIDDLEWARE] Path: {:?}", path);
|
||||
|
||||
if is_public_path(path) {
|
||||
tracing::info!("[MIDDLEWARE] Public path, skipping auth: {}", path);
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let headers = request.headers();
|
||||
tracing::info!("[MIDDLEWARE] All headers: {:?}", headers);
|
||||
|
||||
let uri = request.uri().clone();
|
||||
let api_key = match extract_api_key(headers, &uri) {
|
||||
Ok(key) => {
|
||||
tracing::info!("[MIDDLEWARE] API key extracted, length: {}", key.len());
|
||||
if key.len() > 8 {
|
||||
tracing::info!(
|
||||
"[MIDDLEWARE] Key value: {}...{}",
|
||||
&key[..4],
|
||||
&key[key.len() - 4..]
|
||||
);
|
||||
} else {
|
||||
tracing::info!("[MIDDLEWARE] Key value: ****");
|
||||
}
|
||||
key
|
||||
}
|
||||
Err(status) => {
|
||||
tracing::warn!("[MIDDLEWARE] API key extraction failed: {:?}", status);
|
||||
return Response::builder()
|
||||
.status(status)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
let key_hash = hash_key(&api_key);
|
||||
tracing::info!("[MIDDLEWARE] Key hash: {}", &key_hash[..16]);
|
||||
|
||||
tracing::info!("[MIDDLEWARE] Querying database for key...");
|
||||
let record = match state.db.get_api_key_by_hash(&key_hash).await {
|
||||
Ok(Some(r)) => {
|
||||
tracing::info!("[MIDDLEWARE] API key found: {}", r.key_id);
|
||||
r
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"[MIDDLEWARE] API key NOT FOUND in database for hash: {}",
|
||||
&key_hash[..16]
|
||||
);
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[MIDDLEWARE] DB error: {}", e);
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
if record.status != "active" {
|
||||
tracing::warn!("[MIDDLEWARE] API key not active: {}", record.status);
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[MIDDLEWARE] API key validated successfully: {}",
|
||||
record.key_id
|
||||
);
|
||||
|
||||
let auth = ApiKeyAuth {
|
||||
key_id: record.key_id.clone(),
|
||||
record,
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.update_api_key_usage(&auth.key_id, None).await {
|
||||
tracing::warn!("[MIDDLEWARE] Failed to update API key usage: {}", e);
|
||||
}
|
||||
|
||||
let mut request = request;
|
||||
request.extensions_mut().insert(auth);
|
||||
|
||||
tracing::info!("[MIDDLEWARE] Passing request to handler");
|
||||
let response = next.run(request).await;
|
||||
tracing::info!("[MIDDLEWARE] Handler returned response");
|
||||
response
|
||||
}
|
||||
|
||||
fn extract_api_key(headers: &HeaderMap, uri: &axum::http::Uri) -> Result<String, StatusCode> {
|
||||
// 1. X-API-Key header
|
||||
if let Some(key) = headers
|
||||
.get("X-API-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
return Ok(key.to_string());
|
||||
}
|
||||
// 2. Authorization: Bearer <key>
|
||||
if let Some(auth) = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if let Some(key) = auth.strip_prefix("Bearer ") {
|
||||
return Ok(key.to_string());
|
||||
}
|
||||
}
|
||||
// 3. ?api_key=<key> query parameter
|
||||
if let Some(query) = uri.query() {
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if let (Some(k), Some(v)) = (parts.next(), parts.next()) {
|
||||
if k == "api_key" {
|
||||
return Ok(percent_decode(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
fn hash_key(key: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
@@ -186,8 +82,161 @@ fn hex_val(c: u8) -> Option<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_key(key: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
fn extract_api_key(headers: &HeaderMap, uri: &axum::http::Uri) -> Result<String, StatusCode> {
|
||||
if let Some(key) = headers
|
||||
.get("X-API-Key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
return Ok(key.to_string());
|
||||
}
|
||||
if let Some(auth) = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
// Check if it's a JWT (starts with eyJ)
|
||||
let trimmed = auth.strip_prefix("Bearer ").unwrap_or(auth);
|
||||
if !jwt::is_jwt(trimmed) {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
// If it IS a JWT, return it as-is — JWT branch handles it
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
if let Some(query) = uri.query() {
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if let (Some(k), Some(v)) = (parts.next(), parts.next()) {
|
||||
if k == "api_key" {
|
||||
return Ok(percent_decode(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
pub async fn unified_auth(
|
||||
State(state): State<ApiState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let headers = request.headers();
|
||||
let uri = request.uri().clone();
|
||||
|
||||
// Priority 1: Cookie session (Portal)
|
||||
let cookies = extract_cookies(headers);
|
||||
if let Some(sid) = cookies.iter().find(|(k, _)| k == "session_id").map(|(_, v)| v.clone()) {
|
||||
match state.db.get_session_by_id(&sid).await {
|
||||
Ok(Some((_id, user_id, api_key_id, _expires_at))) => {
|
||||
let key_hash = hash_key(&api_key_id);
|
||||
match state.db.get_api_key_by_hash(&key_hash).await {
|
||||
Ok(Some(record)) if record.status == "active" => {
|
||||
let auth = UserAuth {
|
||||
user_id: user_id,
|
||||
role: record.key_type.clone(),
|
||||
source: AuthSource::Session,
|
||||
key_id: record.key_id.clone(),
|
||||
jwt_jti: None,
|
||||
jwt_exp: None,
|
||||
};
|
||||
if let Err(e) = state.db.update_api_key_usage(&record.key_id, None).await {
|
||||
tracing::warn!("[AUTH] Failed to update key usage: {}", e);
|
||||
}
|
||||
request.extensions_mut().insert(auth);
|
||||
return next.run(request).await;
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
tracing::warn!("[AUTH] Session API key not active, removing session");
|
||||
state.db.delete_session(&sid).await.ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("[AUTH] Session lookup error: {}", e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: JWT (Authorization: Bearer <eyJ...>)
|
||||
if let Some(auth_header) = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
||||
if jwt::is_jwt(token) {
|
||||
match jwt::verify_jwt(token) {
|
||||
Ok(claims) => {
|
||||
if !state.db.is_jwt_blacklisted(&claims.jti).await.unwrap_or(false) {
|
||||
let exp = chrono::DateTime::from_timestamp(claims.exp as i64, 0);
|
||||
let user_id: i32 = claims.sub.parse().unwrap_or(0);
|
||||
let auth = UserAuth {
|
||||
user_id,
|
||||
role: claims.role,
|
||||
source: AuthSource::Jwt,
|
||||
key_id: String::new(),
|
||||
jwt_jti: Some(claims.jti),
|
||||
jwt_exp: exp,
|
||||
};
|
||||
request.extensions_mut().insert(auth);
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[AUTH] JWT verification failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: API Key header / query param
|
||||
let api_key = match extract_api_key(headers, &uri) {
|
||||
Ok(key) => key,
|
||||
Err(status) => {
|
||||
return Response::builder()
|
||||
.status(status)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
let key_hash = hash_key(&api_key);
|
||||
let record = match state.db.get_api_key_by_hash(&key_hash).await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[AUTH] DB error: {}", e);
|
||||
return Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
if record.status != "active" {
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let auth = UserAuth {
|
||||
user_id: record.user_id.unwrap_or(0) as i32,
|
||||
role: record.key_type.clone(),
|
||||
source: AuthSource::ApiKey,
|
||||
key_id: record.key_id.clone(),
|
||||
jwt_jti: None,
|
||||
jwt_exp: None,
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.update_api_key_usage(&record.key_id, None).await {
|
||||
tracing::warn!("[AUTH] Failed to update key usage: {}", e);
|
||||
}
|
||||
|
||||
request.extensions_mut().insert(auth);
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod media_api;
|
||||
pub mod middleware;
|
||||
pub mod search;
|
||||
pub mod server;
|
||||
pub mod tmdb_api;
|
||||
pub mod trace_agent_api;
|
||||
pub mod universal_search;
|
||||
pub mod visual_chunk_search;
|
||||
|
||||
@@ -94,84 +94,31 @@ pub async fn smart_search(
|
||||
},
|
||||
)?;
|
||||
|
||||
if db_parents.is_empty() {
|
||||
return Ok(Json(SmartSearchResponse {
|
||||
query: req.query,
|
||||
results: vec![],
|
||||
page,
|
||||
page_size,
|
||||
strategy: "semantic_vector_search".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Collect Parent IDs
|
||||
let parent_ids: Vec<i32> = db_parents.iter().map(|p| p.id).collect();
|
||||
|
||||
// 3. Fetch Children for these Parents (Drill Down)
|
||||
// We fetch all children for these parents (limit can be adjusted)
|
||||
let children: Vec<crate::core::db::postgres_db::ChildChunkResult> = db
|
||||
.get_children_for_parents(&parent_ids, 10) // Fetch top 10 children per parent
|
||||
.await
|
||||
.map_err(
|
||||
|e: anyhow::Error| -> (StatusCode, Json<serde_json::Value>) {
|
||||
tracing::error!("Fetching children failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e.to_string() })),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
// 4. Map Parents to a lookup table
|
||||
let parent_map: std::collections::HashMap<
|
||||
i32,
|
||||
&crate::core::db::postgres_db::SemanticSearchResult,
|
||||
> = db_parents.iter().map(|p| (p.id, p)).collect();
|
||||
|
||||
// Map Children to API response struct
|
||||
let results: Vec<SearchResult> = children
|
||||
// Return parent chunks directly as search results
|
||||
let results: Vec<SearchResult> = db_parents
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let parent = parent_map.get(&c.parent_id);
|
||||
SearchResult {
|
||||
id: c.id,
|
||||
parent_id: c.parent_id,
|
||||
scene_order: parent.map(|p| p.scene_order),
|
||||
|
||||
start_frame: c.start_frame,
|
||||
end_frame: c.end_frame,
|
||||
fps: c.fps,
|
||||
|
||||
start_time: c.start_time,
|
||||
end_time: c.end_time,
|
||||
raw_text: Some(c.raw_text),
|
||||
summary: parent.map(|p| p.summary.clone()),
|
||||
metadata: parent.map(|p| p.metadata.clone()),
|
||||
similarity: parent.and_then(|p| p.similarity),
|
||||
}
|
||||
.map(|p| SearchResult {
|
||||
id: 0,
|
||||
parent_id: p.scene_order,
|
||||
scene_order: Some(p.scene_order),
|
||||
start_frame: 0,
|
||||
end_frame: 0,
|
||||
fps: 0.0,
|
||||
start_time: p.start_time,
|
||||
end_time: p.end_time,
|
||||
raw_text: None,
|
||||
summary: Some(p.summary),
|
||||
metadata: p.metadata.clone(),
|
||||
similarity: p.similarity,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 6. Sort results by similarity (descending)
|
||||
// Since all children of a parent have the same parent similarity, this groups relevant chunks together
|
||||
let mut results = results;
|
||||
results.sort_by(|a, b| {
|
||||
b.similarity
|
||||
.partial_cmp(&a.similarity)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// 7. Limit the final results (optional, but good for API consistency)
|
||||
let truncate_limit = hard_limit.min(page_size * 5); // Allow more children per parent context
|
||||
results.truncate(truncate_limit);
|
||||
|
||||
// 8. Format Response
|
||||
let response = SmartSearchResponse {
|
||||
query: req.query,
|
||||
results,
|
||||
page,
|
||||
page_size,
|
||||
strategy: "drill_down_semantic_search".to_string(),
|
||||
strategy: "semantic_vector_search".to_string(),
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
282
src/api/tmdb_api.rs
Normal file
282
src/api/tmdb_api.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::server::AppState;
|
||||
use crate::core::config;
|
||||
use crate::core::db::PostgresDb;
|
||||
use crate::core::tmdb;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TmdbPrefetchResponse {
|
||||
success: bool,
|
||||
file_uuid: String,
|
||||
message: String,
|
||||
cache_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TmdbProbeResponse {
|
||||
success: bool,
|
||||
file_uuid: String,
|
||||
tmdb_id: Option<u64>,
|
||||
movie_title: Option<String>,
|
||||
cast_count: Option<usize>,
|
||||
identities_created: Option<usize>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TmdbResourceResponse {
|
||||
success: bool,
|
||||
status: tmdb::status::TmdbResourceStatus,
|
||||
identities_seeded: i64,
|
||||
identities_with_embedding: i64,
|
||||
cache_files: usize,
|
||||
operations: Vec<TmdbOperation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TmdbOperation {
|
||||
method: String,
|
||||
path: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TmdbCheckResponse {
|
||||
success: bool,
|
||||
status: tmdb::status::TmdbResourceStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PrefetchRequest {
|
||||
file_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FileUuidParam {
|
||||
file_uuid: String,
|
||||
}
|
||||
|
||||
pub fn tmdb_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/agents/tmdb/prefetch", post(tmdb_prefetch))
|
||||
.route("/api/v1/file/:file_uuid/tmdb-probe", post(tmdb_probe_handler))
|
||||
.route("/api/v1/resource/tmdb", get(tmdb_resource_status))
|
||||
.route("/api/v1/resource/tmdb/check", post(tmdb_resource_check))
|
||||
}
|
||||
|
||||
async fn tmdb_prefetch(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<PrefetchRequest>,
|
||||
) -> Json<TmdbPrefetchResponse> {
|
||||
let file_uuid = req.file_uuid;
|
||||
|
||||
// Verify file exists in DB
|
||||
let file_exists: bool = sqlx::query_scalar(
|
||||
&format!("SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1", crate::core::db::schema::table_name("videos"))
|
||||
)
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !file_exists {
|
||||
return Json(TmdbPrefetchResponse {
|
||||
success: false,
|
||||
file_uuid: file_uuid.clone(),
|
||||
message: format!("File not found: {}", file_uuid),
|
||||
cache_path: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Offline-first: check if identity files already exist on disk (pre-prepared)
|
||||
let identities_dir = std::path::Path::new(&*config::OUTPUT_DIR).join("identities");
|
||||
let index_path = identities_dir.join("_index.json");
|
||||
let cache_path = format!("{}/{}.tmdb.json", *config::OUTPUT_DIR, file_uuid);
|
||||
let cache_file = std::path::Path::new(&cache_path);
|
||||
|
||||
if index_path.exists() && cache_file.exists() {
|
||||
return Json(TmdbPrefetchResponse {
|
||||
success: true,
|
||||
file_uuid,
|
||||
message: format!(
|
||||
"Offline: using local identity files from {}.",
|
||||
identities_dir.display()
|
||||
),
|
||||
cache_path: Some(cache_path),
|
||||
});
|
||||
}
|
||||
|
||||
if config::tmdb::API_KEY.is_none() {
|
||||
return Json(TmdbPrefetchResponse {
|
||||
success: false,
|
||||
file_uuid: file_uuid.clone(),
|
||||
message: "TMDB_API_KEY not configured and no local cache found.".to_string(),
|
||||
cache_path: None,
|
||||
});
|
||||
}
|
||||
|
||||
let scripts_dir = config::SCRIPTS_DIR.clone();
|
||||
let python_path = config::PYTHON_PATH.clone();
|
||||
let agent_script = std::path::Path::new(&scripts_dir).join("tmdb_agent.py");
|
||||
|
||||
if !agent_script.exists() {
|
||||
return Json(TmdbPrefetchResponse {
|
||||
success: false,
|
||||
file_uuid,
|
||||
message: format!("tmdb_agent.py not found at {}", agent_script.display()),
|
||||
cache_path: None,
|
||||
});
|
||||
}
|
||||
|
||||
let db_url = config::DATABASE_URL.clone();
|
||||
let output = tokio::process::Command::new(&*python_path)
|
||||
.arg(&agent_script)
|
||||
.arg("--file-uuid")
|
||||
.arg(&file_uuid)
|
||||
.env("DATABASE_URL", &db_url)
|
||||
.env("DATABASE_SCHEMA", &*config::DATABASE_SCHEMA)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(o) => {
|
||||
if o.status.success() {
|
||||
let out = String::from_utf8_lossy(&o.stdout);
|
||||
Json(TmdbPrefetchResponse {
|
||||
success: true,
|
||||
file_uuid,
|
||||
message: out.lines().last().unwrap_or("OK").to_string(),
|
||||
cache_path: Some(cache_path),
|
||||
})
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
Json(TmdbPrefetchResponse {
|
||||
success: false,
|
||||
file_uuid,
|
||||
message: stderr.to_string(),
|
||||
cache_path: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => Json(TmdbPrefetchResponse {
|
||||
success: false,
|
||||
file_uuid,
|
||||
message: format!("Failed to run tmdb_agent.py: {}", e),
|
||||
cache_path: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn tmdb_probe_handler(
|
||||
Path(params): Path<FileUuidParam>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<TmdbProbeResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let file_uuid = params.file_uuid;
|
||||
|
||||
// Verify file exists
|
||||
let file_exists: bool = sqlx::query_scalar(
|
||||
&format!("SELECT COUNT(*) > 0 FROM {} WHERE file_uuid = $1", crate::core::db::schema::table_name("videos"))
|
||||
)
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !file_exists {
|
||||
return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({
|
||||
"error": "Video not found", "file_uuid": file_uuid
|
||||
}))));
|
||||
}
|
||||
|
||||
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
|
||||
),
|
||||
})),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("not found") {
|
||||
Ok(Json(TmdbProbeResponse {
|
||||
success: false,
|
||||
file_uuid,
|
||||
tmdb_id: None,
|
||||
movie_title: None,
|
||||
cast_count: None,
|
||||
identities_created: None,
|
||||
message: "No TMDb cache found. Run tmdb-prefetch first.".to_string(),
|
||||
}))
|
||||
} else {
|
||||
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": msg, "file_uuid": file_uuid
|
||||
}))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn tmdb_resource_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<TmdbResourceResponse> {
|
||||
let status = tmdb::status::quick_status();
|
||||
let identities_seeded = tmdb::status::count_tmdb_identities(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let identities_with_embedding = tmdb::status::count_tmdb_identities_with_embedding(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let cache_files = tmdb::status::count_cache_files();
|
||||
|
||||
Json(TmdbResourceResponse {
|
||||
success: true,
|
||||
status,
|
||||
identities_seeded,
|
||||
identities_with_embedding,
|
||||
cache_files,
|
||||
operations: vec![
|
||||
TmdbOperation {
|
||||
method: "GET".to_string(),
|
||||
path: "/api/v1/resource/tmdb".to_string(),
|
||||
description: "TMDb resource status".to_string(),
|
||||
},
|
||||
TmdbOperation {
|
||||
method: "POST".to_string(),
|
||||
path: "/api/v1/resource/tmdb/check".to_string(),
|
||||
description: "Ping TMDb API health".to_string(),
|
||||
},
|
||||
TmdbOperation {
|
||||
method: "POST".to_string(),
|
||||
path: "/api/v1/agents/tmdb/prefetch".to_string(),
|
||||
description: "Fetch TMDb data and cache locally".to_string(),
|
||||
},
|
||||
TmdbOperation {
|
||||
method: "POST".to_string(),
|
||||
path: "/api/v1/file/:file_uuid/tmdb-probe".to_string(),
|
||||
description: "Read cache and create identities".to_string(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async fn tmdb_resource_check() -> Json<TmdbCheckResponse> {
|
||||
let status = tmdb::status::check_tmdb_api().await;
|
||||
Json(TmdbCheckResponse {
|
||||
success: status.api_reachable.unwrap_or(false) && status.api_key_configured,
|
||||
status,
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use crate::core::db::PostgresDb;
|
||||
pub fn trace_agent_routes() -> Router<crate::api::server::AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/face_trace/sortby",
|
||||
"/api/v1/file/:file_uuid/traces",
|
||||
post(list_traces_sorted),
|
||||
)
|
||||
.route(
|
||||
|
||||
Reference in New Issue
Block a user