From f27e51a9052aba16e4c5cb3cc5c5be31d3d77854 Mon Sep 17 00:00:00 2001 From: accusys Date: Wed, 25 Mar 2026 03:26:40 +0800 Subject: [PATCH] feat: add POST /api/v1/probe endpoint - Add ProbeRequest/ProbeResponse structures - Support relative and absolute paths - Cache probe.json for repeated requests - Return video metadata (uuid, duration, width, height, fps) - Include cached flag to indicate cache hit - Export FormatInfo and StreamInfo from probe module - Update API_ENDPOINTS.md documentation --- docs/API_ENDPOINTS.md | 217 ++++++++++++++++++++++++++++++++++++++++++ src/api/server.rs | 203 +++++++++++++++++++++++++++++++++++---- src/core/probe/mod.rs | 4 +- 3 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 docs/API_ENDPOINTS.md diff --git a/docs/API_ENDPOINTS.md b/docs/API_ENDPOINTS.md new file mode 100644 index 0000000..3989dfc --- /dev/null +++ b/docs/API_ENDPOINTS.md @@ -0,0 +1,217 @@ +# Momentry Core API 端點總覽 + +| 項目 | 內容 | +|------|------| +| 版本 | V1.1 | +| 日期 | 2026-03-25 | + +--- + +## Base URL + +| 環境 | URL | +|------|-----| +| 本地 | `http://localhost:3002` | +| 外部 | `https://api.momentry.ddns.net` | + +--- + +## 端點列表 + +### 健康檢查 + +| 方法 | 端點 | 說明 | +|------|------|------| +| GET | `/health` | 基本健康檢查 | +| GET | `/health/detailed` | 詳細健康檢查(含服務狀態) | + +**範例**: +```bash +curl http://localhost:3002/health +# {"status":"ok","version":"0.1.0","uptime_ms":123456} +``` + +--- + +### 影片搜尋 + +| 方法 | 端點 | 說明 | +|------|------|------| +| POST | `/api/v1/search` | 語意搜尋(標準格式) | +| POST | `/api/v1/n8n/search` | 語意搜尋(n8n 專用格式) | +| POST | `/api/v1/search/hybrid` | 混合搜尋 | + +**標準搜尋** (`/api/v1/search`): +```bash +curl -X POST http://localhost:3002/api/v1/search \ + -H "Content-Type: application/json" \ + -d '{"query": "test", "limit": 10}' +``` + +**n8n 格式搜尋** (`/api/v1/n8n/search`): +```bash +curl -X POST http://localhost:3002/api/v1/n8n/search \ + -H "Content-Type: application/json" \ + -d '{"query": "test", "limit": 10}' +``` + +--- + +### 影片管理 + +| 方法 | 端點 | 說明 | +|------|------|------| +| POST | `/api/v1/register` | 註冊影片 | +| POST | `/api/v1/probe` | 探測影片資訊(不註冊) | +| GET | `/api/v1/videos` | 列出所有影片 | +| GET | `/api/v1/lookup` | 查詢影片資訊 | +| GET | `/api/v1/progress/:uuid` | 取得處理進度 | + +**註冊影片**: +```bash +curl -X POST http://localhost:3002/api/v1/register \ + -H "Content-Type: application/json" \ + -d '{"path": "/path/to/video.mp4"}' +``` + +**探測影片** (不註冊,只取得影片資訊): +```bash +curl -X POST http://localhost:3002/api/v1/probe \ + -H "Content-Type: application/json" \ + -d '{"path": "./demo/video.mp4"}' +``` + +**Probe 回應範例**: +```json +{ + "uuid": "a1b10138a6bbb0cd", + "file_name": "video.mp4", + "duration": 120.5, + "width": 1920, + "height": 1080, + "fps": 30.0, + "cached": false, + "format": { + "filename": "/path/to/video.mp4", + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "duration": "120.5", + "size": "12345678", + "bit_rate": "819200" + }, + "streams": [ + { + "index": 0, + "codec_name": "h264", + "codec_type": "video", + "width": 1920, + "height": 1080, + "r_frame_rate": "30/1", + "duration": "120.5" + } + ] +} +``` + +**列出影片**: +```bash +curl http://localhost:3002/api/v1/videos +``` + +**查詢影片**: +```bash +curl "http://localhost:3002/api/v1/lookup?uuid=5dea6618a606e7c7" +``` + +**處理進度**: +```bash +curl http://localhost:3002/api/v1/progress/5dea6618a606e7c7 +``` + +--- + +## 端點對照表 + +| 功能 | n8n 使用 | WordPress 使用 | curl 測試 | +|------|-----------|----------------|------------| +| 健康檢查 | ✓ | ✓ | ✓ | +| 語意搜尋 | ✓ (n8n格式) | ✓ (標準格式) | ✓ | +| 影片探測 | ✓ | ✓ | ✓ | +| 註冊影片 | ✓ | ✓ | ✓ | +| 列出影片 | ✓ | ✓ | ✓ | +| 查詢影片 | ✓ | ✓ | ✓ | +| 處理進度 | ✓ | ✓ | ✓ | + +--- + +## 回應格式 + +### n8n 格式 (`/api/v1/n8n/search`) +```json +{ + "query": "charade", + "count": 10, + "hits": [ + { + "id": "sentence_0001", + "vid": "a1b10138a6bbb0cd", + "start": 48.8, + "end": 55.44, + "title": "Chunk sentence_0001", + "text": "...", + "score": 0.92, + "media_url": "https://wp.momentry.ddns.net/video.mp4" + } + ] +} +``` + +### 標準格式 (`/api/v1/search`) +```json +{ + "results": [ + { + "uuid": "a1b10138a6bbb0cd", + "chunk_id": "sentence_0001", + "chunk_type": "sentence", + "start_time": 48.8, + "end_time": 55.44, + "text": "...", + "score": 0.92 + } + ], + "query": "charade" +} +``` + +--- + +## HTTP 狀態碼 + +| 狀態 | 說明 | +|------|------| +| 200 | 成功 | +| 400 | 請求格式錯誤 | +| 404 | 端點或資源不存在 | +| 500 | 伺服器內部錯誤 | +| 502 | API 服務未啟動 | + +--- + +## 錯誤處理 + +### 502 Bad Gateway + +**原因**: Momentry API 服務未啟動 + +**解決**: +```bash +sudo launchctl load /Library/LaunchDaemons/com.momentry.api.plist +``` + +--- + +## 相關文件 + +- [API_INDEX.md](./API_INDEX.md) - 文件總覽(起點) +- [API_N8N_GUIDE.md](./API_N8N_GUIDE.md) - n8n 使用範例 +- [API_WORDPRESS_GUIDE.md](./API_WORDPRESS_GUIDE.md) - WordPress 使用範例 diff --git a/src/api/server.rs b/src/api/server.rs index 49526bb..680db34 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -10,7 +10,6 @@ use sha2::{Digest, Sha256}; use std::time::Instant; use crate::core::cache::{keys, MongoCache, RedisCache}; -use crate::core::config::USER_DATA_ROOT; use crate::core::db::{Database, PostgresDb, QdrantDb, RedisClient, VideoRecord, VideoStatus}; use crate::{Embedder, FileManager}; @@ -79,6 +78,24 @@ struct RegisterResponse { already_exists: bool, } +#[derive(Debug, Deserialize)] +struct ProbeRequest { + path: String, +} + +#[derive(Debug, Serialize)] +struct ProbeResponse { + uuid: String, + file_name: String, + duration: f64, + width: u32, + height: u32, + fps: f64, + cached: bool, + format: crate::core::probe::FormatInfo, + streams: Vec, +} + #[derive(Debug, Serialize)] struct JobListResponse { jobs: Vec, @@ -395,29 +412,42 @@ async fn register( ) -> Result, StatusCode> { let path = req.path; - // Canonicalize path first to ensure consistent UUID computation - let canonical_path = std::path::Path::new(&path) - .canonicalize() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| path.clone()); + // Support both relative and absolute paths + // Relative: ./demo/video.mp4 or demo/video.mp4 + // Absolute: /Users/.../sftpgo/data/demo/video.mp4 + let (relative_path, canonical_path) = if path.starts_with("./") || path.starts_with("../") { + // Relative path - keep as is for UUID, resolve to absolute for storage + let rel = path.clone(); + let abs = std::path::Path::new(&path) + .canonicalize() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.clone()); + (rel, abs) + } else if std::path::Path::new(&path).is_absolute() { + // Absolute path - use as is + (path.clone(), path.clone()) + } else { + // Assume relative path without ./ + let rel = format!("./{}", path); + let abs = std::path::Path::new(&rel) + .canonicalize() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.clone()); + (rel, abs) + }; - // Compute UUID using USER_DATA_ROOT to extract relative path - // This ensures consistent UUIDs even when data root changes - // Relative path format: username/video.mp4 (e.g., demo/video.mp4) - let user_data_root = USER_DATA_ROOT.as_str(); - let uuid = crate::core::storage::uuid::compute_uuid_from_path_with_root( - &canonical_path, - user_data_root, - ); + // Compute UUID from relative path (username/filepath) + // Extract: ./demo/video.mp4 -> username="demo", filepath="video.mp4" + let uuid = crate::core::storage::uuid::compute_uuid_from_relative_path(&relative_path); - // Extract relative path for display/logging (username/filename) - let (user_dir, filename) = - crate::core::storage::uuid::extract_relative_path(&canonical_path, user_data_root); + // Extract username and filepath for logging + let (username, filepath) = + crate::core::storage::uuid::extract_user_from_relative_path(&relative_path); tracing::info!( - "Registering video: uuid={}, user={}, file={}, full_path={}", + "Registering video: uuid={}, username={}, filepath={}, canonical={}", uuid, - user_dir, - filename, + username, + filepath, canonical_path ); @@ -553,6 +583,138 @@ async fn register( })) } +async fn probe( + State(_state): State, + Json(req): Json, +) -> Result, StatusCode> { + let path = req.path; + + // Support both relative and absolute paths + let (relative_path, canonical_path) = if path.starts_with("./") || path.starts_with("../") { + let rel = path.clone(); + let abs = std::path::Path::new(&path) + .canonicalize() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.clone()); + (rel, abs) + } else if std::path::Path::new(&path).is_absolute() { + (path.clone(), path.clone()) + } else { + let rel = format!("./{}", path); + let abs = std::path::Path::new(&rel) + .canonicalize() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.clone()); + (rel, abs) + }; + + // Compute UUID from relative path + let uuid = crate::core::storage::uuid::compute_uuid_from_relative_path(&relative_path); + + let (username, filepath) = + crate::core::storage::uuid::extract_user_from_relative_path(&relative_path); + tracing::info!( + "Probing video: uuid={}, username={}, filepath={}, canonical={}", + uuid, + username, + filepath, + canonical_path + ); + + // Check for cached probe.json + let probe_path = format!( + "{}/{}.probe.json", + crate::core::config::OUTPUT_DIR.as_str(), + 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 + })?; + (result, true) + } else { + tracing::info!("Running ffprobe for: {}", canonical_path); + let result = crate::core::probe::probe_video(&canonical_path).map_err(|e| { + tracing::error!("ffprobe failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Save probe.json + let file_manager = FileManager::new(std::path::PathBuf::from(".")); + let json_str = serde_json::to_string(&result).map_err(|e| { + tracing::error!("Failed to serialize probe result: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + file_manager + .save_json(&uuid, "probe", &json_str) + .map_err(|e| { + tracing::error!("Failed to save probe.json: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + (result, false) + }; + + // 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); + // Parse fps from r_frame_rate (e.g., "30/1" or "29.97") + 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 file_name = std::path::Path::new(&canonical_path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + Ok(Json(ProbeResponse { + uuid, + file_name, + duration, + width, + height, + fps, + cached, + format: probe_result.format, + streams: probe_result.streams, + })) +} + async fn search( State(state): State, Json(req): Json, @@ -1115,6 +1277,7 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { .route("/health", get(health)) .route("/health/detailed", get(health_detailed)) .route("/api/v1/register", post(register)) + .route("/api/v1/probe", post(probe)) .route("/api/v1/search", post(search)) .route("/api/v1/n8n/search", post(n8n_search)) .route("/api/v1/search/hybrid", post(hybrid_search)) diff --git a/src/core/probe/mod.rs b/src/core/probe/mod.rs index cc0eb14..9788061 100644 --- a/src/core/probe/mod.rs +++ b/src/core/probe/mod.rs @@ -1,3 +1,3 @@ -pub mod probe; +pub mod ffprobe; -pub use probe::{probe_video, ProbeResult}; +pub use ffprobe::{probe_video, FormatInfo, ProbeResult, StreamInfo};