#![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>> = Mutex::new(None); static PROFILE_CACHE: Mutex>> = Mutex::new(None); static RAW_DIM_CACHE: Mutex>> = 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>>> = Mutex::new(None); fn acquire_thumb_semaphore(uuid: &str) -> Arc { 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, } #[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, #[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, source: String, tmdb_id: Option, } #[derive(Serialize)] struct FaceInfo { id: i64, file_uuid: String, frame_number: i64, timestamp_secs: f64, face_id: Option, confidence: f64, bbox: Option, } #[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, } #[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, file_uuid: String, frame_number: i64, confidence: f64, bbox: Option, } #[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, file_uuid: Option, trace_id: Option, start_frame: Option, end_frame: Option, start_time: f64, end_time: f64, text_content: Option, } 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, 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 = 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) -> Result { 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, 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 = 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 { 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 { 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) -> Result { 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 { 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 { 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) -> Result { 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, 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, 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 = 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, } #[tauri::command(rename_all = "camelCase")] async fn get_traces(uuid: String, per_page: usize, page: Option) -> Result { 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 = 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) -> Result { 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 { 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, 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 { 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 { 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::().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, bbox_y: Option, bbox_w: Option, bbox_h: Option) -> Result { 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 { 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 { 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, metadata_json: String) -> Result { 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) -> Result { 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, id: Option, trace_id: Option, frame_number: Option) -> 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, _face_row_id: Option, file_uuid: String, trace_ids: Option>) -> Result { 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, 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 = 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, 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 = 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, face_row_id: Option, file_uuid: String, expand_to_trace: Option) -> 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, face_row_id: Option, file_uuid: String, _frame_number: Option) -> 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) -> Result { 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) -> Result { 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, page_size: Option) -> Result { 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) -> Result { 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) -> Result { 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, page_size: Option) -> Result { 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 { 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 { 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, target_uuid: Option, page: Option, page_size: Option) -> Result { 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 { 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 { 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 { 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, String> { db::get_user_by_username(&username) } // === Client Commands === #[tauri::command(rename_all = "camelCase")] async fn list_client_files(path: String) -> Result { 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 = 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"); }