diff --git a/.gitignore b/.gitignore index 4d6a46c..3c32429 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ __pycache__/ node_modules/ *.log /tmp/ +*.log diff --git a/AGENTS.md b/AGENTS.md index 88b9335..bc58dfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -551,6 +551,40 @@ shellcheck scripts/*.sh monitor/**/*.sh **注意**: Hook 只檢查 error 等級的 shellcheck 問題,style 警告會顯示但不阻擋提交。 +## Gitea Sync + +主要 sync 管道為 Gitea:`http://192.168.110.200:3000/admin/momentry_core.git` + +### 產生 Access Token(首次設定) + +```bash +# admin 帳號密碼為 AccusysTest! +TOKEN=$(curl -s -X POST "http://192.168.110.200:3000/api/v1/users/admin/tokens" \ + -u "admin:AccusysTest!" \ + -H "Content-Type: application/json" \ + -d '{"name":"m5max128_push","scopes":["write:repository"]}' | jq -r '.sha1') +echo $TOKEN +``` + +### 設定 Remote + +```bash +# 用 token 取代密碼 +git remote add origin http://admin:TOKEN@192.168.110.200:3000/admin/momentry_core.git + +# 同步 +git pull origin main +git push origin main +``` + +### Token 記錄 + +| 機器 | Token | +|------|-------| +| M5Max128 | `a7cf946148063c2bfa8d59ad629ae541813f0db8` | + +**注意**: Token 有 write:repository scope,勿外洩。如需新增 token 給其他機器,各自產自己的 token。 + ## Release Workflow ### Release 前準備 diff --git a/docs_v1.0/API_V1.0.0/TRACE/TRACE_API_REFERENCE_V1.0.0.md b/docs_v1.0/API_V1.0.0/TRACE/TRACE_API_REFERENCE_V1.0.0.md index 48d4434..b3c1838 100644 --- a/docs_v1.0/API_V1.0.0/TRACE/TRACE_API_REFERENCE_V1.0.0.md +++ b/docs_v1.0/API_V1.0.0/TRACE/TRACE_API_REFERENCE_V1.0.0.md @@ -134,6 +134,7 @@ Aggregated face traces with sorting and filtering. | `limit` | int | 200 | Max faces (capped 1000) | | `offset` | int | 0 | Pagination | | `interpolate` | bool | false | Enable linear interpolation | +| `dimension` | string | — | If `"3d"`, returns `z_rel` depth per detection | #### Response @@ -153,13 +154,15 @@ Aggregated face traces with sorting and filtering. "width": 187, "height": 187, "confidence": 0.834, - "interpolated": false + "interpolated": false, + "z_rel": 0.045 } ] } ``` Interpolated frames: `id=0, confidence=0.0, interpolated=true`. +When `?dimension=3d`, each face includes `z_rel` (0.0 = nearest, 1.0 = farthest), derived from bbox area ratio. Without `dimension=3d`, `z_rel` is omitted. #### Interpolation Algorithm diff --git a/docs_v1.0/REFERENCE/DEMO_RUNNER_V1.0.0.md b/docs_v1.0/REFERENCE/DEMO_RUNNER_V1.0.0.md index 52d9924..9a5800a 100644 --- a/docs_v1.0/REFERENCE/DEMO_RUNNER_V1.0.0.md +++ b/docs_v1.0/REFERENCE/DEMO_RUNNER_V1.0.0.md @@ -39,6 +39,7 @@ python3.11 scripts/demo_runner.py demo.json --voice en_US | `markdown` | 用 md_reader Preview 渲染 .md 文件(含 Mermaid) | `cmd`(檔案路徑) | | `note` | 純文字解說 | `note` | | `separator` | 章節分隔線 | `label` | +| `ask` | 互動問答 — 問問題、等回應、顯示解答 | `question`, `answer` | ## JSON 腳本結構 @@ -66,6 +67,12 @@ python3.11 scripts/demo_runner.py demo.json --voice en_US "note": "說明文字", "cmd": "docs_v1.0/API_V1.0.0/API_USAGE_GUIDE_V1.0.0.md", "focus": "自動聚焦的章節名稱" + }, + { + "type": "ask", + "label": "互動問答", + "question": "問題文字(語音會朗讀)", + "answer": "解答文字(語音會朗讀)" } ] } @@ -92,7 +99,7 @@ python3.11 scripts/demo_runner.py demo.json --voice en_US ## 語音指令(--voice-control) -啟用麥克風語音控制,可用說的操作展示流程: +啟用 Display Audio 麥克風語音控制,可用說的操作展示流程: ```bash python3 scripts/demo_runner.py demo.json --voice zh_TW --voice-control @@ -105,7 +112,25 @@ python3 scripts/demo_runner.py demo.json --voice zh_TW --voice-control | "重複" | "repeat" / "again" | 重複朗讀當前解說 | | "跳到第 5 步" | "go to 5" | 跳到指定步驟 | -語音辨識使用 Google Speech Recognition(需網路),背景執行不影響主流程。 +語音辨識使用 **faster-whisper small**(離線、中英雙語),背景執行不影響主流程。 +模型快取:`~/.cache/huggingface/hub/models--Systran--faster-whisper-small/`。 + +## 互動問答(ask 步驟) + +`ask` 步驟讓展示系統問問題、等待使用者回答、顯示預設解答: + +- 有 `--voice-control` 時:自動錄音 4 秒 → faster-whisper 轉文字 → 顯示辨識結果 +- 無語音控制時:鍵盤輸入(Enter 送出) +- 解答由 TTS 朗讀 + 螢幕顯示 + +```json +{ + "type": "ask", + "label": "互動問答", + "question": "您知道 Momentry Core 可以分析哪些類型的資料嗎?", + "answer": "可以分析影片中的人臉、文字、物件、姿勢、聲音等。" +} +``` ## 展示節奏 @@ -155,5 +180,5 @@ python3 scripts/demo_runner.py demo.json --voice zh_TW --voice-control | 檔案 | 說明 | |------|------| | `scripts/demo_runner.py` | 執行器主程式 | -| `docs_v1.0/API_V1.0.0/DEMO_SCRIPT_v1.0.0.json` | 21 步驟預設展示腳本 | +| `docs_v1.0/API_V1.0.0/DEMO_SCRIPT_v1.0.0.json` | 23 步驟預設展示腳本(含 ask 互動問答) | | `~/_md_reader/target/release/md_reader` | Markdown 渲染工具 | diff --git a/src/api/agent_api.rs b/src/api/agent_api.rs index 7505885..74db3f3 100644 --- a/src/api/agent_api.rs +++ b/src/api/agent_api.rs @@ -2,7 +2,7 @@ use axum::{extract::State, http::StatusCode, response::Json, routing::post, Rout use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::api::server::AppState; +use crate::api::types::AppState; pub fn agent_routes() -> Router { Router::new().route("/api/v1/agents/translate", post(translate_text)) diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..7594cba --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,139 @@ +use axum::{extract::State, http::StatusCode, response::Json, routing::post, Router}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; + +use super::middleware::extract_cookies; +use super::types::AppState; + +static DEMO_USER_API_KEY: Lazy = Lazy::new(|| { + std::env::var("MOMENTRY_DEMO_API_KEY") + .unwrap_or_else(|_| "muser_demo_key_32chars_abcdef1234567890".to_string()) +}); + +#[derive(Debug, Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +#[derive(Debug, Serialize)] +struct LoginResponse { + success: bool, + message: Option, + api_key: Option, + user: Option, +} + +#[derive(Debug, Serialize)] +struct UserInfo { + username: String, +} + +async fn login( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let (user_id, username, role) = 'resolve: { + if let Ok(Some((uid, uname, pw_hash, role_str))) = + state.db.get_user_by_username(&req.username).await + { + if crate::core::auth::password::verify_password(&req.password, &pw_hash) { + break 'resolve (uid, uname, role_str); + } + tracing::debug!( + "[LOGIN] Local password mismatch for {}, trying SFTPGo", + &req.username + ); + } + + if req.username == "demo" && req.password == "demo" { + let uid = state + .db + .get_user_by_username("demo") + .await + .ok() + .flatten() + .map(|(id, _, _, _)| id) + .unwrap_or(0); + break 'resolve (uid, "demo".to_string(), "user".to_string()); + } + + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "success": false, "message": "Invalid username or password" + })), + )); + }; + + let jwt_token = crate::core::auth::jwt::create_jwt(user_id, &username, &role).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "success": false, "message": format!("JWT creation failed: {}", e) + })), + ) + })?; + + let session_id = uuid::Uuid::new_v4().to_string().replace('-', ""); + state + .db + .create_session(&session_id, user_id, &DEMO_USER_API_KEY, 24) + .await + .ok(); + + if user_id > 0 { + state.db.update_last_login(user_id).await.ok(); + } + + let body = serde_json::json!({ + "success": true, + "jwt": jwt_token, + "api_key": DEMO_USER_API_KEY.clone(), + "user": { + "username": username, + "role": role + }, + "expires_at": (chrono::Utc::now() + chrono::Duration::hours(24)).to_rfc3339() + }); + + let json_body = axum::body::Body::from(serde_json::to_string(&body).unwrap_or_default()); + let response = axum::response::Response::builder() + .header("Content-Type", "application/json") + .header( + "Set-Cookie", + format!( + "session_id={}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400", + session_id + ), + ) + .body(json_body) + .unwrap(); + + Ok(response) +} + +async fn logout( + State(state): State, + headers: axum::http::HeaderMap, +) -> Json { + let cookies = extract_cookies(&headers); + if let Some(sid) = cookies + .iter() + .find(|(k, _)| k == "session_id") + .map(|(_, v)| v.clone()) + { + state.db.delete_session(&sid).await.ok(); + } + + Json(serde_json::json!({ + "success": true, + "message": "Logged out" + })) +} + +pub fn auth_routes() -> Router { + Router::new() + .route("/api/v1/auth/login", post(login)) + .route("/api/v1/auth/logout", post(logout)) +} diff --git a/src/api/docs.rs b/src/api/docs.rs new file mode 100644 index 0000000..9a51a8b --- /dev/null +++ b/src/api/docs.rs @@ -0,0 +1,54 @@ +use axum::{extract::Path, http::StatusCode, routing::get, Router}; + +use super::types::AppState; + +async fn doc_redirect() -> axum::response::Redirect { + axum::response::Redirect::to("/doc-wasm") +} + +async fn wasm_doc_handler() -> Result +{ + let path = + std::path::Path::new("/Users/accusys/momentry_core_0.1/docs_v1.0/doc_wasm/index.html"); + match tokio::fs::read_to_string(path).await { + Ok(html) => Ok(([("content-type", "text/html; charset=utf-8")], html)), + Err(_) => Err((StatusCode::NOT_FOUND, "Doc not found")), + } +} + +async fn wasm_doc_file_handler( + Path(file): Path, +) -> Result { + if file.contains("..") || file.contains("//") { + return Err((StatusCode::NOT_FOUND, "Invalid path")); + } + let base = std::path::Path::new("/Users/accusys/momentry_core_0.1/docs_v1.0/doc_wasm"); + let path = base.join(&file); + if !path.exists() || !path.starts_with(base) { + return Err((StatusCode::NOT_FOUND, "File not found")); + } + let data = tokio::fs::read(&path) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Read error"))?; + let mime = if file.ends_with(".wasm") { + "application/wasm" + } else if file.ends_with(".js") { + "application/javascript" + } else if file.ends_with(".md") { + "text/markdown; charset=utf-8" + } else if file.ends_with(".css") { + "text/css" + } else { + "application/octet-stream" + }; + Ok(([("content-type", mime)], data)) +} + +pub fn doc_routes() -> Router { + Router::new() + .route("/doc", get(doc_redirect)) + .route("/doc/*file", get(doc_redirect)) + .route("/dev-doc", get(doc_redirect)) + .route("/doc-wasm", get(wasm_doc_handler)) + .route("/doc-wasm/*file", get(wasm_doc_file_handler)) +} diff --git a/src/api/files.rs b/src/api/files.rs new file mode 100644 index 0000000..cd272d2 --- /dev/null +++ b/src/api/files.rs @@ -0,0 +1,1105 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::collections::HashMap; + +use super::types::AppState; +use crate::core::config; +use crate::core::db::schema; +use crate::core::db::{Database, PostgresDb}; +use crate::core::storage::content_hash; +use crate::FileManager; + +#[derive(Debug, Deserialize)] +struct RegisterFileRequest { + file_path: String, + user_id: Option, + content_hash: Option, + pattern: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct FileLookupMatch { + file_uuid: String, + file_name: String, + file_type: Option, + status: String, + content_hash: Option, + file_size: Option, + duration: Option, + width: Option, + height: Option, +} + +#[derive(Serialize)] +struct FileLookupResponse { + file_name: String, + exists: bool, + matches: Vec, + next_name: String, +} + +async fn lookup_file_by_name( + State(state): State, + Query(params): Query>, +) -> Result, StatusCode> { + let file_name = params.get("file_name").ok_or(StatusCode::BAD_REQUEST)?; + let base = file_name.to_string(); + let dot_pos = base.rfind('.'); + let (stem, _ext) = match dot_pos { + Some(p) => (base[..p].to_string(), base[p..].to_string()), + None => (base.clone(), String::new()), + }; + let pattern = format!("{}%%", &stem); + + let table = schema::table_name("videos"); + let query_sql = format!("SELECT file_uuid, file_name, file_type, status, content_hash, duration, width, height FROM {} WHERE file_name = $1 OR file_name LIKE $2 ORDER BY file_name", table); + let rows = sqlx::query(&query_sql) + .bind(&base) + .bind(&pattern) + .fetch_all(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("lookup query error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let exists = rows.iter().any(|r| r.get::("file_name") == base); + let matches: Vec = rows + .iter() + .map(|r| FileLookupMatch { + file_uuid: r.get("file_uuid"), + file_name: r.get("file_name"), + file_type: r.get("file_type"), + status: r.get("status"), + content_hash: r.get("content_hash"), + file_size: None, + duration: r.get("duration"), + width: r.get("width"), + height: r.get("height"), + }) + .collect(); + + let next_name = if exists { + let mut attempt = 1usize; + loop { + let candidate = if let Some(p) = dot_pos { + format!("{} ({}){}", &base[..p], attempt, &base[p..]) + } else { + format!("{} ({})", base, attempt) + }; + let conflict: Option = sqlx::query_scalar(&format!( + "SELECT file_uuid FROM {} WHERE file_name = $1", + table + )) + .bind(&candidate) + .fetch_optional(state.db.pool()) + .await + .unwrap_or(None); + if conflict.is_none() { + break candidate; + } + attempt += 1; + } + } else { + base.clone() + }; + + Ok(Json(FileLookupResponse { + file_name: base, + exists, + matches, + next_name, + })) +} + +#[derive(Debug, Serialize)] +struct RegisterFileResponse { + success: bool, + file_uuid: String, + file_name: String, + file_path: String, + file_type: Option, + duration: f64, + width: u32, + height: u32, + fps: f64, + total_frames: u64, + registration_time: Option, + already_exists: bool, + message: String, +} + +#[derive(Debug, Serialize)] +struct ProbeResponse { + file_uuid: String, + file_name: String, + file_size: Option, + duration: f64, + width: u32, + height: u32, + fps: f64, + total_frames: u64, + file_type: Option, + probe_result: Option, +} + +// --- Fields-only structs for response --- + +fn sha256_file(path: &std::path::Path) -> Option { + content_hash::compute_sha256(path).ok() +} + +async fn resolve_filename(db: &PostgresDb, file_name: &str, content_hash: &str) -> String { + let table = schema::table_name("videos"); + let base = file_name.to_string(); + let dot_pos = base.rfind('.'); + let (stem, ext) = match dot_pos { + Some(p) => (base[..p].to_string(), base[p..].to_string()), + None => (base.clone(), String::new()), + }; + let mut candidate = base.clone(); + let mut attempt = 0usize; + loop { + let conflict: Option = sqlx::query_scalar( + &format!("SELECT file_uuid FROM {} WHERE file_name = $1 AND (content_hash IS DISTINCT FROM $2 OR content_hash IS NULL)", table) + ) + .bind(&candidate) + .bind(content_hash) + .fetch_optional(db.pool()) + .await + .unwrap_or(None); + if conflict.is_none() { + return candidate; + } + attempt += 1; + candidate = format!("{} ({}){}", stem, attempt, ext); + } +} + +async fn register_single_file( + state: &AppState, + file_path: &str, + _user_id: Option, + provided_hash: Option, +) -> RegisterFileResponse { + tracing::info!("[REGISTER] Starting registration for: {}", file_path); + + let path = std::path::Path::new(file_path); + if !path.exists() { + return RegisterFileResponse { + success: false, + file_uuid: String::new(), + file_name: String::new(), + file_path: file_path.to_string(), + file_type: None, + duration: 0.0, + width: 0, + height: 0, + fps: 0.0, + total_frames: 0, + registration_time: None, + already_exists: false, + message: format!("File not found: {}", file_path), + }; + } + + let canonical_path = path + .canonicalize() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| file_path.to_string()); + + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let db = match PostgresDb::init().await { + Ok(db) => db, + Err(e) => { + tracing::error!("[REGISTER] DB init failed: {}", e); + return RegisterFileResponse { + success: false, + file_uuid: String::new(), + file_name, + file_path: canonical_path, + file_type: None, + duration: 0.0, + width: 0, + height: 0, + fps: 0.0, + total_frames: 0, + registration_time: None, + already_exists: false, + message: format!("DB init failed: {}", e), + }; + } + }; + + let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); + + let birthday = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()) + .map(|t| { + let secs = t + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + chrono::DateTime::from_timestamp(secs as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()) + }) + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + + let mac_address = crate::core::storage::uuid::get_mac_address(); + let pre_file_uuid = crate::core::storage::uuid::compute_birth_uuid( + &mac_address, + &birthday, + &canonical_path, + &file_name, + ); + let pre_path = std::path::Path::new(&output_dir).join(format!("{}.pre.json", pre_file_uuid)); + let pre_data: Option = std::fs::read_to_string(&pre_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); + + let (content_hash_val, birthday, _pre_file_uuid) = if let Some(ref pre) = pre_data { + let h = pre + .get("content_hash") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let b = pre + .get("birthday") + .and_then(|v| v.as_str()) + .unwrap_or(&birthday) + .to_string(); + let u = pre + .get("file_uuid") + .and_then(|v| v.as_str()) + .unwrap_or(&pre_file_uuid) + .to_string(); + (h, b, u) + } else { + let h = provided_hash + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| sha256_file(&path).unwrap_or_default()); + (h, birthday, pre_file_uuid) + }; + + let file_uuid = crate::core::storage::uuid::compute_birth_uuid( + &mac_address, + &birthday, + &canonical_path, + &file_name, + ); + tracing::info!( + "[REGISTER] UUID inputs: mac={} birthday={} path={} name={} pre_found={} → {}", + mac_address, + birthday, + canonical_path, + file_name, + pre_data.is_some(), + file_uuid + ); + + let videos_table = schema::table_name("videos"); + if !content_hash_val.is_empty() { + if let Ok(Some(existing_uuid)) = sqlx::query_scalar::<_, String>(&format!( + "SELECT file_uuid FROM {} WHERE content_hash = $1 LIMIT 1", + videos_table + )) + .bind(&content_hash_val) + .fetch_optional(db.pool()) + .await + { + tracing::info!( + "[REGISTER] Content hash collision → already registered: {}", + existing_uuid + ); + let existing_info: Option<(String, String, f64, i32, i32, f64, i64, Option)> = sqlx::query_as( + &format!("SELECT file_name, file_path, duration, width, height, fps, total_frames, registration_time::text FROM {} WHERE file_uuid = $1", videos_table) + ).bind(&existing_uuid).fetch_optional(db.pool()).await.unwrap_or(None); + if let Some((ename, epath, dur, w, h, f, tf, rt)) = existing_info { + return RegisterFileResponse { + success: true, + file_uuid: existing_uuid, + file_name: ename, + file_path: epath.clone(), + file_type: None, + duration: dur, + width: w as u32, + height: h as u32, + fps: f, + total_frames: tf as u64, + registration_time: rt, + already_exists: true, + message: format!("Content already registered: {}", epath), + }; + } + return RegisterFileResponse { + success: true, + file_uuid: existing_uuid, + file_name: file_name.clone(), + file_path: canonical_path.clone(), + file_type: None, + duration: 0.0, + width: 0, + height: 0, + fps: 0.0, + total_frames: 0, + registration_time: None, + already_exists: true, + message: "Content already registered (identical file)".to_string(), + }; + } + } + + let final_name = resolve_filename(&db, &file_name, &content_hash_val).await; + + let mac_address = crate::core::storage::uuid::get_mac_address(); + let file_uuid = crate::core::storage::uuid::compute_birth_uuid( + &mac_address, + &birthday, + &canonical_path, + &final_name, + ); + + let temp_probe_json: serde_json::Value = if let Some(ref pre) = pre_data { + pre.get("probe_json").cloned().unwrap_or_default() + } else { + let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry_core_0.1/scripts".to_string()); + let python_path = std::env::var("MOMENTRY_PYTHON_PATH") + .unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string()); + crate::core::probe::unified::unified_probe(&path, &scripts_dir, &python_path).await + }; + let probe_json = Some(temp_probe_json.clone()); + + let has_video = temp_probe_json + .get("streams") + .and_then(|s| s.as_array()) + .map_or(false, |streams| { + streams + .iter() + .any(|st| st.get("codec_type").and_then(|c| c.as_str()) == Some("video")) + }); + let has_audio = temp_probe_json + .get("streams") + .and_then(|s| s.as_array()) + .map_or(false, |streams| { + streams + .iter() + .any(|st| st.get("codec_type").and_then(|c| c.as_str()) == Some("audio")) + }); + + let final_file_type = if has_video { + Some("video".to_string()) + } else if has_audio { + Some("audio".to_string()) + } else { + Some( + temp_probe_json + .get("format") + .and_then(|f| f.get("file_type")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + ) + }; + + let duration = temp_probe_json + .get("format") + .and_then(|f| { + let src = if has_video { f.get("duration") } else { None }; + src.and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + }) + .unwrap_or(0.0); + let mut width = 0u32; + let mut height = 0u32; + let mut fps = 0.0; + let mut total_frames = 0u64; + if let Some(streams) = temp_probe_json.get("streams").and_then(|s| s.as_array()) { + if let Some(s) = streams + .iter() + .find(|st| st.get("codec_type").and_then(|c| c.as_str()) == Some("video")) + { + width = s.get("width").and_then(|v| v.as_i64()).unwrap_or(0) as u32; + height = s.get("height").and_then(|v| v.as_i64()).unwrap_or(0) as u32; + if let Some(fps_str) = s.get("r_frame_rate").and_then(|v| v.as_str()) { + if let Some((num, den)) = fps_str.split_once('/') { + if let (Ok(n), Ok(d)) = (num.parse::(), den.parse::()) { + if d > 0.0 { + fps = n / d; + } + } + } + } + total_frames = s + .get("nb_frames") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| (duration * fps) as u64); + } + } + + let status = "registered"; + let _ = sqlx::query(&format!( + "INSERT INTO {} (file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, status, content_hash, registration_time) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ON CONFLICT (file_uuid) DO UPDATE SET file_path = EXCLUDED.file_path, file_name = EXCLUDED.file_name, status = EXCLUDED.status, content_hash = EXCLUDED.content_hash", + videos_table + )) + .bind(&file_uuid).bind(&canonical_path).bind(&final_name).bind(&final_file_type) + .bind(duration).bind(width as i32).bind(height as i32).bind(fps) + .bind(&probe_json).bind(status).bind(&content_hash_val) + .execute(db.pool()).await; + + let mut cut_done = false; + let mut scene_done = false; + if has_video && total_frames > 0 && fps > 0.0 { + let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); + let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry_core_0.1/scripts".to_string()); + let python_path = std::env::var("MOMENTRY_PYTHON_PATH") + .unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string()); + + let cut_path = std::path::Path::new(&output_dir).join(format!("{}.cut.json", file_uuid)); + if !cut_path.exists() { + let cut_script = std::path::Path::new(&scripts_dir).join("cut_processor.py"); + if cut_script.exists() { + let cut_output = std::process::Command::new(&python_path) + .arg(&cut_script) + .arg(&canonical_path) + .arg(&cut_path) + .output(); + if let Ok(output) = cut_output { + if output.status.success() { + cut_done = true; + tracing::info!("[REGISTER] CUT completed for {}", file_uuid); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::error!("[REGISTER] CUT failed for {}: {}", file_uuid, stderr); + } + } else { + tracing::error!("[REGISTER] CUT execution error for {}", file_uuid); + } + } + } else { + cut_done = true; + if let Ok(content) = std::fs::read_to_string(&cut_path) { + if let Ok(cut_data) = serde_json::from_str::(&content) { + let scenes = cut_data + .get("scenes") + .and_then(|s| s.as_array()) + .map(|a| a.len() as i32) + .unwrap_or(0); + tracing::info!( + "[REGISTER] CUT already exists: {} scenes for {}", + scenes, + file_uuid + ); + } + } + } + + let scene_path = + std::path::Path::new(&output_dir).join(format!("{}.scene.json", file_uuid)); + if !scene_path.exists() { + let scene_script = std::path::Path::new(&scripts_dir).join("scene_classifier.py"); + if scene_script.exists() { + let scene_output = std::process::Command::new(&python_path) + .arg(&scene_script) + .arg(&canonical_path) + .arg(&scene_path) + .arg("--sample-interval") + .arg("2") + .output(); + if let Ok(output) = scene_output { + if output.status.success() { + scene_done = true; + tracing::info!( + "[REGISTER] Scene classification completed for {}", + file_uuid + ); + } + } + } + } else { + scene_done = true; + } + } + + let audio_tracks: Vec = temp_probe_json + .get("streams") + .and_then(|s| s.as_array()) + .map_or(vec![], |streams| { + streams + .iter() + .filter(|st| st.get("codec_type").and_then(|c| c.as_str()) == Some("audio")) + .map(|st| { + serde_json::json!({ + "index": st.get("index").and_then(|v| v.as_i64()), + "codec": st.get("codec_name").and_then(|v| v.as_str()), + "channels": st.get("channels").and_then(|v| v.as_i64()), + "sample_rate": st.get("sample_rate").and_then(|v| v.as_str()), + "language": st.get("tags").and_then(|t| t.get("language")), + }) + }) + .collect() + }); + let audio_tracks_json = serde_json::to_value(&audio_tracks).ok(); + let cut_path = std::path::Path::new( + &std::env::var("MOMENTRY_OUTPUT_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()), + ) + .join(format!("{}.cut.json", file_uuid)); + let mut cut_count = 0i32; + let mut cut_max_duration = 0.0f64; + if let Ok(content) = std::fs::read_to_string(&cut_path) { + if let Ok(cut_data) = serde_json::from_str::(&content) { + if let Some(scenes) = cut_data.get("scenes").and_then(|s| s.as_array()) { + cut_count = scenes.len() as i32; + cut_max_duration = scenes + .iter() + .filter_map(|s| { + let start = s.get("start_time").and_then(|v| v.as_f64()).unwrap_or(0.0); + let end = s.get("end_time").and_then(|v| v.as_f64()).unwrap_or(0.0); + if end > start { + Some(end - start) + } else { + None + } + }) + .fold(0.0_f64, f64::max); + } + } + } + let _ = sqlx::query( + &format!("UPDATE {} SET cut_done = $1, scene_done = $2, audio_tracks = $3, cut_count = $4, cut_max_duration = $5 WHERE file_uuid = $6", videos_table) + ) + .bind(cut_done).bind(scene_done).bind(&audio_tracks_json).bind(cut_count).bind(cut_max_duration).bind(&file_uuid) + .execute(db.pool()).await; + + if let Some(json_val) = probe_json { + let probe_path = std::path::Path::new( + &std::env::var("MOMENTRY_OUTPUT_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()), + ) + .join(format!("{}.probe.json", file_uuid)); + let json_str = serde_json::to_string_pretty(&json_val).unwrap_or_default(); + let _ = std::fs::write(&probe_path, json_str); + } + + if final_file_type.as_deref() == Some("video") { + let auto_file_uuid = file_uuid.clone(); + let auto_db = db.clone(); + tokio::spawn(async move { + let identities_dir = + std::path::Path::new(&*crate::core::config::OUTPUT_DIR).join("identities"); + let index_path = identities_dir.join("_index.json"); + let cache_path = format!( + "{}/{}.tmdb.json", + *crate::core::config::OUTPUT_DIR, + auto_file_uuid + ); + let cache_file = std::path::Path::new(&cache_path); + + if index_path.exists() && cache_file.exists() { + tracing::info!( + "[AUTO-TMDB] Offline cache found for {}, running probe", + auto_file_uuid + ); + if let Err(e) = + crate::core::tmdb::probe::probe_from_cache(&auto_db, &auto_file_uuid).await + { + tracing::warn!("[AUTO-TMDB] Probe failed for {}: {}", auto_file_uuid, e); + } else { + tracing::info!("[AUTO-TMDB] Probe completed for {}", auto_file_uuid); + } + } else { + tracing::info!( + "[AUTO-TMDB] No offline cache for {}, skipping", + auto_file_uuid + ); + } + }); + } + + RegisterFileResponse { + success: true, + file_uuid, + file_name, + file_path: canonical_path, + file_type: final_file_type, + duration, + width, + height, + fps, + total_frames, + registration_time: None, + already_exists: false, + message: "File registered successfully".to_string(), + } +} + +async fn register_file( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let file_path = req.file_path.clone(); + let pattern = req.pattern; + + if let Some(ref pat) = pattern { + let dir = std::path::Path::new(&file_path); + if !dir.is_dir() { + return Ok(Json(RegisterFileResponse { + success: false, + file_uuid: String::new(), + file_name: String::new(), + file_path: file_path.clone(), + file_type: None, + duration: 0.0, + width: 0, + height: 0, + fps: 0.0, + total_frames: 0, + registration_time: None, + already_exists: false, + message: format!( + "Pattern requires a directory, but path is not a dir: {}", + file_path + ), + })); + } + let re = regex::Regex::new(pat).map_err(|e| { + tracing::error!("[REGISTER] Invalid regex pattern: {}", e); + StatusCode::BAD_REQUEST + })?; + + let mut registered = 0u32; + let mut failed = 0u32; + let mut skipped = 0u32; + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let entry_path = entry.path(); + if !entry_path.is_file() { + continue; + } + let fname = entry_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + if !re.is_match(fname) { + continue; + } + + let result = register_single_file( + &state, + &entry_path.to_string_lossy().to_string(), + req.user_id, + None, + ) + .await; + if result.success { + registered += 1; + } else if result.already_exists { + skipped += 1; + } else { + failed += 1; + } + } + } + + return Ok(Json(RegisterFileResponse { + success: true, + file_uuid: format!("batch_{}_registered_{}_failed", registered, failed), + file_name: format!( + "{} files registered, {} skipped, {} failed", + registered, skipped, failed + ), + file_path: file_path.clone(), + file_type: None, + duration: 0.0, + width: 0, + height: 0, + fps: 0.0, + total_frames: 0, + registration_time: None, + already_exists: false, + message: format!( + "Batch register: {} registered, {} skipped, {} failed", + registered, skipped, failed + ), + })); + } + + let resp = register_single_file(&state, &file_path, req.user_id, req.content_hash).await; + + if resp.success + && !resp.already_exists + && resp.file_type.as_deref() == Some("video") + && crate::core::config::get_auto_pipeline_enabled() + { + let auto_uuid = resp.file_uuid.clone(); + let auto_state = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let video_path: Option = sqlx::query_scalar(&format!( + "SELECT file_path FROM {} WHERE file_uuid = $1", + schema::table_name("videos") + )) + .bind(&auto_uuid) + .fetch_optional(auto_state.db.pool()) + .await + .ok() + .flatten(); + + if let Some(ref vp) = video_path { + if let Ok(job) = auto_state.db.create_monitor_job(&auto_uuid, Some(vp)).await { + tracing::info!("[AUTO-PIPELINE] Job {} created for {}", job.id, auto_uuid); + let all_procs: Vec<&str> = vec![ + "asr", + "cut", + "yolo", + "ocr", + "face", + "pose", + "asrx", + "visual_chunk", + "5w1h", + ]; + let total = sqlx::query_scalar::<_, i64>(&format!( + "SELECT COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1", + schema::table_name("videos") + )) + .bind(&auto_uuid) + .fetch_one(auto_state.db.pool()) + .await + .unwrap_or(0); + let _ = auto_state + .db + .init_processing_status(&auto_uuid, all_procs, total as u64) + .await; + let _ = sqlx::query(&format!( + "UPDATE {} SET status = 'processing' WHERE file_uuid = $1", + schema::table_name("videos") + )) + .bind(&auto_uuid) + .execute(auto_state.db.pool()) + .await; + tracing::info!("[AUTO-PIPELINE] Pipeline triggered for {}", auto_uuid); + } + } + }); + } + + Ok(Json(resp)) +} + +async fn probe_by_uuid( + State(state): State, + Path(file_uuid): Path, +) -> Result, (StatusCode, Json)> { + let table = schema::table_name("videos"); + let row: Option<(String, String)> = sqlx::query_as(&format!( + "SELECT file_name, file_path FROM {} WHERE file_uuid = $1", + table + )) + .bind(&file_uuid) + .fetch_optional(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("DB error fetching video: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("DB error: {}", e), "file_uuid": file_uuid})), + ) + })?; + + let (file_name, path) = row.ok_or_else(|| { + tracing::warn!("Video not found: {}", file_uuid); + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Video not found", "file_uuid": file_uuid})), + ) + })?; + + let probe_path = format!( + "{}/{}.probe.json", + crate::core::config::OUTPUT_DIR.as_str(), + file_uuid + ); + + let (probe_result, cached) = if let Ok(content) = std::fs::read_to_string(&probe_path) { + tracing::info!("Using cached probe.json: {}", probe_path); + let result: crate::core::probe::ProbeResult = + serde_json::from_str(&content).map_err(|e| { + tracing::error!("Failed to parse cached probe.json: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to parse cached probe.json: {}", e), "file_uuid": file_uuid})), + ) + })?; + (result, true) + } else { + let file_path = std::path::Path::new(&path); + if !file_path.exists() { + tracing::error!("File not found at path: {}", path); + return Err(( + StatusCode::NOT_FOUND, + Json( + serde_json::json!({"error": "File does not exist at registered path", "file_uuid": file_uuid, "file_path": path}), + ), + )); + } + + tracing::info!("Running ffprobe for: {}", path); + let result = crate::core::probe::probe_video(&path).map_err(|e| { + tracing::error!("ffprobe failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("ffprobe failed: {}", e), "file_uuid": file_uuid, "file_path": path})), + ) + })?; + + let file_manager = FileManager::new(std::path::PathBuf::from( + crate::core::config::OUTPUT_DIR.as_str(), + )); + let json_str = serde_json::to_string(&result).map_err(|e| { + tracing::error!("Failed to serialize probe result: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to serialize probe result: {}", e), "file_uuid": file_uuid})), + ) + })?; + file_manager + .save_json(&file_uuid, "probe", &json_str) + .map_err(|e| { + tracing::error!("Failed to save probe.json: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to save probe.json: {}", e), "file_uuid": file_uuid})), + ) + })?; + + (result, false) + }; + + let duration = probe_result + .format + .duration + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + + let mut width = 0u32; + let mut height = 0u32; + let mut fps = 0.0; + + for stream in &probe_result.streams { + if stream.codec_type.as_deref() == Some("video") { + width = stream.width.unwrap_or(0); + height = stream.height.unwrap_or(0); + if let Some(fps_str) = &stream.r_frame_rate { + fps = if fps_str.contains('/') { + let parts: Vec<&str> = fps_str.split('/').collect(); + if parts.len() == 2 { + let num: f64 = parts[0].parse().unwrap_or(0.0); + let den: f64 = parts[1].parse().unwrap_or(1.0); + if den > 0.0 { + num / den + } else { + 0.0 + } + } else { + 0.0 + } + } else { + fps_str.parse().unwrap_or(0.0) + }; + } + } + } + + let total_frames = probe_result + .streams + .iter() + .find(|s| s.codec_type.as_deref() == Some("video")) + .and_then(|s| s.nb_frames.as_ref()) + .and_then(|n| n.parse::().ok()) + .unwrap_or_else(|| (duration * fps).floor() as i64); + + let _ = sqlx::query(&format!( + "UPDATE {} SET duration = $1, width = $2, height = $3, fps = $4, total_frames = $5 WHERE file_uuid = $6", + schema::table_name("videos") + )) + .bind(duration) + .bind(width as i32) + .bind(height as i32) + .bind(fps) + .bind(total_frames) + .bind(&file_uuid) + .execute(state.db.pool()) + .await; + + let file_size = std::fs::metadata(&path).ok().map(|m| m.len() as i64); + + let file_type = if width > 0 && height > 0 { + Some("video".to_string()) + } else { + None + }; + + Ok(Json(ProbeResponse { + file_uuid, + file_name, + file_size, + duration, + width, + height, + fps, + total_frames: total_frames as u64, + file_type, + probe_result: if cached { + Some(serde_json::to_value(&probe_result).unwrap_or_default()) + } else { + None + }, + })) +} + +#[derive(Debug, Serialize)] +struct UnregisterResponse { + success: bool, + message: String, + file_uuid: String, + deleted_face_detections: u64, + deleted_processor_results: u64, + deleted_chunks: u64, +} + +#[derive(Debug, Deserialize)] +struct UnregisterRequest { + file_uuid: Option, + file_path: Option, +} + +fn delete_output_files(uuid: &str) { + let output_dir = config::OUTPUT_DIR.to_string(); + if let Ok(entries) = std::fs::read_dir(&output_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with(uuid) { + let _ = std::fs::remove_file(&path); + } + } + } + } +} + +async fn unregister( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let uuid = match req.file_uuid { + Some(u) => u, + None => return Err(StatusCode::BAD_REQUEST), + }; + + tracing::info!("[UNREGISTER] Unregistering file: {}", uuid); + + let videos_table = schema::table_name("videos"); + let face_table = schema::table_name("face_detections"); + let processor_table = schema::table_name("processor_results"); + let chunks_table = schema::table_name("chunk"); + let parent_chunks_table = schema::table_name("parent_chunks"); + + let deleted_faces: i64 = + sqlx::query(&format!("DELETE FROM {} WHERE file_uuid = $1", face_table)) + .bind(&uuid) + .execute(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("[unregister] Failed to delete faces: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .rows_affected() as i64; + + let deleted_processors: i64 = sqlx::query(&format!( + "DELETE FROM {} WHERE file_uuid = $1", + processor_table + )) + .bind(&uuid) + .execute(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("[unregister] Failed to delete processors: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .rows_affected() as i64; + + let deleted_parent_chunks: i64 = sqlx::query(&format!( + "DELETE FROM {} WHERE uuid = $1", + parent_chunks_table + )) + .bind(&uuid) + .execute(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("[unregister] Failed to delete parent chunks: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .rows_affected() as i64; + + let deleted_chunks: i64 = sqlx::query(&format!("DELETE FROM {} WHERE uuid = $1", chunks_table)) + .bind(&uuid) + .execute(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("[unregister] Failed to delete chunks: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .rows_affected() as i64; + + sqlx::query(&format!( + "DELETE FROM {} WHERE file_uuid = $1", + videos_table + )) + .bind(&uuid) + .execute(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("[unregister] Failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + delete_output_files(&uuid); + + Ok(Json(UnregisterResponse { + success: true, + message: format!("File {} unregistered successfully.", uuid), + file_uuid: uuid, + deleted_face_detections: deleted_faces as u64, + deleted_processor_results: deleted_processors as u64, + deleted_chunks: (deleted_chunks + deleted_parent_chunks) as u64, + })) +} + +pub fn file_routes() -> Router { + Router::new() + .route("/api/v1/files/register", post(register_file)) + .route("/api/v1/files/lookup", get(lookup_file_by_name)) + .route("/api/v1/unregister", post(unregister)) + .route("/api/v1/file/:file_uuid/probe", get(probe_by_uuid)) +} diff --git a/src/api/five_w1h_agent_api.rs b/src/api/five_w1h_agent_api.rs index 3732a67..99a06df 100644 --- a/src/api/five_w1h_agent_api.rs +++ b/src/api/five_w1h_agent_api.rs @@ -9,7 +9,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::Row; -use crate::api::server::AppState; +use crate::api::types::AppState; use crate::core::db::qdrant_db::QdrantDb; use crate::core::db::schema; use crate::core::db::{PostgresDb, VectorPayload}; diff --git a/src/api/health.rs b/src/api/health.rs new file mode 100644 index 0000000..547e468 --- /dev/null +++ b/src/api/health.rs @@ -0,0 +1,680 @@ +use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router}; +use once_cell::sync::OnceCell; +use serde::Serialize; +use std::time::Instant; + +use super::types::AppState; +use crate::core::cache::MongoCache; +use crate::core::config; +use crate::core::db::{Database, PostgresDb, RedisClient}; +use crate::worker::resources::SystemResources; + +// Global State +static SERVER_START: OnceCell = OnceCell::new(); +static SERVER_HOST: OnceCell = OnceCell::new(); +static SERVER_PORT: OnceCell = OnceCell::new(); + +pub fn init_server_state(host: &str, port: u16) { + let _ = SERVER_START.set(Instant::now()); + let resolved_ip = if host == "0.0.0.0" { + if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&"localhost:0") { + addrs + .filter_map(|a| if a.is_ipv4() { Some(a.ip()) } else { None }) + .next() + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "127.0.0.1".to_string()) + } else { + "127.0.0.1".to_string() + } + } else { + host.to_string() + }; + let _ = SERVER_HOST.set(resolved_ip); + let _ = SERVER_PORT.set(port); +} + +pub fn get_host() -> String { + SERVER_HOST + .get() + .cloned() + .unwrap_or_else(|| "0.0.0.0".to_string()) +} + +pub fn get_port() -> u16 { + SERVER_PORT.get().copied().unwrap_or(0) +} + +pub fn get_uptime_ms() -> u64 { + SERVER_START + .get() + .map(|i| i.elapsed().as_millis() as u64) + .unwrap_or(0) +} + +#[derive(Debug, Serialize)] +struct HealthResponse { + ip: String, + port: u16, + status: String, + version: String, + build_git_hash: String, + build_timestamp: String, + uptime_ms: u64, + watcher_running: bool, + worker_running: bool, + auto_pipeline_enabled: bool, + watcher_auto_register_enabled: bool, + system_timezone: String, +} + +#[derive(Debug, Serialize)] +struct DetailedHealthResponse { + ip: String, + port: u16, + status: String, + version: String, + build_git_hash: String, + build_timestamp: String, + uptime_ms: u64, + services: ServiceHealth, + resources: ResourceStatus, + pipeline: PipelineStatus, + schema: SchemaHealth, + identities: IdentityHealth, + integrations: IntegrationHealth, + config: ConfigHealth, +} + +#[derive(Debug, Serialize)] +struct IntegrationHealth { + tmdb: crate::core::tmdb::status::TmdbResourceStatus, +} + +#[derive(Debug, Serialize)] +struct IdentityHealth { + directory_exists: bool, + files_count: usize, + index_ok: bool, + db_count: i64, + synced: bool, +} + +#[derive(Debug, Serialize)] +struct ConfigHealth { + cache_enabled: bool, + auto_pipeline_enabled: bool, + watcher_auto_register_enabled: bool, + system_timezone: String, +} + +#[derive(Debug, Serialize)] +pub struct SchemaHealth { + pub table_exists: bool, + pub applied: Vec, + pub required: Vec, + pub ok: bool, +} + +#[derive(Debug, Serialize)] +pub struct MigrationInfo { + pub filename: String, + pub checksum: String, +} + +#[derive(Debug, Serialize)] +struct PipelineStatus { + scripts_ready: bool, + scripts_count: usize, + processors: ProcessorInventory, + models_ready: bool, + models_count: usize, + scripts_integrity: ScriptIntegrity, + ffmpeg: bool, + embedding_server: ServiceStatus, + gdino_api: ServiceStatus, + llm: ServiceStatus, + rsync: ServiceStatus, + watcher_running: bool, + worker_running: bool, +} + +#[derive(Debug, Serialize)] +struct ScriptIntegrity { + matched: usize, + total: usize, + ok: bool, +} + +#[derive(Debug, Serialize)] +struct ProcessorInventory { + asr: bool, + yolo: bool, + face: bool, + pose: bool, + ocr: bool, + cut: bool, + caption: bool, + scene: bool, + story: bool, + asrx: bool, + probe: bool, + visual_chunk: bool, + total_py_files: usize, +} + +#[derive(Debug, Serialize)] +struct ResourceStatus { + cpu_used_percent: f64, + cpu_idle_percent: f64, + memory_available_mb: u64, + memory_total_mb: u64, + memory_used_percent: f64, + gpu_available: bool, + gpu_utilization: Option, + gpu_memory_used_pct: Option, +} + +#[derive(Debug, Serialize)] +struct ServiceHealth { + postgres: ServiceStatus, + redis: ServiceStatus, + qdrant: ServiceStatus, + mongodb: ServiceStatus, +} + +#[derive(Debug, Serialize)] +struct ServiceStatus { + status: String, + latency_ms: Option, + error: Option, +} + +async fn health(State(state): State) -> Json { + let postgres = check_postgres().await; + let redis = check_redis().await; + let qdrant = check_qdrant().await; + let mongodb = check_mongodb(&state.mongo_cache).await; + + let all_ok = postgres.status == "ok" + && redis.status == "ok" + && qdrant.status == "ok" + && mongodb.status == "ok"; + + let status = if all_ok { "ok" } else { "degraded" }; + + if all_ok { + let _ = state.redis_cache.set_health(status).await; + } + + Json(HealthResponse { + ip: get_host(), + port: get_port(), + status: status.to_string(), + version: env!("BUILD_VERSION").to_string(), + build_git_hash: env!("BUILD_GIT_HASH").to_string(), + build_timestamp: env!("BUILD_TIMESTAMP").to_string(), + uptime_ms: get_uptime_ms(), + watcher_running: check_process_running("watcher"), + worker_running: check_process_running("worker"), + auto_pipeline_enabled: config::get_auto_pipeline_enabled(), + watcher_auto_register_enabled: config::get_watcher_auto_register(), + system_timezone: config::SYSTEM_TIMEZONE.clone(), + }) +} + +async fn health_detailed(State(state): State) -> Json { + let postgres = check_postgres().await; + let redis = check_redis().await; + let qdrant = check_qdrant().await; + let mongodb = check_mongodb(&state.mongo_cache).await; + + let overall_status = if postgres.status == "ok" + && redis.status == "ok" + && qdrant.status == "ok" + && mongodb.status == "ok" + { + "ok" + } else { + "degraded" + }; + + let sys = SystemResources::check(); + + let scripts_base = config::SCRIPTS_DIR.clone(); + let scripts_dir = std::path::Path::new(&scripts_base); + let scripts_path = scripts_dir.to_path_buf(); + let models_path = std::path::PathBuf::from("/Users/accusys/momentry_core_0.1/models"); + + let py_files = std::fs::read_dir(&scripts_path) + .map(|d| { + d.filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "py").unwrap_or(false)) + .count() + }) + .unwrap_or(0); + + let total_model_files = std::fs::read_dir(&models_path) + .map(|d| { + d.filter_map(|e| e.ok()) + .filter(|e| { + let p = e.path(); + let ext = p.extension().and_then(|x| x.to_str()).unwrap_or(""); + matches!(ext, "pt" | "mlpackage" | "gguf" | "bin" | "onnx") + }) + .count() + }) + .unwrap_or(0); + + let check_script = |name: &str| -> bool { + let candidate = scripts_path.join(name); + candidate.exists() + }; + + let check_python_module = |module: &str| -> bool { + std::process::Command::new(&*config::PYTHON_PATH) + .arg("-c") + .arg(format!("import {}", module)) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }; + + let checksums_path = scripts_path.join("checksums.sha256"); + let scripts_integrity = match std::fs::read_to_string(&checksums_path) { + Ok(content) => { + let mut matched = 0usize; + let mut total = 0usize; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let parts: Vec<&str> = line.splitn(2, ' ').collect(); + if parts.len() < 2 { + continue; + } + let expected_hash = parts[0]; + let file_path = parts[1].trim_start(); + total += 1; + let full_path = scripts_path.join(file_path); + if full_path.exists() { + if let Ok(actual) = std::process::Command::new("shasum") + .arg("-a") + .arg("256") + .arg(&full_path) + .output() + { + let out = String::from_utf8_lossy(&actual.stdout); + let actual_hash = out.split(' ').next().unwrap_or("").to_string(); + if actual_hash == expected_hash { + matched += 1; + } + } + } + } + ScriptIntegrity { + matched, + total, + ok: matched == total, + } + } + Err(_) => ScriptIntegrity { + matched: 0, + total: 0, + ok: false, + }, + }; + + Json(DetailedHealthResponse { + ip: get_host(), + port: get_port(), + status: overall_status.to_string(), + version: env!("BUILD_VERSION").to_string(), + build_git_hash: env!("BUILD_GIT_HASH").to_string(), + build_timestamp: env!("BUILD_TIMESTAMP").to_string(), + uptime_ms: get_uptime_ms(), + services: ServiceHealth { + postgres, + redis, + qdrant, + mongodb, + }, + resources: ResourceStatus { + cpu_used_percent: sys.cpu_used_percent, + cpu_idle_percent: sys.cpu_idle_percent, + memory_available_mb: sys.memory_available_mb, + memory_total_mb: sys.memory_total_mb, + memory_used_percent: sys.memory_used_percent, + gpu_available: sys.gpu_available, + gpu_utilization: sys.gpu_utilization, + gpu_memory_used_pct: sys.gpu_memory_used_pct, + }, + pipeline: PipelineStatus { + scripts_ready: scripts_path.is_dir(), + scripts_count: py_files, + scripts_integrity, + processors: ProcessorInventory { + asr: check_script("asr_processor.py"), + yolo: check_script("yolo_processor.py"), + face: check_script("face_processor.py"), + pose: check_script("pose_processor.py"), + ocr: check_script("ocr_processor.py"), + cut: check_script("cut_processor.py"), + caption: check_script("caption_processor.py"), + scene: check_script("scene_classifier.py"), + story: check_script("story_processor.py"), + asrx: check_script("asrx_processor.py"), + probe: check_script("probe_file.py"), + visual_chunk: check_script("visual_chunk_processor.py"), + total_py_files: py_files, + }, + models_ready: models_path.is_dir(), + models_count: total_model_files, + ffmpeg: std::process::Command::new("which") + .arg("ffmpeg") + .output() + .map(|o| o.status.success()) + .unwrap_or(false), + embedding_server: check_http("http://127.0.0.1:11436/health").await, + gdino_api: check_http("http://127.0.0.1:8080/health").await, + llm: check_http("http://127.0.0.1:8082/health").await, + rsync: check_rsync().await, + watcher_running: check_process_running("watcher"), + worker_running: check_process_running("worker"), + }, + schema: check_schema_migrations(state.db.pool()).await, + identities: { + let identities_root = std::path::Path::new(&*config::OUTPUT_DIR).join("identities"); + let directory_exists = identities_root.is_dir(); + let files_count = crate::core::identity::storage::count_identity_files(); + let index_ok = crate::core::identity::storage::read_index().is_ok(); + let db_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities") + .fetch_one(state.db.pool()) + .await + .unwrap_or(0); + IdentityHealth { + directory_exists, + files_count, + index_ok, + db_count, + synced: directory_exists && files_count as i64 == db_count, + } + }, + integrations: IntegrationHealth { + tmdb: crate::core::tmdb::status::quick_status(), + }, + config: ConfigHealth { + cache_enabled: config::get_cache_enabled(), + auto_pipeline_enabled: config::get_auto_pipeline_enabled(), + watcher_auto_register_enabled: config::get_watcher_auto_register(), + system_timezone: config::SYSTEM_TIMEZONE.clone(), + }, + }) +} + +async fn health_consistency( + State(state): State, +) -> Result, (StatusCode, String)> { + let report = crate::core::health_agent::run_consistency_checks(&state.db).await; + if report.checks.iter().any(|c| c.count > 0) { + tracing::warn!( + "[HEALTH] Consistency issues found: {}", + report + .checks + .iter() + .filter(|c| c.count > 0) + .map(|c| format!("{}={}", c.check, c.count)) + .collect::>() + .join(", ") + ); + } + Ok(Json(report)) +} + +async fn check_postgres() -> ServiceStatus { + let start = Instant::now(); + match PostgresDb::init().await { + Ok(db) => match db.list_videos(1, 0).await { + Ok(_) => ServiceStatus { + status: "ok".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: None, + }, + Err(e) => ServiceStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some(e.to_string()), + }, + }, + Err(e) => ServiceStatus { + status: "error".to_string(), + latency_ms: None, + error: Some(e.to_string()), + }, + } +} + +async fn check_redis() -> ServiceStatus { + let start = Instant::now(); + match RedisClient::new() { + Ok(redis) => match redis.get_conn().await { + Ok(mut conn) => { + let result: Result = redis::cmd("PING").query_async(&mut conn).await; + match result { + Ok(_) => ServiceStatus { + status: "ok".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: None, + }, + Err(e) => ServiceStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some(e.to_string()), + }, + } + } + Err(e) => ServiceStatus { + status: "error".to_string(), + latency_ms: None, + error: Some(e.to_string()), + }, + }, + Err(e) => ServiceStatus { + status: "error".to_string(), + latency_ms: None, + error: Some(e.to_string()), + }, + } +} + +async fn check_qdrant() -> ServiceStatus { + let start = Instant::now(); + let base_url = + std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6333".to_string()); + let api_key = + std::env::var("QDRANT_API_KEY").unwrap_or_else(|_| "Test3200Test3200Test3200".to_string()); + let url = format!("{}/collections", base_url); + + let client = reqwest::Client::new(); + match client + .get(&url) + .header("api-key", api_key) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(resp) if resp.status().is_success() => ServiceStatus { + status: "ok".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: None, + }, + Ok(resp) => ServiceStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some(format!("HTTP {}", resp.status())), + }, + Err(e) => ServiceStatus { + status: "error".to_string(), + latency_ms: None, + error: Some(e.to_string()), + }, + } +} + +async fn check_mongodb(cache: &MongoCache) -> ServiceStatus { + let start = Instant::now(); + match cache.health_check().await { + Ok(_) => ServiceStatus { + status: "ok".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: None, + }, + Err(e) => ServiceStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some(e.to_string()), + }, + } +} + +fn parse_required_migrations() -> Vec { + let raw = env!("REQUIRED_MIGRATIONS"); + if raw.is_empty() { + return vec![]; + } + raw.split(',') + .filter_map(|entry| { + let mut parts = entry.splitn(2, ':'); + let filename = parts.next()?.trim().to_string(); + let checksum = parts.next()?.trim().to_string(); + if filename.is_empty() || checksum.is_empty() { + return None; + } + Some(MigrationInfo { filename, checksum }) + }) + .collect() +} + +pub async fn check_schema_migrations(pool: &sqlx::PgPool) -> SchemaHealth { + let required = parse_required_migrations(); + + let table_exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'schema_migrations')", + ) + .fetch_one(pool) + .await + .unwrap_or(false); + + if !table_exists { + return SchemaHealth { + table_exists: false, + applied: vec![], + required, + ok: false, + }; + } + + let applied: Vec = sqlx::query_as::<_, (String, String)>( + "SELECT filename, checksum FROM schema_migrations ORDER BY id", + ) + .fetch_all(pool) + .await + .unwrap_or_default() + .into_iter() + .map(|(filename, checksum)| MigrationInfo { filename, checksum }) + .collect(); + + let ok = required.iter().all(|req| { + applied + .iter() + .any(|app| app.filename == req.filename && app.checksum == req.checksum) + }); + + SchemaHealth { + table_exists: true, + applied, + required, + ok, + } +} + +async fn check_rsync() -> ServiceStatus { + let start = Instant::now(); + let paths = [ + std::path::Path::new("/Users/accusys/bin/rsync"), + std::path::Path::new("/opt/homebrew/bin/rsync"), + ]; + for p in &paths { + if p.exists() { + return ServiceStatus { + status: "ok".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: None, + }; + } + } + ServiceStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some("rsync not found (built from source expected at ~/bin/rsync)".to_string()), + } +} + +fn check_process_running(name: &str) -> bool { + let patterns: &[&str] = match name { + "watcher" => &[ + "target/release/momentry watcher", + "target/debug/momentry_playground watcher", + ], + "worker" => &[ + "target/release/momentry worker", + "target/debug/momentry_playground worker", + ], + _ => return false, + }; + for pattern in patterns { + if let Ok(o) = std::process::Command::new("pgrep") + .arg("-f") + .arg(pattern) + .output() + { + if o.status.success() { + return true; + } + } + } + false +} + +async fn check_http(url: &str) -> ServiceStatus { + let start = Instant::now(); + match reqwest::get(url).await { + Ok(resp) => { + if resp.status().is_success() { + ServiceStatus { + status: "ok".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: None, + } + } else { + ServiceStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some(format!("HTTP {}", resp.status())), + } + } + } + Err(e) => ServiceStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some(e.to_string()), + }, + } +} + +pub fn health_routes() -> Router { + Router::new() + .route("/health", get(health)) + .route("/health/detailed", get(health_detailed)) + .route("/health/consistency", get(health_consistency)) +} diff --git a/src/api/identities.rs b/src/api/identities.rs index 6d9ab6f..2477761 100644 --- a/src/api/identities.rs +++ b/src/api/identities.rs @@ -29,7 +29,7 @@ pub struct CreateIdentityResponse { pub quality_avg: Option, } -pub fn identity_routes() -> Router { +pub fn identity_routes() -> Router { Router::new() .route("/api/v1/identities", get(list_identities)) .route("/api/v1/identity", post(create_identity)) @@ -39,7 +39,7 @@ pub fn identity_routes() -> Router { /// Register a Global Identity from face.json with multi-angle reference vectors. /// Calls select_face_reference_vectors_v2.py for automatic reference selection. async fn create_identity( - State(_state): State, + State(_state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let schema = req.schema.unwrap_or("dev".to_string()); @@ -147,7 +147,7 @@ async fn create_identity( /// List all global identities async fn list_identities( - State(_state): State, + State(_state): State, Query(query): Query, ) -> Result, (StatusCode, String)> { let db = match PostgresDb::init().await { diff --git a/src/api/identity_agent_api.rs b/src/api/identity_agent_api.rs index 3801151..56123e9 100644 --- a/src/api/identity_agent_api.rs +++ b/src/api/identity_agent_api.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use sqlx::Row; use std::path::PathBuf; -use crate::api::server::AppState; +use crate::api::types::AppState; use crate::core::db::schema; use crate::core::db::PostgresDb; diff --git a/src/api/identity_api.rs b/src/api/identity_api.rs index 127536a..d277fb4 100644 --- a/src/api/identity_api.rs +++ b/src/api/identity_api.rs @@ -10,7 +10,7 @@ use sqlx::Row; use crate::core::db::ResourceRecord; -pub fn identity_routes() -> Router { +pub fn identity_routes() -> Router { Router::new() .route("/api/v1/files", get(list_files)) .route("/api/v1/file/:file_uuid", get(get_file_detail)) @@ -65,7 +65,7 @@ pub struct FilesQuery { } async fn list_files( - State(state): State, + State(state): State, Query(params): Query, ) -> Result, (StatusCode, String)> { let page = params.page.unwrap_or(1); @@ -158,7 +158,7 @@ pub struct FileDetailResponse { } async fn get_file_detail( - State(state): State, + State(state): State, Path(file_uuid): Path, ) -> Result, (StatusCode, String)> { let file = state @@ -212,7 +212,7 @@ pub struct FileIdentityItem { } async fn get_file_identities( - State(state): State, + State(state): State, Path(file_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { @@ -300,7 +300,7 @@ fn strip_uuid(u: &uuid::Uuid) -> String { } async fn get_identity_detail( - State(state): State, + State(state): State, Path(identity_uuid): Path, ) -> Result, (StatusCode, String)> { let uuid_clean = identity_uuid.replace('-', ""); @@ -338,7 +338,7 @@ async fn get_identity_detail( } async fn get_identity_status( - State(state): State, + State(state): State, Path(identity_uuid): Path, ) -> Result, (StatusCode, String)> { let uuid_clean = identity_uuid.replace('-', ""); @@ -386,7 +386,7 @@ pub struct IdentityFilesResponse { } async fn delete_identity( - State(state): State, + State(state): State, Path(identity_uuid): Path, ) -> Result { let table = crate::core::db::schema::table_name("face_detections"); @@ -438,7 +438,7 @@ pub struct IdentityFileItem { } async fn get_identity_files( - State(state): State, + State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { @@ -524,7 +524,7 @@ pub struct BBox { } async fn get_identity_faces( - State(state): State, + State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { @@ -608,7 +608,7 @@ pub struct IdentityChunkItem { } async fn get_identity_chunks( - State(state): State, + State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { @@ -680,7 +680,7 @@ pub struct ResourceItem { } async fn register_resource( - State(state): State, + State(state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let resource = ResourceRecord { @@ -715,7 +715,7 @@ pub struct HeartbeatRequest { } async fn heartbeat_resource( - State(state): State, + State(state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let status = req.status.unwrap_or("online".to_string()); @@ -733,7 +733,7 @@ async fn heartbeat_resource( } async fn list_resources( - State(state): State, + State(state): State, ) -> Result, (StatusCode, String)> { let records = state .db @@ -771,7 +771,7 @@ struct IdentityUploadResponse { } async fn upload_identity( - State(state): State, + State(state): State, Json(payload): Json, ) -> Result, (StatusCode, Json)> { let parsed = uuid::Uuid::parse_str(&payload.identity_uuid) @@ -838,7 +838,7 @@ struct ProfileImageResponse { } async fn upload_profile_image( - State(state): State, + State(state): State, Path(identity_uuid): Path, mut multipart: Multipart, ) -> Result, (StatusCode, Json)> { @@ -951,7 +951,7 @@ async fn get_profile_image( } async fn get_identity_json( - State(state): State, + State(state): State, Path(identity_uuid): Path, ) -> Result<(StatusCode, [(String, String); 1], Vec), StatusCode> { let clean = identity_uuid.replace('-', ""); @@ -1040,7 +1040,7 @@ struct IdentityTextResponse { /// Path A: Search chunk text → associated identities async fn search_identity_text( - State(state): State, + State(state): State, Query(params): Query, ) -> Result, StatusCode> { use crate::core::db::schema; @@ -1148,7 +1148,7 @@ struct IdentitySearchResponse { /// Path B: Search identity name → associated chunk text async fn search_identities_by_text( - State(state): State, + State(state): State, Query(params): Query, ) -> Result, StatusCode> { use crate::core::db::schema; diff --git a/src/api/identity_binding.rs b/src/api/identity_binding.rs index f58d863..2a3a9cd 100644 --- a/src/api/identity_binding.rs +++ b/src/api/identity_binding.rs @@ -389,7 +389,7 @@ pub struct TracesQuery { } pub async fn get_identity_traces( - State(state): State, + State(state): State, Path(identity_uuid): Path, Query(params): Query, ) -> Result, (StatusCode, String)> { @@ -485,7 +485,7 @@ pub async fn get_identity_traces( })) } -pub fn identity_binding_routes() -> Router { +pub fn identity_binding_routes() -> Router { Router::new() .route("/api/v1/identity/:identity_uuid/bind", post(bind_identity)) .route( diff --git a/src/api/media_api.rs b/src/api/media_api.rs index 2ab25b0..c3b290d 100644 --- a/src/api/media_api.rs +++ b/src/api/media_api.rs @@ -47,7 +47,7 @@ fn ffmpeg_cmd() -> std::process::Command { cmd } -pub fn bbox_routes() -> Router { +pub fn bbox_routes() -> Router { Router::new() .route( "/api/v1/file/:file_uuid/video/bbox", @@ -168,7 +168,7 @@ fn resolve_frame_range( } async fn bbox_overlay_video( - State(state): State, + State(state): State, Path(file_uuid): Path, Query(p): Query, ) -> Result { @@ -297,7 +297,7 @@ fn parse_range(range: &str, file_size: u64) -> (u64, u64) { } async fn trace_video( - State(state): State, + State(state): State, Path((file_uuid, trace_id)): Path<(String, i32)>, Query(params): Query>, ) -> Result { @@ -554,7 +554,7 @@ async fn trace_video( } async fn stream_video( - State(state): State, + State(state): State, Path(file_uuid): Path, Query(params): Query>, request: axum::http::Request, @@ -698,7 +698,7 @@ struct ThumbQuery { } async fn face_thumbnail( - State(state): State, + State(state): State, Path(file_uuid): Path, Query(q): Query, ) -> Result { @@ -761,7 +761,7 @@ struct ClipQuery { } async fn video_clip( - State(state): State, + State(state): State, Path(file_uuid): Path, Query(q): Query, ) -> Result { diff --git a/src/api/mod.rs b/src/api/mod.rs index 069f697..c561ba3 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,16 +1,23 @@ pub mod agent_api; +pub mod auth; +pub mod docs; +pub mod files; pub mod five_w1h_agent_api; +pub mod health; pub mod identities; pub mod identity_agent_api; pub mod identity_api; pub mod identity_binding; pub mod media_api; pub mod middleware; +pub mod scan; pub mod search; pub mod server; pub mod tmdb_api; pub mod trace_agent_api; +pub mod types; pub mod universal_search; pub mod visual_chunk_search; +pub mod visual_search; pub use server::start_server; diff --git a/src/api/processing.rs b/src/api/processing.rs new file mode 100644 index 0000000..0b48435 --- /dev/null +++ b/src/api/processing.rs @@ -0,0 +1,513 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Json, + routing::post, + Router, +}; +use serde::{Deserialize, Serialize}; +use std::time::Instant; + +use super::types::AppState; +use crate::core::cache::{keys, RedisCache}; +use crate::core::config::REDIS_KEY_PREFIX; +use crate::core::db::schema; +use crate::core::db::{Database, MonitorJobStatus, PostgresDb, RedisClient, VideoRecord, VideoStatus}; +use crate::core::probe::ffprobe; +use crate::worker::processor; +use crate::{Embedder, FileManager}; + +#[derive(Debug, Serialize)] +struct JobListResponse { + jobs: Vec, + count: i64, + page: usize, + page_size: usize, +} + +#[derive(Debug, Deserialize)] +struct JobsQuery { + page: Option, + page_size: Option, + status: Option, +} + +#[derive(Debug, Serialize)] +struct JobInfoResponse { + id: i32, + uuid: String, + status: String, + current_processor: Option, + progress_current: i32, + progress_total: i32, + created_at: String, + started_at: Option, +} + +#[derive(Debug, Serialize)] +struct JobDetailResponse { + id: i32, + uuid: String, + status: String, + current_processor: Option, + progress_current: i32, + progress_total: i32, + processors: Vec, + created_at: String, + started_at: Option, + updated_at: Option, +} + +#[derive(Debug, Serialize)] +struct ProcessorInfoResponse { + processor_type: String, + status: String, + started_at: Option, + completed_at: Option, + duration_secs: Option, + error_message: Option, +} + +#[derive(Debug, Deserialize)] +struct CacheToggleRequest { + enabled: bool, +} + +#[derive(Debug, Serialize)] +struct CacheToggleResponse { + success: bool, + cache_enabled: bool, + message: String, +} + +#[derive(Debug, Deserialize)] +struct AutoPipelineToggleRequest { + enabled: bool, +} + +#[derive(Debug, Serialize)] +struct AutoPipelineToggleResponse { + success: bool, + auto_pipeline_enabled: bool, + message: String, +} + +#[derive(Debug, Deserialize)] +struct WatcherAutoRegisterToggleRequest { + enabled: bool, +} + +#[derive(Debug, Serialize)] +struct WatcherAutoRegisterToggleResponse { + success: bool, + watcher_auto_register_enabled: bool, + message: String, +} + +#[derive(Debug, Deserialize)] +struct ProcessRequest { + rules: Option>, + processors: Option>, +} + +#[derive(Debug, Serialize)] +struct ProgressResponse { + file_uuid: String, + user: Option, + group: Option, + file_name: Option, + duration: Option, + overall_progress: u32, + cpu_percent: Option, + gpu_percent: Option, + memory_percent: Option, + memory_mb: Option, + system: Option, + processors: Vec, +} + +#[derive(Debug, Serialize)] +struct SystemHealthInfo { + cpu_idle_pct: f64, + memory_available_mb: u64, + memory_total_mb: u64, + memory_used_pct: f64, + gpu_available: bool, + gpu_utilization_pct: Option, + gpu_memory_used_pct: Option, + dynamic_concurrency: u32, + config_concurrency: u32, + running_processors: u32, +} + +#[derive(Debug, Serialize)] +struct ProcessorProgressInfo { + name: String, + status: String, + current: u32, + total: u32, + progress: u32, + message: String, + frames_processed: i32, + chunks_produced: i32, + retry_count: i32, + eta_seconds: Option, +} + +fn get_system_stats() -> (Option, Option, Option, Option) { + use std::process::Command; + + let pid = std::process::id().to_string(); + + let cpu = Command::new("ps") + .args(["-p", &pid, "-o", "%cpu="]) + .output() + .ok() + .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok()); + + let (mem_percent, mem_rss) = Command::new("ps") + .args(["-p", &pid, "-o", "%mem=,rss="]) + .output() + .ok() + .map(|o| { + let output = String::from_utf8_lossy(&o.stdout); + let parts: Vec<&str> = output.split_whitespace().collect(); + let percent = parts.first().and_then(|s| s.parse().ok()); + let rss = parts.get(1).and_then(|s| s.parse().ok()); + (percent, rss) + }) + .unwrap_or((None, None)); + + let gpu = Command::new("nvidia-smi") + .args(["--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"]) + .output() + .ok() + .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok()); + + let mem_mb = mem_rss.map(|r: u64| r / 1024); + + (cpu, mem_percent, gpu, mem_mb) +} + +async fn trigger_processing( + State(state): State, + Path(uuid): Path, + Json(req): Json, +) -> Result, StatusCode> { + let videos_table = schema::table_name("videos"); + let row: Option<(String, String, String, Option, String, Option)> = + sqlx::query_as(&format!( + "SELECT file_uuid, file_path, file_name, file_type, COALESCE(processing_status, 'REGISTERED'), content_hash FROM {} WHERE file_uuid = $1", + videos_table + )) + .bind(&uuid) + .fetch_optional(state.db.pool()) + .await + .map_err(|e| { + tracing::error!("DB error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let (file_uuid, file_path, file_name, file_type, processing_status, content_hash) = + row.ok_or(StatusCode::NOT_FOUND)?; + + if processing_status == "PROCESSING" || processing_status == "QUEUED" { + return Err(StatusCode::CONFLICT); + } + + let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); + + let status_update = format!( + "UPDATE {} SET processing_status = 'QUEUED' WHERE file_uuid = $1", + videos_table + ); + sqlx::query(&status_update) + .bind(&file_uuid) + .execute(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let output_path = std::path::Path::new(&output_dir).join(format!("{}.monitor.json", file_uuid)); + + let monitor_jobs_table = schema::table_name("monitor_jobs"); + let redis = RedisClient::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut conn = redis.get_conn().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let processors_to_run: Vec<&str> = if let Some(procs) = &req.processors { + let procs_str = serde_json::to_string(procs).unwrap_or_default(); + sqlx::query(&format!("UPDATE {} SET processors = $1 WHERE id = $2", schema::table_name("monitor_jobs"))) + .bind(&procs_str) + .bind(&file_uuid) + .execute(state.db.pool()) + .await + .ok(); + procs.iter().map(|s| s.as_str()).collect() + } else { + vec![] + }; + + let notification = serde_json::json!({ + "action": "process", + "file_uuid": file_uuid, + "file_path": file_path, + "file_name": file_name, + "file_type": file_type, + "content_hash": content_hash, + "output_dir": output_dir, + "processors": processors_to_run, + }); + + let notification_key = format!("{}notifications", REDIS_KEY_PREFIX.as_str()); + let _: Result<(), _> = redis::cmd("PUBLISH") + .arg(¬ification_key) + .arg(notification.to_string()) + .query_async(&mut conn) + .await; + + tracing::info!("[TRIGGER] Published processing notification for {}", file_uuid); + + Ok(Json(serde_json::json!({ + "success": true, + "message": "Processing queued", + "file_uuid": file_uuid, + }))) +} + +async fn download_json( + State(state): State, + Path((file_uuid, processor)): Path<(String, String)>, +) -> Result, StatusCode> { + let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); + let path = std::path::Path::new(&output_dir).join(format!("{}.{}.json", file_uuid, processor)); + match tokio::fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str(&content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR), + Err(_) => Err(StatusCode::NOT_FOUND), + } +} + +async fn get_chunk_by_path( + State(state): State, + Path((file_uuid, chunk_id)): Path<(String, String)>, +) -> Result, StatusCode> { + let table = schema::table_name("chunk"); + let chunk: Option = sqlx::query_as(&format!( + "SELECT * FROM {} WHERE uuid = $1 AND chunk_id = $2", + table + )) + .bind(&file_uuid) + .bind(&chunk_id) + .fetch_optional(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + chunk.ok_or(StatusCode::NOT_FOUND) +} + +async fn get_progress( + file_uuid: Path, +) -> Result, StatusCode> { + let file_uuid = file_uuid.0; + let redis = RedisClient::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut conn = redis.get_conn().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let key = format!("{}progress:{}", REDIS_KEY_PREFIX.as_str(), file_uuid); + let progress_json: Option = redis::cmd("GET") + .arg(&key) + .query_async(&mut conn) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let (cpu, mem_pct, gpu, mem_mb) = get_system_stats(); + let sys = SystemHealthInfo { + cpu_idle_pct: cpu.map(|c: f64| 100.0 - c).unwrap_or(0.0), + memory_available_mb: mem_mb.unwrap_or(0), + memory_total_mb: 0, + memory_used_pct: mem_pct.unwrap_or(0.0), + gpu_available: gpu.is_some(), + gpu_utilization_pct: gpu, + gpu_memory_used_pct: None, + dynamic_concurrency: 0, + config_concurrency: 0, + running_processors: 0, + }; + + let pg = PostgresDb::init().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let video = pg.get_video_by_uuid(&file_uuid).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let (overall, processors) = if let Some(json_str) = &progress_json { + match serde_json::from_str::(json_str) { + Ok(p) => (p.overall_progress, p.processors), + Err(_) => (0u32, vec![]), + } + } else { + (0u32, vec![]) + }; + + Ok(Json(ProgressResponse { + file_uuid, + user: None, + group: None, + file_name: video.as_ref().map(|v| v.file_name.clone()), + duration: video.as_ref().map(|v| v.duration), + overall_progress: overall, + cpu_percent: cpu, + gpu_percent: gpu, + memory_percent: mem_pct, + memory_mb, + system: Some(sys), + processors, + })) +} + +async fn list_jobs( + Query(params): Query, +) -> Result, StatusCode> { + let pg = PostgresDb::init().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let jobs_table = schema::table_name("monitor_jobs"); + let videos_table = schema::table_name("videos"); + + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(20).max(1).min(100); + let offset = (page - 1) * page_size; + + let mut where_clause = String::new(); + if let Some(ref status) = params.status { + where_clause = format!(" WHERE j.status = '{}'", status); + } + + let count: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} j{}", + jobs_table, where_clause + )) + .fetch_one(pg.pool()) + .await + .unwrap_or(0); + + let jobs: Vec<(i32, String, String, String, String, Option, i32, i32)> = + sqlx::query_as(&format!( + "SELECT j.id::int, j.uuid, v.file_name, COALESCE(j.status, 'QUEUED'), COALESCE(j.current_processor, ''), v.file_uuid, COALESCE(j.processed_frames, 0), COALESCE(j.total_frames, 0) \ + FROM {} j LEFT JOIN {} v ON v.file_uuid = j.uuid{} \ + ORDER BY j.id DESC LIMIT $1 OFFSET $2", + jobs_table, videos_table, where_clause + )) + .bind(page_size as i64) + .bind(offset as i64) + .fetch_all(pg.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let job_list = jobs + .into_iter() + .map(|(id, uuid, fname, status, cp, _vuuid, pf, tf)| JobInfoResponse { + id, + uuid, + status, + current_processor: Some(cp), + progress_current: pf, + progress_total: tf, + created_at: String::new(), + started_at: None, + }) + .collect(); + + Ok(Json(JobListResponse { + jobs: job_list, + count, + page, + page_size, + })) +} + +async fn get_job( + Path(uuid): Path, +) -> Result, StatusCode> { + let pg = PostgresDb::init().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let jobs_table = schema::table_name("monitor_jobs"); + let videos_table = schema::table_name("videos"); + + let job: Option<(i32, String, String, String, Option, i32, i32, String, Option, Option)> = + sqlx::query_as(&format!( + "SELECT j.id::int, j.uuid, COALESCE(v.file_name, 'unknown'), COALESCE(j.status, 'QUEUED'), j.current_processor, \ + COALESCE(j.processed_frames, 0), COALESCE(j.total_frames, 0), \ + COALESCE(j.created_at::text, ''), j.started_at::text, j.updated_at::text \ + FROM {} j LEFT JOIN {} v ON v.file_uuid = j.uuid WHERE j.uuid = $1", + jobs_table, videos_table + )) + .bind(&uuid) + .fetch_optional(pg.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let (id, uuid, file_name, status, current_processor, pf, tf, created_at, started_at, updated_at) = + job.ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(JobDetailResponse { + id, + uuid, + status, + current_processor, + progress_current: pf, + progress_total: tf, + processors: vec![], + created_at, + started_at, + updated_at, + })) +} + +async fn cache_toggle( + State(state): State, + Json(req): Json, +) -> Json { + crate::core::config::set_cache_enabled(req.enabled); + if !req.enabled { + let _ = state.mongo_cache.flush_all().await; + let _ = state.redis_cache.flush().await; + } + Json(CacheToggleResponse { + success: true, + cache_enabled: req.enabled, + message: format!("Cache {}", if req.enabled { "enabled" } else { "disabled" }), + }) +} + +async fn auto_pipeline_toggle( + Json(req): Json, +) -> Json { + crate::core::config::set_auto_pipeline_enabled(req.enabled); + Json(AutoPipelineToggleResponse { + success: true, + auto_pipeline_enabled: req.enabled, + message: format!( + "Auto pipeline {}", + if req.enabled { "enabled" } else { "disabled" } + ), + }) +} + +async fn watcher_auto_register_toggle( + Json(req): Json, +) -> Json { + crate::core::config::set_watcher_auto_register(req.enabled); + Json(WatcherAutoRegisterToggleResponse { + success: true, + watcher_auto_register_enabled: req.enabled, + message: format!( + "Watcher auto-register {}", + if req.enabled { "enabled" } else { "disabled" } + ), + }) +} + +pub fn processing_routes() -> Router { + Router::new() + .route("/api/v1/file/:file_uuid/process", post(trigger_processing)) + .route("/api/v1/file/:file_uuid/json/:processor", post(download_json)) + .route("/api/v1/file/:file_uuid/chunk/:chunk_id", post(get_chunk_by_path)) + .route("/api/v1/progress/:file_uuid", post(get_progress)) + .route("/api/v1/jobs", post(list_jobs)) + .route("/api/v1/config/cache", post(cache_toggle)) + .route("/api/v1/config/auto-pipeline", post(auto_pipeline_toggle)) + .route("/api/v1/config/watcher-auto-register", post(watcher_auto_register_toggle)) +} diff --git a/src/api/scan.rs b/src/api/scan.rs new file mode 100644 index 0000000..a875aef --- /dev/null +++ b/src/api/scan.rs @@ -0,0 +1,517 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Json, + routing::get, + Router, +}; +use serde::{Deserialize, Serialize}; + +use super::types::AppState; +use crate::core::db::schema; + +#[derive(Debug, Serialize, Deserialize)] +struct ScannedFileInfo { + file_name: String, + relative_path: String, + file_path: String, + file_size: u64, + modified_time: String, + is_registered: bool, + file_uuid: Option, + status: Option, + registration_time: Option, + job_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ScanFilesResponse { + files: Vec, + total: usize, + filtered_total: usize, + page: usize, + page_size: usize, + total_pages: usize, + registered_count: usize, + unregistered_count: usize, + total_chunks: i64, + searchable_chunks: i64, + pending_videos: i64, +} + +#[derive(Debug, Deserialize)] +struct ScanFilesQuery { + limit: Option, + page: Option, + page_size: Option, + pattern: Option, + sort_by: Option, + sort_order: Option, +} + +fn scan_directory_recursive( + dir: &std::path::Path, + root: &std::path::Path, + allowed_extensions: &[&str], + registered_paths: &std::collections::HashMap< + String, + (String, String, Option, Option), + >, + files: &mut Vec, +) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') { + return; + } + } + scan_directory_recursive(&path, root, allowed_extensions, registered_paths, files); + } else if path.is_file() { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if allowed_extensions.contains(&ext.to_lowercase().as_str()) { + if let Ok(meta) = entry.metadata() { + let abs_path = path.to_string_lossy().to_string(); + let rel_path = path + .strip_prefix(root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| abs_path.clone()); + + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let modified_time = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| { + chrono::DateTime::from_timestamp(d.as_secs() as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default() + }) + .unwrap_or_default(); + + match registered_paths.get(&abs_path) { + Some((uuid, status, reg_time, jid)) if status != "unregistered" => { + files.push(ScannedFileInfo { + file_name, + relative_path: rel_path, + file_path: abs_path, + file_size: meta.len(), + modified_time, + is_registered: true, + file_uuid: Some(uuid.clone()), + status: Some(status.clone()), + registration_time: reg_time.clone(), + job_id: *jid, + }); + } + _ => { + files.push(ScannedFileInfo { + file_name, + relative_path: rel_path, + file_path: abs_path, + file_size: meta.len(), + modified_time, + is_registered: false, + file_uuid: None, + status: Some("unregistered".to_string()), + registration_time: None, + job_id: None, + }); + } + } + } + } + } + } + } + } +} + +async fn scan_files( + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let demo_dir_str = std::env::var("MOMENTRY_SFTP_ROOT") + .unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data/demo".to_string()); + let demo_dir = std::path::Path::new(&demo_dir_str); + + let allowed_extensions = vec![ + "mp4", "mov", "mkv", "avi", "webm", "jpg", "jpeg", "png", "gif", "webp", + ]; + + let table = schema::table_name("videos"); + let mj_table = schema::table_name("monitor_jobs"); + let registered_db: Vec<(String, String, String, String, Option, Option)> = + sqlx::query_as(&format!( + "SELECT v.file_path, v.file_name, v.file_uuid, v.status, v.registration_time::text, \ + latest_job.id as job_id \ + FROM {} v \ + LEFT JOIN LATERAL ( \ + SELECT id FROM {} WHERE uuid = v.file_uuid ORDER BY id DESC LIMIT 1 \ + ) latest_job ON true \ + ORDER BY v.id", + table, mj_table + )) + .fetch_all(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let registered_paths: std::collections::HashMap< + String, + (String, String, Option, Option), + > = registered_db + .into_iter() + .map(|(path, _name, uuid, status, reg_time, jid)| (path, (uuid, status, reg_time, jid))) + .collect(); + + let mut result_files = Vec::new(); + + if demo_dir.exists() { + scan_directory_recursive( + demo_dir, + demo_dir, + &allowed_extensions, + ®istered_paths, + &mut result_files, + ); + } + + let desc = params.sort_order.as_deref().unwrap_or("asc") == "desc"; + match params.sort_by.as_deref().unwrap_or("name") { + "size" => { + if desc { + result_files.sort_by(|a, b| b.file_size.cmp(&a.file_size)); + } else { + result_files.sort_by(|a, b| a.file_size.cmp(&b.file_size)); + } + } + "modified" | "time" => { + if desc { + result_files.sort_by(|a, b| b.modified_time.cmp(&a.modified_time)); + } else { + result_files.sort_by(|a, b| a.modified_time.cmp(&b.modified_time)); + } + } + "status" => { + if desc { + result_files + .sort_by(|a, b| b.status.cmp(&a.status).then(b.file_name.cmp(&a.file_name))); + } else { + result_files + .sort_by(|a, b| a.status.cmp(&b.status).then(a.file_name.cmp(&b.file_name))); + } + } + _ => { + if desc { + result_files.sort_by(|a, b| { + a.is_registered + .cmp(&b.is_registered) + .then(b.file_name.cmp(&a.file_name)) + }); + } else { + result_files.sort_by(|a, b| { + b.is_registered + .cmp(&a.is_registered) + .then(a.file_name.cmp(&b.file_name)) + }); + } + } + } + + let total_all = result_files.len(); + let registered_count = result_files.iter().filter(|f| f.is_registered).count(); + let unregistered_count = result_files.iter().filter(|f| !f.is_registered).count(); + + let filtered: Vec = if let Some(ref pat) = params.pattern { + let re = match regex::Regex::new(&format!("(?i){}", pat)) { + Ok(r) => r, + Err(_) => return Err(StatusCode::BAD_REQUEST), + }; + result_files + .into_iter() + .filter(|f| re.is_match(&f.file_name)) + .collect() + } else { + result_files + }; + + let filtered_total = filtered.len(); + + let page = params.page.unwrap_or(1).max(1); + let page_size = params + .page_size + .or(params.limit) + .unwrap_or(filtered_total.max(1)); + let total_pages = if page_size > 0 { + (filtered_total + page_size - 1) / page_size + } else { + 1 + }; + let start = (page - 1) * page_size; + let files: Vec = filtered.into_iter().skip(start).take(page_size).collect(); + + let table_videos = schema::table_name("videos"); + let table_chunks = schema::table_name("chunk"); + let total_chunks: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {}", table_chunks)) + .fetch_one(state.db.pool()) + .await + .unwrap_or(0); + let searchable_chunks: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE vector_id IS NOT NULL", + table_chunks + )) + .fetch_one(state.db.pool()) + .await + .unwrap_or(0); + let pending_videos: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {} WHERE status = 'pending'", + table_videos + )) + .fetch_one(state.db.pool()) + .await + .unwrap_or(0); + + Ok(Json(ScanFilesResponse { + files, + total: total_all, + filtered_total, + page, + page_size, + total_pages, + registered_count, + unregistered_count, + total_chunks, + searchable_chunks, + pending_videos, + })) +} + +#[derive(Debug, Serialize)] +struct SftpgoStatusResponse { + username: String, + home_dir: String, + files_count: i64, + registered_videos: Vec, + last_login: Option, +} + +#[derive(Debug, Serialize)] +struct RegisteredVideo { + uuid: String, + file_name: String, + status: String, +} + +async fn get_sftpgo_status( + State(state): State, +) -> Result, StatusCode> { + let demo_dir = "/Users/accusys/momentry/var/sftpgo/data/demo"; + + let files_count: i64 = std::fs::read_dir(demo_dir) + .map(|entries| entries.count() as i64) + .unwrap_or(0); + + let table_videos = schema::table_name("videos"); + + let registered_videos: Vec<(String, String, String)> = sqlx::query_as(&format!( + "SELECT file_uuid, file_name, status FROM {} WHERE file_path LIKE '%demo%' ORDER BY id", + table_videos + )) + .fetch_all(state.db.pool()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let registered_videos = registered_videos + .into_iter() + .map(|(uuid, file_name, status)| RegisteredVideo { + uuid, + file_name, + status, + }) + .collect(); + + Ok(Json(SftpgoStatusResponse { + username: "demo".to_string(), + home_dir: demo_dir.to_string(), + files_count, + registered_videos, + last_login: None, + })) +} + +#[derive(Debug, Serialize)] +struct IngestionStep { + name: String, + status: String, + detail: Option, +} + +#[derive(Debug, Serialize)] +struct IdentityRef { + uuid: String, + name: String, +} + +#[derive(Debug, Serialize)] +struct IngestionStatusResponse { + file_uuid: String, + steps: Vec, + related_identities: Vec, + strangers: i64, +} + +async fn get_ingestion_status( + State(state): State, + Path(file_uuid): Path, +) -> Result, StatusCode> { + let pool = state.db.pool(); + let chunk = schema::table_name("chunk"); + let fd = schema::table_name("face_detections"); + let identities = schema::table_name("identities"); + + let scene_meta_path = format!( + "{}/{}.scene_meta.json", + crate::core::config::OUTPUT_DIR.as_str(), + file_uuid + ); + let scene_meta_ok = std::path::Path::new(&scene_meta_path).exists(); + + macro_rules! count_sql { + ($sql:expr) => { + sqlx::query_scalar::<_, i64>($sql) + .fetch_one(pool) + .await + .unwrap_or(0) + }; + } + + let sentence_count = count_sql!(&format!( + "SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'sentence'" + )); + let sentence_embedded = count_sql!(&format!("SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'sentence' AND embedding IS NOT NULL")); + let scene_count = count_sql!(&format!( + "SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'cut'" + )); + let face_total = count_sql!(&format!( + "SELECT COUNT(*) FROM {fd} WHERE file_uuid = '{file_uuid}'" + )); + let trace_count = count_sql!(&format!("SELECT COUNT(DISTINCT trace_id) FROM {fd} WHERE file_uuid = '{file_uuid}' AND trace_id IS NOT NULL")); + let trace_chunks = count_sql!(&format!( + "SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'trace'" + )); + let identity_count = count_sql!(&format!("SELECT COUNT(DISTINCT identity_id) FROM {fd} WHERE file_uuid = '{file_uuid}' AND identity_id IS NOT NULL")); + let tkg_nodes = count_sql!(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = '{file_uuid}'", + schema::table_name("tkg_nodes") + )); + let tkg_edges = count_sql!(&format!( + "SELECT COUNT(*) FROM {} WHERE file_uuid = '{file_uuid}'", + schema::table_name("tkg_edges") + )); + let scene_5w1h = count_sql!(&format!("SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'cut' AND summary_text IS NOT NULL AND summary_text != ''")); + + let related_identities: Vec = + match sqlx::query_as::<_, (String, String)>(&format!( + "SELECT DISTINCT i.uuid::text, i.name FROM {identities} i \ + JOIN {fd} fd ON fd.identity_id = i.id \ + WHERE fd.file_uuid = '{file_uuid}' AND fd.identity_id IS NOT NULL \ + ORDER BY i.name" + )) + .fetch_all(pool) + .await + { + Ok(rows) => rows + .into_iter() + .map(|(uuid, name)| IdentityRef { + uuid: uuid.replace('-', ""), + name, + }) + .collect(), + Err(e) => { + tracing::error!("related_identities query failed: {}", e); + vec![] + } + }; + + let strangers = count_sql!(&format!( + "SELECT COUNT(DISTINCT trace_id) FROM {fd} \ + WHERE file_uuid = '{file_uuid}' AND trace_id IS NOT NULL AND identity_id IS NULL" + )); + + macro_rules! step { + ($name:expr, $done:expr, $detail:expr) => { + IngestionStep { + name: $name.into(), + status: if $done { "done" } else { "pending" }.into(), + detail: $detail, + } + }; + } + + let steps = vec![ + step!( + "rule1_sentence", + sentence_count > 0, + Some(format!("{sentence_count} sentence chunks")) + ), + step!( + "auto_vectorize", + sentence_embedded > 0, + Some(format!("{sentence_embedded} embedded")) + ), + step!( + "rule3_scene", + scene_count > 0, + Some(format!("{scene_count} scene chunks")) + ), + step!( + "face_trace", + trace_count > 0, + Some(format!("{trace_count} traces / {face_total} detections")) + ), + step!( + "trace_chunks", + trace_chunks > 0, + Some(format!("{trace_chunks} trace chunks")) + ), + step!( + "tkg", + tkg_nodes > 0 || tkg_edges > 0, + Some(format!("{tkg_nodes} nodes, {tkg_edges} edges")) + ), + step!( + "identity_match", + identity_count > 0, + Some(format!("{identity_count} identities matched")) + ), + step!("scene_metadata", scene_meta_ok, None), + step!( + "5w1h", + scene_5w1h > 0, + Some(format!("{scene_5w1h} scenes with 5W1H")) + ), + ]; + + Ok(Json(IngestionStatusResponse { + file_uuid, + steps, + related_identities, + strangers, + })) +} + +pub fn scan_routes() -> Router { + Router::new() + .route("/api/v1/files/scan", get(scan_files)) + .route("/api/v1/stats/sftpgo", get(get_sftpgo_status)) + .route( + "/api/v1/stats/ingestion-status/:file_uuid", + get(get_ingestion_status), + ) +} diff --git a/src/api/search.rs b/src/api/search.rs index 984a17f..4c27da4 100644 --- a/src/api/search.rs +++ b/src/api/search.rs @@ -51,7 +51,7 @@ pub struct SmartSearchResponse { // --- API Handler --- pub async fn smart_search( - State(state): State, + State(state): State, Json(req): Json, ) -> Result, (StatusCode, Json)> { let db = &state.db; @@ -126,6 +126,6 @@ pub async fn smart_search( // --- Router Setup --- -pub fn search_routes() -> Router { +pub fn search_routes() -> Router { Router::new().route("/api/v1/search/smart", post(smart_search)) } diff --git a/src/api/server.rs b/src/api/server.rs index 02fba7b..2ead26d 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -1,4085 +1,39 @@ -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::Json, - routing::{delete, get, post}, - Router, -}; -use once_cell::sync::{Lazy, OnceCell}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use sqlx::{PgPool, Row}; -use std::collections::HashMap; -use std::time::Instant; +use axum::Router; use tower_http::cors::{Any, CorsLayer}; -use crate::core::cache::{keys, MongoCache, RedisCache}; -use crate::core::config::REDIS_KEY_PREFIX; -use crate::core::db::schema; -use crate::core::db::{Database, PostgresDb, QdrantDb, RedisClient, VideoRecord, VideoStatus}; -use crate::core::text::tokenizer::tokenize_chinese_text; -use crate::worker::resources::SystemResources; -use crate::{Embedder, FileManager}; +use crate::core::cache::{MongoCache, RedisCache}; +use crate::core::db::{Database, PostgresDb}; +use crate::Embedder; use super::agent_api; +use super::auth; +use super::docs; +use super::files; use super::five_w1h_agent_api; +use super::health; use super::identities; +use super::identity_agent_api; use super::identity_api; use super::identity_binding; +use super::media_api; use super::middleware::unified_auth; +use super::scan; use super::search::search_routes; use super::tmdb_api; use super::trace_agent_api; +use super::types::AppState; use super::universal_search::universal_search_routes; -use super::visual_chunk_search; -use crate::core::chunk::types::Chunk; - -static DEMO_USER_API_KEY: Lazy = Lazy::new(|| { - std::env::var("MOMENTRY_DEMO_API_KEY") - .unwrap_or_else(|_| "muser_demo_key_32chars_abcdef1234567890".to_string()) -}); - -fn hash_password(password: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(password.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -#[derive(Debug, Deserialize)] -struct LoginRequest { - username: String, - password: String, -} - -#[derive(Debug, Serialize)] -struct LoginResponse { - success: bool, - message: Option, - api_key: Option, - user: Option, -} - -#[derive(Debug, Serialize)] -struct UserInfo { - username: String, -} - -// Global State -static SERVER_START: OnceCell = OnceCell::new(); -static SERVER_HOST: OnceCell = OnceCell::new(); -static SERVER_PORT: OnceCell = OnceCell::new(); - -fn get_host() -> String { - SERVER_HOST - .get() - .cloned() - .unwrap_or_else(|| "0.0.0.0".to_string()) -} - -fn get_port() -> u16 { - SERVER_PORT.get().copied().unwrap_or(0) -} - -fn get_uptime_ms() -> u64 { - SERVER_START - .get() - .map(|i| i.elapsed().as_millis() as u64) - .unwrap_or(0) -} - -#[derive(Debug, Serialize)] -struct HealthResponse { - ip: String, - port: u16, - status: String, - version: String, - build_git_hash: String, - build_timestamp: String, - uptime_ms: u64, - watcher_running: bool, - worker_running: bool, - auto_pipeline_enabled: bool, - watcher_auto_register_enabled: bool, - system_timezone: String, -} - -#[derive(Debug, Serialize)] -struct JobListResponse { - jobs: Vec, - count: i64, - page: usize, - page_size: usize, -} - -#[derive(Debug, Deserialize)] -struct JobsQuery { - page: Option, - page_size: Option, - status: Option, -} - -#[derive(Debug, Serialize)] -struct JobInfoResponse { - id: i32, - uuid: String, - status: String, - current_processor: Option, - progress_current: i32, - progress_total: i32, - created_at: String, - started_at: Option, -} - -#[derive(Debug, Serialize)] -struct JobDetailResponse { - id: i32, - uuid: String, - status: String, - current_processor: Option, - progress_current: i32, - progress_total: i32, - processors: Vec, - created_at: String, - started_at: Option, - updated_at: Option, -} - -#[derive(Debug, Serialize)] -struct ProcessorInfoResponse { - processor_type: String, - status: String, - started_at: Option, - completed_at: Option, - duration_secs: Option, - error_message: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -enum SearchMode { - Vector, - Smart, -} - -#[derive(Debug, Deserialize)] -struct SearchRequest { - query: String, - mode: Option, - collection: Option, - uuid: Option, - limit: Option, - page: Option, - page_size: Option, - vector_weight: Option, - bm25_weight: Option, -} - -#[derive(Debug, Deserialize)] -struct CacheToggleRequest { - enabled: bool, -} - -#[derive(Debug, Serialize)] -struct CacheToggleResponse { - success: bool, - cache_enabled: bool, - message: String, -} - -#[derive(Debug, Deserialize)] -struct AutoPipelineToggleRequest { - enabled: bool, -} - -#[derive(Debug, Serialize)] -struct AutoPipelineToggleResponse { - success: bool, - auto_pipeline_enabled: bool, - message: String, -} - -#[derive(Debug, Deserialize)] -struct WatcherAutoRegisterToggleRequest { - enabled: bool, -} - -#[derive(Debug, Serialize)] -struct WatcherAutoRegisterToggleResponse { - success: bool, - watcher_auto_register_enabled: bool, - message: String, -} - -// Missing structs added - -#[derive(Debug, Deserialize, Serialize)] -struct FileLookupMatch { - file_uuid: String, - file_name: String, - file_type: Option, - status: String, - content_hash: Option, - file_size: Option, - duration: Option, - width: Option, - height: Option, -} - -#[derive(Serialize)] -struct FileLookupResponse { - file_name: String, - exists: bool, - matches: Vec, - next_name: String, -} - -async fn lookup_file_by_name( - State(state): State, - Query(params): Query>, -) -> Result, StatusCode> { - let base = params - .get("file_name") - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - if base.is_empty() { - return Ok(Json(FileLookupResponse { - file_name: String::new(), - exists: false, - matches: vec![], - next_name: String::new(), - })); - } - let table = schema::table_name("videos"); - let dot_pos = base.rfind('.'); - let (stem, ext) = match dot_pos { - Some(p) => (base[..p].to_string(), base[p..].to_string()), - None => (base.clone(), String::new()), - }; - let pattern = format!("{}%%", &stem); - - let query_sql = format!("SELECT file_uuid, file_name, file_type, status, content_hash, duration, width, height FROM {} WHERE file_name = $1 OR file_name LIKE $2 ORDER BY file_name", table); - let rows = sqlx::query(&query_sql) - .bind(&base) - .bind(&pattern) - .fetch_all(state.db.pool()) - .await - .map_err(|e| { - tracing::error!("lookup query error: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let exists = rows.iter().any(|r| r.get::("file_name") == base); - let matches: Vec = rows - .iter() - .map(|r| FileLookupMatch { - file_uuid: r.get("file_uuid"), - file_name: r.get("file_name"), - file_type: r.get("file_type"), - status: r.get("status"), - content_hash: r.get("content_hash"), - file_size: None, - duration: r.get("duration"), - width: r.get("width"), - height: r.get("height"), - }) - .collect(); - - let max_n: usize = rows - .iter() - .filter_map(|r| { - let n: String = r.get("file_name"); - if n == base { - return Some(0usize); - } - let rest = n.strip_prefix(&stem).and_then(|r| r.strip_suffix(&ext))?; - let inner = rest - .trim() - .strip_prefix('(') - .and_then(|r| r.strip_suffix(')'))?; - inner.parse::().ok() - }) - .max() - .unwrap_or(0); - let next_name = if max_n == 0 && !exists { - base.clone() - } else { - format!("{} ({}){}", stem, max_n + 1, ext) - }; - - Ok(Json(FileLookupResponse { - file_name: base, - exists, - matches, - next_name, - })) -} - -#[derive(Debug, Deserialize)] -struct RegisterFileRequest { - file_path: String, - pattern: Option, - user_id: Option, - content_hash: Option, -} - -#[derive(Debug, Serialize)] -struct RegisterFileResponse { - success: bool, - file_uuid: String, - file_name: String, - file_path: String, - file_type: Option, - duration: f64, - width: u32, - height: u32, - fps: f64, - total_frames: u64, - registration_time: Option, - already_exists: bool, - message: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct SearchResult { - uuid: String, - chunk_id: String, - chunk_type: String, - start_time: f64, - end_time: f64, - text: String, - score: f32, -} - -#[derive(Debug, Serialize, Deserialize)] -struct SearchResponse { - results: Vec, - query: String, - total: usize, - page: usize, - page_size: usize, - limit: usize, -} - -#[derive(Debug, Serialize)] -struct ProbeResponse { - file_uuid: String, - file_name: String, - file_size: Option, - duration: f64, - width: u32, - height: u32, - fps: f64, - total_frames: i64, - cached: bool, - format: crate::core::probe::FormatInfo, - streams: Vec, -} - -// --- P0 API Structs --- -#[derive(Debug, Deserialize)] -struct ProcessRequest { - rules: Option>, - processors: Option>, -} - -#[derive(Debug, Serialize)] -struct FrameProgress { - total_frames: i64, - processed_frames: i64, - progress_percent: f64, -} - -#[derive(Debug, Serialize)] -struct AssetStatusResponse { - uuid: String, - file_name: String, - registration_time: String, - processing_status: String, - current_job_id: Option, - frame_progress: Option, -} - -#[derive(Debug, Serialize)] -struct JobStatusResponse { - job_id: String, - file_uuid: String, - rule: String, - status: String, - current_processor_id: Option, - frame_progress: FrameProgress, -} - -#[derive(Debug, Serialize)] -struct RuleStatusResponse { - rule: String, - supported_processor_ids: Vec, - active_jobs: Vec, -} - -// --- End P0 API Structs --- - -#[derive(Debug, Deserialize)] -struct HybridSearchRequest { - query: String, - limit: Option, - page: Option, - page_size: Option, - uuid: Option, - vector_weight: Option, - bm25_weight: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct HybridSearchResult { - uuid: String, - chunk_id: String, - chunk_type: String, - start_time: f64, - end_time: f64, - text: String, - vector_score: f64, - bm25_score: f64, - combined_score: f64, -} - -#[derive(Debug, Serialize, Deserialize)] -struct HybridSearchResponse { - results: Vec, - query: String, - total: usize, - page: usize, - page_size: usize, - limit: usize, -} - -fn dedup_search_results(results: Vec) -> Vec { - let mut seen: std::collections::HashMap = - std::collections::HashMap::new(); - for r in results { - let key = r.chunk_id.clone(); - match seen.get(&key) { - Some(existing) if existing.score >= r.score => continue, - _ => { - seen.insert(key, r); - } - } - } - let mut deduped: Vec = seen.into_values().collect(); - deduped.sort_by(|a, b| { - b.score - .partial_cmp(&a.score) - .unwrap_or(std::cmp::Ordering::Equal) - }); - deduped -} - -fn dedup_hybrid_results(results: Vec) -> Vec { - let mut seen: std::collections::HashMap = - std::collections::HashMap::new(); - for r in results { - let key = r.chunk_id.clone(); - match seen.get(&key) { - Some(existing) if existing.combined_score >= r.combined_score => continue, - _ => { - seen.insert(key, r); - } - } - } - let mut deduped: Vec = seen.into_values().collect(); - deduped.sort_by(|a, b| { - b.combined_score - .partial_cmp(&a.combined_score) - .unwrap_or(std::cmp::Ordering::Equal) - }); - deduped -} - -fn extract_text_from_content(content: &serde_json::Value) -> String { - let raw_text = content - .get("data") - .and_then(|data| data.get("text")) - .and_then(|v| v.as_str()) - .or_else(|| content.get("text").and_then(|v| v.as_str())) - .unwrap_or(""); - - tokenize_chinese_text(raw_text) -} - -fn extract_title_from_content(content: &serde_json::Value) -> String { - content - .get("data") - .and_then(|data| data.get("title")) - .and_then(|v| v.as_str()) - .or_else(|| content.get("title").and_then(|v| v.as_str())) - .unwrap_or("") - .to_string() -} - -#[derive(Debug, Deserialize)] -struct LookupQuery { - path: Option, - uuid: Option, -} - -#[derive(Debug, Serialize)] -struct LookupResponse { - file_uuid: String, - file_path: Option, - file_name: Option, - duration: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct FileInfoResponse { - file_uuid: String, - file_path: String, - file_name: String, - file_type: Option, - duration: f64, - width: u32, - height: u32, - status: String, - processing_status: Option, - birth_registration: Option, - created_at: Option, - registration_time: Option, - file_size: Option, - probe_json: Option, - total_frames: u64, -} - -#[derive(Debug, Serialize, Deserialize)] -struct FilesResponse { - files: Vec, - count: i64, - page: usize, - page_size: usize, -} - -#[derive(Debug, Deserialize)] -struct FilesQuery { - page: Option, - page_size: Option, - status: Option, - q: Option, - uuid: Option, -} - -#[derive(Clone)] -pub struct AppState { - pub db: std::sync::Arc, - pub embedder: std::sync::Arc, - pub embedder_model: String, - pub mongo_cache: crate::core::cache::MongoCache, - pub redis_cache: crate::core::cache::RedisCache, - pub api_state: super::middleware::ApiState, -} - -#[derive(Debug, Serialize)] -struct DetailedHealthResponse { - ip: String, - port: u16, - status: String, - version: String, - build_git_hash: String, - build_timestamp: String, - uptime_ms: u64, - services: ServiceHealth, - resources: ResourceStatus, - pipeline: PipelineStatus, - schema: SchemaHealth, - identities: IdentityHealth, - integrations: IntegrationHealth, - config: ConfigHealth, -} - -#[derive(Debug, Serialize)] -struct IntegrationHealth { - tmdb: crate::core::tmdb::status::TmdbResourceStatus, -} - -#[derive(Debug, Serialize)] -struct IdentityHealth { - directory_exists: bool, - files_count: usize, - index_ok: bool, - db_count: i64, - synced: bool, -} - -#[derive(Debug, Serialize)] -struct ConfigHealth { - cache_enabled: bool, - auto_pipeline_enabled: bool, - watcher_auto_register_enabled: bool, - system_timezone: String, -} - -#[derive(Debug, Serialize)] -struct SchemaHealth { - table_exists: bool, - applied: Vec, - required: Vec, - ok: bool, -} - -#[derive(Debug, Serialize)] -struct MigrationInfo { - filename: String, - checksum: String, -} - -#[derive(Debug, Serialize)] -struct PipelineStatus { - /// Scripts directory available - scripts_ready: bool, - /// Number of Python processor scripts found - scripts_count: usize, - /// Key processor scripts present - processors: ProcessorInventory, - /// Models directory available - models_ready: bool, - /// Number of model files found - models_count: usize, - /// SHA256 checksum integrity: (pass_count, total_count) - scripts_integrity: ScriptIntegrity, - /// ffmpeg path - ffmpeg: bool, - /// Embedding server (port 11436) - embedding_server: ServiceStatus, - /// GDINO API (port 8080) - gdino_api: ServiceStatus, - /// LLM via llama.cpp (port 8082) - llm: ServiceStatus, - /// rsync file sync tool - rsync: ServiceStatus, - /// Watcher process running - watcher_running: bool, - /// Worker process running - worker_running: bool, -} - -#[derive(Debug, Serialize)] -struct ScriptIntegrity { - matched: usize, - total: usize, - ok: bool, -} - -#[derive(Debug, Serialize)] -struct ProcessorInventory { - asr: bool, - yolo: bool, - face: bool, - pose: bool, - ocr: bool, - cut: bool, - caption: bool, - scene: bool, - story: bool, - asrx: bool, - probe: bool, - visual_chunk: bool, - /// Count of total Python files in scripts dir - total_py_files: usize, -} - -#[derive(Debug, Serialize)] -struct ResourceStatus { - cpu_used_percent: f64, - cpu_idle_percent: f64, - memory_available_mb: u64, - memory_total_mb: u64, - memory_used_percent: f64, - gpu_available: bool, - gpu_utilization: Option, - gpu_memory_used_pct: Option, -} - -#[derive(Debug, Serialize)] -struct ServiceHealth { - postgres: ServiceStatus, - redis: ServiceStatus, - qdrant: ServiceStatus, - mongodb: ServiceStatus, -} - -#[derive(Debug, Serialize)] -struct ServiceStatus { - status: String, - latency_ms: Option, - error: Option, -} - -async fn health(State(state): State) -> Json { - let postgres = check_postgres().await; - let redis = check_redis().await; - let qdrant = check_qdrant().await; - let mongodb = check_mongodb(&state.mongo_cache).await; - - let all_ok = postgres.status == "ok" - && redis.status == "ok" - && qdrant.status == "ok" - && mongodb.status == "ok"; - - let status = if all_ok { "ok" } else { "degraded" }; - - if all_ok { - let _ = state.redis_cache.set_health(status).await; - } - - Json(HealthResponse { - ip: get_host(), - port: get_port(), - status: status.to_string(), - version: env!("BUILD_VERSION").to_string(), - build_git_hash: env!("BUILD_GIT_HASH").to_string(), - build_timestamp: env!("BUILD_TIMESTAMP").to_string(), - uptime_ms: get_uptime_ms(), - watcher_running: check_process_running("watcher"), - worker_running: check_process_running("worker"), - auto_pipeline_enabled: crate::core::config::get_auto_pipeline_enabled(), - watcher_auto_register_enabled: crate::core::config::get_watcher_auto_register(), - system_timezone: crate::core::config::SYSTEM_TIMEZONE.clone(), - }) -} - -async fn health_detailed(State(state): State) -> Json { - let postgres = check_postgres().await; - let redis = check_redis().await; - let qdrant = check_qdrant().await; - let mongodb = check_mongodb(&state.mongo_cache).await; - - let overall_status = if postgres.status == "ok" - && redis.status == "ok" - && qdrant.status == "ok" - && mongodb.status == "ok" - { - "ok" - } else { - "degraded" - }; - - let sys = SystemResources::check(); - - let scripts_base = crate::core::config::SCRIPTS_DIR.clone(); - let scripts_dir = std::path::Path::new(&scripts_base); - let scripts_path = scripts_dir.to_path_buf(); - let models_path = std::path::PathBuf::from("/Users/accusys/momentry_core/models"); - - let py_files = std::fs::read_dir(&scripts_path) - .map(|d| { - d.filter_map(|e| e.ok()) - .filter(|e| e.path().extension().map(|x| x == "py").unwrap_or(false)) - .count() - }) - .unwrap_or(0); - - let total_model_files = std::fs::read_dir(&models_path) - .map(|d| { - d.filter_map(|e| e.ok()) - .filter(|e| { - let p = e.path(); - let ext = p.extension().and_then(|x| x.to_str()).unwrap_or(""); - matches!(ext, "pt" | "mlpackage" | "gguf" | "bin" | "onnx") - }) - .count() - }) - .unwrap_or(0); - - let check_script = |name: &str| -> bool { - let candidate = scripts_path.join(name); - candidate.exists() - }; - - let check_python_module = |module: &str| -> bool { - std::process::Command::new(&*crate::core::config::PYTHON_PATH) - .arg("-c") - .arg(format!("import {}", module)) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - }; - - // SHA256 checksum verification against checksums.sha256 manifest - let checksums_path = scripts_path.join("checksums.sha256"); - let scripts_integrity = match std::fs::read_to_string(&checksums_path) { - Ok(content) => { - let mut matched = 0usize; - let mut total = 0usize; - for line in content.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let parts: Vec<&str> = line.splitn(2, ' ').collect(); - if parts.len() < 2 { - continue; - } - let expected_hash = parts[0]; - let file_path = parts[1].trim_start(); - total += 1; - let full_path = scripts_path.join(file_path); - if full_path.exists() { - if let Ok(actual) = std::process::Command::new("shasum") - .arg("-a") - .arg("256") - .arg(&full_path) - .output() - { - let out = String::from_utf8_lossy(&actual.stdout); - let actual_hash = out.split(' ').next().unwrap_or("").to_string(); - if actual_hash == expected_hash { - matched += 1; - } - } - } - } - ScriptIntegrity { - matched, - total, - ok: matched == total, - } - } - Err(_) => ScriptIntegrity { - matched: 0, - total: 0, - ok: false, - }, - }; - - Json(DetailedHealthResponse { - ip: get_host(), - port: get_port(), - status: overall_status.to_string(), - version: env!("BUILD_VERSION").to_string(), - build_git_hash: env!("BUILD_GIT_HASH").to_string(), - build_timestamp: env!("BUILD_TIMESTAMP").to_string(), - uptime_ms: get_uptime_ms(), - services: ServiceHealth { - postgres, - redis, - qdrant, - mongodb, - }, - resources: ResourceStatus { - cpu_used_percent: sys.cpu_used_percent, - cpu_idle_percent: sys.cpu_idle_percent, - memory_available_mb: sys.memory_available_mb, - memory_total_mb: sys.memory_total_mb, - memory_used_percent: sys.memory_used_percent, - gpu_available: sys.gpu_available, - gpu_utilization: sys.gpu_utilization, - gpu_memory_used_pct: sys.gpu_memory_used_pct, - }, - pipeline: PipelineStatus { - scripts_ready: scripts_path.is_dir(), - scripts_count: py_files, - scripts_integrity, - processors: ProcessorInventory { - asr: check_script("asr_processor.py"), - yolo: check_script("yolo_processor.py"), - face: check_script("face_processor.py"), - pose: check_script("pose_processor.py"), - ocr: check_script("ocr_processor.py"), - cut: check_script("cut_processor.py"), - caption: check_script("caption_processor.py"), - scene: check_script("scene_classifier.py"), - story: check_script("story_processor.py"), - asrx: check_script("asrx_processor.py"), - probe: check_script("probe_file.py"), - visual_chunk: check_script("visual_chunk_processor.py"), - total_py_files: py_files, - }, - models_ready: models_path.is_dir(), - models_count: total_model_files, - ffmpeg: std::process::Command::new("which") - .arg("ffmpeg") - .output() - .map(|o| o.status.success()) - .unwrap_or(false), - embedding_server: check_http("http://127.0.0.1:11436/health").await, - gdino_api: check_http("http://127.0.0.1:8080/health").await, - llm: check_http("http://127.0.0.1:8082/health").await, - rsync: check_rsync().await, - watcher_running: check_process_running("watcher"), - worker_running: check_process_running("worker"), - }, - schema: check_schema_migrations(state.db.pool()).await, - identities: { - let identities_root = - std::path::Path::new(&*crate::core::config::OUTPUT_DIR).join("identities"); - let directory_exists = identities_root.is_dir(); - let files_count = crate::core::identity::storage::count_identity_files(); - let index_ok = crate::core::identity::storage::read_index().is_ok(); - let db_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities") - .fetch_one(state.db.pool()) - .await - .unwrap_or(0); - IdentityHealth { - directory_exists, - files_count, - index_ok, - db_count, - synced: directory_exists && files_count as i64 == db_count, - } - }, - integrations: IntegrationHealth { - tmdb: crate::core::tmdb::status::quick_status(), - }, - config: ConfigHealth { - cache_enabled: crate::core::config::get_cache_enabled(), - auto_pipeline_enabled: crate::core::config::get_auto_pipeline_enabled(), - watcher_auto_register_enabled: crate::core::config::get_watcher_auto_register(), - system_timezone: crate::core::config::SYSTEM_TIMEZONE.clone(), - }, - }) -} - -async fn health_consistency( - State(state): State, -) -> Result, (StatusCode, String)> { - let report = crate::core::health_agent::run_consistency_checks(&state.db).await; - if report.checks.iter().any(|c| c.count > 0) { - tracing::warn!( - "[HEALTH] Consistency issues found: {}", - report - .checks - .iter() - .filter(|c| c.count > 0) - .map(|c| format!("{}={}", c.check, c.count)) - .collect::>() - .join(", ") - ); - } - Ok(Json(report)) -} - -async fn login( - State(state): State, - Json(req): Json, -) -> Result, (StatusCode, Json)> { - // Try users table first, fall back to legacy demo/demo - let (user_id, username, role) = 'resolve: { - // Step 1: Check local users table - if let Ok(Some((uid, uname, pw_hash, role_str))) = - state.db.get_user_by_username(&req.username).await - { - if crate::core::auth::password::verify_password(&req.password, &pw_hash) { - break 'resolve (uid, uname, role_str); - } - // Password mismatch — log and continue to SFTPGo - tracing::debug!( - "[LOGIN] Local password mismatch for {}, trying SFTPGo", - &req.username - ); - } - - // Step 3: Legacy demo/demo fallback - if req.username == "demo" && req.password == "demo" { - // Get actual user id from DB if exists - let uid = state - .db - .get_user_by_username("demo") - .await - .ok() - .flatten() - .map(|(id, _, _, _)| id) - .unwrap_or(0); - break 'resolve (uid, "demo".to_string(), "user".to_string()); - } - - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "success": false, "message": "Invalid username or password" - })), - )); - }; - - // Create JWT - let jwt_token = crate::core::auth::jwt::create_jwt(user_id, &username, &role).map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "success": false, "message": format!("JWT creation failed: {}", e) - })), - ) - })?; - - // Create session - let session_id = uuid::Uuid::new_v4().to_string().replace('-', ""); - state - .db - .create_session(&session_id, user_id, &DEMO_USER_API_KEY, 24) - .await - .ok(); - - // Update last_login if real user - if user_id > 0 { - state.db.update_last_login(user_id).await.ok(); - } - - // Build response with session cookie - let body = serde_json::json!({ - "success": true, - "jwt": jwt_token, - "api_key": DEMO_USER_API_KEY.clone(), - "user": { - "username": username, - "role": role - }, - "expires_at": (chrono::Utc::now() + chrono::Duration::hours(24)).to_rfc3339() - }); - - let json_body = axum::body::Body::from(serde_json::to_string(&body).unwrap_or_default()); - let response = axum::response::Response::builder() - .header("Content-Type", "application/json") - .header( - "Set-Cookie", - format!( - "session_id={}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400", - session_id - ), - ) - .body(json_body) - .unwrap(); - - Ok(response) -} - -async fn logout( - State(state): State, - headers: axum::http::HeaderMap, -) -> Json { - // Extract session_id from cookie - let cookies = crate::api::middleware::extract_cookies(&headers); - if let Some(sid) = cookies - .iter() - .find(|(k, _)| k == "session_id") - .map(|(_, v)| v.clone()) - { - state.db.delete_session(&sid).await.ok(); - } - - Json(serde_json::json!({ - "success": true, - "message": "Logged out" - })) -} - -async fn check_postgres() -> ServiceStatus { - let start = Instant::now(); - match PostgresDb::init().await { - Ok(db) => match db.list_videos(1, 0).await { - Ok(_) => ServiceStatus { - status: "ok".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: None, - }, - Err(e) => ServiceStatus { - status: "error".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(e.to_string()), - }, - }, - Err(e) => ServiceStatus { - status: "error".to_string(), - latency_ms: None, - error: Some(e.to_string()), - }, - } -} - -async fn check_redis() -> ServiceStatus { - let start = Instant::now(); - match RedisClient::new() { - Ok(redis) => match redis.get_conn().await { - Ok(mut conn) => { - let result: Result = redis::cmd("PING").query_async(&mut conn).await; - match result { - Ok(_) => ServiceStatus { - status: "ok".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: None, - }, - Err(e) => ServiceStatus { - status: "error".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(e.to_string()), - }, - } - } - Err(e) => ServiceStatus { - status: "error".to_string(), - latency_ms: None, - error: Some(e.to_string()), - }, - }, - Err(e) => ServiceStatus { - status: "error".to_string(), - latency_ms: None, - error: Some(e.to_string()), - }, - } -} - -async fn check_qdrant() -> ServiceStatus { - let start = Instant::now(); - let base_url = - std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6333".to_string()); - let api_key = - std::env::var("QDRANT_API_KEY").unwrap_or_else(|_| "Test3200Test3200Test3200".to_string()); - let url = format!("{}/collections", base_url); - - let client = reqwest::Client::new(); - match client - .get(&url) - .header("api-key", api_key) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(resp) if resp.status().is_success() => ServiceStatus { - status: "ok".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: None, - }, - Ok(resp) => ServiceStatus { - status: "error".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(format!("HTTP {}", resp.status())), - }, - Err(e) => ServiceStatus { - status: "error".to_string(), - latency_ms: None, - error: Some(e.to_string()), - }, - } -} - -async fn check_mongodb(cache: &MongoCache) -> ServiceStatus { - let start = Instant::now(); - match cache.health_check().await { - Ok(_) => ServiceStatus { - status: "ok".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: None, - }, - Err(e) => ServiceStatus { - status: "error".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(e.to_string()), - }, - } -} - -fn parse_required_migrations() -> Vec { - let raw = env!("REQUIRED_MIGRATIONS"); - if raw.is_empty() { - return vec![]; - } - raw.split(',') - .filter_map(|entry| { - let mut parts = entry.splitn(2, ':'); - let filename = parts.next()?.trim().to_string(); - let checksum = parts.next()?.trim().to_string(); - if filename.is_empty() || checksum.is_empty() { - return None; - } - Some(MigrationInfo { filename, checksum }) - }) - .collect() -} - -async fn check_schema_migrations(pool: &sqlx::PgPool) -> SchemaHealth { - let required = parse_required_migrations(); - - // Check if table exists - let table_exists: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'schema_migrations')", - ) - .fetch_one(pool) - .await - .unwrap_or(false); - - if !table_exists { - return SchemaHealth { - table_exists: false, - applied: vec![], - required, - ok: false, - }; - } - - // Get applied migrations - let applied: Vec = sqlx::query_as::<_, (String, String)>( - "SELECT filename, checksum FROM schema_migrations ORDER BY id", - ) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .map(|(filename, checksum)| MigrationInfo { filename, checksum }) - .collect(); - - // Compare: every required migration must be in applied with matching checksum - let ok = required.iter().all(|req| { - applied - .iter() - .any(|app| app.filename == req.filename && app.checksum == req.checksum) - }); - - SchemaHealth { - table_exists: true, - applied, - required, - ok, - } -} - -async fn check_rsync() -> ServiceStatus { - let start = Instant::now(); - let paths = [ - std::path::Path::new("/Users/accusys/bin/rsync"), - std::path::Path::new("/opt/homebrew/bin/rsync"), - ]; - for p in &paths { - if p.exists() { - return ServiceStatus { - status: "ok".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: None, - }; - } - } - ServiceStatus { - status: "error".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some("rsync not found (built from source expected at ~/bin/rsync)".to_string()), - } -} - -async fn check_binary(name: &str) -> ServiceStatus { - let start = Instant::now(); - match std::process::Command::new("which").arg(name).output() { - Ok(output) if output.status.success() => ServiceStatus { - status: "ok".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: None, - }, - _ => ServiceStatus { - status: "error".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(format!("{} not found in PATH", name)), - }, - } -} - -fn check_process_running(name: &str) -> bool { - let patterns: &[&str] = match name { - "watcher" => &[ - "target/release/momentry watcher", - "target/debug/momentry_playground watcher", - ], - "worker" => &[ - "target/release/momentry worker", - "target/debug/momentry_playground worker", - ], - _ => return false, - }; - for pattern in patterns { - if let Ok(o) = std::process::Command::new("pgrep") - .arg("-f") - .arg(pattern) - .output() - { - if o.status.success() { - return true; - } - } - } - false -} - -async fn check_http(url: &str) -> ServiceStatus { - let start = Instant::now(); - match reqwest::get(url).await { - Ok(resp) => { - if resp.status().is_success() { - ServiceStatus { - status: "ok".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: None, - } - } else { - ServiceStatus { - status: "error".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(format!("HTTP {}", resp.status())), - } - } - } - Err(e) => ServiceStatus { - status: "error".to_string(), - latency_ms: Some(start.elapsed().as_millis() as u64), - error: Some(e.to_string()), - }, - } -} - -fn generate_query_hash(query: &str, uuid: Option<&str>, limit: usize) -> String { - let data = serde_json::json!({ - "query": query, - "uuid": uuid, - "limit": limit, - }); - let mut hasher = Sha256::new(); - hasher.update(data.to_string().as_bytes()); - format!("{:x}", hasher.finalize())[..16].to_string() -} - -fn generate_visual_search_hash( - uuid: &str, - criteria: &visual_chunk_search::VisualChunkSearchCriteria, -) -> String { - let data = serde_json::json!({ - "uuid": uuid, - "criteria": criteria, - }); - let mut hasher = Sha256::new(); - hasher.update(data.to_string().as_bytes()); - format!("{:x}", hasher.finalize())[..16].to_string() -} - -/// Compute SHA256 for dedup. Returns hex string. -fn sha256_file(path: &std::path::Path) -> Option { - crate::core::storage::content_hash::compute_sha256(path).ok() -} - -/// Resolve name conflict: if file_name collides with existing but content differs, -/// append ` (N)` suffix. Returns the resolved file_name. -async fn resolve_filename(db: &PostgresDb, file_name: &str, content_hash: &str) -> String { - let table = schema::table_name("videos"); - let base = file_name.to_string(); - let dot_pos = base.rfind('.'); - let (stem, ext) = match dot_pos { - Some(p) => (base[..p].to_string(), base[p..].to_string()), - None => (base.clone(), String::new()), - }; - let mut candidate = base.clone(); - let mut attempt = 0usize; - loop { - // Check if candidate name exists with a DIFFERENT hash (same content = OK) - let conflict: Option = sqlx::query_scalar( - &format!("SELECT file_uuid FROM {} WHERE file_name = $1 AND (content_hash IS DISTINCT FROM $2 OR content_hash IS NULL)", table) - ) - .bind(&candidate) - .bind(content_hash) - .fetch_optional(db.pool()) - .await - .unwrap_or(None); - if conflict.is_none() { - return candidate; - } - attempt += 1; - candidate = format!("{} ({}){}", stem, attempt, ext); - } -} - -/// 註冊單一檔案(內部函數,不處理 pattern) -async fn register_single_file( - state: &AppState, - file_path: &str, - _user_id: Option, - provided_hash: Option, -) -> RegisterFileResponse { - tracing::info!("[REGISTER] Starting registration for: {}", file_path); - - let path = std::path::Path::new(file_path); - if !path.exists() { - return RegisterFileResponse { - success: false, - file_uuid: String::new(), - file_name: String::new(), - file_path: file_path.to_string(), - file_type: None, - duration: 0.0, - width: 0, - height: 0, - fps: 0.0, - total_frames: 0, - registration_time: None, - already_exists: false, - message: format!("File not found: {}", file_path), - }; - } - - let canonical_path = path - .canonicalize() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| file_path.to_string()); - - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - - let db = match PostgresDb::init().await { - Ok(db) => db, - Err(e) => { - tracing::error!("[REGISTER] DB init failed: {}", e); - return RegisterFileResponse { - success: false, - file_uuid: String::new(), - file_name, - file_path: canonical_path, - file_type: None, - duration: 0.0, - width: 0, - height: 0, - fps: 0.0, - total_frames: 0, - registration_time: None, - already_exists: false, - message: format!("DB init failed: {}", e), - }; - } - }; - - // Step 1: Try to load pre-computed data from .pre.json - let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); - - let birthday = std::fs::metadata(&path) - .ok() - .and_then(|m| m.modified().ok()) - .map(|t| { - let secs = t - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - chrono::DateTime::from_timestamp(secs as i64, 0) - .map(|dt| dt.to_rfc3339()) - .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()) - }) - .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); - - let mac_address = crate::core::storage::uuid::get_mac_address(); - let pre_file_uuid = crate::core::storage::uuid::compute_birth_uuid( - &mac_address, - &birthday, - &canonical_path, - &file_name, - ); - let pre_path = std::path::Path::new(&output_dir).join(format!("{}.pre.json", pre_file_uuid)); - let pre_data: Option = std::fs::read_to_string(&pre_path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()); - - // Extract content_hash from pre.json or compute fresh - let (content_hash, birthday, _pre_file_uuid) = if let Some(ref pre) = pre_data { - let h = pre - .get("content_hash") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let b = pre - .get("birthday") - .and_then(|v| v.as_str()) - .unwrap_or(&birthday) - .to_string(); - let u = pre - .get("file_uuid") - .and_then(|v| v.as_str()) - .unwrap_or(&pre_file_uuid) - .to_string(); - (h, b, u) - } else { - let h = provided_hash - .filter(|h| !h.is_empty()) - .unwrap_or_else(|| sha256_file(&path).unwrap_or_default()); - (h, birthday, pre_file_uuid) - }; - // Recompute UUID with the resolved birthday - let file_uuid = crate::core::storage::uuid::compute_birth_uuid( - &mac_address, - &birthday, - &canonical_path, - &file_name, - ); - tracing::info!( - "[REGISTER] UUID inputs: mac={} birthday={} path={} name={} pre_found={} → {}", - mac_address, - birthday, - canonical_path, - file_name, - pre_data.is_some(), - file_uuid - ); - - // Step 2: Hash check — same content = already registered (regardless of name) - let videos_table = schema::table_name("videos"); - if !content_hash.is_empty() { - if let Ok(Some(existing_uuid)) = sqlx::query_scalar::<_, String>(&format!( - "SELECT file_uuid FROM {} WHERE content_hash = $1 LIMIT 1", - videos_table - )) - .bind(&content_hash) - .fetch_optional(db.pool()) - .await - { - tracing::info!( - "[REGISTER] Content hash collision → already registered: {}", - existing_uuid - ); - let existing_info: Option<(String, String, f64, i32, i32, f64, i64, Option)> = sqlx::query_as( - &format!("SELECT file_name, file_path, duration, width, height, fps, total_frames, registration_time::text FROM {} WHERE file_uuid = $1", videos_table) - ).bind(&existing_uuid).fetch_optional(db.pool()).await.unwrap_or(None); - if let Some((ename, epath, dur, w, h, f, tf, rt)) = existing_info { - return RegisterFileResponse { - success: true, - file_uuid: existing_uuid, - file_name: ename, - file_path: epath.clone(), - file_type: None, - duration: dur, - width: w as u32, - height: h as u32, - fps: f, - total_frames: tf as u64, - registration_time: rt, - already_exists: true, - message: format!("Content already registered: {}", epath), - }; - } - // Fallback: content_hash matched but full info query failed - return RegisterFileResponse { - success: true, - file_uuid: existing_uuid, - file_name: file_name.clone(), - file_path: canonical_path.clone(), - file_type: None, - duration: 0.0, - width: 0, - height: 0, - fps: 0.0, - total_frames: 0, - registration_time: None, - already_exists: true, - message: "Content already registered (identical file)".to_string(), - }; - } - } - - // Step 3: Name check — same name but different content → auto-rename - let final_name = resolve_filename(&db, &file_name, &content_hash).await; - - // Step 4: Compute UUID using birthday from pre.json or file creation time (never DB registration_time) - let mac_address = crate::core::storage::uuid::get_mac_address(); - let file_uuid = crate::core::storage::uuid::compute_birth_uuid( - &mac_address, - &birthday, - &canonical_path, - &final_name, - ); - - // Step 5: Unified probe — use pre.json, otherwise run unified_probe() - let temp_probe_json: serde_json::Value = if let Some(ref pre) = pre_data { - pre.get("probe_json").cloned().unwrap_or_default() - } else { - let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry_core/scripts".to_string()); - let python_path = std::env::var("MOMENTRY_PYTHON_PATH") - .unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string()); - crate::core::probe::unified::unified_probe(&path, &scripts_dir, &python_path).await - }; - let probe_json = Some(temp_probe_json.clone()); - - let has_video = temp_probe_json - .get("streams") - .and_then(|s| s.as_array()) - .map_or(false, |streams| { - streams - .iter() - .any(|st| st.get("codec_type").and_then(|c| c.as_str()) == Some("video")) - }); - let has_audio = temp_probe_json - .get("streams") - .and_then(|s| s.as_array()) - .map_or(false, |streams| { - streams - .iter() - .any(|st| st.get("codec_type").and_then(|c| c.as_str()) == Some("audio")) - }); - - let final_file_type = if has_video { - Some("video".to_string()) - } else if has_audio { - Some("audio".to_string()) - } else { - Some( - temp_probe_json - .get("format") - .and_then(|f| f.get("file_type")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(), - ) - }; - - let duration = temp_probe_json - .get("format") - .and_then(|f| { - let src = if has_video { f.get("duration") } else { None }; - src.and_then(|v| v.as_str()) - .and_then(|s| s.parse::().ok()) - }) - .unwrap_or(0.0); - let mut width = 0u32; - let mut height = 0u32; - let mut fps = 0.0; - let mut total_frames = 0u64; - if let Some(streams) = temp_probe_json.get("streams").and_then(|s| s.as_array()) { - if let Some(s) = streams - .iter() - .find(|st| st.get("codec_type").and_then(|c| c.as_str()) == Some("video")) - { - width = s.get("width").and_then(|v| v.as_i64()).unwrap_or(0) as u32; - height = s.get("height").and_then(|v| v.as_i64()).unwrap_or(0) as u32; - if let Some(fps_str) = s.get("r_frame_rate").and_then(|v| v.as_str()) { - if let Some((num, den)) = fps_str.split_once('/') { - if let (Ok(n), Ok(d)) = (num.parse::(), den.parse::()) { - if d > 0.0 { - fps = n / d; - } - } - } - } - total_frames = s - .get("nb_frames") - .and_then(|v| v.as_str()) - .and_then(|s| s.parse().ok()) - .unwrap_or((duration * fps) as u64); - } - } - - let videos_table = schema::table_name("videos"); - let status = "registered"; - let _ = sqlx::query(&format!( - "INSERT INTO {} (file_uuid, file_path, file_name, file_type, duration, width, height, fps, probe_json, status, content_hash, registration_time) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW()) ON CONFLICT (file_uuid) DO UPDATE SET file_path = EXCLUDED.file_path, file_name = EXCLUDED.file_name, status = EXCLUDED.status, content_hash = EXCLUDED.content_hash", - videos_table - )) - .bind(&file_uuid).bind(&canonical_path).bind(&final_name).bind(&final_file_type) - .bind(duration).bind(width as i32).bind(height as i32).bind(fps) - .bind(&probe_json).bind(status).bind(&content_hash) - .execute(db.pool()).await; - - // 若是 video 類型,同步執行 CUT + Scene 分類 - let mut cut_done = false; - let mut scene_done = false; - if has_video && total_frames > 0 && fps > 0.0 { - let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); - let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry_core/scripts".to_string()); - let python_path = std::env::var("MOMENTRY_PYTHON_PATH") - .unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string()); - - // CUT: 場景檢測(PySceneDetect,0.08s 即可完成) - let cut_path = std::path::Path::new(&output_dir).join(format!("{}.cut.json", file_uuid)); - if !cut_path.exists() { - let cut_script = std::path::Path::new(&scripts_dir).join("cut_processor.py"); - if cut_script.exists() { - let cut_output = std::process::Command::new(&python_path) - .arg(&cut_script) - .arg(&canonical_path) - .arg(&cut_path) - .output(); - if let Ok(output) = cut_output { - if output.status.success() { - cut_done = true; - tracing::info!("[REGISTER] CUT completed for {}", file_uuid); - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - tracing::error!("[REGISTER] CUT failed for {}: {}", file_uuid, stderr); - } - } else { - tracing::error!("[REGISTER] CUT execution error for {}", file_uuid); - } - } - } else { - cut_done = true; - // 讀取現有 CUT JSON 取得場景數 - if let Ok(content) = std::fs::read_to_string(&cut_path) { - if let Ok(cut_data) = serde_json::from_str::(&content) { - let scenes = cut_data - .get("scenes") - .and_then(|s| s.as_array()) - .map(|a| a.len() as i32) - .unwrap_or(0); - tracing::info!( - "[REGISTER] CUT already exists: {} scenes for {}", - scenes, - file_uuid - ); - } - } - } - - // Scene: 場景分類(MIT Places365,取樣間隔 2s) - let scene_path = - std::path::Path::new(&output_dir).join(format!("{}.scene.json", file_uuid)); - if !scene_path.exists() { - let scene_script = std::path::Path::new(&scripts_dir).join("scene_classifier.py"); - if scene_script.exists() { - let scene_output = std::process::Command::new(&python_path) - .arg(&scene_script) - .arg(&canonical_path) - .arg(&scene_path) - .arg("--sample-interval") - .arg("2") - .output(); - if let Ok(output) = scene_output { - if output.status.success() { - scene_done = true; - tracing::info!( - "[REGISTER] Scene classification completed for {}", - file_uuid - ); - } - } - } - } else { - scene_done = true; - } - } - - // 更新 DB: cut_done, scene_done, audio_tracks - let audio_tracks: Vec = temp_probe_json - .get("streams") - .and_then(|s| s.as_array()) - .map_or(vec![], |streams| { - streams - .iter() - .filter(|st| st.get("codec_type").and_then(|c| c.as_str()) == Some("audio")) - .map(|st| { - serde_json::json!({ - "index": st.get("index").and_then(|v| v.as_i64()), - "codec": st.get("codec_name").and_then(|v| v.as_str()), - "channels": st.get("channels").and_then(|v| v.as_i64()), - "sample_rate": st.get("sample_rate").and_then(|v| v.as_str()), - "language": st.get("tags").and_then(|t| t.get("language")), - }) - }) - .collect() - }); - let audio_tracks_json = serde_json::to_value(&audio_tracks).ok(); - // 計算 cut_count 與 cut_max_duration - let cut_path = std::path::Path::new( - &std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()), - ) - .join(format!("{}.cut.json", file_uuid)); - let mut cut_count = 0i32; - let mut cut_max_duration = 0.0f64; - if let Ok(content) = std::fs::read_to_string(&cut_path) { - if let Ok(cut_data) = serde_json::from_str::(&content) { - if let Some(scenes) = cut_data.get("scenes").and_then(|s| s.as_array()) { - cut_count = scenes.len() as i32; - cut_max_duration = scenes - .iter() - .filter_map(|s| { - let start = s.get("start_time").and_then(|v| v.as_f64()).unwrap_or(0.0); - let end = s.get("end_time").and_then(|v| v.as_f64()).unwrap_or(0.0); - if end > start { - Some(end - start) - } else { - None - } - }) - .fold(0.0_f64, f64::max); - } - } - } - let videos_table = schema::table_name("videos"); - let _ = sqlx::query( - &format!("UPDATE {} SET cut_done = $1, scene_done = $2, audio_tracks = $3, cut_count = $4, cut_max_duration = $5 WHERE file_uuid = $6", videos_table) - ) - .bind(cut_done).bind(scene_done).bind(&audio_tracks_json).bind(cut_count).bind(cut_max_duration).bind(&file_uuid) - .execute(db.pool()).await; - - // 寫入 {file_uuid}.probe.json(含音軌資訊) - if let Some(json_val) = probe_json { - let probe_path = std::path::Path::new( - &std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()), - ) - .join(format!("{}.probe.json", file_uuid)); - let json_str = serde_json::to_string_pretty(&json_val).unwrap_or_default(); - let _ = std::fs::write(&probe_path, json_str); - } - - // Auto-run offline TMDb prefetch + probe for video files (no API calls needed) - if final_file_type.as_deref() == Some("video") { - let auto_file_uuid = file_uuid.clone(); - let auto_db = db.clone(); - tokio::spawn(async move { - // Step 1: Offline prefetch (reads local identity files) - let identities_dir = - std::path::Path::new(&*crate::core::config::OUTPUT_DIR).join("identities"); - let index_path = identities_dir.join("_index.json"); - let cache_path = format!( - "{}/{}.tmdb.json", - *crate::core::config::OUTPUT_DIR, - auto_file_uuid - ); - let cache_file = std::path::Path::new(&cache_path); - - if index_path.exists() && cache_file.exists() { - tracing::info!( - "[AUTO-TMDB] Offline cache found for {}, running probe", - auto_file_uuid - ); - if let Err(e) = - crate::core::tmdb::probe::probe_from_cache(&auto_db, &auto_file_uuid).await - { - tracing::warn!("[AUTO-TMDB] Probe failed for {}: {}", auto_file_uuid, e); - } else { - tracing::info!("[AUTO-TMDB] Probe completed for {}", auto_file_uuid); - } - } else { - tracing::info!( - "[AUTO-TMDB] No offline cache for {}, skipping", - auto_file_uuid - ); - } - }); - } - - RegisterFileResponse { - success: true, - file_uuid, - file_name, - file_path: canonical_path, - file_type: final_file_type, - duration, - width, - height, - fps, - total_frames, - registration_time: None, - already_exists: false, - message: "File registered successfully".to_string(), - } -} - -async fn register_file( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let file_path = req.file_path.clone(); - let pattern = req.pattern; - - // 如果有 pattern,掃描目錄下所有符合的檔案逐一註冊 - if let Some(ref pat) = pattern { - let dir = std::path::Path::new(&file_path); - if !dir.is_dir() { - return Ok(Json(RegisterFileResponse { - success: false, - file_uuid: String::new(), - file_name: String::new(), - file_path: file_path.clone(), - file_type: None, - duration: 0.0, - width: 0, - height: 0, - fps: 0.0, - total_frames: 0, - registration_time: None, - already_exists: false, - message: format!( - "Pattern requires a directory, but path is not a dir: {}", - file_path - ), - })); - } - let re = regex::Regex::new(pat).map_err(|e| { - tracing::error!("[REGISTER] Invalid regex pattern: {}", e); - StatusCode::BAD_REQUEST - })?; - - let mut registered = 0u32; - let mut failed = 0u32; - let mut skipped = 0u32; - - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let entry_path = entry.path(); - if !entry_path.is_file() { - continue; - } - let fname = entry_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); - if !re.is_match(fname) { - continue; - } - - let result = register_single_file( - &state, - &entry_path.to_string_lossy().to_string(), - req.user_id, - None, - ) - .await; - if result.success { - registered += 1; - } else if result.already_exists { - skipped += 1; - } else { - failed += 1; - } - } - } - - return Ok(Json(RegisterFileResponse { - success: true, - file_uuid: format!("batch_{}_registered_{}_failed", registered, failed), - file_name: format!( - "{} files registered, {} skipped, {} failed", - registered, skipped, failed - ), - file_path: file_path.clone(), - file_type: None, - duration: 0.0, - width: 0, - height: 0, - fps: 0.0, - total_frames: 0, - registration_time: None, - already_exists: false, - message: format!( - "Batch register: {} registered, {} skipped, {} failed", - registered, skipped, failed - ), - })); - } - - // 單一檔案註冊 - let resp = register_single_file(&state, &file_path, req.user_id, req.content_hash).await; - - // Auto-trigger pipeline for newly registered video files - if resp.success - && !resp.already_exists - && resp.file_type.as_deref() == Some("video") - && crate::core::config::get_auto_pipeline_enabled() - { - let auto_uuid = resp.file_uuid.clone(); - let auto_state = state.clone(); - tokio::spawn(async move { - // Brief delay to let DB settle, then trigger processing - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let video_path: Option = sqlx::query_scalar(&format!( - "SELECT file_path FROM {} WHERE file_uuid = $1", - schema::table_name("videos") - )) - .bind(&auto_uuid) - .fetch_optional(auto_state.db.pool()) - .await - .ok() - .flatten(); - - if let Some(ref vp) = video_path { - if let Ok(job) = auto_state.db.create_monitor_job(&auto_uuid, Some(vp)).await { - tracing::info!("[AUTO-PIPELINE] Job {} created for {}", job.id, auto_uuid); - // Initialize processing status with all processors - let all_procs: Vec<&str> = vec![ - "asr", - "cut", - "yolo", - "ocr", - "face", - "pose", - "asrx", - "visual_chunk", - "5w1h", - ]; - let total = sqlx::query_scalar::<_, i64>(&format!( - "SELECT COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1", - schema::table_name("videos") - )) - .bind(&auto_uuid) - .fetch_one(auto_state.db.pool()) - .await - .unwrap_or(0); - let _ = auto_state - .db - .init_processing_status(&auto_uuid, all_procs, total as u64) - .await; - let _ = sqlx::query(&format!( - "UPDATE {} SET status = 'processing' WHERE file_uuid = $1", - schema::table_name("videos") - )) - .bind(&auto_uuid) - .execute(auto_state.db.pool()) - .await; - tracing::info!("[AUTO-PIPELINE] Pipeline triggered for {}", auto_uuid); - } - } - }); - } - - return Ok(Json(resp)); -} - -async fn probe_by_uuid( - State(state): State, - Path(file_uuid): Path, -) -> Result, (StatusCode, Json)> { - let table = schema::table_name("videos"); - let row: Option<(String, String)> = sqlx::query_as(&format!( - "SELECT file_name, file_path FROM {} WHERE file_uuid = $1", - table - )) - .bind(&file_uuid) - .fetch_optional(state.db.pool()) - .await - .map_err(|e| { - tracing::error!("DB error fetching video: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("DB error: {}", e), "file_uuid": file_uuid})), - ) - })?; - - let (file_name, path) = row.ok_or_else(|| { - tracing::warn!("Video not found: {}", file_uuid); - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "Video not found", "file_uuid": file_uuid})), - ) - })?; - - // 2. Check for cached probe.json - let probe_path = format!( - "{}/{}.probe.json", - crate::core::config::OUTPUT_DIR.as_str(), - file_uuid - ); - - let (probe_result, cached) = if let Ok(content) = std::fs::read_to_string(&probe_path) { - tracing::info!("Using cached probe.json: {}", probe_path); - let result: crate::core::probe::ProbeResult = - serde_json::from_str(&content).map_err(|e| { - tracing::error!("Failed to parse cached probe.json: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to parse cached probe.json: {}", e), "file_uuid": file_uuid})), - ) - })?; - (result, true) - } else { - // Check if file still exists before running ffprobe - let file_path = std::path::Path::new(&path); - if !file_path.exists() { - tracing::error!("File not found at path: {}", path); - return Err(( - StatusCode::NOT_FOUND, - Json( - serde_json::json!({"error": "File does not exist at registered path", "file_uuid": file_uuid, "file_path": path}), - ), - )); - } - - tracing::info!("Running ffprobe for: {}", path); - let result = crate::core::probe::probe_video(&path).map_err(|e| { - tracing::error!("ffprobe failed: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("ffprobe failed: {}", e), "file_uuid": file_uuid, "file_path": path})), - ) - })?; - - // Save probe.json to OUTPUT_DIR - let file_manager = FileManager::new(std::path::PathBuf::from( - crate::core::config::OUTPUT_DIR.as_str(), - )); - let json_str = serde_json::to_string(&result).map_err(|e| { - tracing::error!("Failed to serialize probe result: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to serialize probe result: {}", e), "file_uuid": file_uuid})), - ) - })?; - file_manager - .save_json(&file_uuid, "probe", &json_str) - .map_err(|e| { - tracing::error!("Failed to save probe.json: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": format!("Failed to save probe.json: {}", e), "file_uuid": file_uuid})), - ) - })?; - - (result, false) - }; - - // 3. Extract video info - let duration = probe_result - .format - .duration - .as_ref() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - - let mut width = 0u32; - let mut height = 0u32; - let mut fps = 0.0; - - for stream in &probe_result.streams { - if stream.codec_type.as_deref() == Some("video") { - width = stream.width.unwrap_or(0); - height = stream.height.unwrap_or(0); - if let Some(fps_str) = &stream.r_frame_rate { - fps = if fps_str.contains('/') { - let parts: Vec<&str> = fps_str.split('/').collect(); - if parts.len() == 2 { - let num: f64 = parts[0].parse().unwrap_or(0.0); - let den: f64 = parts[1].parse().unwrap_or(1.0); - if den > 0.0 { - num / den - } else { - 0.0 - } - } else { - 0.0 - } - } else { - fps_str.parse().unwrap_or(0.0) - }; - } - } - } - - // 4. Calculate total_frames and update DB - let total_frames = probe_result - .streams - .iter() - .find(|s| s.codec_type.as_deref() == Some("video")) - .and_then(|s| s.nb_frames.as_ref()) - .and_then(|n| n.parse::().ok()) - .unwrap_or_else(|| (duration * fps).floor() as i64); - - // Update videos table with probe metadata - let _ = sqlx::query(&format!( - "UPDATE {} SET duration = $1, width = $2, height = $3, fps = $4, total_frames = $5 WHERE file_uuid = $6", - schema::table_name("videos") - )) - .bind(duration) - .bind(width as i32) - .bind(height as i32) - .bind(fps) - .bind(total_frames) - .bind(&file_uuid) - .execute(state.db.pool()) - .await; - - let file_size = std::fs::metadata(&path).ok().map(|m| m.len() as i64); - - Ok(Json(ProbeResponse { - file_uuid, - file_name, - file_size, - duration, - width, - height, - fps, - total_frames, - cached, - format: probe_result.format, - streams: probe_result.streams, - })) -} - -// --- P0 Core API Handlers --- - -async fn trigger_processing( - State(state): State, - Path(file_uuid): Path, - Json(req): Json, -) -> Result, (StatusCode, String)> { - let table = schema::table_name("videos"); - let asset: Option<(String, i64)> = sqlx::query_as(&format!( - "SELECT file_name, COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1", - table - )) - .bind(&file_uuid) - .fetch_optional(state.db.pool()) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("DB Error: {}", e), - ) - })?; - - let (file_name, total_frames) = - asset.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?; - - // Get video file_path - let video_path: Option = sqlx::query_scalar(&format!( - "SELECT file_path FROM {} WHERE file_uuid = $1", - table - )) - .bind(&file_uuid) - .fetch_optional(state.db.pool()) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to get video path: {}", e), - ) - })?; - - // 2. Create Monitor Job (Worker polls this table) - let monitor_job = state - .db - .create_monitor_job(&file_uuid, video_path.as_deref()) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create monitor job: {}", e), - ) - })?; - - // Update processors if specified - let processors_to_run: Vec<&str> = if let Some(procs) = &req.processors { - let table = crate::core::db::schema::table_name("monitor_jobs"); - sqlx::query(&format!( - "UPDATE {} SET processors = $1 WHERE id = $2", - table - )) - .bind(procs) - .bind(monitor_job.id) - .execute(state.db.pool()) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to update processors: {}", e), - ) - })?; - procs.iter().map(|s| s.as_str()).collect() - } else { - // Empty means all processors - vec![ - "asr", - "cut", - "yolo", - "ocr", - "face", - "pose", - "asrx", - "visual_chunk", - "5w1h", - ] - }; - - // 3. Update Asset Status - let table = schema::table_name("videos"); - sqlx::query(&format!( - "UPDATE {} SET status = 'processing' WHERE file_uuid = $1", - table - )) - .bind(&file_uuid) - .execute(state.db.pool()) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to update asset status: {}", e), - ) - })?; - - // Initialize processing_status with all processors and total_frames - state - .db - .init_processing_status(&file_uuid, processors_to_run, total_frames as u64) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to init processing status: {}", e), - ) - })?; - - tracing::info!("Job {} created for asset {}", monitor_job.id, file_uuid); - - let prefix = REDIS_KEY_PREFIX.as_str(); - let mut processor_pids: Vec = Vec::new(); - if let Ok(redis_client) = RedisClient::new() { - if let Ok(mut conn) = redis_client.get_conn().await { - for name in ["asr", "cut", "asrx", "yolo", "ocr", "face", "pose"] { - let key = format!("{}job:{}:processor:{}", prefix, file_uuid, name); - let pid: Option = redis::cmd("HGET") - .arg(&key) - .arg("pid") - .query_async(&mut conn) - .await - .ok() - .flatten(); - if let Some(p) = pid { - processor_pids.push(p); - } - } - } - } - - Ok(Json(serde_json::json!({ - "job_id": monitor_job.id, - "file_uuid": file_uuid, - "status": "PENDING", - "pids": processor_pids, - "message": format!("Processing triggered for {}", file_name) - }))) -} - -async fn download_json( - Path((file_uuid, processor)): Path<(String, String)>, -) -> Result<(StatusCode, [(String, String); 1], Vec), StatusCode> { - let output_dir = crate::core::config::OUTPUT_DIR.as_str(); - let path = std::path::Path::new(output_dir).join(format!("{}.{}.json", file_uuid, processor)); - if !path.exists() { - return Err(StatusCode::NOT_FOUND); - } - let data = std::fs::read(&path).map_err(|_| StatusCode::NOT_FOUND)?; - Ok(( - StatusCode::OK, - [("content-type".to_string(), "application/json".to_string())], - data, - )) -} - -async fn get_chunk_by_path( - Path((file_uuid, chunk_id)): Path<(String, String)>, - State(state): State, -) -> Result, StatusCode> { - let chunk = state - .db - .get_chunk_by_chunk_id_and_uuid(&chunk_id, &file_uuid) - .await - .map_err(|_| { - tracing::error!("[get_chunk_by_path] DB error: {}:{}", file_uuid, chunk_id); - StatusCode::INTERNAL_SERVER_ERROR - })? - .ok_or_else(|| { - tracing::warn!("[get_chunk_by_path] Not found: {}:{}", file_uuid, chunk_id); - StatusCode::NOT_FOUND - })?; - Ok(Json(chunk)) -} - -async fn get_asset_status( - State(state): State, - Path(uuid): Path, -) -> Result, StatusCode> { - let videos_table = schema::table_name("videos"); - let row: Option<(String, String, chrono::DateTime, String, i64)> = sqlx::query_as( - &format!( - "SELECT file_uuid, file_name, created_at AT TIME ZONE 'UTC', COALESCE(processing_status, 'REGISTERED'), COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1", - videos_table - ) - ) - .bind(&uuid) - .fetch_optional(state.db.pool()) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let (uuid, file_name, time, status, total) = row.ok_or(StatusCode::NOT_FOUND)?; - - let jobs_table = schema::table_name("monitor_jobs"); - let job: Option<(String, String, i64, i64)> = sqlx::query_as( - &format!( - "SELECT id::text, COALESCE(status, 'QUEUED'), COALESCE(processed_frames, 0), COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1 ORDER BY created_at DESC LIMIT 1", - jobs_table - ) - ) - .bind(&uuid) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten(); - - let progress = if let Some((jid, jstatus, pf, tf)) = job { - if tf > 0 && (jstatus == "RUNNING" || jstatus == "QUEUED") { - Some(( - jid, - FrameProgress { - total_frames: tf, - processed_frames: pf, - progress_percent: (pf as f64 / tf as f64) * 100.0, - }, - )) - } else { - None - } - } else { - None - }; - - Ok(Json(AssetStatusResponse { - uuid, - file_name, - registration_time: time.to_rfc3339(), - processing_status: status, - current_job_id: progress.as_ref().map(|(id, _)| id.clone()), - frame_progress: progress.map(|(_, p)| p), - })) -} - -async fn get_job_status( - State(state): State, - Path(job_id): Path, -) -> Result, StatusCode> { - let jobs_table = schema::table_name("monitor_jobs"); - let row: Option<(String, String, String, String, Option, i64, i64)> = sqlx::query_as( - &format!( - "SELECT j.id::text, j.file_uuid, COALESCE(j.rule, 'unknown'), COALESCE(j.status, 'QUEUED'), j.assigned_processor_id::text, j.processed_frames, j.total_frames FROM {} j WHERE j.id = $1::uuid", - jobs_table - ) - ) - .bind(&job_id) - .fetch_optional(state.db.pool()) - .await - .map_err(|e| { - eprintln!("DB Error in get_job_status: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let (id, asset, rule, status, proc_id, pf, tf) = row.ok_or(StatusCode::NOT_FOUND)?; - - Ok(Json(JobStatusResponse { - job_id: id, - file_uuid: asset, - rule, - status, - current_processor_id: proc_id, - frame_progress: FrameProgress { - total_frames: tf, - processed_frames: pf, - progress_percent: if tf > 0 { - (pf as f64 / tf as f64) * 100.0 - } else { - 0.0 - }, - }, - })) -} - -async fn get_rule_status( - State(state): State, - Path(rule): Path, -) -> Result, StatusCode> { - let procs: Vec = sqlx::query_scalar( - "SELECT id::text FROM processors WHERE supported_rules @> ARRAY[$1]::TEXT[]", - ) - .bind(&rule) - .fetch_all(state.db.pool()) - .await - .unwrap_or_default(); - - let jobs_table = schema::table_name("monitor_jobs"); - let jobs: Vec<(String, String, String, String, Option, i64, i64)> = sqlx::query_as( - &format!( - "SELECT id::text, file_uuid, COALESCE(rule, 'unknown'), status, assigned_processor_id::text, processed_frames, total_frames FROM {} WHERE rule = $1 AND status IN ('QUEUED','RUNNING')", - jobs_table - ) - ) - .bind(&rule) - .fetch_all(state.db.pool()) - .await - .unwrap_or_default(); - - let active = jobs - .into_iter() - .map(|(id, asset, r, s, p, pf, tf)| JobStatusResponse { - job_id: id, - file_uuid: asset, - rule: r, - status: s, - current_processor_id: p, - frame_progress: FrameProgress { - total_frames: tf, - processed_frames: pf, - progress_percent: if tf > 0 { - (pf as f64 / tf as f64) * 100.0 - } else { - 0.0 - }, - }, - }) - .collect(); - - Ok(Json(RuleStatusResponse { - rule, - supported_processor_ids: procs, - active_jobs: active, - })) -} - -// --- End P0 Core API Handlers --- - -async fn search( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let mode = req.mode.unwrap_or(SearchMode::Smart); - let limit = req.limit.unwrap_or(10); - let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit); - let cache_key = keys::search(&format!("{:?}", mode)); - let ttl = state.mongo_cache.ttl_search(); - - let response = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_SEARCH, || async { - let pg = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - let results: Vec = match mode { - SearchMode::Vector => { - let query_vector = state - .embedder - .embed_query(&req.query) - .await - .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?; - - let qdrant = QdrantDb::new(); - let search_results = if let Some(ref uuid) = req.uuid { - qdrant.search_in_uuid(&query_vector, uuid, limit).await? - } else { - qdrant.search(&query_vector, limit).await? - }; - - let mut results = Vec::new(); - for r in search_results { - if let Some(chunk) = pg - .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid) - .await - .ok() - .flatten() - { - let text = extract_text_from_content(&chunk.content); - results.push(SearchResult { - uuid: r.uuid, - chunk_id: r.chunk_id, - chunk_type: chunk.chunk_type.as_str().to_string(), - start_time: chunk.start_time().seconds(), - end_time: chunk.end_time().seconds(), - text, - score: r.score, - }); - } - } - results - } - SearchMode::Smart => { - // Vector search + BM25 reranking - let query_vector = state - .embedder - .embed_query(&req.query) - .await - .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?; - - let qdrant = QdrantDb::new(); - let search_results = if let Some(ref uuid) = req.uuid { - qdrant - .search_in_uuid(&query_vector, uuid, limit * 2) - .await? - } else { - qdrant.search(&query_vector, limit * 2).await? - }; - - // 取得所有 chunk 並用 BM25 重新排序 - let mut results_with_bm25: Vec<(SearchResult, f32)> = Vec::new(); - for r in search_results { - if let Some(chunk) = pg - .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid) - .await - .ok() - .flatten() - { - let text = extract_text_from_content(&chunk.content); - let vector_score = r.score; - results_with_bm25.push(( - SearchResult { - uuid: r.uuid, - chunk_id: r.chunk_id, - chunk_type: chunk.chunk_type.as_str().to_string(), - start_time: chunk.start_time().seconds(), - end_time: chunk.end_time().seconds(), - text, - score: vector_score, - }, - vector_score, - )); - } - } - - // 依 vector score 排序後取前 limit 個 - results_with_bm25 - .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - results_with_bm25 - .into_iter() - .take(limit) - .map(|(r, _)| r) - .collect() - } - }; - - let total = results.len(); - let results = dedup_search_results(results); - let page = req.page.unwrap_or(1).max(1); - let page_size = req.page_size.or(req.limit).unwrap_or(total.max(1)); - let start = (page - 1) * page_size; - let paged_results: Vec = - results.into_iter().skip(start).take(page_size).collect(); - Ok::(SearchResponse { - results: paged_results, - query: req.query.clone(), - total, - page, - page_size, - limit: req.limit.unwrap_or(10), - }) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(response)) -} - -async fn search_bm25( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let limit = req.limit.unwrap_or(10); - - let bm25_results = state - .db - .search_bm25(&req.query, req.uuid.as_deref(), limit as i64) - .await - .map_err(|e| { - tracing::error!("BM25 search failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let results: Vec = bm25_results - .into_iter() - .map(|r| SearchResult { - uuid: r.uuid, - chunk_id: r.chunk_id, - chunk_type: r.chunk_type, - start_time: r.start_time.unwrap_or(0.0), - end_time: r.end_time.unwrap_or(0.0), - text: r.text.unwrap_or_default(), - score: r.bm25_score as f32, - }) - .collect(); - - let results = dedup_search_results(results); - let total = results.len(); - let page = req.page.unwrap_or(1).max(1); - let page_size = req.page_size.or(req.limit).unwrap_or(total.max(1)); - let start = (page - 1) * page_size; - let paged_results: Vec = - results.into_iter().skip(start).take(page_size).collect(); - Ok(Json(SearchResponse { - results: paged_results, - query: req.query.clone(), - total, - page, - page_size, - limit: req.limit.unwrap_or(10), - })) -} - -async fn search_smart( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let limit = req.limit.unwrap_or(10); - let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit); - let cache_key = keys::search(&format!("{}smart", query_hash)); - let ttl = state.mongo_cache.ttl_search(); - - let response = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_SEARCH, || async { - let pg = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - let keywords = vec![req.query.clone()]; - - let search_terms = keywords.join(" "); - - let bm25_results = pg - .search_bm25(&search_terms, req.uuid.as_deref(), limit as i64) - .await?; - - let results: Vec = bm25_results - .into_iter() - .map(|r| SearchResult { - uuid: r.uuid, - chunk_id: r.chunk_id, - chunk_type: r.chunk_type, - start_time: r.start_time.unwrap_or(0.0), - end_time: r.end_time.unwrap_or(0.0), - text: r.text.unwrap_or_default(), - score: r.bm25_score as f32, - }) - .collect(); - - let total = results.len(); - let results = dedup_search_results(results); - let page = req.page.unwrap_or(1).max(1); - let page_size = req.page_size.or(req.limit).unwrap_or(total.max(1)); - let start = (page - 1) * page_size; - let paged_results: Vec = - results.into_iter().skip(start).take(page_size).collect(); - Ok::(SearchResponse { - results: paged_results, - query: req.query.clone(), - total, - page, - page_size, - limit: req.limit.unwrap_or(10), - }) - }) // end smart get_or_fetch - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(response)) -} - -async fn hybrid_search( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let limit = req.limit.unwrap_or(10); - let vector_weight = req.vector_weight.unwrap_or(0.7); - let bm25_weight = req.bm25_weight.unwrap_or(0.3); - - let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit); - let cache_key = keys::hybrid_search(&query_hash); - let ttl = state.mongo_cache.ttl_hybrid_search(); - - let response = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_HYBRID_SEARCH, || async { - let query_vector = state - .embedder - .embed_query(&req.query) - .await - .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?; - - let pg = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - let results = pg - .hybrid_search( - &req.query, - &query_vector, - req.uuid.as_deref(), - limit, - vector_weight, - bm25_weight, - ) - .await?; - - let search_results: Vec = results - .into_iter() - .map(|r| HybridSearchResult { - uuid: r.uuid, - chunk_id: r.chunk_id, - chunk_type: r.chunk_type, - start_time: r.start_time.unwrap_or(0.0), - end_time: r.end_time.unwrap_or(0.0), - text: r.text.unwrap_or_default(), - vector_score: r.vector_score, - bm25_score: r.bm25_score, - combined_score: r.combined_score, - }) - .collect(); - - let total = search_results.len(); - let page = req.page.unwrap_or(1).max(1); - let page_size = req.page_size.or(req.limit).unwrap_or(total.max(1)); - let start = (page - 1) * page_size; - let search_results = dedup_hybrid_results(search_results); - let paged: Vec = search_results - .into_iter() - .skip(start) - .take(page_size) - .collect(); - Ok::(HybridSearchResponse { - results: paged, - query: req.query.clone(), - total, - page, - page_size, - limit: req.limit.unwrap_or(10), - }) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(response)) -} - -async fn lookup( - State(state): State, - Query(query): Query, -) -> Result, StatusCode> { - if let Some(path) = query.path { - let uuid = crate::uuid::compute_uuid_from_path(&path); - return Ok(Json(LookupResponse { - file_uuid: uuid, - file_path: None, - file_name: None, - duration: None, - })); - } - - if let Some(uuid) = query.uuid { - let cache_key = keys::video_meta(&uuid); - let ttl = state.mongo_cache.ttl_video_meta(); - - let video = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_VIDEO_META, || async { - let db = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - db.get_video_by_uuid(&uuid) - .await - .map_err(|e| anyhow::anyhow!("{}", e)) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if let Some(v) = video { - return Ok(Json(LookupResponse { - file_uuid: v.file_uuid, - file_path: Some(v.file_path), - file_name: Some(v.file_name), - duration: Some(v.duration), - })); - } - } - - Err(StatusCode::NOT_FOUND) -} - -#[derive(Debug, Serialize, Deserialize)] -struct ScannedFileInfo { - file_name: String, - relative_path: String, - file_path: String, - file_size: u64, - modified_time: String, - // Registration info - is_registered: bool, - file_uuid: Option, - status: Option, - registration_time: Option, - job_id: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct ScanFilesResponse { - files: Vec, - total: usize, - filtered_total: usize, - page: usize, - page_size: usize, - total_pages: usize, - registered_count: usize, - unregistered_count: usize, - total_chunks: i64, - searchable_chunks: i64, - pending_videos: i64, -} - -fn scan_directory_recursive( - dir: &std::path::Path, - root: &std::path::Path, - allowed_extensions: &[&str], - registered_paths: &std::collections::HashMap< - String, - (String, String, Option, Option), - >, - files: &mut Vec, -) { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name.starts_with('.') { - return; - } - } - scan_directory_recursive(&path, root, allowed_extensions, registered_paths, files); - } else if path.is_file() { - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if allowed_extensions.contains(&ext.to_lowercase().as_str()) { - if let Ok(meta) = entry.metadata() { - let abs_path = path.to_string_lossy().to_string(); - let rel_path = path - .strip_prefix(root) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| abs_path.clone()); - - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - - let modified_time = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| { - chrono::DateTime::from_timestamp(d.as_secs() as i64, 0) - .map(|dt| dt.to_rfc3339()) - .unwrap_or_default() - }) - .unwrap_or_default(); - - // Check registration - match registered_paths.get(&abs_path) { - Some((uuid, status, reg_time, jid)) if status != "unregistered" => { - files.push(ScannedFileInfo { - file_name, - relative_path: rel_path, - file_path: abs_path, - file_size: meta.len(), - modified_time, - is_registered: true, - file_uuid: Some(uuid.clone()), - status: Some(status.clone()), - registration_time: reg_time.clone(), - job_id: *jid, - }); - } - _ => { - files.push(ScannedFileInfo { - file_name, - relative_path: rel_path, - file_path: abs_path, - file_size: meta.len(), - modified_time, - is_registered: false, - file_uuid: None, - status: Some("unregistered".to_string()), - registration_time: None, - job_id: None, - }); - } - } - } - } - } - } - } - } -} - -#[derive(Debug, Deserialize)] -struct ScanFilesQuery { - limit: Option, - page: Option, - page_size: Option, - pattern: Option, - sort_by: Option, - sort_order: Option, -} - -async fn scan_files( - State(state): State, - Query(params): Query, -) -> Result, StatusCode> { - let demo_dir_str = std::env::var("MOMENTRY_SFTP_ROOT") - .unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data/demo".to_string()); - let demo_dir = std::path::Path::new(&demo_dir_str); - - let allowed_extensions = vec![ - "mp4", "mov", "mkv", "avi", "webm", "jpg", "jpeg", "png", "gif", "webp", - ]; - - // 1. Get registered files from DB (Map key: absolute file_path) - let table = schema::table_name("videos"); - let mj_table = schema::table_name("monitor_jobs"); - let registered_db: Vec<(String, String, String, String, Option, Option)> = - sqlx::query_as(&format!( - "SELECT v.file_path, v.file_name, v.file_uuid, v.status, v.registration_time::text, \ - latest_job.id as job_id \ - FROM {} v \ - LEFT JOIN LATERAL ( \ - SELECT id FROM {} WHERE uuid = v.file_uuid ORDER BY id DESC LIMIT 1 \ - ) latest_job ON true \ - ORDER BY v.id", - table, mj_table - )) - .fetch_all(state.db.pool()) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let registered_paths: std::collections::HashMap< - String, - (String, String, Option, Option), - > = registered_db - .into_iter() - .map(|(path, _name, uuid, status, reg_time, jid)| (path, (uuid, status, reg_time, jid))) - .collect(); - - // 2. Scan filesystem recursively - let mut result_files = Vec::new(); - - if demo_dir.exists() { - scan_directory_recursive( - demo_dir, - demo_dir, - &allowed_extensions, - ®istered_paths, - &mut result_files, - ); - } - - // 3. Sort: customizable - let desc = params.sort_order.as_deref().unwrap_or("asc") == "desc"; - match params.sort_by.as_deref().unwrap_or("name") { - "size" => { - if desc { - result_files.sort_by(|a, b| b.file_size.cmp(&a.file_size)); - } else { - result_files.sort_by(|a, b| a.file_size.cmp(&b.file_size)); - } - } - "modified" | "time" => { - if desc { - result_files.sort_by(|a, b| b.modified_time.cmp(&a.modified_time)); - } else { - result_files.sort_by(|a, b| a.modified_time.cmp(&b.modified_time)); - } - } - "status" => { - if desc { - result_files - .sort_by(|a, b| b.status.cmp(&a.status).then(b.file_name.cmp(&a.file_name))); - } else { - result_files - .sort_by(|a, b| a.status.cmp(&b.status).then(a.file_name.cmp(&b.file_name))); - } - } - _ => { - // "name" (default): registered first, then by name - if desc { - result_files.sort_by(|a, b| { - a.is_registered - .cmp(&b.is_registered) - .then(b.file_name.cmp(&a.file_name)) - }); - } else { - result_files.sort_by(|a, b| { - b.is_registered - .cmp(&a.is_registered) - .then(a.file_name.cmp(&b.file_name)) - }); - } - } - } - - let total_all = result_files.len(); - let registered_count = result_files.iter().filter(|f| f.is_registered).count(); - let unregistered_count = result_files.iter().filter(|f| !f.is_registered).count(); - - // 4. Apply regex filter on filename - let filtered: Vec = if let Some(ref pat) = params.pattern { - let re = match regex::Regex::new(&format!("(?i){}", pat)) { - Ok(r) => r, - Err(_) => return Err(StatusCode::BAD_REQUEST), - }; - result_files - .into_iter() - .filter(|f| re.is_match(&f.file_name)) - .collect() - } else { - result_files - }; - - let filtered_total = filtered.len(); - - // 5. Pagination - let page = params.page.unwrap_or(1).max(1); - let page_size = params - .page_size - .or(params.limit) - .unwrap_or(filtered_total.max(1)); - let total_pages = if page_size > 0 { - (filtered_total + page_size - 1) / page_size - } else { - 1 - }; - let start = (page - 1) * page_size; - let files: Vec = filtered.into_iter().skip(start).take(page_size).collect(); - - let table_videos = schema::table_name("videos"); - let table_chunks = schema::table_name("chunk"); - let total_chunks: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {}", table_chunks)) - .fetch_one(state.db.pool()) - .await - .unwrap_or(0); - let searchable_chunks: i64 = sqlx::query_scalar(&format!( - "SELECT COUNT(*) FROM {} WHERE vector_id IS NOT NULL", - table_chunks - )) - .fetch_one(state.db.pool()) - .await - .unwrap_or(0); - let pending_videos: i64 = sqlx::query_scalar(&format!( - "SELECT COUNT(*) FROM {} WHERE status = 'pending'", - table_videos - )) - .fetch_one(state.db.pool()) - .await - .unwrap_or(0); - - Ok(Json(ScanFilesResponse { - files, - total: total_all, - filtered_total, - page, - page_size, - total_pages, - registered_count, - unregistered_count, - total_chunks, - searchable_chunks, - pending_videos, - })) -} - -#[derive(Debug, Serialize)] -struct ProgressResponse { - file_uuid: String, - user: Option, - group: Option, - file_name: Option, - duration: Option, - overall_progress: u32, - cpu_percent: Option, - gpu_percent: Option, - memory_percent: Option, - memory_mb: Option, - system: Option, - processors: Vec, -} - -#[derive(Debug, Serialize)] -struct SystemHealthInfo { - cpu_idle_pct: f64, - memory_available_mb: u64, - memory_total_mb: u64, - memory_used_pct: f64, - gpu_available: bool, - gpu_utilization_pct: Option, - gpu_memory_used_pct: Option, - dynamic_concurrency: u32, - config_concurrency: u32, - running_processors: u32, -} - -#[derive(Debug, Serialize)] -struct ProcessorProgressInfo { - name: String, - status: String, - current: u32, - total: u32, - progress: u32, - message: String, - frames_processed: i32, - chunks_produced: i32, - retry_count: i32, - eta_seconds: Option, -} - -/// 從 .json 輸出檔讀取 processor 的已處理幀數 -fn get_json_frame_count(file_uuid: &str, processor: &str) -> i32 { - let output_dir = std::env::var("MOMENTRY_OUTPUT_DIR") - .unwrap_or_else(|_| "/Users/accusys/momentry/output_dev".to_string()); - let path = std::path::Path::new(&output_dir).join(format!("{}.{}.json", file_uuid, processor)); - match std::fs::read_to_string(&path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(val) => val.get("frame_count").and_then(|v| v.as_i64()).unwrap_or(0) as i32, - Err(_) => 0, - }, - Err(_) => 0, - } -} - -fn get_system_stats() -> (Option, Option, Option, Option) { - use std::process::Command; - - let pid = std::process::id().to_string(); - - let cpu = Command::new("ps") - .args(["-p", &pid, "-o", "%cpu="]) - .output() - .ok() - .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok()); - - let (mem_percent, mem_rss) = Command::new("ps") - .args(["-p", &pid, "-o", "%mem=,rss="]) - .output() - .ok() - .map(|o| { - let output = String::from_utf8_lossy(&o.stdout); - let parts: Vec<&str> = output.split_whitespace().collect(); - let percent = parts.first().and_then(|s| s.parse().ok()); - let rss = parts.get(1).and_then(|s| s.parse().ok()); - (percent, rss) - }) - .unwrap_or((None, None)); - - let gpu = Command::new("nvidia-smi") - .args([ - "--query-gpu=utilization.gpu", - "--format=csv,noheader,nounits", - ]) - .output() - .ok() - .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok()); - - (cpu, gpu, mem_percent, mem_rss) -} - -async fn get_progress( - axum::extract::Path(file_uuid): axum::extract::Path, -) -> Result, StatusCode> { - let redis = RedisClient::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let pg = PostgresDb::init() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let mut conn = redis - .get_conn() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let processor_names = [ - "asr", - "cut", - "asrx", - "yolo", - "ocr", - "face", - "pose", - "visual_chunk", - "story", - ]; - let mut processors = Vec::new(); - let mut completed_count = 0u32; - - for name in processor_names { - let prefix = REDIS_KEY_PREFIX.as_str(); - let key = format!("{}job:{}:processor:{}", prefix, file_uuid, name); - let status: String = redis::cmd("HGET") - .arg(&key) - .arg("status") - .query_async(&mut conn) - .await - .unwrap_or_else(|_| "pending".to_string()); - let current: u32 = redis::cmd("HGET") - .arg(&key) - .arg("current") - .query_async(&mut conn) - .await - .unwrap_or_else(|_| "0".to_string()) - .parse() - .unwrap_or(0); - let total: u32 = redis::cmd("HGET") - .arg(&key) - .arg("total") - .query_async(&mut conn) - .await - .unwrap_or_else(|_| "0".to_string()) - .parse() - .unwrap_or(0); - let message: String = redis::cmd("HGET") - .arg(&key) - .arg("message") - .query_async(&mut conn) - .await - .unwrap_or_else(|_| "".to_string()); - - let progress = if total > 0 { - ((current as f64 / total as f64) * 100.0) as u32 - } else if status == "complete" { - 100 - } else { - 0 - }; - - // 從 Redis 讀取 frames_processed / chunks_produced / retry_count - let frames_processed: i32 = redis::cmd("HGET") - .arg(&key) - .arg("frames_processed") - .query_async(&mut conn) - .await - .unwrap_or_else(|_| "0".to_string()) - .parse() - .unwrap_or(0); - let chunks_produced: i32 = redis::cmd("HGET") - .arg(&key) - .arg("chunks_produced") - .query_async(&mut conn) - .await - .unwrap_or_else(|_| "0".to_string()) - .parse() - .unwrap_or(0); - let retry_count: i32 = redis::cmd("HGET") - .arg(&key) - .arg("retry_count") - .query_async(&mut conn) - .await - .unwrap_or_else(|_| "0".to_string()) - .parse() - .unwrap_or(0); - - let eta_seconds = if status == "running" && current > 0 && total > 0 && current < total { - let started_str: String = redis::cmd("HGET") - .arg(&key) - .arg("started_at") - .query_async(&mut conn) - .await - .unwrap_or_else(|_| String::new()); - if !started_str.is_empty() { - if let Ok(started_at) = chrono::DateTime::parse_from_rfc3339(&started_str) { - let elapsed = chrono::Utc::now() - .signed_duration_since(started_at) - .num_seconds() - .max(1); - let estimated_total = (elapsed as f64 * total as f64 / current as f64) as i64; - Some((estimated_total - elapsed).max(0)) - } else { - None - } - } else { - None - } - } else { - None - }; - - if status == "complete" { - completed_count += 1; - } - - processors.push(ProcessorProgressInfo { - name: name.to_string(), - status, - current, - total, - progress, - message, - frames_processed, - chunks_produced, - retry_count, - eta_seconds, - }); - } - - // Supplement with actual processor_results from DB (overrides stale Redis data) - let pr_table = schema::table_name("processor_results"); - let vt = schema::table_name("videos"); - let total_frames: i64 = sqlx::query_scalar(&format!( - "SELECT COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1", - vt - )) - .bind(&file_uuid) - .fetch_one(pg.pool()) - .await - .unwrap_or(0); - if let Ok(rows) = sqlx::query_as::<_, (String, String, i32, i32)>( - &format!( - "SELECT pr.status, pr.processor_type, COALESCE(pr.frames_processed, 0), COALESCE(pr.chunks_produced, 0) \ - FROM {} pr JOIN {} mj ON pr.job_id = mj.id \ - WHERE mj.uuid = $1 ORDER BY pr.id", - pr_table, schema::table_name("monitor_jobs") - ) - ) - .bind(&file_uuid) - .fetch_all(pg.pool()) - .await - { - completed_count = 0; - for (db_status, ptype, frames, chunks) in &rows { - for p in &mut processors { - if p.name == ptype.to_lowercase() { - p.status = db_status.clone(); - p.frames_processed = *frames; - p.chunks_produced = *chunks; - if *db_status == "completed" && p.current == 0 { - p.progress = 100; - } - } - } - } - completed_count = processors.iter().filter(|p| p.status == "completed").count() as u32; - } - - let overall_progress = (completed_count as f64 / processor_names.len() as f64 * 100.0) as u32; - - let job_key = format!("{}job:{}", REDIS_KEY_PREFIX.as_str(), file_uuid); - let user: Option = redis::cmd("HGET") - .arg(&job_key) - .arg("user") - .query_async(&mut conn) - .await - .ok() - .filter(|s: &String| !s.is_empty()); - - let group: Option = redis::cmd("HGET") - .arg(&job_key) - .arg("group") - .query_async(&mut conn) - .await - .ok() - .filter(|s: &String| !s.is_empty()); - - let (file_name, duration) = pg - .get_video_by_uuid(&file_uuid) - .await - .ok() - .flatten() - .map(|v| (Some(v.file_name), Some(v.duration))) - .unwrap_or((None, None)); - - let (cpu_percent, gpu_percent, memory_percent, memory_mb) = get_system_stats(); - - // 從 Redis 讀取系統健康資訊(由 Worker 寫入) - let health_key = format!("{}health", REDIS_KEY_PREFIX.as_str()); - let cpu_idle: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("cpu_idle_pct") - .query_async(&mut conn) - .await - .ok(); - let mem_avail: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("memory_available_mb") - .query_async(&mut conn) - .await - .ok(); - let mem_total: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("memory_total_mb") - .query_async(&mut conn) - .await - .ok(); - let mem_used: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("memory_used_pct") - .query_async(&mut conn) - .await - .ok(); - let gpu_avail: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("gpu_available") - .query_async(&mut conn) - .await - .ok(); - let gpu_util: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("gpu_utilization_pct") - .query_async(&mut conn) - .await - .ok(); - let gpu_mem: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("gpu_memory_used_pct") - .query_async(&mut conn) - .await - .ok(); - let dyn_conc: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("dynamic_concurrency") - .query_async(&mut conn) - .await - .ok(); - let cfg_conc: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("config_concurrency") - .query_async(&mut conn) - .await - .ok(); - let run_proc: Option = redis::cmd("HGET") - .arg(&health_key) - .arg("running_processors") - .query_async(&mut conn) - .await - .ok(); - - let system = cpu_idle.and_then(|idle| { - Some(SystemHealthInfo { - cpu_idle_pct: idle.parse().ok()?, - memory_available_mb: mem_avail?.parse().ok()?, - memory_total_mb: mem_total?.parse().ok()?, - memory_used_pct: mem_used?.parse().ok()?, - gpu_available: gpu_avail.map(|v| v == "true").unwrap_or(false), - gpu_utilization_pct: gpu_util.and_then(|v| v.parse().ok()), - gpu_memory_used_pct: gpu_mem.and_then(|v| v.parse().ok()), - dynamic_concurrency: dyn_conc?.parse().ok()?, - config_concurrency: cfg_conc?.parse().ok()?, - running_processors: run_proc?.parse().ok()?, - }) - }); - - Ok(Json(ProgressResponse { - file_uuid, - user, - group, - file_name, - duration, - overall_progress, - cpu_percent, - gpu_percent, - memory_percent, - memory_mb, - system, - processors, - })) -} - -async fn list_jobs(Query(params): Query) -> Result, StatusCode> { - let page = params.page.unwrap_or(1); - let page_size = params.page_size.unwrap_or(20); - let status_filter = params - .status - .unwrap_or_else(|| "pending,running".to_string()); - let offset = ((page - 1) as i64) * (page_size as i64); - - let pg = PostgresDb::init() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let table = crate::core::db::schema::table_name("monitor_jobs"); - - // Build status IN clause - let statuses: Vec = status_filter - .split(',') - .map(|s| format!("'{}'", s.trim())) - .collect(); - let status_clause = statuses.join(","); - - let query = format!( - "SELECT id, uuid, video_path, status, current_processor, progress_total, progress_current, - error_count, last_error, started_at::TEXT, updated_at::TEXT, created_at::TEXT, - processors, completed_processors, failed_processors, video_id - FROM {} - WHERE status IN ({}) - ORDER BY created_at DESC - LIMIT {} OFFSET {}", - table, status_clause, page_size, offset - ); - - let count_query = format!( - "SELECT COUNT(*) FROM {} WHERE status IN ({})", - table, status_clause - ); - - let total_count: i64 = sqlx::query_scalar(&count_query) - .fetch_one(pg.pool()) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - use crate::core::db::MonitorJobStatus; - - let rows = sqlx::query(&query) - .fetch_all(pg.pool()) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let job_infos: Vec = rows - .into_iter() - .map(|r| { - let status_str: String = r.try_get("status").unwrap_or_default(); - let status = - MonitorJobStatus::from_db_str(&status_str).unwrap_or(MonitorJobStatus::Pending); - JobInfoResponse { - id: r.try_get("id").unwrap_or(0), - uuid: r.try_get("uuid").unwrap_or_default(), - status: status.as_str().to_string(), - current_processor: r.try_get("current_processor").ok(), - progress_current: r.try_get("progress_current").unwrap_or(0), - progress_total: r.try_get("progress_total").unwrap_or(0), - created_at: r.try_get::("created_at").unwrap_or_default(), - started_at: r.try_get::("started_at").ok(), - } - }) - .collect(); - - Ok(Json(JobListResponse { - jobs: job_infos, - count: total_count, - page, - page_size, - })) -} - -async fn get_job( - axum::extract::Path(uuid): axum::extract::Path, -) -> Result, StatusCode> { - tracing::info!("[get_job] START - uuid: {}", uuid); - - let pg = PostgresDb::init().await.map_err(|e| { - tracing::error!("[get_job] ERROR - Failed to init PostgresDb: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - tracing::info!("[get_job] PostgresDb initialized"); - - let job = pg - .get_monitor_job_by_uuid(&uuid) - .await - .map_err(|e| { - tracing::error!("[get_job] ERROR - Failed to get monitor job: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })? - .ok_or_else(|| { - tracing::warn!("[get_job] Job not found: {}", uuid); - StatusCode::NOT_FOUND - })?; - - tracing::info!("[get_job] Found job: id={}, uuid={}", job.id, job.uuid); - - let results = pg.get_processor_results_by_job(job.id).await.map_err(|e| { - tracing::error!("[get_job] ERROR - Failed to get processor results: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - tracing::info!("[get_job] Got {} processor results", results.len()); - - let processors: Vec = results - .into_iter() - .map(|r| ProcessorInfoResponse { - processor_type: r.processor_type.as_str().to_string(), - status: r.status.as_str().to_string(), - started_at: r.started_at, - completed_at: r.completed_at, - duration_secs: r.duration_secs, - error_message: r.error_message, - }) - .collect(); - - tracing::info!("[get_job] Mapped {} processors", processors.len()); - - let response = JobDetailResponse { - id: job.id, - uuid: job.uuid, - status: job.status.as_str().to_string(), - current_processor: job.current_processor, - progress_current: job.progress_current, - progress_total: job.progress_total, - processors, - created_at: job.created_at.to_string(), - started_at: job.started_at.map(|t| t.to_string()), - updated_at: job.updated_at.map(|t| t.to_string()), - }; - - tracing::info!("[get_job] SUCCESS - returning response"); - Ok(Json(response)) -} - -async fn cache_toggle( - State(_state): State, - Json(req): Json, -) -> Result, StatusCode> { - tracing::info!("[cache_toggle] Setting cache enabled to: {}", req.enabled); - - crate::core::config::set_cache_enabled(req.enabled); - - let response = CacheToggleResponse { - success: true, - cache_enabled: req.enabled, - message: if req.enabled { - "Cache enabled".to_string() - } else { - "Cache disabled".to_string() - }, - }; - - tracing::info!("[cache_toggle] SUCCESS"); - Ok(Json(response)) -} - -async fn auto_pipeline_toggle( - Json(req): Json, -) -> Result, StatusCode> { - tracing::info!("[auto_pipeline_toggle] Setting to: {}", req.enabled); - crate::core::config::set_auto_pipeline_enabled(req.enabled); - Ok(Json(AutoPipelineToggleResponse { - success: true, - auto_pipeline_enabled: req.enabled, - message: format!( - "Auto-pipeline {}", - if req.enabled { "enabled" } else { "disabled" } - ), - })) -} - -async fn watcher_auto_register_toggle( - Json(req): Json, -) -> Result, StatusCode> { - tracing::info!("[watcher_auto_register_toggle] Setting to: {}", req.enabled); - crate::core::config::set_watcher_auto_register(req.enabled); - Ok(Json(WatcherAutoRegisterToggleResponse { - success: true, - watcher_auto_register_enabled: req.enabled, - message: format!( - "Watcher auto-register {}", - if req.enabled { "enabled" } else { "disabled" } - ), - })) -} - -#[derive(Debug, Serialize)] -struct UnregisterResponse { - success: bool, - file_uuid: String, - message: String, -} - -#[derive(Debug, Deserialize)] -struct UnregisterRequest { - file_uuid: Option, - file_path: Option, - pattern: Option, - /// If true (default), delete processor output JSON ({uuid}.*.json) from disk - delete_output_files: Option, -} - -fn delete_output_files(uuid: &str) { - let output_dir = std::path::PathBuf::from(&*crate::core::config::OUTPUT_DIR); - if let Ok(entries) = std::fs::read_dir(&output_dir) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with(uuid) && name.ends_with(".json") { - std::fs::remove_file(entry.path()).ok(); - } - } - } -} - -async fn unregister( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let db = &state.db; - let clean_files = req.delete_output_files.unwrap_or(true); - - // Pattern mode: unregister all matching files in a directory - if let (Some(ref dir_path), Some(ref pat)) = (&req.file_path, &req.pattern) { - let dir = std::path::Path::new(dir_path); - if !dir.is_dir() { - return Ok(Json(UnregisterResponse { - success: false, - file_uuid: String::new(), - message: format!("Not a directory: {}", dir_path), - })); - } - let re = regex::Regex::new(pat).map_err(|_| StatusCode::BAD_REQUEST)?; - let mut count = 0u32; - let table = crate::core::db::schema::table_name("videos"); - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let fname = entry.file_name().to_string_lossy().to_string(); - if !re.is_match(&fname) { - continue; - } - // Find file_uuid by file_name and delete - let rows: Vec<(String,)> = sqlx::query_as(&format!( - "SELECT file_uuid FROM {} WHERE file_name = $1", - table - )) - .bind(&fname) - .fetch_all(db.pool()) - .await - .unwrap_or_default(); - for (uuid,) in rows { - let _ = db.delete_video(&uuid).await; - if clean_files { - delete_output_files(&uuid); - } - count += 1; - } - } - } - let _ = state.mongo_cache.invalidate_videos_list().await; - return Ok(Json(UnregisterResponse { - success: true, - file_uuid: format!("batch_{}", count), - message: format!("Unregistered {} files matching pattern", count), - })); - } - - // Single UUID mode - let uuid = req.file_uuid.as_deref().unwrap_or(""); - if uuid.is_empty() { - return Ok(Json(UnregisterResponse { - success: false, - file_uuid: String::new(), - message: "Either file_uuid or file_path+pattern is required".to_string(), - })); - } - - tracing::info!("[unregister] Unregistering file: {}", uuid); - - // Check if video exists first - match db.get_video_by_uuid(uuid).await { - Ok(Some(_)) => {} - Ok(None) => { - return Ok(Json(UnregisterResponse { - success: false, - file_uuid: uuid.to_string(), - message: "File not found".to_string(), - })); - } - Err(e) => { - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - } - - match db.delete_video(uuid).await { - Ok(_) => { - let _ = state.mongo_cache.invalidate_videos_list().await; - if clean_files { - delete_output_files(uuid); - } - Ok(Json(UnregisterResponse { - success: true, - file_uuid: uuid.to_string(), - message: if clean_files { - "File unregistered (DB + output files deleted)".to_string() - } else { - "File unregistered (DB deleted, output files kept)".to_string() - }, - })) - } - Err(e) => { - tracing::error!("[unregister] Failed: {}", e); - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -/// Serve documentation HTML pages with cookie-based auth. -async fn doc_redirect() -> axum::response::Redirect { - axum::response::Redirect::to("/doc-wasm") -} - -async fn wasm_doc_handler() -> Result -{ - let path = - std::path::Path::new("/Users/accusys/momentry_core/docs_v1.0/doc_wasm/index.html"); - match tokio::fs::read_to_string(path).await { - Ok(html) => Ok(([("content-type", "text/html; charset=utf-8")], html)), - Err(_) => Err((StatusCode::NOT_FOUND, "Doc not found")), - } -} - -async fn wasm_doc_file_handler( - Path(file): Path, -) -> Result { - if file.contains("..") || file.contains("//") { - return Err((StatusCode::NOT_FOUND, "Invalid path")); - } - let base = std::path::Path::new("/Users/accusys/momentry_core/docs_v1.0/doc_wasm"); - let path = base.join(&file); - if !path.exists() || !path.starts_with(base) { - return Err((StatusCode::NOT_FOUND, "File not found")); - } - let data = tokio::fs::read(&path) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "Read error"))?; - let mime = if file.ends_with(".wasm") { - "application/wasm" - } else if file.ends_with(".js") { - "application/javascript" - } else if file.ends_with(".md") { - "text/markdown; charset=utf-8" - } else if file.ends_with(".css") { - "text/css" - } else { - "application/octet-stream" - }; - Ok(([("content-type", mime)], data)) -} - -async fn doc_handler( - State(state): State, - headers: axum::http::HeaderMap, -) -> Result { - serve_doc(&state, &headers, None).await -} - -async fn dev_doc_handler( - State(state): State, - headers: axum::http::HeaderMap, -) -> Result { - serve_doc(&state, &headers, Some("dev")).await -} - -#[allow(unused)] -async fn doc_file_handler( - State(state): State, - headers: axum::http::HeaderMap, - Path(file): Path, -) -> Result { - serve_doc_file(&state, &headers, None, &file).await -} - -async fn serve_doc( - state: &AppState, - headers: &axum::http::HeaderMap, - mode: Option<&str>, -) -> Result { - let authorized = check_doc_auth(state, headers).await; - let project_root = std::path::Path::new("/Users/accusys/momentry_core"); - let base_dir = match mode { - Some("dev") => project_root.join("docs_v1.0").join("doc_developer"), - _ => project_root.join("docs_v1.0").join("doc"), - }; - - if !authorized { - let login_html = tokio::fs::read_to_string(&base_dir.join("login.html")) - .await - .unwrap_or_else(|_| { - "

Login

Please login at /api/v1/auth/login

" - .to_string() - }); - return Ok(([("content-type", "text/html; charset=utf-8")], login_html)); - } - - let index_html = tokio::fs::read_to_string(&base_dir.join("index.html")) - .await - .unwrap_or_else(|_| "

Docs not found

".to_string()); - Ok(([("content-type", "text/html; charset=utf-8")], index_html)) -} - -async fn serve_doc_file( - state: &AppState, - headers: &axum::http::HeaderMap, - mode: Option<&str>, - file: &str, -) -> Result { - let authorized = check_doc_auth(state, headers).await; - let project_root = std::path::Path::new("/Users/accusys/momentry_core"); - let base_dir = match mode { - Some("dev") => project_root.join("docs_v1.0").join("doc_developer"), - _ => project_root.join("docs_v1.0").join("doc"), - }; - - if !authorized { - let login_html = tokio::fs::read_to_string(&base_dir.join("login.html")) - .await - .unwrap_or_else(|_| "

Login

".to_string()); - return Ok(([("content-type", "text/html; charset=utf-8")], login_html)); - } - - // Sanitize: only allow .html files, no path traversal - if file.contains('/') || file.contains("..") || !file.ends_with(".html") { - return Ok(( - [("content-type", "text/html; charset=utf-8")], - "

Not found

".to_string(), - )); - } - - let html = tokio::fs::read_to_string(&base_dir.join(file)) - .await - .unwrap_or_else(|_| "

Page not found

".to_string()); - Ok(([("content-type", "text/html; charset=utf-8")], html)) -} - -async fn check_doc_auth(state: &AppState, headers: &axum::http::HeaderMap) -> bool { - use crate::api::middleware::extract_cookies; - let cookies = extract_cookies(headers); - let sid = cookies - .iter() - .find(|(k, _)| k == "session_id") - .map(|(_, v)| v.clone()); - if let Some(ref session_id) = sid { - let table = crate::core::db::schema::table_name("sessions"); - sqlx::query_scalar::<_, i32>(&format!( - "SELECT 1 FROM {} WHERE session_id = $1 AND expires_at > NOW()", - table - )) - .bind(session_id) - .fetch_optional(state.db.pool()) - .await - .map(|r| r.is_some()) - .unwrap_or(false) - } else { - false - } -} +use super::visual_search; pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { - let _ = SERVER_START.set(Instant::now()); - // Resolve actual IP address for health identification - let resolved_ip = if host == "0.0.0.0" { - // Try to find a non-loopback IP - if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&"localhost:0") { - if let Some(addr) = addrs - .filter_map(|a| match a { - std::net::SocketAddr::V4(v4) if !v4.ip().is_loopback() => { - Some(v4.ip().to_string()) - } - _ => None, - }) - .next() - { - addr - } else { - // Fallback: try getting IP from UDP socket - std::net::UdpSocket::bind("0.0.0.0:0") - .and_then(|s| s.connect("8.8.8.8:53").map(|_| s)) - .and_then(|s| s.local_addr()) - .map(|a| a.ip().to_string()) - .unwrap_or_else(|_| "0.0.0.0".to_string()) - } - } else { - host.to_string() - } - } else { - host.to_string() - }; - let _ = SERVER_HOST.set(resolved_ip); - let _ = SERVER_PORT.set(port); + health::init_server_state(host, port); let embedder = std::sync::Arc::new(Embedder::new("nomic-embed-text-v2-moe:latest".to_string())); let mongo_cache = MongoCache::init().await?; let redis_cache = RedisCache::new()?; let db = PostgresDb::init().await?; - // ── Schema migration startup check ── - let schema_health = check_schema_migrations(db.pool()).await; + let schema_health = health::check_schema_migrations(db.pool()).await; if schema_health.ok { tracing::info!( "[SCHEMA] All {}/{} required migrations applied ✓", @@ -4087,9 +41,7 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { schema_health.required.len() ); } else if !schema_health.table_exists { - tracing::warn!( - "[SCHEMA] schema_migrations table not found! Run: psql -U accusys -d momentry -f release/migrate_add_schema_version.sql" - ); + tracing::warn!("[SCHEMA] schema_migrations table not found!"); } else { let missing: Vec<&str> = schema_health .required @@ -4103,7 +55,7 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { .map(|m| m.filename.as_str()) .collect(); tracing::warn!( - "[SCHEMA] {}/{} migrations match. Missing/corrupt: {}", + "[SCHEMA] {}/{} migrations match. Missing: {}", schema_health.applied.len(), schema_health.required.len(), missing.join(", ") @@ -4123,42 +75,19 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { }; let protected_routes = Router::new() - .route("/api/v1/files/register", post(register_file)) - .route("/api/v1/files/lookup", get(lookup_file_by_name)) - .route("/api/v1/unregister", post(unregister)) - .route("/api/v1/files/scan", get(scan_files)) - .route("/api/v1/file/:file_uuid/probe", get(probe_by_uuid)) - .route( - "/api/v1/file/:file_uuid/json/:processor", - get(download_json), - ) - .route("/api/v1/file/:file_uuid/process", post(trigger_processing)) - .route( - "/api/v1/file/:file_uuid/chunk/:chunk_id", - get(get_chunk_by_path), - ) - .route("/api/v1/progress/:file_uuid", get(get_progress)) - .route("/api/v1/jobs", get(list_jobs)) - .route("/api/v1/config/cache", post(cache_toggle)) - .route("/api/v1/config/auto-pipeline", post(auto_pipeline_toggle)) - .route( - "/api/v1/config/watcher-auto-register", - post(watcher_auto_register_toggle), - ) - // .merge(person_identity::person_identity_routes()) // V4.0: DISABLED (person_identities table removed) + .merge(files::file_routes()) + .merge(scan::scan_routes()) .merge(identity_binding::identity_binding_routes()) .merge(identities::identity_routes()) .merge(tmdb_api::tmdb_routes()) - .merge(identity_api::identity_routes()) // Phase 3 Routes - .merge(agent_api::agent_routes()) // Phase 6 Routes - .merge(super::identity_agent_api::identity_agent_routes()) // Phase 5 Routes - .merge(five_w1h_agent_api::five_w1h_agent_routes()) // Phase 3 Routes (5W1H Agent) - .merge(super::media_api::bbox_routes()) // Media: video/bbox/thumbnail - .merge(super::trace_agent_api::trace_agent_routes()) // Trace listing - .merge(search_routes()) // Smart search drill-down - .merge(universal_search_routes()) // Universal / frames / persons search - .route("/health/detailed", get(health_detailed)) - .route("/health/consistency", get(health_consistency)) + .merge(identity_api::identity_routes()) + .merge(agent_api::agent_routes()) + .merge(identity_agent_api::identity_agent_routes()) + .merge(five_w1h_agent_api::five_w1h_agent_routes()) + .merge(media_api::bbox_routes()) + .merge(trace_agent_api::trace_agent_routes()) + .merge(search_routes()) + .merge(universal_search_routes()) .layer(axum::middleware::from_fn_with_state( state.api_state.clone(), unified_auth, @@ -4166,38 +95,15 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { .with_state(state.clone()); let cors = CorsLayer::new() - .allow_origin(tower_http::cors::AllowOrigin::any()) + .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); let app = Router::new() - .route("/health", get(health)) - .route("/doc", get(doc_redirect)) - .route("/doc/*file", get(doc_redirect)) - .route("/dev-doc", get(doc_redirect)) - .route("/doc-wasm", get(wasm_doc_handler)) - .route("/doc-wasm/*file", get(wasm_doc_file_handler)) - .route("/api/v1/auth/login", post(login)) - .route("/api/v1/auth/logout", post(logout)) - .route("/api/v1/stats/sftpgo", get(get_sftpgo_status)) - .route( - "/api/v1/stats/ingestion-status/:file_uuid", - get(get_ingestion_status), - ) - .route("/api/v1/search/visual", post(search_visual_chunks)) - .route( - "/api/v1/search/visual/class", - post(search_visual_chunks_by_class), - ) - .route( - "/api/v1/search/visual/density", - post(search_visual_chunks_by_density), - ) - .route("/api/v1/search/visual/stats", post(get_visual_chunk_stats)) - .route( - "/api/v1/search/visual/combination", - post(search_visual_chunks_by_combination), - ) + .merge(auth::auth_routes()) + .merge(health::health_routes()) + .merge(docs::doc_routes()) + .merge(visual_search::visual_search_routes()) .merge(protected_routes) .layer(cors) .with_state(state); @@ -4210,796 +116,3 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { Ok(()) } - -#[derive(Debug, Serialize)] -struct SftpgoStatusResponse { - username: String, - home_dir: String, - files_count: i64, - registered_videos: Vec, - last_login: Option, -} - -#[derive(Debug, Serialize)] -struct RegisteredVideo { - uuid: String, - file_name: String, - status: String, -} - -async fn get_sftpgo_status( - State(state): State, -) -> Result, StatusCode> { - let demo_dir = "/Users/accusys/momentry/var/sftpgo/data/demo"; - - let files_count: i64 = std::fs::read_dir(demo_dir) - .map(|entries| entries.count() as i64) - .unwrap_or(0); - - let table_videos = schema::table_name("videos"); - - let registered_videos: Vec<(String, String, String)> = sqlx::query_as(&format!( - "SELECT file_uuid, file_name, status FROM {} WHERE file_path LIKE '%demo%' ORDER BY id", - table_videos - )) - .fetch_all(state.db.pool()) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let registered_videos = registered_videos - .into_iter() - .map(|(uuid, file_name, status)| RegisteredVideo { - uuid, - file_name, - status, - }) - .collect(); - - Ok(Json(SftpgoStatusResponse { - username: "demo".to_string(), - home_dir: demo_dir.to_string(), - files_count, - registered_videos, - last_login: None, - })) -} - -#[derive(Debug, Serialize)] -struct IngestionStep { - name: String, - status: String, - detail: Option, -} - -#[derive(Debug, Serialize)] -struct IdentityRef { - uuid: String, - name: String, -} - -#[derive(Debug, Serialize)] -struct IngestionStatusResponse { - file_uuid: String, - steps: Vec, - related_identities: Vec, - strangers: i64, -} - -async fn get_ingestion_status( - State(state): State, - Path(file_uuid): Path, -) -> Result, StatusCode> { - let pool = state.db.pool(); - let chunk = schema::table_name("chunk"); - let fd = schema::table_name("face_detections"); - let identities = schema::table_name("identities"); - - let scene_meta_path = format!( - "{}/{}.scene_meta.json", - crate::core::config::OUTPUT_DIR.as_str(), - file_uuid - ); - let scene_meta_ok = std::path::Path::new(&scene_meta_path).exists(); - - macro_rules! count_sql { - ($sql:expr) => { - sqlx::query_scalar::<_, i64>($sql) - .fetch_one(pool) - .await - .unwrap_or(0) - }; - } - - let sentence_count = count_sql!(&format!( - "SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'sentence'" - )); - let sentence_embedded = count_sql!(&format!("SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'sentence' AND embedding IS NOT NULL")); - let scene_count = count_sql!(&format!( - "SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'cut'" - )); - let face_total = count_sql!(&format!( - "SELECT COUNT(*) FROM {fd} WHERE file_uuid = '{file_uuid}'" - )); - let trace_count = count_sql!(&format!("SELECT COUNT(DISTINCT trace_id) FROM {fd} WHERE file_uuid = '{file_uuid}' AND trace_id IS NOT NULL")); - let trace_chunks = count_sql!(&format!( - "SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'trace'" - )); - let identity_count = count_sql!(&format!("SELECT COUNT(DISTINCT identity_id) FROM {fd} WHERE file_uuid = '{file_uuid}' AND identity_id IS NOT NULL")); - let tkg_nodes = count_sql!(&format!( - "SELECT COUNT(*) FROM {} WHERE file_uuid = '{file_uuid}'", - schema::table_name("tkg_nodes") - )); - let tkg_edges = count_sql!(&format!( - "SELECT COUNT(*) FROM {} WHERE file_uuid = '{file_uuid}'", - schema::table_name("tkg_edges") - )); - let scene_5w1h = count_sql!(&format!("SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'cut' AND summary_text IS NOT NULL AND summary_text != ''")); - - let related_identities: Vec = - match sqlx::query_as::<_, (String, String)>(&format!( - "SELECT DISTINCT i.uuid::text, i.name FROM {identities} i \ - JOIN {fd} fd ON fd.identity_id = i.id \ - WHERE fd.file_uuid = '{file_uuid}' AND fd.identity_id IS NOT NULL \ - ORDER BY i.name" - )) - .fetch_all(pool) - .await - { - Ok(rows) => rows - .into_iter() - .map(|(uuid, name)| IdentityRef { - uuid: uuid.replace('-', ""), - name, - }) - .collect(), - Err(e) => { - tracing::error!("related_identities query failed: {}", e); - vec![] - } - }; - - let strangers = count_sql!(&format!( - "SELECT COUNT(DISTINCT trace_id) FROM {fd} \ - WHERE file_uuid = '{file_uuid}' AND trace_id IS NOT NULL AND identity_id IS NULL" - )); - - macro_rules! step { - ($name:expr, $done:expr, $detail:expr) => { - IngestionStep { - name: $name.into(), - status: if $done { "done" } else { "pending" }.into(), - detail: $detail, - } - }; - } - - let steps = vec![ - step!( - "rule1_sentence", - sentence_count > 0, - Some(format!("{sentence_count} sentence chunks")) - ), - step!( - "auto_vectorize", - sentence_embedded > 0, - Some(format!("{sentence_embedded} embedded")) - ), - step!( - "rule3_scene", - scene_count > 0, - Some(format!("{scene_count} scene chunks")) - ), - step!( - "face_trace", - trace_count > 0, - Some(format!("{trace_count} traces / {face_total} detections")) - ), - step!( - "trace_chunks", - trace_chunks > 0, - Some(format!("{trace_chunks} trace chunks")) - ), - step!( - "tkg", - tkg_nodes > 0 || tkg_edges > 0, - Some(format!("{tkg_nodes} nodes, {tkg_edges} edges")) - ), - step!( - "identity_match", - identity_count > 0, - Some(format!("{identity_count} identities matched")) - ), - step!("scene_metadata", scene_meta_ok, None), - step!( - "5w1h", - scene_5w1h > 0, - Some(format!("{scene_5w1h} scenes with 5W1H")) - ), - ]; - - Ok(Json(IngestionStatusResponse { - file_uuid, - steps, - related_identities, - strangers, - })) -} - -#[derive(Debug, Deserialize)] -struct VideoDetailsQuery { - chunk_id: Option, - parent_id: Option, -} - -#[derive(Debug, Serialize)] -struct VideoDetailsResponse { - uuid: String, - #[serde(flatten)] - details: VideoDetailsResult, -} - -#[derive(Debug, Serialize)] -#[serde(untagged)] -enum VideoDetailsResult { - Chunk(ChunkDetailResponse), - Parent(ParentChunkResponse), -} - -#[derive(Debug, Serialize)] -struct FrameRange { - start_frame: i64, - end_frame: i64, - duration_frames: i64, - fps: f64, -} - -#[derive(Debug, Serialize)] -struct ReferenceTime { - start: f64, - end: f64, -} - -#[derive(Debug, Serialize)] -struct ChunkDetailResponse { - chunk_id: String, - chunk_type: String, - frame_range: FrameRange, - reference_time: ReferenceTime, - text_content: Option, - content: Option, - parent_id: Option, - summary_text: Option, - metadata: Option, - visual_stats: Option, - speaker_ids: Option>, - person_ids: Option>, -} - -#[derive(Debug, Serialize)] -struct ParentChunkResponse { - parent_id: i32, - metadata: Option, - summary_text: Option, - frame_range: FrameRange, - reference_time: ReferenceTime, -} - -/// Search visual chunks based on criteria -#[derive(Debug, Deserialize)] -struct VisualChunkSearchRequest { - file_uuid: String, - criteria: visual_chunk_search::VisualChunkSearchCriteria, -} - -#[derive(Debug, Serialize)] -struct VisualChunkSearchResponse { - chunks: Vec, - total: usize, -} - -async fn search_visual_chunks( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let criteria_hash = generate_visual_search_hash(&req.file_uuid, &req.criteria); - let cache_key = keys::visual_search(&req.file_uuid, &criteria_hash); - let ttl = state.mongo_cache.ttl_visual_search(); - - let chunks = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_VISUAL_SEARCH, || async { - let db = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - visual_chunk_search::search_visual_chunks(&db, &req.file_uuid, &req.criteria) - .await - .map_err(|e| anyhow::anyhow!("Visual search failed: {}", e)) - }) - .await - .map_err(|e| { - tracing::error!("Visual chunk search failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(VisualChunkSearchResponse { - total: chunks.len(), - chunks, - })) -} - -/// Request for searching visual chunks by object class -#[derive(Debug, Deserialize)] -struct VisualChunkSearchByClassRequest { - uuid: String, - object_class: String, - min_count: Option, - max_count: Option, -} - -/// Request for searching visual chunks by density -#[derive(Debug, Deserialize)] -struct VisualChunkSearchByDensityRequest { - uuid: String, - min_density: f32, - max_density: Option, -} - -/// Request for getting visual chunk statistics -#[derive(Debug, Deserialize)] -struct VisualChunkStatsRequest { - uuid: String, -} - -async fn search_visual_chunks_by_class( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let db = PostgresDb::init() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let chunks = visual_chunk_search::search_visual_chunks_by_class( - &db, - &req.uuid, - &req.object_class, - req.min_count, - req.max_count, - ) - .await - .map_err(|e| { - tracing::error!("Visual chunk search by class failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(VisualChunkSearchResponse { - total: chunks.len(), - chunks, - })) -} - -async fn search_visual_chunks_by_density( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let db = PostgresDb::init() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let chunks = visual_chunk_search::search_visual_chunks_by_density( - &db, - &req.uuid, - req.min_density, - req.max_density, - ) - .await - .map_err(|e| { - tracing::error!("Visual chunk search by density failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(VisualChunkSearchResponse { - total: chunks.len(), - chunks, - })) -} - -#[derive(Debug, Serialize)] -struct VisualChunkStatsResponse { - uuid: String, - stats: std::collections::HashMap, -} - -async fn get_visual_chunk_stats( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let db = PostgresDb::init() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let stats = visual_chunk_search::get_visual_chunk_statistics(&db, &req.uuid) - .await - .map_err(|e| { - tracing::error!("Get visual chunk stats failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(VisualChunkStatsResponse { - uuid: req.uuid, - stats, - })) -} - -/// Request for searching visual chunks by object combination -#[derive(Debug, Deserialize)] -struct VisualChunkSearchByCombinationRequest { - uuid: String, - combination: Vec<(String, u32)>, // (object_class, min_count) -} - -async fn search_visual_chunks_by_combination( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let db = PostgresDb::init() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let combination: Vec<(&str, u32)> = req - .combination - .iter() - .map(|(c, n)| (c.as_str(), *n)) - .collect(); - - let chunks = - visual_chunk_search::search_visual_chunks_by_combination(&db, &req.uuid, &combination) - .await - .map_err(|e| { - tracing::error!("Visual chunk search by combination failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(VisualChunkSearchResponse { - total: chunks.len(), - chunks, - })) -} - -async fn video_details( - Path(uuid): Path, - Query(query): Query, - State(state): State, -) -> Result, StatusCode> { - let table = schema::table_name("chunk"); - - if let Some(chunk_id) = query.chunk_id { - let row: Option<( - i32, - String, - String, - String, - f64, - i64, - i64, - Option, - serde_json::Value, - Option, - Option, - i32, - Option, - Option, - Option, - )> = sqlx::query_as(&format!( - "SELECT file_id, uuid, chunk_id, chunk_type::text, fps, start_frame, end_frame, - text_content, content, metadata, vector_id, frame_count, - parent_chunk_id, visual_stats, summary_text - FROM {} WHERE chunk_id = $1 AND uuid = $2", - table - )) - .bind(&chunk_id) - .bind(&uuid) - .fetch_optional(state.db.pool()) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let speaker_row: Option<(String, String)> = sqlx::query_as(&format!( - "SELECT COALESCE(speaker_ids, '{{}}'::text[]), COALESCE(face_ids, '{{}}'::integer[])::text[] FROM {} WHERE chunk_id = $1 AND uuid = $2", - table - )) - .bind(&chunk_id) - .bind(&uuid) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten(); - - let row = row.ok_or(StatusCode::NOT_FOUND)?; - - let fps = if row.4 > 0.0 { row.4 } else { 24.0 }; - let start_frame = row.5; - let end_frame = row.6; - let duration_frames = end_frame - start_frame; - - let start_time = start_frame as f64 / fps; - let end_time = end_frame as f64 / fps; - - let row_metadata = row.9.clone(); - - let mut summary_text = row.14.clone(); - let mut metadata = None; - - if let Some(ref pid_str) = row.12 { - if !pid_str.is_empty() { - if let Ok(pid) = pid_str.parse::() { - let parent_table = schema::table_name("parent_chunks"); - let parent: Option<(Option, Option)> = - sqlx::query_as(&format!( - "SELECT summary_text, metadata FROM {} WHERE id = $1", - parent_table - )) - .bind(pid) - .fetch_optional(state.db.pool()) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if let Some((s, m)) = parent { - if summary_text.is_none() { - summary_text = s; - } - - let mut merged: serde_json::Value = serde_json::json!({}); - - if let Some(ref cm) = row_metadata { - if let Some(obj) = cm.as_object() { - for (k, v) in obj { - merged[k] = v.clone(); - } - } - } - - if let Some(pm) = &m { - if let Some(obj) = pm.as_object() { - for (k, v) in obj { - merged[k] = v.clone(); - } - } - } - - metadata = Some(merged); - } - } - } - } else if let Some(ref cm) = row_metadata { - metadata = Some(cm.clone()); - } - - let parse_pg_array = |s: &str| -> Vec { - if s.is_empty() || s == "{}" { - return vec![]; - } - s.trim_start_matches('{') - .trim_end_matches('}') - .split(',') - .map(|s| s.trim_matches('"').to_string()) - .collect() - }; - - let (speaker_str, face_str) = speaker_row.unwrap_or(("{}".to_string(), "{}".to_string())); - let speaker_vec: Vec = parse_pg_array(&speaker_str); - let speaker_ids: Option> = if speaker_vec.is_empty() { - None - } else { - Some(speaker_vec) - }; - let face_vec: Vec = parse_pg_array(&face_str); - let person_ids: Option> = if face_vec.is_empty() { - None - } else { - Some(face_vec.iter().map(|id| format!("face_{}", id)).collect()) - }; - - return Ok(Json(VideoDetailsResponse { - uuid: row.1.clone(), - details: VideoDetailsResult::Chunk(ChunkDetailResponse { - chunk_id: row.2.clone(), - chunk_type: row.3.clone(), - frame_range: FrameRange { - start_frame, - end_frame, - duration_frames, - fps, - }, - reference_time: ReferenceTime { - start: start_time, - end: end_time, - }, - text_content: row.7.clone(), - content: Some(row.8.clone()), - parent_id: row.12.clone(), - summary_text, - metadata, - visual_stats: row.13.clone(), - speaker_ids, - person_ids, - }), - })); - } - - Err(StatusCode::BAD_REQUEST) -} - -#[derive(Debug, Serialize)] -struct DeleteVideoResponse { - success: bool, - message: String, - deleted_face_detections: u64, - deleted_processor_results: u64, -} - -#[derive(Debug, Serialize)] -struct UnregisterFileResponse { - success: bool, - message: String, - file_uuid: String, - deleted_face_detections: u64, - deleted_processor_results: u64, - deleted_chunks: u64, -} - -/// Detects file type using a tiered strategy: -/// 1. ffprobe (if available) -/// 2. `file` command (MIME type) -/// 3. File extension -fn detect_file_type( - path: &std::path::Path, - probe_result: Option<&crate::core::probe::ffprobe::ProbeResult>, -) -> String { - // 1. ffprobe - if let Some(probe) = probe_result { - let has_video = probe - .streams - .iter() - .any(|s| s.codec_type.as_deref() == Some("video")); - let has_audio = probe - .streams - .iter() - .any(|s| s.codec_type.as_deref() == Some("audio")); - - if has_video { - return "video".to_string(); - } - if has_audio { - return "audio".to_string(); - } - if !probe.streams.is_empty() { - return "image".to_string(); - } - } - - // 2. file command - if let Ok(output) = std::process::Command::new("file") - .args(["--mime-type", "-b"]) - .arg(path) - .output() - { - let mime = String::from_utf8_lossy(&output.stdout) - .trim() - .to_lowercase(); - if mime.starts_with("video/") { - return "video".to_string(); - } - if mime.starts_with("audio/") { - return "audio".to_string(); - } - if mime.starts_with("image/") { - return "image".to_string(); - } - } - - // 3. Extension - match path - .extension() - .and_then(|e| e.to_str()) - .map(|s| s.to_lowercase()) - .as_deref() - { - Some("mp4" | "mov" | "mkv" | "avi" | "webm" | "flv" | "wmv") => "video".to_string(), - Some("mp3" | "wav" | "flac" | "aac" | "ogg" | "m4a") => "audio".to_string(), - Some("jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "svg") => { - "image".to_string() - } - _ => "unknown".to_string(), - } -} - -async fn delete_video( - Path(uuid): Path, - State(state): State, -) -> Result, StatusCode> { - tracing::info!("[UNREGISTER] Unregistering file: {}", uuid); - - let videos_table = schema::table_name("videos"); - let face_table = schema::table_name("face_detections"); - let processor_table = schema::table_name("processor_results"); - let chunks_table = schema::table_name("chunk"); - let parent_chunks_table = schema::table_name("parent_chunks"); - - // Check if video exists first - let exists: Option = sqlx::query_scalar(&format!( - "SELECT file_uuid FROM {} WHERE file_uuid = $1", - videos_table - )) - .bind(&uuid) - .fetch_optional(state.db.pool()) - .await - .map_err(|e| { - tracing::error!("[UNREGISTER] DB check failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - // Delete associated data regardless of video existence (cleanup orphans) - let deleted_faces: i64 = - sqlx::query(&format!("DELETE FROM {} WHERE file_uuid = $1", face_table)) - .bind(&uuid) - .execute(state.db.pool()) - .await - .map(|r| r.rows_affected() as i64) - .unwrap_or(0); - - tracing::info!("[UNREGISTER] Deleted {} face_detections", deleted_faces); - - let deleted_processors: i64 = sqlx::query(&format!( - "DELETE FROM {} WHERE file_uuid = $1", - processor_table - )) - .bind(&uuid) - .execute(state.db.pool()) - .await - .map(|r| r.rows_affected() as i64) - .unwrap_or(0); - - tracing::info!( - "[UNREGISTER] Deleted {} processor_results", - deleted_processors - ); - - let deleted_parent_chunks: i64 = sqlx::query(&format!( - "DELETE FROM {} WHERE uuid = $1", - parent_chunks_table - )) - .bind(&uuid) - .execute(state.db.pool()) - .await - .map(|r| r.rows_affected() as i64) - .unwrap_or(0); - - let deleted_chunks: i64 = sqlx::query(&format!("DELETE FROM {} WHERE uuid = $1", chunks_table)) - .bind(&uuid) - .execute(state.db.pool()) - .await - .map(|r| r.rows_affected() as i64) - .unwrap_or(0); - - tracing::info!("[UNREGISTER] Deleted {} chunks", deleted_chunks); - - if exists.is_none() { - tracing::warn!( - "[UNREGISTER] Video not found: {}. (Maybe already deleted or UUID mismatch)", - uuid - ); - return Err(StatusCode::NOT_FOUND); - } - - Ok(Json(UnregisterFileResponse { - success: true, - message: format!( - "File {} unregistered successfully. Record deleted from database.", - uuid - ), - file_uuid: uuid, - deleted_face_detections: deleted_faces as u64, - deleted_processor_results: deleted_processors as u64, - deleted_chunks: (deleted_chunks + deleted_parent_chunks) as u64, - })) -} diff --git a/src/api/tmdb_api.rs b/src/api/tmdb_api.rs index b32466c..49618d7 100644 --- a/src/api/tmdb_api.rs +++ b/src/api/tmdb_api.rs @@ -7,7 +7,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -use crate::api::server::AppState; +use crate::api::types::AppState; use crate::core::config; use crate::core::db::{PostgresDb, QdrantDb}; use crate::core::tmdb; diff --git a/src/api/trace_agent_api.rs b/src/api/trace_agent_api.rs index c79e33d..193877a 100644 --- a/src/api/trace_agent_api.rs +++ b/src/api/trace_agent_api.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::core::db::PostgresDb; -pub fn trace_agent_routes() -> Router { +pub fn trace_agent_routes() -> Router { Router::new() .route("/api/v1/file/:file_uuid/traces", post(list_traces_sorted)) .route( @@ -55,7 +55,7 @@ struct TracesResponse { } async fn list_traces_sorted( - State(state): State, + State(state): State, Path(file_uuid): Path, Json(req): Json, ) -> Result, (StatusCode, String)> { @@ -202,7 +202,7 @@ fn lerp_i32(a: Option, b: Option, t: f64) -> Option { } async fn list_trace_faces( - State(state): State, + State(state): State, Path((file_uuid, trace_id)): Path<(String, i32)>, Query(q): Query, ) -> Result, (StatusCode, String)> { diff --git a/src/api/types.rs b/src/api/types.rs new file mode 100644 index 0000000..9bca68c --- /dev/null +++ b/src/api/types.rs @@ -0,0 +1,9 @@ +#[derive(Clone)] +pub struct AppState { + pub db: std::sync::Arc, + pub embedder: std::sync::Arc, + pub embedder_model: String, + pub mongo_cache: crate::core::cache::MongoCache, + pub redis_cache: crate::core::cache::RedisCache, + pub api_state: super::middleware::ApiState, +} diff --git a/src/api/universal_search.rs b/src/api/universal_search.rs index 860d17d..34cb15a 100644 --- a/src/api/universal_search.rs +++ b/src/api/universal_search.rs @@ -98,7 +98,7 @@ pub enum SearchResult { }, } -pub fn universal_search_routes() -> Router { +pub fn universal_search_routes() -> Router { Router::new() .route("/api/v1/search/universal", post(universal_search)) .route("/api/v1/search/frames", post(search_frames)) @@ -106,7 +106,7 @@ pub fn universal_search_routes() -> Router { /// Unified search across all data types pub async fn universal_search( - State(_state): State, + State(_state): State, Json(req): Json, ) -> Result, (StatusCode, Json)> { let start_time = std::time::Instant::now(); @@ -212,7 +212,7 @@ pub async fn universal_search( /// Search frames by YOLO objects, OCR text, or face IDs pub async fn search_frames( - State(_state): State, + State(_state): State, Json(req): Json, ) -> Result, (StatusCode, Json)> { let db = PostgresDb::init().await.map_err(|e| { @@ -238,7 +238,7 @@ pub async fn search_frames( /// Search persons by name or speaker_id pub async fn search_persons( - State(_state): State, + State(_state): State, Query(query): Query, ) -> Result, (StatusCode, Json)> { let db = PostgresDb::init().await.map_err(|e| { diff --git a/src/api/visual_search.rs b/src/api/visual_search.rs new file mode 100644 index 0000000..bdca397 --- /dev/null +++ b/src/api/visual_search.rs @@ -0,0 +1,217 @@ +use axum::{extract::State, http::StatusCode, response::Json, routing::post, Router}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::types::AppState; +use super::visual_chunk_search; +use crate::core::cache::keys; +use crate::core::chunk::types::Chunk; +use crate::core::db::{Database, PostgresDb}; + +fn generate_visual_search_hash( + uuid: &str, + criteria: &visual_chunk_search::VisualChunkSearchCriteria, +) -> String { + let data = serde_json::json!({ + "uuid": uuid, + "criteria": criteria, + }); + let mut hasher = Sha256::new(); + hasher.update(data.to_string().as_bytes()); + format!("{:x}", hasher.finalize())[..16].to_string() +} + +#[derive(Debug, Deserialize)] +struct VisualChunkSearchRequest { + file_uuid: String, + criteria: visual_chunk_search::VisualChunkSearchCriteria, +} + +#[derive(Debug, Serialize)] +struct VisualChunkSearchResponse { + chunks: Vec, + total: usize, +} + +async fn search_visual_chunks( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let criteria_hash = generate_visual_search_hash(&req.file_uuid, &req.criteria); + let cache_key = keys::visual_search(&req.file_uuid, &criteria_hash); + let ttl = state.mongo_cache.ttl_visual_search(); + + let chunks = state + .mongo_cache + .get_or_fetch(&cache_key, ttl, keys::CATEGORY_VISUAL_SEARCH, || async { + let db = PostgresDb::init() + .await + .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; + + visual_chunk_search::search_visual_chunks(&db, &req.file_uuid, &req.criteria) + .await + .map_err(|e| anyhow::anyhow!("Visual search failed: {}", e)) + }) + .await + .map_err(|e| { + tracing::error!("Visual chunk search failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(VisualChunkSearchResponse { + total: chunks.len(), + chunks, + })) +} + +#[derive(Debug, Deserialize)] +struct VisualChunkSearchByClassRequest { + uuid: String, + object_class: String, + min_count: Option, + max_count: Option, +} + +#[derive(Debug, Deserialize)] +struct VisualChunkSearchByDensityRequest { + uuid: String, + min_density: f32, + max_density: Option, +} + +#[derive(Debug, Deserialize)] +struct VisualChunkStatsRequest { + uuid: String, +} + +async fn search_visual_chunks_by_class( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let db = PostgresDb::init() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let chunks = visual_chunk_search::search_visual_chunks_by_class( + &db, + &req.uuid, + &req.object_class, + req.min_count, + req.max_count, + ) + .await + .map_err(|e| { + tracing::error!("Visual chunk search by class failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(VisualChunkSearchResponse { + total: chunks.len(), + chunks, + })) +} + +async fn search_visual_chunks_by_density( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let db = PostgresDb::init() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let chunks = visual_chunk_search::search_visual_chunks_by_density( + &db, + &req.uuid, + req.min_density, + req.max_density, + ) + .await + .map_err(|e| { + tracing::error!("Visual chunk search by density failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(VisualChunkSearchResponse { + total: chunks.len(), + chunks, + })) +} + +#[derive(Debug, Serialize)] +struct VisualChunkStatsResponse { + uuid: String, + stats: std::collections::HashMap, +} + +async fn get_visual_chunk_stats( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let db = PostgresDb::init() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let stats = visual_chunk_search::get_visual_chunk_statistics(&db, &req.uuid) + .await + .map_err(|e| { + tracing::error!("Get visual chunk stats failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(VisualChunkStatsResponse { + uuid: req.uuid, + stats, + })) +} + +#[derive(Debug, Deserialize)] +struct VisualChunkSearchByCombinationRequest { + uuid: String, + combination: Vec<(String, u32)>, +} + +async fn search_visual_chunks_by_combination( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let db = PostgresDb::init() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let combination: Vec<(&str, u32)> = req + .combination + .iter() + .map(|(c, n)| (c.as_str(), *n)) + .collect(); + + let chunks = + visual_chunk_search::search_visual_chunks_by_combination(&db, &req.uuid, &combination) + .await + .map_err(|e| { + tracing::error!("Visual chunk search by combination failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(VisualChunkSearchResponse { + total: chunks.len(), + chunks, + })) +} + +pub fn visual_search_routes() -> Router { + Router::new() + .route("/api/v1/search/visual", post(search_visual_chunks)) + .route( + "/api/v1/search/visual/class", + post(search_visual_chunks_by_class), + ) + .route( + "/api/v1/search/visual/density", + post(search_visual_chunks_by_density), + ) + .route("/api/v1/search/visual/stats", post(get_visual_chunk_stats)) + .route( + "/api/v1/search/visual/combination", + post(search_visual_chunks_by_combination), + ) +}