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

View File

@@ -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();

View File

@@ -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 ──

View File

@@ -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

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)]

View File

@@ -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!(

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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
View 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,
})
}

View File

@@ -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(