核心改动: 1. db.rs - 默认密码从环境变量读取或自动生成 - 优先级: MOMENTRY_DEFAULT_PASSWORD > 自动生成 - 格式: demo_<timestamp> 或自定义 - 首次创建时打印密码到控制台 2. main.rs - SMB 服务密码动态化 - 从 SMB_PASSWORD 或 MOMENTRY_DEFAULT_PASSWORD 读取 - 支持自定义 SMB 用户名 3. user_tool.rs - 新增用户管理工具 - list: 列出所有用户 - create: 创建新用户 - set-password: 修改密码 - delete: 删除用户 - init-demo: 初始化 demo 用户 4. install.sh - 安装时自动生成密码 - 生成随机密码: demo_$(openssl rand -hex 4) - 保存到 .env 文件 - 初始化数据库 5. .env.template - 新增密码配置 - MOMENTRY_DEFAULT_PASSWORD - SMB_USER / SMB_PASSWORD 安全改进: - 消除硬编码密码 'demo123' - 每次安装密码唯一 - 密码哈希存储 (Argon2id) - 支持自定义强密码 文档: - docs/DEFAULT_PASSWORD_SYSTEM.md 完整说明
1675 lines
64 KiB
Rust
1675 lines
64 KiB
Rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
use dirs;
|
|
use markbase_core::vfs::VfsBackend;
|
|
mod proxy;
|
|
mod db;
|
|
mod validate;
|
|
mod paths;
|
|
|
|
// Import path helper functions
|
|
use paths::{
|
|
get_home_dir,
|
|
get_momentry_core_root,
|
|
get_markbase_root,
|
|
get_studio_root,
|
|
get_momentry_root,
|
|
get_storage_root,
|
|
get_markbase_engine_root,
|
|
get_worker_pid_file,
|
|
};
|
|
|
|
use serde::Serialize;
|
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::num::NonZeroUsize;
|
|
use lru::LruCache;
|
|
use image::GenericImageView;
|
|
use tokio::sync::Semaphore;
|
|
|
|
static THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
|
static PROFILE_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
|
static RAW_DIM_CACHE: Mutex<Option<HashMap<String, (u32, u32)>>> = Mutex::new(None);
|
|
/// Per-file semaphore: only one thumbnail extraction per file at a time,
|
|
/// preventing ffmpeg storms when many traces hit the same file concurrently.
|
|
static THUMB_SEM_MAP: Mutex<Option<HashMap<String, Arc<Semaphore>>>> = Mutex::new(None);
|
|
|
|
fn acquire_thumb_semaphore(uuid: &str) -> Arc<Semaphore> {
|
|
let mut map = THUMB_SEM_MAP.lock().unwrap();
|
|
let map = map.get_or_insert_with(HashMap::new);
|
|
map.entry(uuid.to_string())
|
|
.or_insert_with(|| Arc::new(Semaphore::new(1)))
|
|
.clone()
|
|
}
|
|
|
|
fn get_client() -> reqwest::Client {
|
|
reqwest::Client::builder()
|
|
.connect_timeout(std::time::Duration::from_secs(10))
|
|
.timeout(std::time::Duration::from_secs(60))
|
|
.pool_max_idle_per_host(10)
|
|
.build()
|
|
.expect("Failed to build HTTP client")
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SearchResult {
|
|
file_uuid: String,
|
|
start_time: f64,
|
|
end_time: f64,
|
|
start_frame: i64,
|
|
end_frame: i64,
|
|
summary: String,
|
|
similarity: f64,
|
|
file_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct FileInfo {
|
|
file_uuid: String,
|
|
file_name: String,
|
|
file_path: String,
|
|
file_size: i64,
|
|
modified_time: String,
|
|
#[serde(rename = "isRegistered")]
|
|
is_registered: bool,
|
|
status: String,
|
|
registration_time: Option<String>,
|
|
#[serde(rename = "ingested")]
|
|
ingested: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PersonInfo {
|
|
identity_uuid: String,
|
|
name: String,
|
|
starred: bool,
|
|
status: String,
|
|
metadata: serde_json::Value,
|
|
file_uuids: Vec<String>,
|
|
source: String,
|
|
tmdb_id: Option<i64>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct FaceInfo {
|
|
id: i64,
|
|
file_uuid: String,
|
|
frame_number: i64,
|
|
timestamp_secs: f64,
|
|
face_id: Option<String>,
|
|
confidence: f64,
|
|
bbox: Option<FaceBBox>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TraceInfo {
|
|
trace_id: i32,
|
|
file_uuid: String,
|
|
frame_count: i64,
|
|
first_frame: i64,
|
|
last_frame: i64,
|
|
first_sec: f64,
|
|
last_sec: f64,
|
|
avg_confidence: f64,
|
|
face_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct FileDetail {
|
|
file_uuid: String,
|
|
file_name: String,
|
|
fps: f64,
|
|
duration: f64,
|
|
width: u32,
|
|
height: u32,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct FaceCandidate {
|
|
id: i64,
|
|
face_id: Option<String>,
|
|
file_uuid: String,
|
|
frame_number: i64,
|
|
confidence: f64,
|
|
bbox: Option<FaceBBox>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct FaceBBox {
|
|
x: f64,
|
|
y: f64,
|
|
width: f64,
|
|
height: f64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SearchIdentityResult {
|
|
identity_id: i64,
|
|
name: String,
|
|
source: String,
|
|
tmdb_id: Option<i64>,
|
|
file_uuid: Option<String>,
|
|
trace_id: Option<i64>,
|
|
start_frame: Option<i64>,
|
|
end_frame: Option<i64>,
|
|
start_time: f64,
|
|
end_time: f64,
|
|
text_content: Option<String>,
|
|
}
|
|
|
|
const CORE_API: &str = "http://localhost:3002";
|
|
const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn search_llm_smart(query: String, limit: usize) -> Result<Vec<SearchResult>, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/search/llm-smart?api_key={}", CORE_API, API_KEY);
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.json(&serde_json::json!({"query": query, "limit": limit}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
|
|
let json: serde_json::Value = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
|
|
let results: Vec<SearchResult> = json["results"]
|
|
.as_array()
|
|
.unwrap_or(&vec![])
|
|
.iter()
|
|
.filter_map(|r| {
|
|
Some(SearchResult {
|
|
file_uuid: r["file_uuid"].as_str()?.to_string(),
|
|
start_time: r["start_time"].as_f64()?,
|
|
end_time: r["end_time"].as_f64()?,
|
|
start_frame: r["start_frame"].as_i64().unwrap_or(0),
|
|
end_frame: r["end_frame"].as_i64().unwrap_or(0),
|
|
summary: r["summary"].as_str()?.to_string(),
|
|
similarity: r["similarity"].as_f64().unwrap_or(0.0),
|
|
file_name: r["file_name"].as_str().map(|s| s.to_string()),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct GetFilesArgs {
|
|
#[serde(rename = "pageSize")]
|
|
page_size: usize,
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn search_agents(query: String, conversation_id: Option<String>) -> Result<serde_json::Value, String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.build()
|
|
.map_err(|e| format!("Client build failed: {}", e))?;
|
|
let url = format!("{}/api/v1/agents/search?api_key={}", CORE_API, API_KEY);
|
|
let mut body = serde_json::json!({"query": query});
|
|
if let Some(cid) = conversation_id {
|
|
body["conversation_id"] = serde_json::Value::String(cid);
|
|
}
|
|
let response = client
|
|
.post(&url)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Agent search request failed: {}", e))?;
|
|
let json: serde_json::Value = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Agent search parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_files(args: GetFilesArgs) -> Result<Vec<FileInfo>, String> {
|
|
let max_files = args.page_size;
|
|
let client = get_client();
|
|
let page_size = 100; // Core API 现在支持更大的 page_size
|
|
|
|
// 获取第一页并获取总数
|
|
let url = format!("{}/api/v1/files?api_key={}&page=1&page_size={}", CORE_API, API_KEY, page_size);
|
|
let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?;
|
|
let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
|
|
|
let total = json["total"].as_u64().unwrap_or(0) as usize;
|
|
let mut all_files: Vec<FileInfo> = Vec::new();
|
|
|
|
// 解析第一页的文件
|
|
if let Some(files_array) = json["data"].as_array() {
|
|
for f in files_array {
|
|
if all_files.len() >= max_files {
|
|
break;
|
|
}
|
|
if let Some(file_info) = parse_file_info(f) {
|
|
all_files.push(file_info);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 计算总页数
|
|
let total_pages = (total + page_size - 1) / page_size;
|
|
|
|
// 获取剩余页(如果有限制)
|
|
let max_pages = (max_files + page_size - 1) / page_size;
|
|
|
|
for page in 2..=max_pages.min(total_pages) {
|
|
if all_files.len() >= max_files {
|
|
break;
|
|
}
|
|
|
|
let url = format!("{}/api/v1/files?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, page_size);
|
|
let response = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?;
|
|
let json: serde_json::Value = response.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
|
|
|
if let Some(files_array) = json["data"].as_array() {
|
|
for f in files_array {
|
|
if all_files.len() >= max_files {
|
|
break;
|
|
}
|
|
if let Some(file_info) = parse_file_info(f) {
|
|
all_files.push(file_info);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(all_files)
|
|
}
|
|
|
|
fn parse_file_info(f: &serde_json::Value) -> Option<FileInfo> {
|
|
let is_reg = f["is_registered"].as_bool().unwrap_or(false);
|
|
let file_uuid = f["file_uuid"].as_str()
|
|
.unwrap_or_else(|| f["file_path"].as_str().unwrap_or("")).to_string();
|
|
let status = f["status"].as_str().unwrap_or("").to_string();
|
|
Some(FileInfo {
|
|
file_uuid,
|
|
file_name: f["file_name"].as_str()?.to_string(),
|
|
file_path: f["file_path"].as_str().unwrap_or("").to_string(),
|
|
file_size: f["file_size"].as_i64().unwrap_or(0),
|
|
modified_time: f["modified_time"].as_str().unwrap_or("").to_string(),
|
|
is_registered: is_reg,
|
|
registration_time: f["registration_time"].as_str().map(|s| s.to_string()),
|
|
ingested: matches!(status.as_str(), "completed" | "keyword_ready" | "semantic_ready" | "face_mgmt_ready" | "agent_ready"),
|
|
status,
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RegisterResult {
|
|
success: bool,
|
|
file_uuid: String,
|
|
file_name: String,
|
|
message: String,
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn register_file(file_path: String) -> Result<RegisterResult, String> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.build()
|
|
.map_err(|e| format!("Client build failed: {}", e))?;
|
|
let url = format!("{}/api/v1/files/register?api_key={}", CORE_API, API_KEY);
|
|
let response = client.post(&url)
|
|
.json(&serde_json::json!({"file_path": file_path}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Register request failed: {}", e))?;
|
|
let json: serde_json::Value = response.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(RegisterResult {
|
|
success: json["success"].as_bool().unwrap_or(false),
|
|
file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(),
|
|
file_name: json["file_name"].as_str().unwrap_or("").to_string(),
|
|
message: json["message"].as_str().unwrap_or("").to_string(),
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ProcessResult {
|
|
success: bool,
|
|
file_uuid: String,
|
|
message: String,
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn process_file(file_uuid: String, processors: Vec<String>) -> Result<ProcessResult, String> {
|
|
let url = format!("{}/api/v1/file/{}/process?api_key={}", CORE_API, file_uuid, API_KEY);
|
|
let response = get_client().post(&url)
|
|
.json(&serde_json::json!({"processors": processors}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Process request failed: {}", e))?;
|
|
let json: serde_json::Value = response.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(ProcessResult {
|
|
success: json["success"].as_bool().unwrap_or(false),
|
|
file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(),
|
|
message: json["message"].as_str().unwrap_or("").to_string(),
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct IngestResult {
|
|
success: bool,
|
|
file_uuid: String,
|
|
message: String,
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn ingest_file(file_uuid: String) -> Result<IngestResult, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/file/{}/checkin?api_key={}", CORE_API, file_uuid, API_KEY);
|
|
let resp = client.post(&url).send().await
|
|
.map_err(|e| format!("Ingest (checkin) request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Ingest (checkin) parse failed: {}", e))?;
|
|
Ok(IngestResult {
|
|
success: json["success"].as_bool().unwrap_or(false),
|
|
file_uuid,
|
|
message: json["message"].as_str().unwrap_or("Checkin complete").to_string(),
|
|
})
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn checkout_file(file_uuid: String) -> Result<IngestResult, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/file/{}/checkout?api_key={}", CORE_API, file_uuid, API_KEY);
|
|
let resp = client.post(&url).send().await
|
|
.map_err(|e| format!("Checkout request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Checkout parse failed: {}", e))?;
|
|
Ok(IngestResult {
|
|
success: json["success"].as_bool().unwrap_or(false),
|
|
file_uuid,
|
|
message: json["message"].as_str().unwrap_or("Checkout complete").to_string(),
|
|
})
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct UnregisterResult {
|
|
success: bool,
|
|
file_uuid: String,
|
|
message: String,
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn unregister_file(file_uuid: String, delete_output_files: Option<bool>) -> Result<UnregisterResult, String> {
|
|
let url = format!("{}/api/v1/unregister?api_key={}", CORE_API, API_KEY);
|
|
let mut body = serde_json::json!({"file_uuid": file_uuid});
|
|
if let Some(dof) = delete_output_files {
|
|
body["delete_output_files"] = serde_json::Value::Bool(dof);
|
|
}
|
|
let response = get_client().post(&url)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Unregister request failed: {}", e))?;
|
|
let json: serde_json::Value = response.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(UnregisterResult {
|
|
success: json["success"].as_bool().unwrap_or(false),
|
|
file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(),
|
|
message: json["message"].as_str().unwrap_or("").to_string(),
|
|
})
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_people(_page: usize, _per_page: usize) -> Result<Vec<PersonInfo>, String> {
|
|
eprintln!("[get_people] called");
|
|
let mut people = Vec::new();
|
|
for page in 1u32..=10 {
|
|
let url = format!("{}/api/v1/identities?api_key={}&page={}&per_page=100", CORE_API, API_KEY, page);
|
|
let resp = match get_client().get(&url).send().await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("[get_people] page {} request failed: {} — continuing", page, e);
|
|
continue;
|
|
}
|
|
};
|
|
let json: serde_json::Value = match resp.json().await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("[get_people] page {} parse error: {} — continuing", page, e);
|
|
continue;
|
|
}
|
|
};
|
|
let identities = match json["identities"].as_array() {
|
|
Some(arr) => arr,
|
|
None => {
|
|
eprintln!("[get_people] page {} has no identities array — stopping", page);
|
|
break;
|
|
}
|
|
};
|
|
if identities.is_empty() {
|
|
eprintln!("[get_people] page {} empty — stopping", page);
|
|
break;
|
|
}
|
|
for i in identities {
|
|
let uuid = match i["identity_uuid"].as_str() {
|
|
Some(u) => u.to_string(),
|
|
None => continue,
|
|
};
|
|
let name = match i["name"].as_str() {
|
|
Some(n) => n.to_string(),
|
|
None => continue,
|
|
};
|
|
people.push(PersonInfo {
|
|
identity_uuid: uuid,
|
|
name,
|
|
starred: i["starred"].as_bool().unwrap_or_else(|| i["metadata"]["starred"].as_bool().unwrap_or(false)),
|
|
status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(),
|
|
metadata: i["metadata"].clone(),
|
|
file_uuids: i["file_uuids"].as_array().map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(),
|
|
source: i["source"].as_str().unwrap_or("").to_string(),
|
|
tmdb_id: i["tmdb_id"].as_i64(),
|
|
});
|
|
}
|
|
}
|
|
eprintln!("[get_people] returning {} people", people.len());
|
|
Ok(people)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_faces(uuid: String, per_page: usize) -> Result<Vec<FaceInfo>, String> {
|
|
let url = format!("{}/api/v1/identity/{}/faces?api_key={}&page_size={}", CORE_API, uuid, API_KEY, per_page);
|
|
let t = std::time::Instant::now();
|
|
eprintln!("[get_faces] --> {}", uuid);
|
|
|
|
let response = match get_client().get(&url).send().await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("[get_faces] <-- {} ERROR {} {:?}", uuid, e, t.elapsed());
|
|
return Ok(vec![]);
|
|
}
|
|
};
|
|
let json: serde_json::Value = match response.json().await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("[get_faces] <-- {} PARSE_ERR {} {:?}", uuid, e, t.elapsed());
|
|
return Ok(vec![]);
|
|
}
|
|
};
|
|
|
|
let faces: Vec<FaceInfo> = json["data"]
|
|
.as_array()
|
|
.unwrap_or(&vec![])
|
|
.iter()
|
|
.filter_map(|f| {
|
|
Some(FaceInfo {
|
|
id: f["id"].as_i64().unwrap_or(0),
|
|
file_uuid: f["file_uuid"].as_str()?.to_string(),
|
|
frame_number: f["frame_number"].as_i64().unwrap_or(0),
|
|
timestamp_secs: f["timestamp_secs"].as_f64().unwrap_or(0.0),
|
|
face_id: f["face_id"].as_str().map(|s| s.to_string()),
|
|
confidence: f["confidence"].as_f64().unwrap_or(0.0),
|
|
bbox: f["bbox"].as_object().map(|o| FaceBBox {
|
|
x: o["x"].as_f64().unwrap_or(0.0),
|
|
y: o["y"].as_f64().unwrap_or(0.0),
|
|
width: o["width"].as_f64().unwrap_or(0.0),
|
|
height: o["height"].as_f64().unwrap_or(0.0),
|
|
}),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
eprintln!("[get_faces] <-- {} {} faces {:?}", uuid, faces.len(), t.elapsed());
|
|
Ok(faces)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TracesResponse {
|
|
total: i64,
|
|
traces: Vec<TraceInfo>,
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_traces(uuid: String, per_page: usize, page: Option<u32>) -> Result<TracesResponse, String> {
|
|
let page_size = per_page.min(100);
|
|
let page_num = page.unwrap_or(1);
|
|
let t = std::time::Instant::now();
|
|
eprintln!("[get_traces] --> {} page={} page_size={}", uuid, page_num, page_size);
|
|
|
|
let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}&page={}", CORE_API, uuid, API_KEY, page_size, page_num);
|
|
|
|
let resp = get_client().get(&url).send().await
|
|
.map_err(|e| format!("Traces request failed: {}", e))?;
|
|
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Traces parse failed: {}", e))?;
|
|
|
|
let total = json["total"].as_i64().unwrap_or(0);
|
|
|
|
let traces: Vec<TraceInfo> = json["traces"]
|
|
.as_array()
|
|
.unwrap_or(&vec![])
|
|
.iter()
|
|
.filter_map(|t| {
|
|
Some(TraceInfo {
|
|
trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32,
|
|
file_uuid: t["file_uuid"].as_str()?.to_string(),
|
|
frame_count: t["frame_count"].as_i64().unwrap_or(0),
|
|
first_frame: t["first_frame"].as_i64().unwrap_or(0),
|
|
last_frame: t["last_frame"].as_i64().unwrap_or(0),
|
|
first_sec: t["first_sec"].as_f64().unwrap_or(0.0),
|
|
last_sec: t["last_sec"].as_f64().unwrap_or(0.0),
|
|
avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0),
|
|
face_id: t["face_id"].as_str().map(String::from),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
eprintln!("[get_traces] <-- {} total={} {} traces {:?}", uuid, total, traces.len(), t.elapsed());
|
|
Ok(TracesResponse { total, traces })
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_unassigned_traces(page: usize, per_page: usize, file_uuid: Option<String>) -> Result<serde_json::Value, String> {
|
|
let page_size = per_page.min(100);
|
|
let mut url = format!("{}/api/v1/traces/unassigned?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, page_size);
|
|
if let Some(ref uuid) = file_uuid {
|
|
url.push_str(&format!("&file_uuid={}", uuid));
|
|
}
|
|
let resp = get_client().get(&url).send().await
|
|
.map_err(|e| format!("Unassigned traces request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Unassigned traces parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_file_info(uuid: String) -> Result<FileDetail, String> {
|
|
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY);
|
|
let resp = get_client().get(&url).send().await
|
|
.map_err(|e| format!("File info request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("File info parse failed: {}", e))?;
|
|
Ok(FileDetail {
|
|
file_uuid: json["file_uuid"].as_str().unwrap_or("").to_string(),
|
|
file_name: json["file_name"].as_str().unwrap_or("").to_string(),
|
|
fps: json["fps"].as_f64().unwrap_or(24.0),
|
|
duration: json["duration"].as_f64().unwrap_or(0.0),
|
|
width: json["width"].as_u64().unwrap_or(0) as u32,
|
|
height: json["height"].as_u64().unwrap_or(0) as u32,
|
|
})
|
|
}
|
|
|
|
/// Read EXIF orientation and physically rotate pixels, stripping orientation tag.
|
|
/// This ensures all browsers see the same pixel dimensions regardless of EXIF handling.
|
|
fn apply_orientation(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
|
use std::io::{Cursor, BufReader};
|
|
// Read EXIF orientation tag
|
|
let orientation = {
|
|
let cursor = Cursor::new(bytes);
|
|
let mut reader = BufReader::new(cursor);
|
|
match exif::Reader::new().read_from_container(&mut reader) {
|
|
Ok(exif) => exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
|
.and_then(|f| f.value.get_uint(0))
|
|
.unwrap_or(1),
|
|
Err(_) => 1,
|
|
}
|
|
};
|
|
let img = image::load_from_memory(bytes).map_err(|e| format!("Image decode: {}", e))?;
|
|
let img = match orientation {
|
|
2 => img.fliph(),
|
|
3 => img.rotate180(),
|
|
4 => img.fliph().rotate180(),
|
|
5 => img.rotate90().flipv(),
|
|
6 => img.rotate270(),
|
|
7 => img.rotate90().fliph(),
|
|
8 => img.rotate90(),
|
|
_ => img,
|
|
};
|
|
let mut buf = Cursor::new(Vec::new());
|
|
img.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("JPEG encode: {}", e))?;
|
|
Ok(buf.into_inner())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_thumbnail(uuid: String, frame: u32) -> Result<String, String> {
|
|
let key = format!("{}:{}", uuid, frame);
|
|
{
|
|
let mut cache = THUMB_CACHE.lock().unwrap();
|
|
if let Some(cache) = cache.as_mut() {
|
|
if let Some(cached) = cache.get(&key) {
|
|
return Ok(cached.clone());
|
|
}
|
|
}
|
|
}
|
|
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
|
|
// Double-check cache after acquiring semaphore
|
|
{
|
|
let mut cache = THUMB_CACHE.lock().unwrap();
|
|
if let Some(cache) = cache.as_mut() {
|
|
if let Some(cached) = cache.get(&key) {
|
|
return Ok(cached.clone());
|
|
}
|
|
}
|
|
}
|
|
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
|
let bytes = get_client().get(&url).send().await
|
|
.map_err(|e| format!("Thumbnail request failed: {}", e))?
|
|
.bytes().await
|
|
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
|
|
let corrected = apply_orientation(&bytes)?;
|
|
let result = format!("data:image/jpeg;base64,{}", STANDARD.encode(&corrected));
|
|
{
|
|
let mut cache = THUMB_CACHE.lock().unwrap();
|
|
let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap()));
|
|
cache.put(key, result.clone());
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_file_thumbnail_by_path(path: String, frame: u32) -> Result<String, String> {
|
|
if !std::path::Path::new(&path).exists() {
|
|
return Err("File not found".into());
|
|
}
|
|
|
|
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
let is_image = matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "gif" | "webp");
|
|
|
|
if is_image {
|
|
let bytes = std::fs::read(&path).map_err(|e| format!("Read failed: {}", e))?;
|
|
let mime = match ext.as_str() {
|
|
"png" => "image/png",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
_ => "image/jpeg",
|
|
};
|
|
Ok(format!("data:{};base64,{}", mime, STANDARD.encode(&bytes)))
|
|
} else {
|
|
let time_sec = frame as f64 / 24.0;
|
|
let output = tokio::process::Command::new("ffmpeg")
|
|
.args([
|
|
"-ss", &time_sec.to_string(),
|
|
"-i", &path,
|
|
"-vframes", "1",
|
|
"-q:v", "2",
|
|
"-f", "mjpeg",
|
|
"-"
|
|
])
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("ffmpeg failed: {}", e))?;
|
|
|
|
if output.status.success() {
|
|
Ok(format!("data:image/jpeg;base64,{}", STANDARD.encode(&output.stdout)))
|
|
} else {
|
|
Err("Thumbnail extraction failed".into())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch raw video dimensions from Core API with in-memory cache.
|
|
/// Returns `None` if the request fails or dimensions are 0.
|
|
async fn fetch_raw_dimensions(uuid: &str) -> Option<(u32, u32)> {
|
|
{
|
|
let cache = RAW_DIM_CACHE.lock().unwrap();
|
|
if let Some(cache) = cache.as_ref() {
|
|
if let Some(dims) = cache.get(uuid) {
|
|
return Some(*dims);
|
|
}
|
|
}
|
|
}
|
|
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY);
|
|
let result = get_client().get(&url).send().await.ok()?
|
|
.json::<serde_json::Value>().await.ok()?;
|
|
let w = result["width"].as_u64().unwrap_or(0) as u32;
|
|
let h = result["height"].as_u64().unwrap_or(0) as u32;
|
|
if w == 0 || h == 0 {
|
|
return None;
|
|
}
|
|
{
|
|
let mut cache = RAW_DIM_CACHE.lock().unwrap();
|
|
let cache = cache.get_or_insert_with(HashMap::new);
|
|
cache.insert(uuid.to_string(), (w, h));
|
|
}
|
|
Some((w, h))
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_face_thumbnail(uuid: String, frame: u32, bbox_x: Option<f64>, bbox_y: Option<f64>, bbox_w: Option<f64>, bbox_h: Option<f64>) -> Result<String, String> {
|
|
let key = format!("face:{}:{}:{:?}:{:?}:{:?}:{:?}", uuid, frame, bbox_x, bbox_y, bbox_w, bbox_h);
|
|
{
|
|
let mut cache = THUMB_CACHE.lock().unwrap();
|
|
if let Some(cache) = cache.as_mut() {
|
|
if let Some(cached) = cache.get(&key) {
|
|
return Ok(cached.clone());
|
|
}
|
|
}
|
|
}
|
|
let _permit = acquire_thumb_semaphore(&uuid).acquire_owned().await.unwrap();
|
|
// Double-check cache after acquiring semaphore
|
|
{
|
|
let mut cache = THUMB_CACHE.lock().unwrap();
|
|
if let Some(cache) = cache.as_mut() {
|
|
if let Some(cached) = cache.get(&key) {
|
|
return Ok(cached.clone());
|
|
}
|
|
}
|
|
}
|
|
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
|
let bytes = get_client().get(&url).send().await
|
|
.map_err(|e| format!("Thumbnail request failed: {}", e))?
|
|
.bytes().await
|
|
.map_err(|e| format!("Thumbnail read failed: {}", e))?;
|
|
let result = if let (Some(bx), Some(by), Some(bw), Some(bh)) = (bbox_x, bbox_y, bbox_w, bbox_h) {
|
|
let img = image::load_from_memory(&bytes).map_err(|e| format!("Image decode failed: {}", e))?;
|
|
let (w, h) = img.dimensions();
|
|
// Determine if CCW rotation is needed by comparing raw video dimensions
|
|
// against thumbnail dimensions. If raw != thumb, Core API rotated the frame
|
|
// and bbox is in raw space. Fall back to aspect ratio if raw dims unavailable.
|
|
let raw_dims = fetch_raw_dimensions(&uuid).await;
|
|
let needs_ccw = match raw_dims {
|
|
Some((rw, rh)) => (rw, rh) != (w, h),
|
|
None => w < h && (w as f64) / (h as f64) < 0.62,
|
|
};
|
|
let (bx, by, bw, bh) = if !(bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0) && needs_ccw {
|
|
let _rw = h as f64;
|
|
let rh = w as f64;
|
|
(rh - by - bh, bx, bh, bw)
|
|
} else {
|
|
(bx, by, bw, bh)
|
|
};
|
|
let (px, py, pw, ph) = if bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0 {
|
|
((bx * w as f64) as u32, (by * h as f64) as u32, (bw * w as f64) as u32, (bh * h as f64) as u32)
|
|
} else {
|
|
(bx as u32, by as u32, bw as u32, bh as u32)
|
|
};
|
|
eprintln!("[get_face_thumbnail] {} frame={} img={}x{} bbox={},{},{},{} -> pixel={},{},{},{} crop={},{},{},{}",
|
|
uuid, frame, w, h, bx, by, bw, bh, px, py, pw, ph,
|
|
px.min(w.saturating_sub(1)), py.min(h.saturating_sub(1)),
|
|
pw.min(w.saturating_sub(px.min(w.saturating_sub(1)))).max(1),
|
|
ph.min(h.saturating_sub(py.min(h.saturating_sub(1)))).max(1));
|
|
let cx = px.min(w.saturating_sub(1));
|
|
let cy = py.min(h.saturating_sub(1));
|
|
let cw = pw.min(w.saturating_sub(cx)).max(1);
|
|
let ch = ph.min(h.saturating_sub(cy)).max(1);
|
|
let cropped = img.crop_imm(cx, cy, cw, ch);
|
|
let mut buf = std::io::Cursor::new(Vec::new());
|
|
cropped.write_to(&mut buf, image::ImageFormat::Jpeg).map_err(|e| format!("Encode failed: {}", e))?;
|
|
format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner()))
|
|
} else {
|
|
format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))
|
|
};
|
|
|
|
{
|
|
let mut cache = THUMB_CACHE.lock().unwrap();
|
|
let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap()));
|
|
cache.put(key, result.clone());
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_identity_profile(uuid: String) -> Result<String, String> {
|
|
let uuid_display = uuid.clone();
|
|
let no_dash = uuid.replace('-', "");
|
|
{
|
|
let mut cache = PROFILE_CACHE.lock().unwrap();
|
|
if let Some(cache) = cache.as_mut() {
|
|
if let Some(cached) = cache.get(&uuid_display) {
|
|
eprintln!("[get_identity_profile] <-- {} (cached)", uuid_display);
|
|
return Ok(cached.clone());
|
|
}
|
|
}
|
|
}
|
|
let t = std::time::Instant::now();
|
|
let result = (|| -> Option<String> {
|
|
let output_dir = crate::proxy::get_output_dir();
|
|
let identities_dir = output_dir.join("identities");
|
|
for candidate in [&no_dash, &uuid_display] {
|
|
let path = identities_dir.join(candidate).join("profile.jpg");
|
|
if path.exists() {
|
|
let bytes = std::fs::read(&path).ok()?;
|
|
return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
|
}
|
|
}
|
|
None
|
|
})();
|
|
match result {
|
|
Some(data) => {
|
|
let mut cache = PROFILE_CACHE.lock().unwrap();
|
|
let cache = cache.get_or_insert_with(|| LruCache::new(NonZeroUsize::new(500).unwrap()));
|
|
cache.put(uuid_display.clone(), data.clone());
|
|
eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "FOUND", t.elapsed().as_millis());
|
|
Ok(data)
|
|
}
|
|
None => {
|
|
eprintln!("[get_identity_profile] <-- {} {} {}ms", uuid_display, "404", t.elapsed().as_millis());
|
|
Err("Profile image not found".to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn update_identity_name(uuid: String, name: String) -> Result<(), String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY);
|
|
let resp = client.patch(&url)
|
|
.json(&serde_json::json!({"name": name}))
|
|
.send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("Update failed: {}", resp.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn update_identity_status(uuid: String, status: String) -> Result<(), String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY);
|
|
// GET current identity to merge metadata (preserve starred, role, notes, etc.)
|
|
let get_resp = client.get(&url).send().await
|
|
.map_err(|e| format!("GET failed: {}", e))?;
|
|
let json: serde_json::Value = get_resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
let mut metadata = json["metadata"].as_object()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
metadata.insert("status".to_string(), serde_json::Value::String(status));
|
|
let resp = client.patch(&url)
|
|
.json(&serde_json::json!({"metadata": metadata}))
|
|
.send().await
|
|
.map_err(|e| format!("PATCH failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("Update failed: {}", resp.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn update_identity_starred(uuid: String, starred: bool) -> Result<(), String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY);
|
|
let get_resp = client.get(&url).send().await
|
|
.map_err(|e| format!("GET failed: {}", e))?;
|
|
let json: serde_json::Value = get_resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
let mut metadata = json["metadata"].as_object()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
metadata.insert("starred".to_string(), serde_json::Value::Bool(starred));
|
|
let resp = client.patch(&url)
|
|
.json(&serde_json::json!({"metadata": metadata}))
|
|
.send().await
|
|
.map_err(|e| format!("PATCH failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("Update failed: {}", resp.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn update_identity(uuid: String, name: Option<String>, metadata_json: String) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY);
|
|
let mut body = serde_json::json!({});
|
|
if let Some(n) = &name {
|
|
body["name"] = serde_json::Value::String(n.clone());
|
|
}
|
|
if !metadata_json.is_empty() {
|
|
let meta: serde_json::Value = serde_json::from_str(&metadata_json).map_err(|e| format!("Invalid metadata JSON: {}", e))?;
|
|
body["metadata"] = meta;
|
|
}
|
|
let resp = client.patch(&url)
|
|
.json(&body)
|
|
.send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn upload_profile_image(uuid: String, file_path: String) -> Result<(), String> {
|
|
let file_bytes = std::fs::read(&file_path).map_err(|e| format!("Failed to read file: {}", e))?;
|
|
let file_name = std::path::Path::new(&file_path)
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("profile.jpg")
|
|
.to_string();
|
|
let part = reqwest::multipart::Part::bytes(file_bytes)
|
|
.file_name(file_name)
|
|
.mime_str("image/jpeg")
|
|
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&file_path).unwrap_or_default()).file_name("profile.jpg"));
|
|
let form = reqwest::multipart::Form::new()
|
|
.part("image", part);
|
|
let url = format!("{}/api/v1/identity/{}/profile-image?api_key={}", CORE_API, uuid, API_KEY);
|
|
let resp = get_client().post(&url)
|
|
.multipart(form)
|
|
.send().await
|
|
.map_err(|e| format!("Upload failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("Upload failed: {} {}", status, body));
|
|
}
|
|
let mut cache = PROFILE_CACHE.lock().unwrap();
|
|
if let Some(c) = cache.as_mut() {
|
|
c.pop(&uuid);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn match_from_photo(identity_uuid: String, file_uuid: String, image_path: Option<String>) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/agents/identity/match-from-photo?api_key={}", CORE_API, API_KEY);
|
|
let mut form = reqwest::multipart::Form::new()
|
|
.text("identity_uuid", identity_uuid.replace('-', ""))
|
|
.text("file_uuid", file_uuid);
|
|
if let Some(path) = image_path {
|
|
let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
|
|
let part = reqwest::multipart::Part::bytes(file_bytes)
|
|
.file_name("profile.jpg")
|
|
.mime_str("image/jpeg")
|
|
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&path).unwrap_or_default()).file_name("profile.jpg"));
|
|
form = form.part("image", part);
|
|
}
|
|
let resp = client.post(&url)
|
|
.multipart(form)
|
|
.send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("Match failed: {} {}", status, body));
|
|
}
|
|
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn set_profile_from_face(uuid: String, file_uuid: String, face_id: Option<String>, id: Option<i64>, trace_id: Option<i32>, frame_number: Option<i64>) -> Result<(), String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}/profile-image/from-face?api_key={}", CORE_API, uuid, API_KEY);
|
|
let mut body = serde_json::json!({"file_uuid": file_uuid});
|
|
if let Some(fid) = face_id {
|
|
body["face_id"] = serde_json::json!(fid);
|
|
} else if let Some(rid) = id {
|
|
body["id"] = serde_json::json!(rid);
|
|
} else if let Some(tid) = trace_id {
|
|
body["trace_id"] = serde_json::json!(tid);
|
|
if let Some(frame) = frame_number {
|
|
body["frame_number"] = serde_json::json!(frame);
|
|
}
|
|
} else {
|
|
return Err("Either face_id, id, or trace_id is required".to_string());
|
|
}
|
|
let resp = client.post(&url)
|
|
.json(&body)
|
|
.send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body_text = resp.text().await.unwrap_or_default();
|
|
return Err(format!("Set profile failed: {} {}", status, body_text));
|
|
}
|
|
let mut cache = PROFILE_CACHE.lock().unwrap();
|
|
if let Some(c) = cache.as_mut() {
|
|
c.pop(&uuid);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn delete_identity(uuid: String) -> Result<(), String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}?api_key={}", CORE_API, uuid, API_KEY);
|
|
let resp = client.delete(&url)
|
|
.send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
if !resp.status().is_success() && resp.status() != 204 {
|
|
return Err(format!("Delete failed: {}", resp.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CreateIdentityFromFaceResult {
|
|
success: bool,
|
|
identity_uuid: String,
|
|
name: String,
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn create_identity_from_face(name: String, _face_id: Option<String>, _face_row_id: Option<i64>, file_uuid: String, trace_ids: Option<Vec<i64>>) -> Result<CreateIdentityFromFaceResult, String> {
|
|
let client = get_client();
|
|
|
|
// Use POST /api/v1/file/:file_uuid/pending-person to create pending identity
|
|
let url = format!("{}/api/v1/file/{}/pending-person?api_key={}", CORE_API, file_uuid, API_KEY);
|
|
let mut body = serde_json::json!({
|
|
"name": name
|
|
});
|
|
if let Some(tids) = &trace_ids {
|
|
body["trace_ids"] = serde_json::json!(tids);
|
|
}
|
|
|
|
let resp = client.post(&url)
|
|
.json(&body)
|
|
.send().await
|
|
.map_err(|e| format!("Create pending person request failed: {}", e))?;
|
|
|
|
let status = resp.status();
|
|
let resp_json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse response failed: {}", e))?;
|
|
|
|
eprintln!("[create_identity_from_face] status={} resp={}", status, resp_json);
|
|
|
|
if !status.is_success() {
|
|
return Err(format!("Create pending person failed: {} - {}", status, resp_json["error"].as_str().unwrap_or("unknown error")));
|
|
}
|
|
|
|
// identity_uuid is inside data object
|
|
let data_obj = resp_json.get("data").unwrap_or(&resp_json);
|
|
let new_uuid = data_obj.get("identity_uuid")
|
|
.and_then(|v| v.as_str())
|
|
.or_else(|| resp_json.get("identity_uuid").and_then(|v| v.as_str()))
|
|
.or_else(|| resp_json.get("uuid").and_then(|v| v.as_str()))
|
|
.unwrap_or("");
|
|
|
|
if new_uuid.is_empty() {
|
|
return Err("Create pending person failed: no uuid returned".to_string());
|
|
}
|
|
|
|
Ok(CreateIdentityFromFaceResult {
|
|
success: true,
|
|
identity_uuid: new_uuid.to_string(),
|
|
name,
|
|
})
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn search_identities(query: String, limit: usize) -> Result<Vec<SearchIdentityResult>, String> {
|
|
let url = format!("{}/api/v1/identities/search?api_key={}&q={}", CORE_API, API_KEY, query);
|
|
let resp = get_client().get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
|
let results: Vec<SearchIdentityResult> = json["results"]
|
|
.as_array()
|
|
.unwrap_or(&vec![])
|
|
.iter()
|
|
.take(limit)
|
|
.filter_map(|r| {
|
|
Some(SearchIdentityResult {
|
|
identity_id: r["identity_id"].as_i64().unwrap_or(0),
|
|
name: r["name"].as_str()?.to_string(),
|
|
source: r["source"].as_str().unwrap_or("").to_string(),
|
|
tmdb_id: r["tmdb_id"].as_i64(),
|
|
file_uuid: r["file_uuid"].as_str().map(|s| s.to_string()),
|
|
trace_id: r["trace_id"].as_i64(),
|
|
start_frame: r["start_frame"].as_i64(),
|
|
end_frame: r["end_frame"].as_i64(),
|
|
start_time: r["start_time"].as_f64().unwrap_or(0.0),
|
|
end_time: r["end_time"].as_f64().unwrap_or(0.0),
|
|
text_content: r["text_content"].as_str().map(|s| s.to_string()),
|
|
})
|
|
})
|
|
.collect();
|
|
Ok(results)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_face_candidates(page: usize, per_page: usize) -> Result<Vec<FaceCandidate>, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/faces/candidates?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, per_page);
|
|
let resp = client.get(&url).send().await.map_err(|e| format!("Request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
|
let candidates: Vec<FaceCandidate> = json["candidates"]
|
|
.as_array()
|
|
.unwrap_or(&vec![])
|
|
.iter()
|
|
.filter_map(|c| {
|
|
Some(FaceCandidate {
|
|
id: c["id"].as_i64().unwrap_or(0),
|
|
face_id: c["face_id"].as_str().map(|s| s.to_string()),
|
|
file_uuid: c["file_uuid"].as_str()?.to_string(),
|
|
frame_number: c["frame_number"].as_i64().unwrap_or(0),
|
|
confidence: c["confidence"].as_f64().unwrap_or(0.0),
|
|
bbox: c["bbox"].as_object().map(|o| FaceBBox {
|
|
x: o["x"].as_f64().unwrap_or(0.0),
|
|
y: o["y"].as_f64().unwrap_or(0.0),
|
|
width: o["width"].as_f64().unwrap_or(0.0),
|
|
height: o["height"].as_f64().unwrap_or(0.0),
|
|
}),
|
|
})
|
|
})
|
|
.collect();
|
|
Ok(candidates)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn merge_identities(uuid: String, into_uuid: String) -> Result<(), String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}/mergeinto?api_key={}", CORE_API, uuid, API_KEY);
|
|
let resp = client.post(&url)
|
|
.json(&serde_json::json!({"into_uuid": into_uuid}))
|
|
.send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("Merge failed: {}", resp.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn bind_face(uuid: String, face_id: Option<String>, face_row_id: Option<i64>, file_uuid: String, expand_to_trace: Option<bool>) -> Result<(), String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}/bind?api_key={}", CORE_API, uuid, API_KEY);
|
|
let mut body = serde_json::json!({"file_uuid": file_uuid});
|
|
if let Some(fid) = &face_id {
|
|
body["face_id"] = serde_json::json!(fid);
|
|
} else if let Some(rid) = face_row_id {
|
|
body["id"] = serde_json::json!(rid);
|
|
} else {
|
|
return Err("Either face_id or face_row_id is required".to_string());
|
|
}
|
|
if let Some(expand) = expand_to_trace {
|
|
body["expand_to_trace"] = serde_json::json!(expand);
|
|
}
|
|
let resp = client.post(&url)
|
|
.json(&body)
|
|
.send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("Bind failed: {}", resp.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn unbind_face(uuid: String, face_id: Option<String>, face_row_id: Option<i64>, file_uuid: String, _frame_number: Option<i64>) -> Result<(), String> {
|
|
let client = get_client();
|
|
let mut body = serde_json::json!({"file_uuid": file_uuid});
|
|
if let Some(fid) = face_id {
|
|
body["face_id"] = serde_json::json!(fid);
|
|
} else if let Some(rid) = face_row_id {
|
|
body["id"] = serde_json::json!(rid);
|
|
} else {
|
|
return Err("Either face_id or face_row_id is required".to_string());
|
|
}
|
|
let url = format!("{}/api/v1/identity/{}/unbind?api_key={}", CORE_API, uuid, API_KEY);
|
|
eprintln!("[unbind_face] POST {} body={}", url, body);
|
|
let resp = client.post(&url)
|
|
.json(&body)
|
|
.send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
let status = resp.status();
|
|
let resp_text = resp.text().await.unwrap_or_default();
|
|
eprintln!("[unbind_face] status={} body={}", status, resp_text);
|
|
if !status.is_success() {
|
|
return Err(format!("Unbind failed: {} - {}", status, resp_text));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn identity_undo(uuid: String, steps: Option<usize>) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}/undo?api_key={}", CORE_API, uuid, API_KEY);
|
|
let mut body = serde_json::json!({});
|
|
if let Some(s) = steps {
|
|
body["steps"] = serde_json::Value::Number(s.into());
|
|
}
|
|
let resp = client.post(&url).json(&body).send().await
|
|
.map_err(|e| format!("Undo failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn identity_redo(uuid: String, steps: Option<usize>) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}/redo?api_key={}", CORE_API, uuid, API_KEY);
|
|
let mut body = serde_json::json!({});
|
|
if let Some(s) = steps {
|
|
body["steps"] = serde_json::Value::Number(s.into());
|
|
}
|
|
let resp = client.post(&url).json(&body).send().await
|
|
.map_err(|e| format!("Redo failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn identity_history(uuid: String, page: Option<usize>, page_size: Option<usize>) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let mut url = format!("{}/api/v1/identity/{}/history?api_key={}", CORE_API, uuid, API_KEY);
|
|
if let Some(p) = page { url = format!("{}&page={}", url, p); }
|
|
if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); }
|
|
let resp = client.get(&url).send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn identity_bind_undo(uuid: String, steps: Option<usize>) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}/bind/undo?api_key={}", CORE_API, uuid, API_KEY);
|
|
let mut body = serde_json::json!({});
|
|
if let Some(s) = steps {
|
|
body["steps"] = serde_json::Value::Number(s.into());
|
|
}
|
|
let resp = client.post(&url).json(&body).send().await
|
|
.map_err(|e| format!("Bind undo failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn identity_bind_redo(uuid: String, steps: Option<usize>) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/{}/bind/redo?api_key={}", CORE_API, uuid, API_KEY);
|
|
let mut body = serde_json::json!({});
|
|
if let Some(s) = steps {
|
|
body["steps"] = serde_json::Value::Number(s.into());
|
|
}
|
|
let resp = client.post(&url).json(&body).send().await
|
|
.map_err(|e| format!("Bind redo failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn identity_bind_history(uuid: String, page: Option<usize>, page_size: Option<usize>) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let mut url = format!("{}/api/v1/identity/{}/bind/history?api_key={}", CORE_API, uuid, API_KEY);
|
|
if let Some(p) = page { url = format!("{}&page={}", url, p); }
|
|
if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); }
|
|
let resp = client.get(&url).send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn merge_undo(merge_id: String) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/merge/{}/undo?api_key={}", CORE_API, merge_id, API_KEY);
|
|
let resp = client.post(&url).send().await
|
|
.map_err(|e| format!("Merge undo failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn merge_redo(merge_id: String) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let url = format!("{}/api/v1/identity/merge/{}/redo?api_key={}", CORE_API, merge_id, API_KEY);
|
|
let resp = client.post(&url).send().await
|
|
.map_err(|e| format!("Merge redo failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn merge_history(source_uuid: Option<String>, target_uuid: Option<String>, page: Option<usize>, page_size: Option<usize>) -> Result<serde_json::Value, String> {
|
|
let client = get_client();
|
|
let mut url = format!("{}/api/v1/identity/merge/history?api_key={}", CORE_API, API_KEY);
|
|
if let Some(s) = source_uuid { url = format!("{}&source_uuid={}", url, s); }
|
|
if let Some(t) = target_uuid { url = format!("{}&target_uuid={}", url, t); }
|
|
if let Some(p) = page { url = format!("{}&page={}", url, p); }
|
|
if let Some(ps) = page_size { url = format!("{}&page_size={}", url, ps); }
|
|
let resp = client.get(&url).send().await
|
|
.map_err(|e| format!("Request failed: {}", e))?;
|
|
let json: serde_json::Value = resp.json().await
|
|
.map_err(|e| format!("Parse failed: {}", e))?;
|
|
Ok(json)
|
|
}
|
|
|
|
|
|
// === Service Management ===
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn stop_service(name: String) -> Result<String, String> {
|
|
if name == "Worker" || name == "worker" {
|
|
// Worker has no port — stop via pidfile / pkill
|
|
let pid_file = get_worker_pid_file();
|
|
let core_root = get_momentry_core_root();
|
|
let cmd = format!(
|
|
"sleep 0.5; WPID=$(cat {} 2>/dev/null); if [ -n \"$WPID\" ]; then pkill -P $WPID 2>/dev/null; kill -9 $WPID 2>/dev/null; fi; pkill -9 -f 'target/release/momentry worker' 2>/dev/null; rm -f {}; echo 'stopped'",
|
|
pid_file, pid_file
|
|
);
|
|
let output = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(&cmd)
|
|
.output()
|
|
.map_err(|e| format!("Failed to stop worker: {}", e))?;
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
return Ok(stdout.trim().to_string());
|
|
}
|
|
let port = match name.as_str() {
|
|
"Core API" | "core" => 3002,
|
|
"MarkBase" | "markbase" => 11438,
|
|
"Proxy" | "proxy" => 8888,
|
|
"MarkBaseEngine" | "mbe" => 8080,
|
|
"SFTP" => 2024,
|
|
"SMB" => 4445,
|
|
_ => return Err(format!("Unknown service: {}", name)),
|
|
};
|
|
let output = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(format!("lsof -ti :{} | xargs kill -9 2>/dev/null; echo 'stopped'", port))
|
|
.output()
|
|
.map_err(|e| format!("Failed to stop service: {}", e))?;
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
Ok(stdout.trim().to_string())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn start_service(name: String) -> Result<String, String> {
|
|
if name == "Worker" || name == "worker" {
|
|
// Worker has no port — verify via pidfile instead of lsof
|
|
let core_root = get_momentry_core_root();
|
|
let cmd = format!("cd {} && nohup bash run-worker-3002.sh > /tmp/momentry_worker.log 2>&1 &", core_root);
|
|
let output = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(&cmd)
|
|
.output()
|
|
.map_err(|e| format!("Failed to start worker: {}", e))?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("Worker start failed: {}", stderr));
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
|
let pid_file = get_worker_pid_file();
|
|
let check_cmd = format!("WPID=$(cat {} 2>/dev/null); [ -n \"$WPID\" ] && kill -0 $WPID 2>/dev/null && echo alive", pid_file);
|
|
let check = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(&check_cmd)
|
|
.output()
|
|
.map_err(|e| format!("Worker check failed: {}", e))?;
|
|
if String::from_utf8_lossy(&check.stdout).contains("alive") {
|
|
return Ok("started".to_string());
|
|
}
|
|
return Err("Worker failed to start".to_string());
|
|
}
|
|
let cmd = match name.as_str() {
|
|
"MarkBase" | "markbase" => {
|
|
let markbase_root = get_markbase_root();
|
|
format!("mkdir -p {}/data && cd {} && nohup ./target/release/markbase-core web-start --port 11438 > /tmp/markbase_web.log 2>&1 &", markbase_root, markbase_root)
|
|
}
|
|
"Core API" | "core" => {
|
|
let core_root = get_momentry_core_root();
|
|
format!("cd {} && nohup bash run-server-3002.sh > /tmp/momentry_start.log 2>&1 &", core_root)
|
|
}
|
|
"Proxy" | "proxy" => {
|
|
let studio_root = get_studio_root();
|
|
format!("cd {}/src-tauri && nohup ./target/debug/momentry-proxy > /tmp/momentry_proxy.log 2>&1 &", studio_root)
|
|
}
|
|
"MarkBaseEngine" | "mbe" => {
|
|
let engine_root = get_markbase_engine_root();
|
|
format!("cd {} && nohup .build/debug/MarkBaseServer > /tmp/markbase_engine.log 2>&1 &", engine_root)
|
|
}
|
|
"SFTP" => {
|
|
let markbase_root = get_markbase_root();
|
|
format!("cd {} && nohup cargo run --bin markbase-core -- ssh-start --port 2024 > /tmp/markbase_ssh.log 2>&1 &", markbase_root)
|
|
}
|
|
"SMB" => {
|
|
let markbase_root = get_markbase_root();
|
|
let storage_root = get_storage_root();
|
|
// Get SMB credentials from environment or use default
|
|
let smb_user = std::env::var("SMB_USER").unwrap_or_else(|_| "demo".to_string());
|
|
let smb_password = std::env::var("SMB_PASSWORD")
|
|
.or_else(|_| std::env::var("MOMENTRY_DEFAULT_PASSWORD"))
|
|
.unwrap_or_else(|_| {
|
|
// Fallback to a generated password
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
let timestamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
format!("smb_{}", timestamp % 1000000)
|
|
});
|
|
format!("cd {} && nohup cargo run --bin markbase-core --features smb-server -- smb-start --port 4445 --share-name markbase --root {} --user {}:{} > /tmp/markbase_smb.log 2>&1 &", markbase_root, storage_root, smb_user, smb_password)
|
|
}
|
|
_ => return Err(format!("Unknown service: {}", name)),
|
|
};
|
|
let output = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(cmd)
|
|
.output()
|
|
.map_err(|e| format!("Failed to start service: {}", e))?;
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("Start failed: {}", stderr));
|
|
}
|
|
let port = match name.as_str() {
|
|
"MarkBase" | "markbase" => 11438,
|
|
"Core API" | "core" => 3002,
|
|
"Proxy" | "proxy" => 8888,
|
|
"MarkBaseEngine" | "mbe" => 8080,
|
|
"SFTP" => 2024,
|
|
"SMB" => 4445,
|
|
_ => 0,
|
|
};
|
|
if port > 0 {
|
|
// MarkBaseEngine takes 5-10s to load the model; skip port check
|
|
if port == 8080 {
|
|
return Ok("started".to_string());
|
|
}
|
|
// Wait a moment then check if port is up
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
let check = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(format!("lsof -ti :{} 2>/dev/null", port))
|
|
.output()
|
|
.map_err(|e| format!("Check failed: {}", e))?;
|
|
if check.stdout.is_empty() {
|
|
return Err(format!("Service {} failed to start on port {}", name, port));
|
|
}
|
|
}
|
|
Ok("started".to_string())
|
|
}
|
|
|
|
// === Admin Commands ===
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn login_user(username: String, password: String) -> Result<db::LoginResult, String> {
|
|
match db::verify_user(&username, &password) {
|
|
Ok(Some(user)) => {
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
Ok(db::LoginResult {
|
|
success: true,
|
|
user: Some(user),
|
|
token: Some(token),
|
|
error: None,
|
|
})
|
|
}
|
|
Ok(None) => Ok(db::LoginResult {
|
|
success: false,
|
|
user: None,
|
|
token: None,
|
|
error: Some("Invalid username or password".to_string()),
|
|
}),
|
|
Err(e) => Ok(db::LoginResult {
|
|
success: false,
|
|
user: None,
|
|
token: None,
|
|
error: Some(e),
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn get_current_user(username: String) -> Result<Option<db::User>, String> {
|
|
db::get_user_by_username(&username)
|
|
}
|
|
|
|
// === Client Commands ===
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn list_client_files(path: String) -> Result<serde_json::Value, String> {
|
|
let home = get_home_dir();
|
|
let root = home.join("momentry/var/sftpgo/data/demo");
|
|
let full_path = if path.is_empty() || path == "/" { root.clone() } else { root.join(&path) };
|
|
|
|
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
|
|
let entries = vfs.read_dir(&full_path)
|
|
.map_err(|e| format!("Failed to read dir: {}", e))?;
|
|
|
|
let files: Vec<serde_json::Value> = entries
|
|
.into_iter()
|
|
.filter_map(|e| {
|
|
Some(serde_json::json!({
|
|
"name": e.name,
|
|
"path": e.long_name,
|
|
"isDir": e.stat.is_dir,
|
|
"size": e.stat.size
|
|
}))
|
|
})
|
|
.collect();
|
|
|
|
Ok(serde_json::Value::Array(files))
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn mkdir_client(path: String) -> Result<(), String> {
|
|
let home = get_home_dir();
|
|
let root = home.join("momentry/var/sftpgo/data/demo");
|
|
let full_path = root.join(&path);
|
|
|
|
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
|
|
vfs.create_dir(&full_path, 0o755)
|
|
.map_err(|e| format!("Failed to mkdir: {}", e))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command(rename_all = "camelCase")]
|
|
async fn rm_client(path: String) -> Result<(), String> {
|
|
let home = get_home_dir();
|
|
let root = home.join("momentry/var/sftpgo/data/demo");
|
|
let full_path = root.join(&path);
|
|
|
|
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
|
|
if full_path.is_dir() {
|
|
vfs.remove_dir(&full_path)
|
|
.map_err(|e| format!("Failed to remove dir: {}", e))?;
|
|
} else {
|
|
vfs.remove_file(&full_path)
|
|
.map_err(|e| format!("Failed to remove file: {}", e))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn main() {
|
|
std::thread::spawn(|| {
|
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
rt.block_on(proxy::start_proxy_server());
|
|
});
|
|
|
|
fn ensure_markbase_running() {
|
|
let check = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg("lsof -ti :11438")
|
|
.output();
|
|
|
|
if let Ok(output) = check {
|
|
if output.stdout.is_empty() {
|
|
let markbase_root = get_markbase_root();
|
|
let cmd = format!("mkdir -p {}/data && cd {} && nohup ./target/release/markbase-core web-start --port 11438 > /tmp/markbase_web.log 2>&1 &", markbase_root, markbase_root);
|
|
let _ = std::process::Command::new("sh")
|
|
.arg("-c")
|
|
.arg(&cmd)
|
|
.spawn();
|
|
eprintln!("[startup] MarkBase started on port 11438");
|
|
} else {
|
|
eprintln!("[startup] MarkBase already running on port 11438");
|
|
}
|
|
}
|
|
}
|
|
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_shell::init())
|
|
.setup(|app| {
|
|
use tauri::Manager;
|
|
let _ = app.get_webview_window("main");
|
|
if let Err(e) = db::init_db() {
|
|
eprintln!("Failed to init database: {}", e);
|
|
}
|
|
ensure_markbase_running();
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
search_llm_smart,
|
|
search_agents,
|
|
get_files,
|
|
register_file,
|
|
process_file,
|
|
ingest_file,
|
|
checkout_file,
|
|
unregister_file,
|
|
get_people,
|
|
get_faces,
|
|
get_traces,
|
|
get_unassigned_traces,
|
|
get_file_info,
|
|
get_thumbnail,
|
|
get_face_thumbnail,
|
|
get_file_thumbnail_by_path,
|
|
get_identity_profile,
|
|
update_identity_name,
|
|
update_identity_status,
|
|
update_identity_starred,
|
|
update_identity,
|
|
upload_profile_image,
|
|
set_profile_from_face,
|
|
match_from_photo,
|
|
delete_identity,
|
|
create_identity_from_face,
|
|
search_identities,
|
|
get_face_candidates,
|
|
merge_identities,
|
|
bind_face,
|
|
unbind_face,
|
|
identity_undo,
|
|
identity_redo,
|
|
identity_history,
|
|
identity_bind_undo,
|
|
identity_bind_redo,
|
|
identity_bind_history,
|
|
merge_undo,
|
|
merge_redo,
|
|
merge_history,
|
|
db::get_search_history,
|
|
db::save_search_history,
|
|
db::rename_search_history,
|
|
db::pin_search_history,
|
|
db::delete_search_history,
|
|
db::get_bookmarks,
|
|
db::save_bookmark,
|
|
db::delete_bookmark,
|
|
db::get_video_marks,
|
|
db::save_video_mark,
|
|
db::delete_video_mark,
|
|
login_user,
|
|
get_current_user,
|
|
list_client_files,
|
|
mkdir_client,
|
|
rm_client,
|
|
stop_service,
|
|
start_service
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|