feat: add migrations, test scripts, and utility tools

- Add database migrations (006-028) for face recognition, identity, file_uuid
- Add test scripts for ASR, face, search, processing
- Add portal frontend (Tauri)
- Add config, benchmark, and monitoring utilities
- Add model checkpoints and pretrained model references
This commit is contained in:
Warren
2026-04-30 15:11:53 +08:00
parent 4d75b2e251
commit b54c2def30
192 changed files with 46721 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
use crate::config::{get_config, PortalConfig};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
pub uptime_ms: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DetailedHealthResponse {
pub status: String,
pub version: String,
pub uptime_ms: u64,
pub services: ServiceHealth,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ServiceHealth {
pub postgres: ServiceStatus,
pub redis: ServiceStatus,
pub qdrant: ServiceStatus,
pub mongodb: ServiceStatus,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ServiceStatus {
pub status: String,
pub latency_ms: Option<u64>,
pub error: Option<String>,
}
#[tauri::command]
pub async fn get_health() -> Result<HealthResponse, String> {
let config = get_config();
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/health", config.api_base_url))
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let result: HealthResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}
#[tauri::command]
pub async fn get_health_detailed() -> Result<DetailedHealthResponse, String> {
let config = get_config();
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/health/detailed", config.api_base_url))
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let result: DetailedHealthResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SftpgoStatus {
pub username: String,
pub home_dir: String,
pub files_count: i64,
pub registered_videos: Vec<RegisteredVideo>,
pub last_login: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisteredVideo {
pub uuid: String,
pub file_name: String,
pub status: String,
}
#[tauri::command]
pub async fn get_sftpgo_status() -> Result<SftpgoStatus, String> {
let config = get_config();
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/api/v1/stats/sftpgo", config.api_base_url))
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let result: SftpgoStatus = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InferenceEngineStatus {
pub engine: String,
pub model: String,
pub status: String,
pub latency_ms: Option<u64>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct InferenceHealthResponse {
pub ollama: InferenceEngineStatus,
pub llama_server: InferenceEngineStatus,
}
#[tauri::command]
pub async fn get_inference_health() -> Result<InferenceHealthResponse, String> {
let config = get_config();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Client build failed: {}", e))?;
let response = client
.get(&format!("{}/api/v1/stats/inference", config.api_base_url))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let result: InferenceHealthResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}
#[tauri::command]
pub fn get_config_info() -> Result<PortalConfig, String> {
Ok(get_config())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IngestStats {
pub total_videos: i64,
pub total_chunks: i64,
pub sentence_chunks: i64,
pub cut_chunks: i64,
pub time_chunks: i64,
pub searchable_chunks: i64,
pub chunks_with_visual: i64,
pub chunks_with_summary: i64,
pub pending_videos: i64,
}
#[tauri::command]
pub async fn get_ingest_stats() -> Result<IngestStats, String> {
let config = get_config();
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/api/v1/stats/ingest", config.api_base_url))
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let result: IngestStats = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}

View File

@@ -0,0 +1,148 @@
use crate::config::get_config;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct IdentitySearchRequest {
pub query: Option<String>,
pub file_uuid: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IdentityResponse {
pub success: bool,
pub total: usize,
pub identities: Vec<IdentityItem>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IdentityItem {
pub id: i32,
pub person_id: String,
pub face_identity_id: Option<i32>,
pub file_uuid: String,
pub profile: IdentityProfile,
pub stats: IdentityStats,
pub is_confirmed: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IdentityProfile {
pub name: Option<String>,
pub original_name: Option<String>,
pub character_name: Option<String>,
pub speaker_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct IdentityStats {
pub appearance_count: i32,
pub total_duration: f64,
pub first_appearance: Option<f64>,
pub last_appearance: Option<f64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterResponse {
pub success: bool,
pub message: String,
pub person_id: String,
pub name: String,
pub face_identity_id: i32,
}
#[tauri::command]
pub async fn list_identities(
query: Option<String>,
file_uuid: Option<String>,
) -> Result<IdentityResponse, String> {
let config = get_config();
let client = reqwest::Client::new();
let request_body = IdentitySearchRequest {
query,
file_uuid,
limit: Some(50),
};
let response = client
.post(&format!("{}/api/v1/identities/search", config.api_base_url))
.header("Content-Type", "application/json")
.header("x-api-key", &config.api_key)
.json(&request_body)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let result: IdentityResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}
#[tauri::command]
pub async fn register_identity(
person_id: String,
file_uuid: String,
) -> Result<RegisterResponse, String> {
let config = get_config();
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/person/{}/register?file_uuid={}",
config.api_base_url, person_id, file_uuid
);
let response = client
.post(&url)
.header("x-api-key", &config.api_key)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let result: RegisterResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}
#[tauri::command]
pub async fn get_identity_videos(identity_id: i32) -> Result<serde_json::Value, String> {
let config = get_config();
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/identities/{}/videos",
config.api_base_url, identity_id
);
let response = client
.get(&url)
.header("x-api-key", &config.api_key)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let result: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}

View File

@@ -0,0 +1,6 @@
pub mod health;
pub mod identity;
pub mod person;
pub mod search;
pub mod translation;
pub mod video;

View File

@@ -0,0 +1,84 @@
use crate::config::get_config;
use base64::{engine::general_purpose, Engine as _};
#[tauri::command]
pub async fn get_person_thumbnail(
person_id: String,
file_uuid: String,
index: Option<usize>,
save_path: String,
) -> Result<String, String> {
let config = get_config();
let client = reqwest::Client::new();
let mut url = format!(
"{}/api/v1/person/{}/thumbnail?file_uuid={}",
config.api_base_url, person_id, file_uuid
);
if let Some(idx) = index {
url = format!("{}&index={}", url, idx);
}
let response = client
.get(&url)
.header("x-api-key", &config.api_key)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
// Save the image to the specified path
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
tokio::fs::write(&save_path, &bytes)
.await
.map_err(|e| format!("Failed to save file: {}", e))?;
Ok(save_path)
}
/// Get person thumbnail as base64 data URI
#[tauri::command]
pub async fn get_person_thumbnail_b64(
person_id: String,
file_uuid: String,
index: Option<usize>,
) -> Result<String, String> {
let config = get_config();
let client = reqwest::Client::new();
let mut url = format!(
"{}/api/v1/person/{}/thumbnail?file_uuid={}",
config.api_base_url, person_id, file_uuid
);
if let Some(idx) = index {
url = format!("{}&index={}", url, idx);
}
let response = client
.get(&url)
.header("x-api-key", &config.api_key)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
let encoded = general_purpose::STANDARD.encode(&bytes);
Ok(format!("data:image/jpeg;base64,{}", encoded))
}

View File

@@ -0,0 +1,175 @@
use crate::config::get_config;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchRequest {
pub query: String,
pub limit: Option<usize>,
pub mode: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResult {
pub query: String,
pub count: usize,
pub hits: Vec<SearchHit>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchHit {
pub id: String,
pub vid: String,
pub start_frame: i64,
pub end_frame: i64,
pub fps: f64,
pub start: f64,
pub end: f64,
pub text: String,
pub score: f64,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub has_visual_stats: Option<bool>,
#[serde(default)]
pub parent_id: Option<String>,
}
#[tauri::command]
pub async fn search_videos(
query: String,
limit: Option<usize>,
mode: Option<String>,
) -> Result<SearchResult, String> {
let config = get_config();
let client = reqwest::Client::new();
let search_mode = mode.unwrap_or_else(|| "vector".to_string());
let request_body = SearchRequest {
query: query.clone(),
limit: limit.or(Some(10)),
mode: Some(search_mode.clone()),
};
let url = format!("{}/api/v1/search", config.api_base_url);
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("x-api-key", &config.api_key)
.json(&request_body)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
// Parse as generic Value to handle mapping manually
let json: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
// Map Backend Response to Frontend SearchResult
// Backend: { "query": "...", "results": [ ... ], "total": N, ... }
// Frontend: { "query": "...", "hits": [ ... ], "count": N }
let backend_results = json.get("results").and_then(|r| r.as_array()).cloned().unwrap_or_default();
let total = json.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;
let hits: Vec<SearchHit> = backend_results.into_iter().filter_map(|item| {
Some(SearchHit {
id: item.get("chunk_id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
vid: item.get("uuid").and_then(|v| v.as_str()).unwrap_or("").to_string(),
start_frame: item.get("start_frame").and_then(|v| v.as_i64()).unwrap_or(0),
end_frame: item.get("end_frame").and_then(|v| v.as_i64()).unwrap_or(0),
fps: item.get("fps").and_then(|v| v.as_f64()).unwrap_or(30.0),
start: item.get("start_time").and_then(|v| v.as_f64()).unwrap_or(0.0),
end: item.get("end_time").and_then(|v| v.as_f64()).unwrap_or(0.0),
text: item.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(),
score: item.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0),
title: item.get("file_name").and_then(|v| v.as_str()).map(|s| s.to_string()),
file_path: item.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string()),
has_visual_stats: item.get("visual_stats").map(|_| true),
parent_id: item.get("parent_chunk_id").and_then(|v| v.as_str()).map(|s| s.to_string()),
})
}).collect();
Ok(SearchResult {
query: json.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string(),
count: total,
hits,
})
}
#[tauri::command]
pub async fn search_chunks(query: String, uuid: Option<String>) -> Result<SearchResult, String> {
let config = get_config();
let client = reqwest::Client::new();
// Backend expects uuid in the body, not query params
let url = format!("{}/api/v1/search", config.api_base_url);
let mut request_body = serde_json::json!({
"query": query,
"limit": 10
});
if let Some(vid) = uuid {
request_body["uuid"] = serde_json::json!(vid);
}
let response = client
.post(&url)
.header("Content-Type", "application/json")
.header("x-api-key", &config.api_key)
.json(&request_body)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
// Parse raw JSON to handle structure mapping
let json: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
// Backend returns "total", frontend expects "count"
let count = json.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
// Backend returns "results", frontend expects "hits"
let results = json.get("results").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let hits: Vec<SearchHit> = results.into_iter().filter_map(|item| {
Some(SearchHit {
id: item.get("chunk_id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
vid: item.get("uuid").and_then(|v| v.as_str()).unwrap_or("").to_string(),
start_frame: item.get("start_frame").and_then(|v| v.as_i64()).unwrap_or(0),
end_frame: item.get("end_frame").and_then(|v| v.as_i64()).unwrap_or(0),
fps: item.get("fps").and_then(|v| v.as_f64()).unwrap_or(30.0),
start: item.get("start_time").and_then(|v| v.as_f64()).unwrap_or(0.0),
end: item.get("end_time").and_then(|v| v.as_f64()).unwrap_or(0.0),
text: item.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(),
score: item.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0),
title: item.get("file_name").and_then(|v| v.as_str()).map(|s| s.to_string()),
file_path: item.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string()),
has_visual_stats: item.get("visual_stats").map(|_| true),
parent_id: item.get("parent_chunk_id").and_then(|v| v.as_str()).map(|s| s.to_string()),
})
}).collect();
Ok(SearchResult {
query: json.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string(),
count,
hits,
})
}

View File

@@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct LlamaCppResponse {
choices: Vec<LlamaCppChoice>,
}
#[derive(Debug, Deserialize, Serialize)]
struct LlamaCppChoice {
message: LlamaCppMessage,
}
#[derive(Debug, Deserialize, Serialize)]
struct LlamaCppMessage {
content: String,
}
/// Translates text using local llama.cpp server running Gemma 4.
#[tauri::command]
pub async fn translate_text(
text: String,
#[allow(non_snake_case)] target_lang: String,
) -> Result<String, String> {
if text.trim().is_empty() {
return Ok(String::new());
}
println!("[Translate] Request: {} -> {}", target_lang, text);
let client = reqwest::Client::new();
let prompt = format!(
"Translate the following text into {}. Only output the translated text without any additional context or notes.\n\nText: {}",
target_lang, text
);
// llama.cpp server endpoint (compatible with OpenAI API format)
let payload = serde_json::json!({
"model": "gemma4",
"messages": [
{
"role": "user",
"content": prompt
}
],
"stream": false,
"temperature": 0.1
});
println!("[Translate] Sending to llama.cpp server...");
let response = client
.post("http://127.0.0.1:8081/v1/chat/completions")
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| {
format!(
"llama.cpp server request failed: {}. Ensure the server is running at port 8081.",
e
)
})?;
if !response.status().is_success() {
return Err(format!("llama.cpp server error: {}", response.status()));
}
let json: LlamaCppResponse = response
.json()
.await
.map_err(|e| format!("Parse error: {}", e))?;
println!("[Translate] Response received");
if let Some(choice) = json.choices.first() {
Ok(choice.message.content.trim().to_string())
} else {
Err("No translation result returned".to_string())
}
}

View File

@@ -0,0 +1,119 @@
use crate::config::get_config;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct VideosResponse {
pub videos: Vec<serde_json::Value>,
#[serde(rename = "count", default)]
pub total: i64,
pub page: usize,
pub page_size: usize,
}
#[tauri::command]
pub async fn get_videos(
query: Option<String>,
status: Option<String>,
page: Option<usize>,
page_size: Option<usize>,
) -> Result<VideosResponse, String> {
let config = get_config();
let client = reqwest::Client::new();
let mut url = format!("{}/api/v1/videos", config.api_base_url);
let mut params = Vec::new();
if let Some(q) = query {
params.push(format!("q={}", q));
}
if let Some(s) = status {
params.push(format!("status={}", s));
}
if let Some(p) = page {
params.push(format!("page={}", p));
}
if let Some(ps) = page_size {
params.push(format!("page_size={}", ps));
}
if !params.is_empty() {
url.push('?');
url.push_str(&params.join("&"));
}
let response = client
.get(&url)
.header("x-api-key", &config.api_key)
.send()
.await
.map_err(|e| format!("Request to API failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API returned error: {}", response.status()));
}
let result: VideosResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse API response: {}", e))?;
Ok(result)
}
#[tauri::command]
pub async fn list_videos(
query: Option<String>,
page: Option<usize>,
page_size: Option<usize>,
) -> Result<VideosResponse, String> {
get_videos(query, None, page, page_size).await
}
#[tauri::command]
pub async fn get_video_faces(file_uuid: String) -> Result<serde_json::Value, String> {
let config = get_config();
let client = reqwest::Client::new();
let url = format!("{}/api/v1/videos/{}/faces", config.api_base_url, file_uuid);
let response = client
.get(&url)
.header("x-api-key", &config.api_key)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))
}
#[tauri::command]
pub async fn get_chunk_detail(uuid: String, chunk_id: String) -> Result<serde_json::Value, String> {
let config = get_config();
let client = reqwest::Client::new();
let url = format!(
"{}/api/v1/videos/{}/details?chunk_id={}",
config.api_base_url, uuid, chunk_id
);
let response = client
.get(&url)
.header("x-api-key", &config.api_key)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("API error: {}", response.status()));
}
response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))
}