From 82955504f35577633a7ff12689804bc441c007c0 Mon Sep 17 00:00:00 2001 From: Warren Date: Thu, 26 Mar 2026 16:16:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Job=20Worker=20?= =?UTF-8?q?=E7=B3=BB=E7=B5=B1=E8=88=87=20API=20=E6=96=87=E6=AA=94=E5=85=A8?= =?UTF-8?q?=E9=9D=A2=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 43 +- .gitignore | 3 + Cargo.toml | 6 +- com.momentry.api.updated.plist | 64 ++ docs/API_ACCESS.md | 53 +- docs/API_CURL_EXAMPLES.md | 116 +++- docs/API_ENDPOINTS.md | 51 +- docs/API_EXAMPLES.md | 79 ++- docs/API_INDEX.md | 15 +- docs/API_KEY_MANAGEMENT.md | 20 +- docs/API_N8N_GUIDE.md | 57 +- docs/API_QUICK_REFERENCE.md | 532 ++++++++++++++++ docs/API_REFERENCE.md | 39 +- docs/API_TRAINING_MARCOM.md | 5 +- docs/API_WORDPRESS_GUIDE.md | 73 ++- docs/DEMO_MANUAL.md | 18 +- docs/DOCUMENT_EMBEDDING_STRATEGY.md | 20 +- docs/INSTALL_MOMENTRY_API.md | 2 +- docs/INSTALL_SFTPGO.md | 16 +- docs/MAC_INSTALLATION_PLAN.md | 2 +- docs/MOMENTRY_RAG_PRESENTATION.md | 52 +- docs/N8N_DEMO.md | 39 +- docs/N8N_DEMO_EXECUTION_LOG.md | 20 +- docs/N8N_DEMO_WORKFLOW.md | 25 +- docs/N8N_HTTP_REQUEST_GUIDE.md | 28 +- docs/N8N_INTEGRATION_GUIDE.md | 19 +- docs/N8N_MCP_SETUP.md | 16 + docs/N8N_MCP_TEST_REPORT.md | 16 + docs/N8N_VIDEO_SEARCH_SUCCESS.md | 34 +- docs/PLAYGROUND_BINARY_IMPLEMENTATION.md | 16 + docs/PROCESSING_PIPELINE.md | 18 +- docs/TEST_AND_BENCHMARK_PLAN.md | 16 + docs/USER_MANUAL.md | 18 +- docs/VERSION_MANAGEMENT.md | 16 + docs/VIDEO_REGISTRATION.md | 27 +- docs/n8n_workflow_simple.json | 27 +- docs/n8n_workflow_video_rag_mcp.json | 29 +- docs/n8n_workflow_video_search.json | 27 +- install_worker_service.sh | 14 + migrations/004_fix_processor_results.sql | 61 ++ migrations/005_change_duration_float.sql | 22 + momentry_runtime/plist/com.momentry.api.plist | 8 +- ...er.plist => com.momentry.worker.plist.bak} | 8 +- .../redis_publisher.cpython-311.pyc | Bin 9145 -> 9145 bytes scripts/asr_processor.py | 54 ++ scripts/caption_processor.py | 0 scripts/ocr_processor.py | 10 + scripts/pose_processor.py | 10 + scripts/story_processor.py | 0 src/api/server.rs | 8 +- src/bin/fix_chunks.rs | 82 +++ src/core/chunk/splitter.rs | 4 +- src/core/chunk/types.rs | 110 +++- src/core/db/mongodb_db.rs | 12 +- src/core/db/postgres_db.rs | 151 +++-- src/core/db/sync_db.rs | 6 +- src/core/mod.rs | 1 + src/core/processor/asr.rs | 2 +- src/core/processor/executor.rs | 12 + src/core/processor/yolo.rs | 160 ++++- src/core/time.rs | 383 ++++++++++++ src/main.rs | 56 +- src/playground.rs | 38 +- src/worker/job_worker.rs | 115 +++- src/worker/processor.rs | 574 +++++++++++++++++- update_all_workflows.py | 142 +++++ update_api_service.sh | 20 + update_asr.py | 56 ++ update_workflow.py | 59 ++ worker.pid | 1 + 70 files changed, 3460 insertions(+), 376 deletions(-) create mode 100644 com.momentry.api.updated.plist create mode 100644 docs/API_QUICK_REFERENCE.md create mode 100644 install_worker_service.sh create mode 100644 migrations/004_fix_processor_results.sql create mode 100644 migrations/005_change_duration_float.sql rename momentry_runtime/plist/{com.momentry.worker.plist => com.momentry.worker.plist.bak} (88%) mode change 100644 => 100755 scripts/asr_processor.py mode change 100644 => 100755 scripts/caption_processor.py mode change 100644 => 100755 scripts/story_processor.py create mode 100644 src/bin/fix_chunks.rs create mode 100644 src/core/time.rs create mode 100644 update_all_workflows.py create mode 100644 update_api_service.sh create mode 100644 update_asr.py create mode 100644 update_workflow.py create mode 100644 worker.pid diff --git a/.env b/.env index 71bc619..991bd11 100644 --- a/.env +++ b/.env @@ -1,40 +1,3 @@ -# Database Configuration -DATABASE_URL=postgres://accusys@localhost:5432/momentry - -# Redis -# Format: redis://[username][:password]@host:port -# Users: default (with password), accusys (custom user with password) -REDIS_URL=redis://accusys:accusys@localhost:6379 - -# MongoDB -MONGODB_URL=mongodb://accusys:Test3200Test3200@localhost:27017/admin -MONGODB_DATABASE=momentry - -# Qdrant Vector Database -QDRANT_URL=http://localhost:6333 -QDRANT_API_KEY=Test3200Test3200Test3200 -QDRANT_COLLECTION=chunks_v3 - -# Gitea -GITEA_URL=http://localhost:3000 - -# API Server (Production) -MOMENTRY_SERVER_PORT=3002 -MOMENTRY_REDIS_PREFIX=momentry: -API_HOST=127.0.0.1 -API_PORT=3002 - -# Worker Configuration (Production) -MOMENTRY_WORKER_ENABLED=true -MOMENTRY_MAX_CONCURRENT=2 -MOMENTRY_POLL_INTERVAL=5 - -# Watch Directories (comma separated) -WATCH_DIRECTORIES=~/Videos,~/momentry_core_project/test_video - -# Ollama (for Mistral 7B LLM) -OLLAMA_HOST=http://localhost:11434 - -# Model Paths -# EMBEDDING_MODEL_PATH=./models/comic-embed-text -# LLM_MODEL_PATH=./models/mistral-7b +DB_MAX_CONNECTIONS=50 +DB_ACQUIRE_TIMEOUT=30 +QDRANT_URL=http://127.0.0.1:6333 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 567d49a..aa65550 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ id_* *.swp *.swo *~ + +# Documentation backups +docs_v1.0/ diff --git a/Cargo.toml b/Cargo.toml index b3b9486..2bc05c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = "0.3" once_cell = "1.19" +libc = "0.2" dotenv = "0.15" # CLI @@ -73,7 +74,6 @@ crossterm = "0.28" atty = "0.2" # System -libc = "0.2" [lib] name = "momentry_core" @@ -94,3 +94,7 @@ path = "src/player/main.rs" [[bin]] name = "momentry_playground" path = "src/playground.rs" + +[[bin]] +name = "fix_chunks" +path = "src/bin/fix_chunks.rs" diff --git a/com.momentry.api.updated.plist b/com.momentry.api.updated.plist new file mode 100644 index 0000000..341fbd2 --- /dev/null +++ b/com.momentry.api.updated.plist @@ -0,0 +1,64 @@ + + + + + Label + com.momentry.api + + UserName + accusys + + GroupName + staff + + WorkingDirectory + /Users/accusys/momentry_core_0.1 + + ProgramArguments + + /Users/accusys/momentry_core_0.1/target/release/momentry + server + --port + 3002 + + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + DATABASE_URL + postgres://accusys@localhost:5432/momentry + + DB_MAX_CONNECTIONS + 50 + + DB_ACQUIRE_TIMEOUT + 30 + + REDIS_URL + redis://:accusys@localhost:6379 + + REDIS_PASSWORD + accusys + + OLLAMA_HOST + http://localhost:11434 + + QDRANT_URL + http://127.0.0.1:6333 + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /Users/accusys/momentry/log/momentry_api.log + + StandardErrorPath + /Users/accusys/momentry/log/momentry_api.error.log + + \ No newline at end of file diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index 79701db..f5dd3db 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -1,5 +1,23 @@ # Momentry Core API 存取指南 +| 項目 | 內容 | +|------|------| +| 版本 | V1.3 | +| 日期 | 2026-03-25 | +| 用途 | API 存取方式、端點與整合指南 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.3 | 2026-03-25 | 更新: n8n 搜尋回傳 `file_path` 取代 `media_url`,新增 API Key 驗證說明 | OpenCode | deepseek-reasoner | +| V1.2 | 2026-03-24 | 更新網址與服務列表 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-23 | 初始版本 | Warren | OpenCode / MiniMax M2.5 | + +--- + ## 基本網址 | 環境 | URL | 說明 | @@ -20,7 +38,16 @@ - 生產環境 ## 認證 -目前為開放狀態(示範用途無需認證)。正式環境將實作 API Key。 +所有 `/api/v1/*` 端點(除了健康檢查 `/health` 與 `/health/detailed`)都需要 API Key 認證。 + +請在請求標頭中加入: +``` +X-API-Key: YOUR_API_KEY +``` + +**目前示範使用的 API Key**: `demo_api_key_12345` + +> **注意**: 正式環境請使用安全的 API Key 管理機制,避免在客戶端暴露 API Key。 --- @@ -91,12 +118,14 @@ "title": "Chunk sentence_0006", "text": "fun plot twists...", "score": 0.526, - "media_url": "https://wp.momentry.ddns.net/Old_Time_Movie_Show_-_Charade_1963.HD.mov" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/Old_Time_Movie_Show_-_Charade_1963.HD.mov" } ] } ``` +> **注意**: API 現在返回 `file_path`(檔案系統路徑)而非 `media_url`(網頁 URL)。如需在網頁中播放影片,請將檔案路徑轉換為可訪問的 URL(例如透過 SFTPGo 分享連結)。 + --- ## 影片管理 API @@ -134,7 +163,10 @@ ```javascript const response = await fetch('http://localhost:3002/api/v1/search', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': 'YOUR_API_KEY' // 替換為實際的 API Key + }, body: JSON.stringify({ query: 'charade', limit: 5 }) }); const data = await response.json(); @@ -149,7 +181,10 @@ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 'query' => 'charade', 'limit' => 5 ])); -curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); +curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'X-API-Key: YOUR_API_KEY' // 替換為實際的 API Key +]); $response = curl_exec($ch); $data = json_decode($response, true); ``` @@ -158,10 +193,12 @@ $data = json_decode($response, true); ## 影片嵌入網址 -影片可透過 SFTPGo 分享連結存取: -``` -https://wp.momentry.ddns.net/{檔案名稱} -``` +> **重要**: API 現在返回 `file_path`(檔案系統路徑),而非直接可訪問的網址。您需要將檔案路徑轉換為 SFTPGo 分享連結才能嵌入影片。 + +**檔案路徑轉換為網址:** +- API 返回的 `file_path` 範例:`/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4` +- 對應的 SFTPGo 分享連結:`https://wp.momentry.ddns.net/demo/video.mp4` +- 轉換方式:移除 `/Users/accusys/momentry/var/sftpgo/data/` 前綴,將剩餘路徑附加到 `https://wp.momentry.ddns.net/` **手動建立分享連結:** 1. 開啟 SFTPGo Web UI:`http://localhost:8080` diff --git a/docs/API_CURL_EXAMPLES.md b/docs/API_CURL_EXAMPLES.md index 4219899..e6a6388 100644 --- a/docs/API_CURL_EXAMPLES.md +++ b/docs/API_CURL_EXAMPLES.md @@ -2,12 +2,23 @@ | 項目 | 內容 | |------|------| -| 版本 | V1.2 | -| 日期 | 2026-03-23 | +| 版本 | V1.4 | +| 日期 | 2026-03-26 | | Base URL | `http://localhost:3002` | --- +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.4 | 2026-03-26 | 新增: 任務管理端點 (`/api/v1/jobs`, `/api/v1/jobs/:uuid`),更新註冊端點回應格式 | OpenCode | deepseek-reasoner | +| V1.3 | 2026-03-25 | 更新: n8n 搜尋回傳 `file_path` 取代 `media_url`,新增 API Key 驗證說明 | OpenCode | deepseek-reasoner | +| V1.2 | 2026-03-23 | 建立 curl 範例文件 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-18 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | + +--- + > **狀態說明**: > - ✅ **已實作**: 健康檢查、搜尋、影片管理端點 > - ⚠️ **規劃中**: API Key 管理功能 @@ -76,6 +87,20 @@ sudo launchctl load /Library/LaunchDaemons/com.momentry.api.plist --- +## API 認證 + +所有 `/api/v1/*` 端點(除了健康檢查)都需要 API Key 認證。請在請求標頭中加入: + +``` +-H "X-API-Key: YOUR_API_KEY" +``` + +**目前示範使用的 API Key**: `demo_api_key_12345` + +> **注意**: 正式環境請使用安全的 API Key 管理機制。 + +--- + ## 1. 已實作端點 ### 健康檢查 @@ -161,6 +186,7 @@ curl -X GET http://localhost:3002/api/v1/api-keys/stats \ ```bash curl -X POST http://localhost:3002/api/v1/register \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"path": "/path/to/video.mp4"}' ``` @@ -168,30 +194,31 @@ curl -X POST http://localhost:3002/api/v1/register \ ```json { - "id": 1, "uuid": "a1b2c3d4e5f6g7h8", - "file_path": "/path/to/video.mp4", + "video_id": 1, + "job_id": 123, "file_name": "video.mp4", "duration": 120.5, "width": 1920, - "height": 1080 + "height": 1080, + "already_exists": false } ``` ### 3.2 列出所有影片 ✅ ```bash -curl http://localhost:3002/api/v1/videos +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos ``` ### 3.3 查詢影片 ✅ ```bash # 依 UUID 查詢 -curl "http://localhost:3002/api/v1/lookup?uuid=a1b2c3d4e5f6g7h8" +curl -H "X-API-Key: YOUR_API_KEY" "http://localhost:3002/api/v1/lookup?uuid=a1b2c3d4e5f6g7h8" # 依路徑查詢 -curl "http://localhost:3002/api/v1/lookup?path=/path/to/video.mp4" +curl -H "X-API-Key: YOUR_API_KEY" "http://localhost:3002/api/v1/lookup?path=/path/to/video.mp4" ``` ### 3.4 處理影片 🔧 *(CLI - 非 API)* @@ -209,7 +236,7 @@ cargo run --bin momentry -- process ### 3.5 取得處理進度 ✅ ```bash -curl http://localhost:3002/api/v1/progress/ +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/progress/ ``` **回應範例**: @@ -247,6 +274,67 @@ curl http://localhost:3002/api/v1/progress/ } ``` +### 3.6 任務管理 ✅ + +```bash +# 列出所有任務 +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/jobs + +# 取得特定任務詳情 +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/jobs/ +``` + +**任務列表回應範例**: +```json +{ + "jobs": [ + { + "id": 123, + "uuid": "a1b2c3d4e5f6g7h8", + "status": "pending", + "current_processor": null, + "progress_current": 0, + "progress_total": 100, + "created_at": "2026-03-26 10:30:00", + "started_at": null + } + ] +} +``` + +**任務詳情回應範例**: +```json +{ + "id": 123, + "uuid": "a1b2c3d4e5f6g7h8", + "status": "processing", + "current_processor": "asr", + "progress_current": 50, + "progress_total": 100, + "processors": [ + { + "processor_type": "asr", + "status": "complete", + "started_at": "2026-03-26 10:30:00", + "completed_at": "2026-03-26 10:35:00", + "duration_secs": 300.5, + "error_message": null + }, + { + "processor_type": "cut", + "status": "pending", + "started_at": null, + "completed_at": null, + "duration_secs": null, + "error_message": null + } + ], + "created_at": "2026-03-26 10:30:00", + "started_at": "2026-03-26 10:30:00", + "updated_at": "2026-03-26 10:35:00" +} +``` + --- ## 4. 查詢與搜索 @@ -256,6 +344,7 @@ curl http://localhost:3002/api/v1/progress/ ```bash curl -X POST http://localhost:3002/api/v1/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{ "query": "測試關鍵字", "limit": 5 @@ -286,6 +375,7 @@ curl -X POST http://localhost:3002/api/v1/search \ ```bash curl -X POST http://localhost:3002/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{ "query": "測試關鍵字", "limit": 5 @@ -307,7 +397,7 @@ curl -X POST http://localhost:3002/api/v1/n8n/search \ "title": "Chunk sentence_0006", "text": "fun plot twists...", "score": 0.92, - "media_url": "https://wp.momentry.ddns.net/video.mp4" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4" } ] } @@ -318,6 +408,7 @@ curl -X POST http://localhost:3002/api/v1/n8n/search \ ```bash curl -X POST http://localhost:3002/api/v1/search/hybrid \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{ "query": "測試關鍵字", "limit": 5 @@ -425,6 +516,8 @@ A: 需要將工作流程切換為 Active 狀態 (右上角開關) | `/api/v1/lookup` | GET | ✅ | 查詢影片 | | `/api/v1/videos` | GET | ✅ | 列出所有影片 | | `/api/v1/progress/:uuid` | GET | ✅ | 處理進度 | +| `/api/v1/jobs` | GET | ✅ | 任務列表 | +| `/api/v1/jobs/:uuid` | GET | ✅ | 任務詳情 | | `/api/v1/api-keys` | * | ⚠️ | API Key 管理 (規劃中) | ### C. 常見錯誤 @@ -475,11 +568,12 @@ curl -s "$API_URL/health" | jq . echo -e "\n=== Search ===" curl -s -X POST "$API_URL/api/v1/search" \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "test", "limit": 3}' | jq . # 列出影片 echo -e "\n=== Videos ===" -curl -s "$API_URL/api/v1/videos" | jq '.videos | length' +curl -s -H "X-API-Key: YOUR_API_KEY" "$API_URL/api/v1/videos" | jq '.videos | length' ``` --- diff --git a/docs/API_ENDPOINTS.md b/docs/API_ENDPOINTS.md index 5fc9a9c..a641fef 100644 --- a/docs/API_ENDPOINTS.md +++ b/docs/API_ENDPOINTS.md @@ -4,7 +4,7 @@ |------|------| | 建立者 | Warren | | 建立時間 | 2026-03-18 | -| 文件版本 | V1.2 | +| 文件版本 | V1.3 | --- @@ -15,6 +15,7 @@ | V1.0 | 2026-03-18 | 創建文件 | OpenCode | | V1.1 | 2026-03-23 | 更新端點與實際一致 | OpenCode | | V1.2 | 2026-03-25 | 新增快取/刪除 API | OpenCode | +| V1.3 | 2026-03-26 | 更新API回應格式 (media_url→file_path) | OpenCode | --- @@ -81,6 +82,7 @@ curl http://localhost:3002/health ```bash curl -X POST http://localhost:3002/api/v1/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ -d '{"query": "test", "limit": 10}' ``` @@ -88,6 +90,7 @@ curl -X POST http://localhost:3002/api/v1/search \ ```bash curl -X POST http://localhost:3002/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ -d '{"query": "test", "limit": 10}' ``` @@ -107,13 +110,29 @@ curl -X POST http://localhost:3002/api/v1/n8n/search \ ```bash curl -X POST http://localhost:3002/api/v1/register \ -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ -d '{"path": "/path/to/video.mp4"}' ``` +**註冊回應範例**: +```json +{ + "uuid": "a1b10138a6bbb0cd", + "video_id": 1, + "job_id": 10, + "file_name": "video.mp4", + "duration": 120.5, + "width": 1920, + "height": 1080, + "already_exists": false +} +``` + **探測影片** (不註冊,只取得影片資訊): ```bash curl -X POST http://localhost:3002/api/v1/probe \ -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ -d '{"path": "./demo/video.mp4"}' ``` @@ -150,17 +169,36 @@ curl -X POST http://localhost:3002/api/v1/probe \ **列出影片**: ```bash -curl http://localhost:3002/api/v1/videos +curl -H "X-API-Key: your-api-key" http://localhost:3002/api/v1/videos ``` **查詢影片**: ```bash -curl "http://localhost:3002/api/v1/lookup?uuid=5dea6618a606e7c7" +curl -H "X-API-Key: your-api-key" "http://localhost:3002/api/v1/lookup?uuid=5dea6618a606e7c7" ``` **處理進度**: ```bash -curl http://localhost:3002/api/v1/progress/5dea6618a606e7c7 +curl -H "X-API-Key: your-api-key" http://localhost:3002/api/v1/progress/5dea6618a606e7c7 +``` + +--- + +### 工作管理 + +| 方法 | 端點 | 說明 | +|------|------|------| +| GET | `/api/v1/jobs` | 列出所有工作 | +| GET | `/api/v1/jobs/:uuid` | 取得指定工作的詳細資訊 | + +**列出工作**: +```bash +curl -H "X-API-Key: your-api-key" http://localhost:3002/api/v1/jobs +``` + +**取得工作詳細資訊**: +```bash +curl -H "X-API-Key: your-api-key" http://localhost:3002/api/v1/jobs/a03485a40b2df2d3 ``` --- @@ -176,6 +214,7 @@ curl http://localhost:3002/api/v1/progress/5dea6618a606e7c7 ```bash curl -X POST http://localhost:3002/api/v1/config/cache \ -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ -d '{"enabled": true}' ``` @@ -183,6 +222,7 @@ curl -X POST http://localhost:3002/api/v1/config/cache \ ```bash curl -X POST http://localhost:3002/api/v1/unregister \ -H "Content-Type: application/json" \ + -H "X-API-Key: your-api-key" \ -d '{"uuid": "5dea6618a606e7c7"}' ``` @@ -199,6 +239,7 @@ curl -X POST http://localhost:3002/api/v1/unregister \ | 列出影片 | ✓ | ✓ | ✓ | | 查詢影片 | ✓ | ✓ | ✓ | | 處理進度 | ✓ | ✓ | ✓ | +| 工作管理 | ✓ | ✓ | ✓ | | 快取設定 | ✓ (管理員) | ✓ (管理員) | ✓ (管理員) | | 刪除影片 | ✓ (管理員) | ✓ (管理員) | ✓ (管理員) | @@ -220,7 +261,7 @@ curl -X POST http://localhost:3002/api/v1/unregister \ "title": "Chunk sentence_0001", "text": "...", "score": 0.92, - "media_url": "https://wp.momentry.ddns.net/video.mp4" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4" } ] } diff --git a/docs/API_EXAMPLES.md b/docs/API_EXAMPLES.md index 1abb193..b1ac92d 100644 --- a/docs/API_EXAMPLES.md +++ b/docs/API_EXAMPLES.md @@ -2,13 +2,22 @@ | 項目 | 內容 | |------|------| -| 版本 | V2.0 | -| 日期 | 2026-03-25 | +| 版本 | V2.1 | +| 日期 | 2026-03-26 | | Base URL (本地) | `http://localhost:3002` | | Base URL (外部) | `https://api.momentry.ddns.net` | --- +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | +|------|------|------|--------| +| V2.0 | 2026-03-25 | 創建完整範例總覽 | OpenCode | +| V2.1 | 2026-03-26 | 更新API回應格式 (media_url→file_path) 與認證標頭 | OpenCode | + +--- + ## 快速參考 ### 環境 URL 選擇 @@ -105,16 +114,19 @@ curl http://localhost:3002/health/detailed # 標準格式搜尋 curl -X POST http://localhost:3002/api/v1/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 5}' # n8n 格式搜尋(推薦) curl -X POST http://localhost:3002/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 5}' # 混合搜尋 curl -X POST http://localhost:3002/api/v1/search/hybrid \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 5}' ``` @@ -150,7 +162,7 @@ curl -X POST http://localhost:3002/api/v1/search/hybrid \ "title": "Chunk sentence_0001", "text": "fun plot twists...", "score": 0.92, - "media_url": "https://wp.momentry.ddns.net/video.mp4" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4" } ] } @@ -160,26 +172,28 @@ curl -X POST http://localhost:3002/api/v1/search/hybrid \ ```bash # 列出所有影片 -curl http://localhost:3002/api/v1/videos +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos # 查詢特定影片(依 UUID) -curl "http://localhost:3002/api/v1/lookup?uuid=a1b10138a6bbb0cd" +curl -H "X-API-Key: YOUR_API_KEY" "http://localhost:3002/api/v1/lookup?uuid=a1b10138a6bbb0cd" # 查詢特定影片(依路徑) -curl "http://localhost:3002/api/v1/lookup?path=/path/to/video.mp4" +curl -H "X-API-Key: YOUR_API_KEY" "http://localhost:3002/api/v1/lookup?path=/path/to/video.mp4" # 取得處理進度 -curl http://localhost:3002/api/v1/progress/a1b10138a6bbb0cd +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/progress/a1b10138a6bbb0cd # 探測影片(不註冊) curl -X POST http://localhost:3002/api/v1/probe \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"path": "/path/to/video.mp4"}' # 註冊影片 curl -X POST http://localhost:3002/api/v1/register \ -H "Content-Type: application/json" \ - -d '{"path": "/path/to/video.mp4", "file_name": "video.mp4"}' + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"path": "/path/to/video.mp4"}' ``` ### 1.4 批次測試腳本 @@ -196,10 +210,11 @@ curl -s "$API_URL/health" | jq . echo -e "\n=== 語意搜尋 ===" curl -s -X POST "$API_URL/api/v1/search" \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 3}' | jq . echo -e "\n=== 影片列表 ===" -curl -s "$API_URL/api/v1/videos" | jq '.videos | length' +curl -s -H "X-API-Key: YOUR_API_KEY" "$API_URL/api/v1/videos" | jq '.videos | length' ``` ### 1.5 外部 URL 範例 @@ -211,6 +226,7 @@ curl https://api.momentry.ddns.net/health # 外部搜尋 curl -X POST https://api.momentry.ddns.net/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 5}' ``` @@ -227,11 +243,14 @@ Node: HTTP Request ├── Authentication: None ├── Send Body: ✓ (checked) ├── Content Type: JSON -└── Body: - { - "query": "={{ $json.query }}", - "limit": "={{ $json.limit || 10 }}" - } +├── Body: +│ { +│ "query": "={{ $json.query }}", +│ "limit": "={{ $json.limit || 10 }}" +│ } +├── Send Headers: ✓ (checked) +└── Header Parameters: + └── X-API-Key: {{ $env.MOMENTRY_API_KEY }} ``` ### 2.2 基本搜尋 Workflow @@ -460,6 +479,24 @@ searchVideos('charade', 5) ```php ['Content-Type' => 'application/json'], + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-API-Key' => 'YOUR_API_KEY' // 替換為實際的 API Key + ], 'body' => json_encode([ 'query' => $atts['query'], 'limit' => (int)$atts['limit'] @@ -492,10 +532,15 @@ add_shortcode('momentry_search', function($atts) { $output = '
    '; foreach ($data['hits'] as $hit) { + // 注意: API 現在返回 file_path 而非 media_url + // 需要將文件路徑轉換為可訪問的 URL + $file_path = $hit['file_path']; + $video_url = convert_file_path_to_url($file_path); // 需要實作此函數 + $output .= sprintf( '
  • %s 播放
  • ', esc_html($hit['text']), - $hit['media_url'], + $video_url, $hit['start'] ); } @@ -569,7 +614,7 @@ Body: {"query": "charade", "limit": 5} "title": "Chunk sentence_0001", "text": "fun plot twists...", "score": 0.92, - "media_url": "https://wp.momentry.ddns.net/video.mp4" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4" } ] } diff --git a/docs/API_INDEX.md b/docs/API_INDEX.md index 422c405..6467593 100644 --- a/docs/API_INDEX.md +++ b/docs/API_INDEX.md @@ -2,8 +2,19 @@ | 項目 | 內容 | |------|------| -| 版本 | V2.2 | -| 日期 | 2026-03-25 | +| 建立者 | OpenCode | +| 建立時間 | 2026-03-25 | +| 文件版本 | V2.2 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V2.0 | 2026-03-22 | 創建 API 文件總覽 | Warren | OpenCode | +| V2.1 | 2026-03-24 | 新增文件分類與快速選擇指南 | OpenCode | deepseek-reasoner | +| V2.2 | 2026-03-25 | 更新 API Key 驗證說明與文件連結 | OpenCode | deepseek-reasoner | --- diff --git a/docs/API_KEY_MANAGEMENT.md b/docs/API_KEY_MANAGEMENT.md index a36cf5a..9dd308f 100644 --- a/docs/API_KEY_MANAGEMENT.md +++ b/docs/API_KEY_MANAGEMENT.md @@ -2,9 +2,23 @@ | 項目 | 內容 | |------|------| -| 版本 | V1.2 | -| 日期 | 2026-03-21 | -| 狀態 | 開發中 | +| 建立者 | Warren | +| 建立時間 | 2026-03-21 | +| 文件版本 | V1.2 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-18 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-20 | 新增 Key 類型與管理流程 | Warren | OpenCode | +| V1.2 | 2026-03-21 | 更新 API Key 格式與驗證流程 | Warren | OpenCode | + +--- + +**狀態**: 開發中 --- diff --git a/docs/API_N8N_GUIDE.md b/docs/API_N8N_GUIDE.md index b5a7402..fc567b9 100644 --- a/docs/API_N8N_GUIDE.md +++ b/docs/API_N8N_GUIDE.md @@ -2,9 +2,22 @@ | 項目 | 內容 | |------|------| -| 版本 | V1.0 | -| 日期 | 2026-03-23 | -| 用途 | 在 n8n workflow 中呼叫 Momentry API | +| 建立者 | Warren | +| 建立時間 | 2026-03-23 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-23 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-26 | 新增 API Key 驗證說明,更新 HTTP Request Node 設定 | OpenCode | deepseek-reasoner | + +--- + +**用途**: 在 n8n workflow 中呼叫 Momentry API --- @@ -29,6 +42,8 @@ https://api.momentry.ddns.net | GET | `/api/v1/videos` | 列出所有影片 | | GET | `/api/v1/lookup` | 查詢影片 | | GET | `/api/v1/progress/:uuid` | 處理進度 | +| GET | `/api/v1/jobs` | 任務列表 | +| GET | `/api/v1/jobs/:uuid` | 任務詳情 | --- @@ -43,11 +58,14 @@ Node: HTTP Request ├── Authentication: None ├── Send Body: ✓ (checked) ├── Content Type: JSON -└── Body: - { - "query": "={{ $json.query }}", - "limit": "={{ $json.limit || 10 }}" - } +├── Body: +│ { +│ "query": "={{ $json.query }}", +│ "limit": "={{ $json.limit || 10 }}" +│ } +├── Send Headers: ✓ (checked) +└── Header Parameters: + └── X-API-Key: {{ $env.MOMENTRY_API_KEY }} ``` ### 測試用(固定關鍵字) @@ -58,11 +76,14 @@ Node: HTTP Request ├── Method: POST ├── Send Body: ✓ ├── Content Type: JSON -└── Body: - { - "query": "charade", - "limit": 3 - } +├── Body: +│ { +│ "query": "charade", +│ "limit": 3 +│ } +├── Send Headers: ✓ (checked) +└── Header Parameters: + └── X-API-Key: {{ $env.MOMENTRY_API_KEY }} ``` --- @@ -174,13 +195,21 @@ sudo launchctl load /Library/LaunchDaemons/com.momentry.api.plist 在終端機中測試 API: +> **注意**: 所有 `/api/v1/*` 端點都需要 API Key 驗證。請設定環境變數或直接替換 API Key。 + +```bash +# 設定環境變數(使用您的 API Key) +export MOMENTRY_API_KEY="muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69" +``` + ```bash # 健康檢查 curl https://api.momentry.ddns.net/health -# 搜尋測試 +# 搜尋測試 (需要 API Key) curl -X POST https://api.momentry.ddns.net/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: $MOMENTRY_API_KEY" \ -d '{"query":"charade","limit":3}' ``` diff --git a/docs/API_QUICK_REFERENCE.md b/docs/API_QUICK_REFERENCE.md new file mode 100644 index 0000000..7b2998d --- /dev/null +++ b/docs/API_QUICK_REFERENCE.md @@ -0,0 +1,532 @@ +# Momentry Core API 快速查詢表 + +| 版本 | 日期 | 建立者 | +|------|------|--------| +| V1.0 | 2026-03-26 | OpenCode | + +--- + +## 📋 快速導覽 + +| 類別 | 端點數量 | 說明 | +|------|----------|------| +| 健康檢查 | 2 | 系統狀態監控 | +| 影片管理 | 5 | 影片註冊、查詢、刪除 | +| 搜尋功能 | 3 | 語意搜尋、混合搜尋 | +| 任務管理 | 2 | 處理任務狀態查詢 | +| 系統管理 | 2 | 快取設定、進度查詢 | + +--- + +## 🔐 認證 + +所有 `/api/v1/*` 端點需要 `X-API-Key` header: + +```bash +curl -H "X-API-Key: YOUR_API_KEY" ... +``` + +**公開端點(無需認證):** +- `GET /health` +- `GET /health/detailed` + +--- + +## 📊 端點總表 + +### 健康檢查 + +| 方法 | 端點 | 認證 | 描述 | +|------|------|------|------| +| GET | `/health` | 公開 | 基本健康檢查 | +| GET | `/health/detailed` | 公開 | 詳細健康檢查(包含所有服務狀態) | + +### 影片管理 + +| 方法 | 端點 | 認證 | 描述 | +|------|------|------|------| +| POST | `/api/v1/register` | 需要 | 註冊影片並開始處理 | +| POST | `/api/v1/unregister` | 需要 | 刪除影片及其所有資料 | +| POST | `/api/v1/probe` | 需要 | 探測影片資訊(不註冊) | +| GET | `/api/v1/videos` | 需要 | 列出所有已註冊影片 | +| GET | `/api/v1/lookup` | 需要 | 查詢影片資訊 | + +### 搜尋功能 + +| 方法 | 端點 | 認證 | 描述 | +|------|------|------|------| +| POST | `/api/v1/search` | 需要 | 語意搜尋(標準格式) | +| POST | `/api/v1/n8n/search` | 需要 | 語意搜尋(n8n 格式) | +| POST | `/api/v1/search/hybrid` | 需要 | 混合搜尋(向量 + 關鍵字) | + +### 任務管理 + +| 方法 | 端點 | 認證 | 描述 | +|------|------|------|------| +| GET | `/api/v1/jobs` | 需要 | 列出所有處理任務 | +| GET | `/api/v1/jobs/:uuid` | 需要 | 取得特定任務詳情 | + +### 系統管理 + +| 方法 | 端點 | 認證 | 描述 | +|------|------|------|------| +| GET | `/api/v1/progress/:uuid` | 需要 | 取得影片處理進度 | +| POST | `/api/v1/config/cache` | 需要 | 切換快取功能 | + +--- + +## 🔧 詳細端點說明 + +### 1. 健康檢查 + +#### GET /health +**基本健康檢查** +```bash +curl http://localhost:3002/health +``` + +**回應:** +```json +{ + "status": "ok", + "version": "0.1.0", + "uptime_ms": 14426558 +} +``` + +#### GET /health/detailed +**詳細健康檢查** +```bash +curl http://localhost:3002/health/detailed +``` + +**回應:** +```json +{ + "status": "ok", + "version": "0.1.0", + "uptime_ms": 14441362, + "services": { + "postgres": {"status": "ok", "latency_ms": 50, "error": null}, + "redis": {"status": "ok", "latency_ms": 0, "error": null}, + "qdrant": {"status": "ok", "latency_ms": 2, "error": null}, + "mongodb": {"status": "ok", "latency_ms": 2, "error": null} + } +} +``` + +### 2. 影片管理 + +#### POST /api/v1/register +**註冊影片並開始處理** +```bash +curl -X POST http://localhost:3002/api/v1/register \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"path": "/path/to/video.mp4"}' +``` + +**請求:** +```json +{ + "path": "/path/to/video.mp4" +} +``` + +**回應:** +```json +{ + "uuid": "5dea6618a606e7c7", + "video_id": 10, + "job_id": 1, + "file_name": "video.mp4", + "duration": 596.458333, + "width": 320, + "height": 180, + "already_exists": false +} +``` + +#### POST /api/v1/unregister +**刪除影片及其所有資料** +```bash +curl -X POST http://localhost:3002/api/v1/unregister \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"uuid": "5dea6618a606e7c7"}' +``` + +**請求:** +```json +{ + "uuid": "5dea6618a606e7c7" +} +``` + +**回應:** +```json +{ + "success": true, + "uuid": "5dea6618a606e7c7", + "message": "Video unregistered successfully" +} +``` + +#### POST /api/v1/probe +**探測影片資訊(不註冊)** +```bash +curl -X POST http://localhost:3002/api/v1/probe \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"path": "/path/to/video.mp4"}' +``` + +**請求:** +```json +{ + "path": "/path/to/video.mp4" +} +``` + +**回應:** +```json +{ + "uuid": "5dea6618a606e7c7", + "file_name": "video.mp4", + "duration": 596.458333, + "width": 320, + "height": 180, + "fps": 24.0, + "cached": true, + "format": {...}, + "streams": [...] +} +``` + +#### GET /api/v1/videos +**列出所有已註冊影片** +```bash +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos +``` + +**回應:** +```json +{ + "videos": [ + { + "uuid": "a03485a40b2df2d3", + "file_path": "/path/to/video.mp4", + "file_name": "video.mp4", + "duration": 596.458333, + "width": 320, + "height": 180 + } + ] +} +``` + +#### GET /api/v1/lookup +**查詢影片資訊** +```bash +# 依 UUID 查詢 +curl -H "X-API-Key: YOUR_API_KEY" "http://localhost:3002/api/v1/lookup?uuid=a03485a40b2df2d3" + +# 依路徑查詢 +curl -H "X-API-Key: YOUR_API_KEY" "http://localhost:3002/api/v1/lookup?path=/path/to/video.mp4" +``` + +**回應:** +```json +{ + "uuid": "a03485a40b2df2d3", + "file_path": "/path/to/video.mp4", + "file_name": "video.mp4", + "duration": 596.458333 +} +``` + +### 3. 搜尋功能 + +#### POST /api/v1/search +**語意搜尋(標準格式)** +```bash +curl -X POST http://localhost:3002/api/v1/search \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"query": "search term", "limit": 5}' +``` + +**請求:** +```json +{ + "query": "search term", + "limit": 5 +} +``` + +**回應:** +```json +{ + "results": [ + { + "uuid": "a1b10138a6bbb0cd", + "chunk_id": "sentence_0001", + "chunk_type": "sentence", + "start_time": 10.5, + "end_time": 15.2, + "text": "Found text matching query", + "score": 0.85 + } + ], + "query": "search term" +} +``` + +#### POST /api/v1/n8n/search +**語意搜尋(n8n 格式)** +```bash +curl -X POST http://localhost:3002/api/v1/n8n/search \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"query": "search term", "limit": 5}' +``` + +**回應:** +```json +{ + "query": "search term", + "count": 1, + "hits": [ + { + "id": "sentence_0001", + "vid": "a1b10138a6bbb0cd", + "start": 10.5, + "end": 15.2, + "title": "Chunk sentence_0001", + "text": "Found text matching query", + "score": 0.85, + "file_path": "/path/to/video.mp4" + } + ] +} +``` + +#### POST /api/v1/search/hybrid +**混合搜尋(向量 + 關鍵字)** +```bash +curl -X POST http://localhost:3002/api/v1/search/hybrid \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"query": "search term", "limit": 5}' +``` + +**請求:** +```json +{ + "query": "search term", + "limit": 5 +} +``` + +**回應:** 與 `/api/v1/search` 相同格式 + +### 4. 任務管理 + +#### GET /api/v1/jobs +**列出所有處理任務** +```bash +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/jobs +``` + +**回應:** +```json +{ + "jobs": [ + { + "id": 10, + "uuid": "a03485a40b2df2d3", + "status": "running", + "current_processor": null, + "progress_current": 0, + "progress_total": 0, + "created_at": "2026-03-26 13:39:37.830465", + "started_at": null + } + ] +} +``` + +#### GET /api/v1/jobs/:uuid +**取得特定任務詳情** +```bash +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/jobs/a03485a40b2df2d3 +``` + +**回應:** +```json +{ + "id": 10, + "uuid": "a03485a40b2df2d3", + "status": "running", + "current_processor": null, + "progress_current": 0, + "progress_total": 0, + "processors": [ + { + "processor_type": "asr", + "status": "completed", + "started_at": "2026-03-26 05:39:40.275468", + "completed_at": "2026-03-26 07:19:43.166613", + "duration_secs": 6002.891145, + "error_message": null + }, + // ... 其他處理器 + ], + "created_at": "2026-03-26 13:39:37.830465", + "started_at": null, + "updated_at": "2026-03-26 07:19:16.338406" +} +``` + +### 5. 系統管理 + +#### GET /api/v1/progress/:uuid +**取得影片處理進度** +```bash +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/progress/a03485a40b2df2d3 +``` + +**回應:** +```json +{ + "uuid": "a03485a40b2df2d3", + "user": null, + "group": null, + "file_name": "video.mp4", + "duration": 596.458333, + "overall_progress": 0, + "cpu_percent": 0.2, + "gpu_percent": null, + "memory_percent": 0.1, + "memory_mb": 16720, + "processors": [ + { + "name": "asr", + "status": "pending", + "current": 0, + "total": 0, + "progress": 0, + "message": "" + }, + // ... 其他處理器 + ] +} +``` + +#### POST /api/v1/config/cache +**切換快取功能** +```bash +curl -X POST http://localhost:3002/api/v1/config/cache \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"enabled": true}' +``` + +**請求:** +```json +{ + "enabled": true +} +``` + +**回應:** +```json +{ + "success": true, + "cache_enabled": true, + "message": "Cache enabled" +} +``` + +--- + +## 🚀 快速工作流程 + +### 1. 註冊並處理影片 +```bash +# 1. 註冊影片 +curl -X POST http://localhost:3002/api/v1/register \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"path": "/path/to/video.mp4"}' + +# 回應包含 UUID: 5dea6618a606e7c7 + +# 2. 監控進度 +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/progress/5dea6618a606e7c7 + +# 3. 查看任務狀態 +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/jobs/5dea6618a606e7c7 +``` + +### 2. 搜尋影片內容 +```bash +# 1. 列出所有影片 +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos + +# 2. 搜尋內容 +curl -X POST http://localhost:3002/api/v1/n8n/search \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"query": "charade scene", "limit": 10}' +``` + +### 3. 系統管理 +```bash +# 1. 檢查系統健康 +curl http://localhost:3002/health/detailed + +# 2. 管理快取 +curl -X POST http://localhost:3002/api/v1/config/cache \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"enabled": false}' + +# 3. 刪除影片(需要時) +curl -X POST http://localhost:3002/api/v1/unregister \ + -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ + -d '{"uuid": "5dea6618a606e7c7"}' +``` + +--- + +## 📝 注意事項 + +1. **API Key 格式:** + - 使用完整 API Key:`muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69` + - 系統存儲的是 SHA256 哈希值 + +2. **路徑格式:** + - 絕對路徑:`/Users/accusys/test_video/video.mp4` + - 相對路徑:`./demo/video.mp4`(相對於 SFTPGo 資料目錄) + +3. **回應時間:** + - 健康檢查:< 100ms + - 搜尋:取決於查詢複雜度,通常 100-500ms + - 影片註冊:立即返回,背景處理可能需要數分鐘到數小時 + +4. **錯誤處理:** + - 401: 認證失敗 + - 404: 資源不存在 + - 500: 伺服器內部錯誤 + +--- + +## 🔗 相關文件 + +- [API 參考指南](./API_REFERENCE.md) - 詳細 API 說明 +- [API 範例總覽](./API_EXAMPLES.md) - 完整使用範例 +- [API 端點列表](./API_ENDPOINTS.md) - 端點簡介 +- [Curl 範例指南](./API_CURL_EXAMPLES.md) - curl 命令範例 +- [n8n 整合指南](./API_N8N_GUIDE.md) - n8n 工作流程整合 \ No newline at end of file diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index f52c417..08f53e0 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -4,7 +4,7 @@ |------|------| | 建立者 | Warren | | 建立時間 | 2026-03-18 | -| 文件版本 | V1.0 | +| 文件版本 | V1.3 | --- @@ -15,6 +15,7 @@ | V1.0 | 2026-03-18 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | | V1.1 | 2026-03-23 | 更新端點與實際一致 | OpenCode | - | | V1.2 | 2026-03-25 | 新增快取/刪除 API | OpenCode | - | +| V1.3 | 2026-03-26 | 修正認證聲明與API回應格式 | OpenCode | - | --- @@ -38,7 +39,22 @@ ## Authentication -Currently no authentication is required. +**API Key 認證:** + +所有 `/api/v1/*` 端點需要 `X-API-Key` header 進行認證。 + +**公開端點:** +- `GET /health` - 健康檢查 +- `GET /health/detailed` - 詳細健康檢查 + +**認證格式:** +```bash +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos +``` + +**API Key 管理:** +- 使用 `/api/v1/api-keys` 端點管理 API Keys +- 詳細說明請參考 [API Key Management Guide](../docs/API_KEY_MANAGEMENT.md) --- @@ -65,10 +81,12 @@ Register a video file to the system. { "uuid": "5dea6618a606e7c7", "video_id": 1, + "job_id": 10, "file_name": "video.mp4", "duration": 120.5, "width": 1920, - "height": 1080 + "height": 1080, + "already_exists": false } ``` @@ -76,6 +94,7 @@ Register a video file to the system. ```bash curl -X POST http://localhost:3002/api/v1/register \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"path": "/Users/accusys/test_video/BigBuckBunny_320x180.mp4"}' ``` @@ -152,7 +171,7 @@ Get real-time processing progress via Redis. **Example:** ```bash # Get progress for specific video -curl http://localhost:3002/api/v1/progress/5dea6618a606e7c7 +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/progress/5dea6618a606e7c7 ``` --- @@ -199,6 +218,7 @@ Search video chunks using natural language queries (RAG). ```bash curl -X POST http://localhost:3002/api/v1/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "machine learning", "limit": 5}' ``` @@ -238,7 +258,7 @@ N8n-compatible search endpoint with standardized response format for direct work "title": "Sunset Scene", "text": "The sun slowly sets over the ocean...", "score": 0.92, - "media_url": "https://wp.momentry.ddns.net/video.mp4" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4" } ] } @@ -255,12 +275,13 @@ N8n-compatible search endpoint with standardized response format for direct work | `hits[].title` | string | Chunk title (from metadata or auto-generated) | | `hits[].text` | string | Text content | | `hits[].score` | number | Relevance score (0-1) | -| `hits[].media_url` | string | Full media URL (optional) | +| `hits[].file_path` | string | Full file path to video file | **Example:** ```bash curl -X POST http://localhost:3002/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "sunset", "limit": 5}' ``` @@ -296,10 +317,10 @@ Lookup video UUID by path or get video details by UUID. **Example:** ```bash # Lookup by path -curl "http://localhost:3002/api/v1/lookup?path=/path/to/video.mp4" +curl -H "X-API-Key: YOUR_API_KEY" "http://localhost:3002/api/v1/lookup?path=/path/to/video.mp4" # Lookup by UUID -curl "http://localhost:3002/api/v1/lookup?uuid=5dea6618a606e7c7" +curl -H "X-API-Key: YOUR_API_KEY" "http://localhost:3002/api/v1/lookup?uuid=5dea6618a606e7c7" ``` --- @@ -327,7 +348,7 @@ List all registered videos. **Example:** ```bash -curl http://localhost:3002/api/v1/videos +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos ``` --- diff --git a/docs/API_TRAINING_MARCOM.md b/docs/API_TRAINING_MARCOM.md index fd6d55c..6b97b3b 100644 --- a/docs/API_TRAINING_MARCOM.md +++ b/docs/API_TRAINING_MARCOM.md @@ -1,7 +1,7 @@ # Momentry Core API 教育訓練手冊 > **對象**: marcom 團隊 -> **版本**: V1.2 | **日期**: 2026-03-25 +> **版本**: V1.4 | **日期**: 2026-03-25 --- @@ -147,7 +147,7 @@ curl -s -H "X-API-Key: muser_68600856036340bcafc01930eb4bd839_1774418104_97221b6 | `story` | 故事線片段(父子關係) | Story 分析 | #### POST /api/v1/n8n/search -n8n 專用搜尋(包含完整影片網址 media_url) +n8n 專用搜尋(包含完整影片檔案路徑 file_path) **請求參數**: 與 `/search` 相同 @@ -385,3 +385,4 @@ GET /api/v1/jobs/{uuid} | V1.1 | 2026-03-25 | 新增快取/刪除 API、搜尋端點文件 | OpenCode | | V1.2 | 2026-03-25 | 新增 Chunk 欄位說明、類型、播放方式 | OpenCode | | V1.3 | 2026-03-25 | 新增 Demo 測試帳號(SFTPGo)| OpenCode | +| V1.4 | 2026-03-25 | 更新 n8n 搜尋回傳欄位說明 (media_url→file_path) | OpenCode | diff --git a/docs/API_WORDPRESS_GUIDE.md b/docs/API_WORDPRESS_GUIDE.md index f91146a..f53ab30 100644 --- a/docs/API_WORDPRESS_GUIDE.md +++ b/docs/API_WORDPRESS_GUIDE.md @@ -2,12 +2,21 @@ | 項目 | 內容 | |------|------| -| 版本 | V1.0 | -| 日期 | 2026-03-23 | +| 版本 | V1.1 | +| 日期 | 2026-03-25 | | 用途 | 在 WordPress 中呼叫 Momentry API | --- +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.1 | 2026-03-25 | 更新: n8n 搜尋回傳 `file_path` 取代 `media_url`,新增 API Key 驗證說明 | OpenCode | deepseek-reasoner | +| V1.0 | 2026-03-23 | 創建 WordPress API 指南 | Warren | OpenCode / MiniMax M2.5 | + +--- + ## API URL 在 WordPress 中呼叫 API,**請使用外部 URL**: @@ -20,6 +29,20 @@ https://api.momentry.ddns.net --- +## API 認證 + +所有 `/api/v1/*` 端點(除了健康檢查)都需要 API Key 認證。請在請求標頭中加入: + +``` +'headers' => ['Content-Type' => 'application/json', 'X-API-Key' => 'YOUR_API_KEY'] +``` + +**目前示範使用的 API Key**: `demo_api_key_12345` + +> **注意**: 正式環境請使用安全的 API Key 管理機制,避免在客戶端 JavaScript 中暴露 API Key。 + +--- + ## 常用端點 | 方法 | 端點 | 說明 | @@ -45,7 +68,7 @@ $data = [ ]; $response = wp_remote_post($api_url, [ - 'headers' => ['Content-Type' => 'application/json'], + 'headers' => ['Content-Type' => 'application/json', 'X-API-Key' => 'YOUR_API_KEY'], 'body' => json_encode($data), 'timeout' => 30 ]); @@ -65,7 +88,10 @@ if (is_wp_error($response)) { 30]); +$response = wp_remote_get($api_url, [ + 'headers' => ['X-API-Key' => 'YOUR_API_KEY'], + 'timeout' => 30 +]); if (!is_wp_error($response)) { $body = json_decode(wp_remote_retrieve_body($response), true); @@ -83,7 +109,10 @@ if (!is_wp_error($response)) { $uuid = '5dea6618a606e7c7'; $api_url = 'https://api.momentry.ddns.net/api/v1/lookup?uuid=' . $uuid; -$response = wp_remote_get($api_url, ['timeout' => 30]); +$response = wp_remote_get($api_url, [ + 'headers' => ['X-API-Key' => 'YOUR_API_KEY'], + 'timeout' => 30 +]); if (!is_wp_error($response)) { $video = json_decode(wp_remote_retrieve_body($response), true); @@ -104,7 +133,7 @@ if (!is_wp_error($response)) { async function searchVideos(query, limit = 10) { const response = await fetch('https://api.momentry.ddns.net/api/v1/n8n/search', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'YOUR_API_KEY' }, body: JSON.stringify({ query, limit }) }); @@ -132,6 +161,24 @@ searchVideos('charade', 5) ```php ['Content-Type' => 'application/json'], + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-API-Key' => 'YOUR_API_KEY' // 替換為實際的 API Key + ], 'body' => json_encode([ 'query' => $atts['query'], 'limit' => (int)$atts['limit'] @@ -164,10 +214,15 @@ add_shortcode('momentry_search', function($atts) { $output = '
      '; foreach ($data['hits'] as $hit) { + // 注意: API 現在返回 file_path 而非 media_url + // 需要將文件路徑轉換為可訪問的 URL + $file_path = $hit['file_path']; + $video_url = convert_file_path_to_url($file_path); // 需要實作此函數 + $output .= sprintf( '
    • %s 播放
    • ', esc_html($hit['text']), - $hit['media_url'], + $video_url, $hit['start'] ); } @@ -199,7 +254,7 @@ add_action('rest_api_init', function() { $response = wp_remote_post( 'https://api.momentry.ddns.net/api/v1/n8n/search', [ - 'headers' => ['Content-Type' => 'application/json'], + 'headers' => ['Content-Type' => 'application/json', 'X-API-Key' => 'YOUR_API_KEY'], 'body' => json_encode([ 'query' => $request->get_param('query'), 'limit' => $request->get_param('limit', 10) diff --git a/docs/DEMO_MANUAL.md b/docs/DEMO_MANUAL.md index 33f2be4..4fb683e 100644 --- a/docs/DEMO_MANUAL.md +++ b/docs/DEMO_MANUAL.md @@ -2,9 +2,21 @@ | 項目 | 內容 | |------|------| -| 版本 | V1.0 | -| 日期 | 2026-03-25 | -| 狀態 | 完成 | +| 建立者 | OpenCode | +| 建立時間 | 2026-03-25 | +| 文件版本 | V1.0 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-25 | 創建示範手冊,包含 Demo API Key 與完整範例 | OpenCode | deepseek-reasoner | + +--- + +**狀態**: 完成 --- diff --git a/docs/DOCUMENT_EMBEDDING_STRATEGY.md b/docs/DOCUMENT_EMBEDDING_STRATEGY.md index e0750c8..4d6597d 100644 --- a/docs/DOCUMENT_EMBEDDING_STRATEGY.md +++ b/docs/DOCUMENT_EMBEDDING_STRATEGY.md @@ -1,5 +1,21 @@ # Document Embedding Strategy - Parent-Child Chunks +| Item | Content | +|------|---------| +| Author | Warren | +| Created | 2026-03-23 | +| Document Version | V1.0 | + +--- + +## Version History + +| Version | Date | Purpose | Operator | Tool/Model | +|---------|------|---------|----------|------------| +| V1.0 | 2026-03-23 | Create document embedding strategy | Warren | OpenCode | + +--- + ## Overview Momentry uses a **parent-child chunk hierarchy** for improved RAG retrieval. This document describes the embedding strategy for this hierarchy. @@ -44,7 +60,7 @@ embedding_text = f"Summary: {parent.text_content} Children: {child_text_1}. {child_text_2}. {child_text_3}..." ``` -**Prefix**: `search_document: ` (for documents in Qdrant) +**Prefix**: `search_document:` (for documents in Qdrant) **Example**: ``` @@ -58,7 +74,7 @@ embedding_text = f"[{child.chunk_type}] {child.text_content} Parent: {parent.description}" ``` -**Prefix**: `search_document: ` +**Prefix**: `search_document:` **Example**: ``` diff --git a/docs/INSTALL_MOMENTRY_API.md b/docs/INSTALL_MOMENTRY_API.md index 719d99d..696dfa8 100644 --- a/docs/INSTALL_MOMENTRY_API.md +++ b/docs/INSTALL_MOMENTRY_API.md @@ -461,4 +461,4 @@ sudo launchctl load /Library/LaunchDaemons/com.momentry.api.plist - `docs/INSTALL_POSTGRESQL.md` - PostgreSQL 安裝 - `docs/INSTALL_REDIS.md` - Redis 安裝 - `docs/INSTALL_QDRANT.md` - Qdrant 安裝 -- `docs/PENDING_ISSUES.md` - 待解決問題 \ No newline at end of file +- `docs/PENDING_ISSUES.md` - 待解決問題 diff --git a/docs/INSTALL_SFTPGO.md b/docs/INSTALL_SFTPGO.md index 9861179..67b8867 100644 --- a/docs/INSTALL_SFTPGO.md +++ b/docs/INSTALL_SFTPGO.md @@ -527,13 +527,13 @@ SFTPGo 提供 RESTful API 用於管理用戶和組,支援自動化運維。 ### API 認證方式 -1. **獲取 Access Token** (使用 Basic Auth): +- **獲取 Access Token** (使用 Basic Auth): ```bash TOKEN=$(curl -s -X GET http://localhost:8080/api/v2/token \ -u "admin:Test3200Test3200" | jq -r '.access_token') ``` -2. **使用 Token 調用 API**: +- **使用 Token 調用 API**: ```bash curl -X GET http://localhost:8080/api/v2/admins \ -H "Authorization: Bearer $TOKEN" @@ -569,7 +569,7 @@ curl -s -X POST http://localhost:8080/api/v2/users \ }' ``` -**權限格式注意**: 必須為 map[string][]string 格式,例如: +**權限格式注意**: 必須為 `map[string][]string` 格式,例如: ```json { "/": ["*"], @@ -752,12 +752,12 @@ sftpgo serve --config-file /Users/accusys/momentry/etc/sftpgo/sftpgo.json & ### Hook 故障排除 -1. **檢查 Hook 日誌**: +- **檢查 Hook 日誌**: ```bash tail -f /Users/accusys/sftpgo_test/hook.log ``` -2. **手動測試 Hook 腳本**: +- **手動測試 Hook 腳本**: ```bash export SFTPGO_USERNAME=demo export SFTPGO_FILEPATH="./test.txt" @@ -766,7 +766,7 @@ export SFTPGO_ACTION=add /Users/accusys/sftpgo_test/register_hook.sh ``` -3. **SFTPGo 錯誤日誌**: +- **SFTPGo 錯誤日誌**: ```bash tail -20 /Users/accusys/momentry/log/sftpgo.error.log ``` @@ -877,12 +877,12 @@ sftp> put test.txt ## 常見問題 -#### "無效的憑證" 即使密碼正確 +### "無效的憑證" 即使密碼正確 - PostgreSQL 中的密碼哈希可能不符合 SFTPGo 預期格式 - 使用 Web 面板的 **Forgot password** 功能而非直接 SQL 更新 -#### CSRF Token 錯誤 +### CSRF Token 錯誤 - 清除瀏覽器中 `localhost:8080` 的 cookies - 使用無痕/私密瀏覽視窗 diff --git a/docs/MAC_INSTALLATION_PLAN.md b/docs/MAC_INSTALLATION_PLAN.md index e2e9b82..e179de4 100644 --- a/docs/MAC_INSTALLATION_PLAN.md +++ b/docs/MAC_INSTALLATION_PLAN.md @@ -779,4 +779,4 @@ log_info "✅ 部署完成!" **負責人**: OpenCode AI Assistant -**最後更新**: 2026-03-23 \ No newline at end of file +**最後更新**: 2026-03-23 diff --git a/docs/MOMENTRY_RAG_PRESENTATION.md b/docs/MOMENTRY_RAG_PRESENTATION.md index 553da67..be7e58b 100644 --- a/docs/MOMENTRY_RAG_PRESENTATION.md +++ b/docs/MOMENTRY_RAG_PRESENTATION.md @@ -1,5 +1,22 @@ # Momentry Core 影片 RAG 系統說明稿 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-22 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-22 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-25 | 更新API回應格式 (media_url→file_path) 與認證標頭 | OpenCode | deepseek-reasoner | + +--- + ## 系統架構 ``` @@ -85,22 +102,9 @@ POST http://localhost:3002/api/v1/search } ``` -**回應:** -```json -{ - "results": [ - { - "uuid": "a1b10138a6bbb0cd", - "chunk_id": "sentence_0006", - "chunk_type": "sentence", - "start_time": 48.8, - "end_time": 55.44, - "text": "fun plot twists, Woody Dialog and charming performances...", - "score": 0.526 - } - ] -} -``` +> **注意**: +> 1. **API 認證**: 所有 `/api/v1/*` 端點需要 `X-API-Key` 標頭 +> 2. **檔案路徑轉換**: API 現在返回 `file_path`(檔案系統路徑),需要轉換為可訪問的 URL(例如透過 SFTPGo 分享連結) --- @@ -132,7 +136,7 @@ POST http://localhost:3002/api/v1/n8n/search "title": "Chunk sentence_0006", "text": "fun plot twists...", "score": 0.526, - "media_url": "https://wp.momentry.ddns.net/Old_Time_Movie_Show_-_Charade_1963.HD.mov" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4" } ] } @@ -187,6 +191,7 @@ POST http://localhost:3002/api/v1/n8n/search Method: POST URL: http://localhost:3002/api/v1/n8n/search Body Content Type: JSON + Headers: X-API-Key (需設定) ``` 3. Body: @@ -215,12 +220,17 @@ const results = hits.map((hit, index) => ({ text: hit.text, time: `${hit.start}s - ${hit.end}s`, score: Math.round(hit.score * 100) + "%", - url: hit.media_url + "#t=" + hit.start + "," + hit.end + // 注意: API 現在返回 file_path(檔案系統路徑),需要轉換為可訪問的 URL + url: hit.file_path + "#t=" + hit.start + "," + hit.end // 需實作檔案路徑轉換為 URL })); return { json: { results } }; ``` +> **注意**: +> 1. **API 認證**: 所有 `/api/v1/*` 端點需要 `X-API-Key` 標頭 +> 2. **檔案路徑轉換**: API 現在返回 `file_path`(檔案系統路徑),需要轉換為可訪問的 URL(例如透過 SFTPGo 分享連結) + --- ### Step 4: 格式化輸出 @@ -248,18 +258,20 @@ return { json: { results } }; # 語意搜尋 curl -X POST http://localhost:3002/api/v1/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 3}' # n8n 格式 curl -X POST http://localhost:3002/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 3}' # 影片列表 -curl http://localhost:3002/api/v1/videos +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos # 特定影片區塊 -curl http://localhost:3002/api/v1/videos/a1b10138a6bbb0cd/chunks +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos/a1b10138a6bbb0cd/chunks ``` --- diff --git a/docs/N8N_DEMO.md b/docs/N8N_DEMO.md index cfb47d4..eb7f720 100644 --- a/docs/N8N_DEMO.md +++ b/docs/N8N_DEMO.md @@ -1,11 +1,29 @@ # n8n 整合範例 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-18 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-18 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-25 | 更新API回應格式 (media_url→file_path) 與認證標頭 | OpenCode | deepseek-reasoner | + +--- + ## 基本設定 ### API 端點 - **Base URL:** `http://localhost:3002/api/v1` - **Method:** `POST` - **Content-Type:** `application/json` +- **Authentication:** `X-API-Key: YOUR_API_KEY` (所有 `/api/v1/*` 端點皆需要) --- @@ -36,7 +54,8 @@ }, "options": { "headers": { - "Content-Type": "application/json" + "Content-Type": "application/json", + "X-API-Key": "YOUR_API_KEY" } } } @@ -62,7 +81,7 @@ return results.map(r => ({ ## Workflow 2: n8n 專用格式 -使用 `/n8n/search` 端點(已包含 media_url) +使用 `/n8n/search` 端點(已包含 file_path) ### HTTP Request ```json @@ -72,6 +91,12 @@ return results.map(r => ({ "body": { "query": "={{ $json.searchTerm }}", "limit": 5 + }, + "options": { + "headers": { + "Content-Type": "application/json", + "X-API-Key": "YOUR_API_KEY" + } } } ``` @@ -90,12 +115,14 @@ return results.map(r => ({ "title": "Chunk sentence_0006", "text": "fun plot twists...", "score": 0.526, - "media_url": "https://wp.momentry.ddns.net/Old_Time_Movie_Show_-_Charade_1963.HD.mov" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4" } ] } ``` +> **注意**: API 現在返回 `file_path`(檔案系統路徑)而非 `media_url`(網頁 URL)。如需在網頁中播放影片,請將檔案路徑轉換為可訪問的 URL(例如透過 SFTPGo 分享連結)。 + --- ## Workflow 3: 訊息機器人整合 @@ -205,16 +232,18 @@ return { # 基本搜尋 curl -X POST http://localhost:3002/api/v1/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 3}' # n8n 格式 curl -X POST http://localhost:3002/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"query": "charade", "limit": 3}' # 取得影片列表 -curl http://localhost:3002/api/v1/videos +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos # 取得特定影片的區塊 -curl http://localhost:3002/api/v1/videos/a1b10138a6bbb0cd/chunks +curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/videos/a1b10138a6bbb0cd/chunks ``` diff --git a/docs/N8N_DEMO_EXECUTION_LOG.md b/docs/N8N_DEMO_EXECUTION_LOG.md index 9b440da..81838ae 100644 --- a/docs/N8N_DEMO_EXECUTION_LOG.md +++ b/docs/N8N_DEMO_EXECUTION_LOG.md @@ -1,7 +1,20 @@ # n8n Video RAG Demo - API 執行記錄 -> 建立時間: 2026-03-22 -> 目標: 完整執行 n8n Video RAG Workflow 並記錄所有 API 呼叫 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-22 | +| 文件版本 | V1.1 | +| 目標 | 完整執行 n8n Video RAG Workflow 並記錄所有 API 呼叫 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-22 | 創建文件 | Warren | OpenCode | +| V1.1 | 2026-03-26 | 更新 API 範例,新增 X-API-Key 驗證標頭 | OpenCode | deepseek-reasoner | --- @@ -297,12 +310,13 @@ Content-Type: application/json --- -### Step 4.2: n8n 搜尋 (含 media_url) +### Step 4.2: n8n 搜尋 (含 file_path) **API 呼叫:** ```bash curl -X POST "http://localhost:3002/api/v1/n8n/search" \ -H "Content-Type: application/json" \ + -H "X-API-Key: demo_api_key_12345" \ -d '{ "query": "What is the movie about?", "limit": 10, diff --git a/docs/N8N_DEMO_WORKFLOW.md b/docs/N8N_DEMO_WORKFLOW.md index 6486120..49389f5 100644 --- a/docs/N8N_DEMO_WORKFLOW.md +++ b/docs/N8N_DEMO_WORKFLOW.md @@ -1,7 +1,19 @@ # n8n Video RAG Workflow - Node 設計 -> 建立時間: 2026-03-22 -> 目標: 讓 marcom 團隊能夠複製、貼上、修改使用的完整操作指南 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-22 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-22 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-25 | 更新API回應格式 (media_url→file_path) 與認證標頭 | OpenCode | deepseek-reasoner | --- @@ -117,7 +129,7 @@ │ │ │ │ │ │ ⑫ Natural Language Search │ │ │ │ ↓ │ │ -│ │ ⑬ Get Media URL (含 media_url) │ │ +│ │ ⑬ Get File Path (含 file_path) │ │ │ │ ↓ │ │ │ │ ⑭ Build Response │ │ │ │ ↓ │ │ @@ -363,7 +375,7 @@ Output: } ``` -**注意**: 只有 asr、asrx、story 三個模組 +> **注意**: API 現在返回 `file_path`(檔案系統路徑)而非 `media_url`(網頁 URL)。如需在網頁中播放影片,請將檔案路徑轉換為可訪問的 URL(例如透過 SFTPGo 分享連結)。 --- @@ -559,7 +571,7 @@ Output: "vid": "a1b10138a6bbb0cd", "text": "Hello and welcome to the old-time movie show...", "score": 0.92, - "media_url": "https://wp.momentry.ddns.net/Old_Time_Movie_Show_-_Charade_1963.HD.mov" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4" } ] } @@ -642,12 +654,13 @@ Configuration: | 項目 | 值 | |------|-----| | API Base | `http://localhost:3002` | +| Authentication | `X-API-Key` header (所有 `/api/v1/*` 端點) | | Register | `POST /api/v1/register` | | Progress | `GET /api/v1/progress/{uuid}` | | Search | `POST /api/v1/search` | | n8n Search | `POST /api/v1/n8n/search` | | Hybrid Search | `POST /api/v1/search/hybrid` | -| Media Base | `https://wp.momentry.ddns.net` | +| Media Base | `https://wp.momentry.ddns.net` (僅供參考,API 返回 `file_path` 而非 URL) | ### Demo 測試資料 diff --git a/docs/N8N_HTTP_REQUEST_GUIDE.md b/docs/N8N_HTTP_REQUEST_GUIDE.md index b0ec5d2..74cf856 100644 --- a/docs/N8N_HTTP_REQUEST_GUIDE.md +++ b/docs/N8N_HTTP_REQUEST_GUIDE.md @@ -1,5 +1,22 @@ # n8n HTTP Request Node 設定指南 +| 項目 | 內容 | +|------|------| +| 建立者 | OpenCode | +| 建立時間 | 2026-03-26 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-23 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-26 | 新增 API Key 驗證說明,更新 curl 範例 | OpenCode | deepseek-reasoner | + +--- + > **API URL 說明**: > - **本地測試**: `http://localhost:3002` > - **n8n workflow**: `https://api.momentry.ddns.net` @@ -32,7 +49,9 @@ Node: HTTP Request │ "query": "={{ $json.query }}", │ "limit": "={{ $json.limit }}" │ } -└── Options: (empty) +├── Send Headers: ✓ (checked) +└── Header Parameters: + └── X-API-Key: {{ $env.MOMENTRY_API_KEY }} ``` ### 方法 2: 使用 Raw Body + Headers @@ -51,7 +70,8 @@ Node: HTTP Request │ } ├── Send Headers: ✓ (checked) └── Header Parameters: - └── Content-Type: application/json + ├── Content-Type: application/json + └── X-API-Key: {{ $env.MOMENTRY_API_KEY }} ``` ### 方法 3: 最簡單的 Hardcoded 測試 @@ -218,8 +238,12 @@ URL: https://api.momentry.ddns.net/api/v1/n8n/search 在終端機測試: ```bash +# 需要 API Key 驗證 (設定環境變數或直接替換) +export MOMENTRY_API_KEY="muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69" + curl -X POST https://api.momentry.ddns.net/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: $MOMENTRY_API_KEY" \ -d '{"query":"charade","limit":2}' ``` diff --git a/docs/N8N_INTEGRATION_GUIDE.md b/docs/N8N_INTEGRATION_GUIDE.md index 2bca11b..241def5 100644 --- a/docs/N8N_INTEGRATION_GUIDE.md +++ b/docs/N8N_INTEGRATION_GUIDE.md @@ -2,9 +2,22 @@ | 項目 | 內容 | |------|------| -| 版本 | V1.1 | -| 日期 | 2026-03-23 | -| 目標讀者 | n8n 使用者、DevOps | +| 建立者 | Warren | +| 建立時間 | 2026-03-23 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-22 | 創建 n8n 整合手冊 | Warren | OpenCode | +| V1.1 | 2026-03-23 | 新增 API Key 驗證與完整工作流範例 | Warren | OpenCode | + +--- + +**目標讀者**: n8n 使用者、DevOps --- diff --git a/docs/N8N_MCP_SETUP.md b/docs/N8N_MCP_SETUP.md index ec47dae..d00670d 100644 --- a/docs/N8N_MCP_SETUP.md +++ b/docs/N8N_MCP_SETUP.md @@ -1,5 +1,21 @@ # OpenCode n8n MCP 整合設定 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-23 | +| 文件版本 | V1.0 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-23 | 創建 n8n MCP 整合設定文件 | Warren | OpenCode | + +--- + > 建立時間: 2026-03-23 > 更新時間: 2026-03-23 diff --git a/docs/N8N_MCP_TEST_REPORT.md b/docs/N8N_MCP_TEST_REPORT.md index 3c12c08..2439b5e 100644 --- a/docs/N8N_MCP_TEST_REPORT.md +++ b/docs/N8N_MCP_TEST_REPORT.md @@ -1,5 +1,21 @@ # n8n MCP 整合測試報告 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-23 | +| 文件版本 | V1.0 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-23 | 創建測試報告 | Warren | OpenCode | + +--- + ## 測試日期 2026-03-23 diff --git a/docs/N8N_VIDEO_SEARCH_SUCCESS.md b/docs/N8N_VIDEO_SEARCH_SUCCESS.md index 1305922..315cf00 100644 --- a/docs/N8N_VIDEO_SEARCH_SUCCESS.md +++ b/docs/N8N_VIDEO_SEARCH_SUCCESS.md @@ -1,7 +1,20 @@ # n8n Video Search 工作流程 - 成功設定指南 -> 建立時間: 2026-03-22 -> 適用版本: n8n 2.3.5 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-22 | +| 文件版本 | V1.1 | +| 適用版本 | n8n 2.3.5 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-22 | 創建文件 | Warren | OpenCode | +| V1.1 | 2026-03-26 | 更新 API 範例,新增 X-API-Key 驗證標頭 | OpenCode | deepseek-reasoner | --- @@ -27,7 +40,11 @@ "sendBody": true, "specifyBody": "json", "jsonBody": "{\"query\":\"charade\",\"limit\":3}", - "options": {} + "options": { + "headers": { + "X-API-Key": "demo_api_key_12345" + } + } } ``` @@ -85,7 +102,11 @@ "sendBody": true, "specifyBody": "json", "jsonBody": "{\"query\":\"charade\",\"limit\":3}", - "options": {} + "options": { + "headers": { + "X-API-Key": "demo_api_key_12345" + } + } }, "name": "Search API", "type": "n8n-nodes-base.httpRequest", @@ -157,7 +178,7 @@ "title": "Chunk sentence_0006", "text": "fun plot twists, Woody Dialog and charming performances...", "score": 0.526, - "media_url": "https://wp.momentry.ddns.net/Old_Time_Movie_Show_-_Charade_1963.HD.mov" + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/Old_Time_Movie_Show_-_Charade_1963.HD.mov" } ] } @@ -203,13 +224,14 @@ ```bash curl -X POST https://api.momentry.ddns.net/api/v1/n8n/search \ -H "Content-Type: application/json" \ + -H "X-API-Key: demo_api_key_12345" \ -d '{"query":"charade","limit":3}' ``` ### 驗證服務狀態 ```bash # 檢查 Momentry Core -curl https://api.momentry.ddns.net/api/v1/videos +curl -H "X-API-Key: demo_api_key_12345" https://api.momentry.ddns.net/api/v1/videos # 檢查 n8n curl http://localhost:5678/api/v1/workflows \ diff --git a/docs/PLAYGROUND_BINARY_IMPLEMENTATION.md b/docs/PLAYGROUND_BINARY_IMPLEMENTATION.md index 66f5798..4f0bc2c 100644 --- a/docs/PLAYGROUND_BINARY_IMPLEMENTATION.md +++ b/docs/PLAYGROUND_BINARY_IMPLEMENTATION.md @@ -1,5 +1,21 @@ # Playground Binary Implementation Plan +| Item | Content | +|------|---------| +| Author | Warren | +| Created | 2026-03-23 | +| Document Version | V1.0 | + +--- + +## Version History + +| Version | Date | Purpose | Operator | Tool/Model | +|---------|------|---------|----------|------------| +| V1.0 | 2026-03-23 | Create implementation plan | Warren | OpenCode | + +--- + ## Overview Create separate `momentry_playground` binary with distinct configuration from `momentry` (production). diff --git a/docs/PROCESSING_PIPELINE.md b/docs/PROCESSING_PIPELINE.md index 03eabc3..2c42463 100644 --- a/docs/PROCESSING_PIPELINE.md +++ b/docs/PROCESSING_PIPELINE.md @@ -1,6 +1,19 @@ # Video Processing Pipeline - 處理流程 -> 建立時間: 2026-03-22 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-22 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-22 | 創建文件 | Warren | OpenCode | +| V1.1 | 2026-03-26 | 更新流程圖文字 (media_url→file_path) | OpenCode | deepseek-reasoner | --- @@ -54,7 +67,7 @@ │ │ │ │ │ │ Natural Language Query ──→ [Embedding] ──→ [Qdrant Search] │ │ │ │ ↓ │ │ -│ │ 返回結果含 media_url │ │ +│ │ 返回結果含 file_path │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ @@ -268,4 +281,3 @@ curl http://localhost:3002/api/v1/progress/{uuid} 3. **獨立 Chunk 命令** - 分離 chunk 生成 4. **獨立 Vectorize 命令** - 分離向量化流程 5. **模型管理** - 新增、選擇、預覽模型 - diff --git a/docs/TEST_AND_BENCHMARK_PLAN.md b/docs/TEST_AND_BENCHMARK_PLAN.md index 238ba2b..b2fa9ca 100644 --- a/docs/TEST_AND_BENCHMARK_PLAN.md +++ b/docs/TEST_AND_BENCHMARK_PLAN.md @@ -1,5 +1,21 @@ # Momentry 系統測試與驗證計劃 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-23 | +| 文件版本 | V1.0 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-23 | 創建測試與驗證計劃 | Warren | OpenCode | + +--- + > **計劃階段** - 僅供討論,尚未執行 > **建立時間**: 2026-03-23 > **目標**: 安裝後測試、跑分、燒機 diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 69465cf..dfc525d 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -2,9 +2,21 @@ | 項目 | 內容 | |------|------| -| 版本 | V1.0 | -| 日期 | 2026-03-21 | -| 目標讀者 | 系統管理員、開發者 | +| 建立者 | Warren | +| 建立時間 | 2026-03-21 | +| 文件版本 | V1.0 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-21 | 創建使用手冊 | Warren | OpenCode | + +--- + +**目標讀者**: 系統管理員、開發者 --- diff --git a/docs/VERSION_MANAGEMENT.md b/docs/VERSION_MANAGEMENT.md index 26e9f36..97fb59f 100644 --- a/docs/VERSION_MANAGEMENT.md +++ b/docs/VERSION_MANAGEMENT.md @@ -1,5 +1,21 @@ # Momentry Core 版本管理規範 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-23 | +| 文件版本 | V1.0 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-23 | 創建版本管理規範 | Warren | OpenCode | + +--- + ## 1. 版本與通訊埠對照表 | 版本 | Binary | Port | Redis Prefix | 用途 | diff --git a/docs/VIDEO_REGISTRATION.md b/docs/VIDEO_REGISTRATION.md index 4d9cab9..9a8ccce 100644 --- a/docs/VIDEO_REGISTRATION.md +++ b/docs/VIDEO_REGISTRATION.md @@ -1,5 +1,22 @@ # Video Registration +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-25 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-25 | 創建文件 | Warren | OpenCode | +| V1.1 | 2026-03-26 | 修正 curl 範例,新增 API Key 驗證標頭 | OpenCode | deepseek-reasoner | + +--- + ## 概述 影片註冊 API (`POST /api/v1/register`) 用於將影片加入 Momentry Core 系統進行處理。 @@ -139,11 +156,13 @@ SFTPgo 的用戶目錄結構: # 使用相對路徑註冊 curl -X POST http://localhost:3002/api/v1/register \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"path": "./demo/video.mp4"}' # 或使用多層目錄 curl -X POST http://localhost:3002/api/v1/register \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"path": "./demo/movies/2024/video.mp4"}' ``` @@ -185,6 +204,7 @@ pub fn extract_user_from_relative_path(relative_path: &str) -> (String, String) ```bash curl -X POST http://localhost:3002/api/v1/probe \ -H "Content-Type: application/json" \ + -H "X-API-Key: YOUR_API_KEY" \ -d '{"path": "./demo/video.mp4"}' ``` @@ -224,10 +244,3 @@ curl -X POST http://localhost:3002/api/v1/probe \ | `src/core/probe/ffprobe.rs` | ffprobe 整合 | | `docs/SFTPGO_DEMO_USER.md` | SFTPgo 用戶設置 | | `docs/API_ENDPOINTS.md` | API 端點總覽 | - -## 歷史 - -| 日期 | 變更 | -|------|------| -| 2026-03-25 | 初始版本 - 新增 UUID 計算規則和重複註冊檢查 | -| 2026-03-25 | 新增 Probe API 說明 | diff --git a/docs/n8n_workflow_simple.json b/docs/n8n_workflow_simple.json index 932ee43..a9e6dd2 100644 --- a/docs/n8n_workflow_simple.json +++ b/docs/n8n_workflow_simple.json @@ -12,7 +12,10 @@ "name": "Webhook (Simple)", "type": "n8n-nodes-base.webhook", "typeVersion": 1, - "position": [250, 300], + "position": [ + 250, + 300 + ], "webhookId": "video-search-simple" }, { @@ -34,7 +37,8 @@ }, "options": { "headers": { - "Content-Type": "application/json" + "Content-Type": "application/json", + "X-API-Key": "demo_api_key_12345" } } }, @@ -42,17 +46,23 @@ "name": "搜尋 Momentry", "type": "n8n-nodes-base.httpRequest", "typeVersion": 3, - "position": [500, 300] + "position": [ + 500, + 300 + ] }, { "parameters": { - "jsCode": "// 處理 Momentry 搜尋結果\nconst data = $input.first().json;\nconst hits = data.hits;\n\nif (!hits || hits.length === 0) {\n return {\n json: {\n success: false,\n message: '找不到相關結果',\n query: data.query\n }\n };\n}\n\n// 格式化結果\nconst formattedResults = hits.map((hit, idx) => ({\n index: idx + 1,\n id: hit.id,\n title: hit.title,\n text: hit.text,\n startTime: hit.start,\n endTime: hit.end,\n relevance: Math.round(hit.score * 100) + '%',\n videoUrl: hit.media_url,\n videoLink: hit.media_url + '#t=' + hit.start + ',' + hit.end\n}));\n\nreturn {\n json: {\n success: true,\n query: data.query,\n totalFound: data.count,\n results: formattedResults\n }\n};" + "jsCode": "// 處理 Momentry 搜尋結果\nconst data = $input.first().json;\nconst hits = data.hits;\n\nif (!hits || hits.length === 0) {\n return {\n json: {\n success: false,\n message: '找不到相關結果',\n query: data.query\n }\n };\n}\n\n// 格式化結果\nconst formattedResults = hits.map((hit, idx) => {\n return {\n index: idx + 1,\n id: hit.id,\n title: hit.title,\n text: hit.text,\n startTime: hit.start,\n endTime: hit.end,\n relevance: Math.round(hit.score * 100) + '%',\n file_path: hit.file_path\n };\n});\n\nreturn {\n json: {\n success: true,\n query: data.query,\n totalFound: data.count,\n results: formattedResults\n }\n};" }, "id": "code-process-simple", "name": "處理結果", "type": "n8n-nodes-base.code", "typeVersion": 1, - "position": [750, 300] + "position": [ + 750, + 300 + ] }, { "parameters": { @@ -63,7 +73,10 @@ "name": "回傳結果", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1, - "position": [1000, 300] + "position": [ + 1000, + 300 + ] } ], "connections": { @@ -107,4 +120,4 @@ "versionId": "1", "createdAt": "2026-03-23T00:00:00.000Z", "updatedAt": "2026-03-23T00:00:00.000Z" -} +} \ No newline at end of file diff --git a/docs/n8n_workflow_video_rag_mcp.json b/docs/n8n_workflow_video_rag_mcp.json index a3b6a91..59405c1 100644 --- a/docs/n8n_workflow_video_rag_mcp.json +++ b/docs/n8n_workflow_video_rag_mcp.json @@ -11,7 +11,10 @@ "name": "Webhook Trigger", "type": "n8n-nodes-base.webhook", "typeVersion": 1, - "position": [250, 300] + "position": [ + 250, + 300 + ] }, { "parameters": { @@ -21,22 +24,31 @@ "contentType": "json", "body": "={{ JSON.stringify({query: $json.body.query || $json.body, limit: $json.body.limit || 5, uuid: $json.body.uuid}) }}", "options": { - "timeout": 30000 + "timeout": 30000, + "headers": { + "X-API-Key": "demo_api_key_12345" + } } }, "name": "Search Momentry Core", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, - "position": [500, 300] + "position": [ + 500, + 300 + ] }, { "parameters": { - "jsCode": "// Process Momentry Core search results\nconst data = $input.first().json;\nconst hits = data.hits || [];\n\nif (hits.length === 0) {\n return {\n json: {\n success: false,\n message: 'No relevant results found',\n query: data.query,\n results: []\n }\n };\n}\n\n// Format results for RAG\nconst formattedResults = hits.map((hit, idx) => ({\n index: idx + 1,\n id: hit.id || hit.chunk_id,\n title: hit.title || 'Unknown Video',\n text: hit.text || hit.content || '',\n startTime: hit.start_time || hit.start || 0,\n endTime: hit.end_time || hit.end || 0,\n relevance: Math.round((hit.score || 0) * 100) + '%',\n videoUuid: hit.video_uuid || hit.uuid,\n mediaUrl: hit.media_url || '',\n deepLink: hit.media_url ? `${hit.media_url}#t=${hit.start_time || hit.start},${hit.end_time || hit.end}` : ''\n}));\n\n// Build context for RAG\nconst context = formattedResults\n .map(r => `[${r.index}] ${r.text} (Video: ${r.title}, Time: ${r.startTime}s-${r.endTime}s)`)\n .join('\\n\\n');\n\nreturn {\n json: {\n success: true,\n query: data.query,\n totalFound: data.count || hits.length,\n context: context,\n results: formattedResults\n }\n};" + "jsCode": "// Process Momentry Core search results\nconst data = $input.first().json;\nconst hits = data.hits || [];\n\nif (hits.length === 0) {\n return {\n json: {\n success: false,\n message: 'No relevant results found',\n query: data.query,\n results: []\n }\n };\n}\n\n// Format results for RAG\nconst formattedResults = hits.map((hit, idx) => {\n return {\n index: idx + 1,\n id: hit.id || hit.chunk_id,\n title: hit.title || 'Unknown Video',\n text: hit.text || hit.content || '',\n startTime: hit.start_time || hit.start || 0,\n endTime: hit.end_time || hit.end || 0,\n relevance: Math.round((hit.score || 0) * 100) + '%',\n videoUuid: hit.video_uuid || hit.uuid,\n file_path: hit.file_path || ''\n };\n});\n\n// Build context for RAG\nconst context = formattedResults\n .map(r => \\`[\\${r.index}] \\${r.text} (Video: \\${r.title}, Time: \\${r.startTime}s-\\${r.endTime}s)\\`)\n .join('\\n\\n');\n\nreturn {\n json: {\n success: true,\n query: data.query,\n totalFound: data.count || hits.length,\n context: context,\n results: formattedResults\n }\n};" }, "name": "Process RAG Results", "type": "n8n-nodes-base.code", "typeVersion": 2, - "position": [750, 300] + "position": [ + 750, + 300 + ] }, { "parameters": { @@ -49,7 +61,10 @@ "name": "Respond to Webhook", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.5, - "position": [1000, 300] + "position": [ + 1000, + 300 + ] } ], "connections": { @@ -91,4 +106,4 @@ "executionOrder": "v1" }, "staticData": null -} +} \ No newline at end of file diff --git a/docs/n8n_workflow_video_search.json b/docs/n8n_workflow_video_search.json index 12b29b2..032249b 100644 --- a/docs/n8n_workflow_video_search.json +++ b/docs/n8n_workflow_video_search.json @@ -12,7 +12,10 @@ "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 1, - "position": [250, 300], + "position": [ + 250, + 300 + ], "webhookId": "video-search" }, { @@ -34,7 +37,8 @@ }, "options": { "headers": { - "Content-Type": "application/json" + "Content-Type": "application/json", + "X-API-Key": "demo_api_key_12345" } } }, @@ -42,17 +46,23 @@ "name": "搜尋 Momentry", "type": "n8n-nodes-base.httpRequest", "typeVersion": 3, - "position": [500, 300] + "position": [ + 500, + 300 + ] }, { "parameters": { - "jsCode": "const hits = $input.first().json.hits;\n\nif (!hits || hits.length === 0) {\n return {\n json: {\n message: '找不到相關結果',\n query: $input.first().json.query\n }\n };\n}\n\nconst results = hits.map((hit, index) => ({\n number: index + 1,\n text: hit.text,\n start: hit.start,\n end: hit.end,\n score: Math.round(hit.score * 100) + '%',\n url: hit.media_url + '#t=' + hit.start + ',' + hit.end,\n video_title: hit.title\n}));\n\nreturn {\n json: {\n query: $input.first().json.query,\n count: $input.first().json.count,\n results: results\n }\n};" + "jsCode": "const hits = $input.first().json.hits;\n\nif (!hits || hits.length === 0) {\n return {\n json: {\n message: '找不到相關結果',\n query: $input.first().json.query\n }\n };\n}\n\nconst results = hits.map((hit, index) => {\n return {\n number: index + 1,\n text: hit.text,\n start: hit.start,\n end: hit.end,\n score: Math.round(hit.score * 100) + '%',\n video_title: hit.title,\n file_path: hit.file_path\n };\n});\n\nreturn {\n json: {\n query: $input.first().json.query,\n count: $input.first().json.count,\n results: results\n }\n};" }, "id": "code-process", "name": "處理結果", "type": "n8n-nodes-base.code", "typeVersion": 1, - "position": [750, 300] + "position": [ + 750, + 300 + ] }, { "parameters": { @@ -77,7 +87,10 @@ "name": "Telegram 通知", "type": "n8n-nodes-base.httpRequest", "typeVersion": 3, - "position": [1000, 300], + "position": [ + 1000, + 300 + ], "continueOnFail": true } ], @@ -122,4 +135,4 @@ "versionId": "1", "createdAt": "2026-03-23T00:00:00.000Z", "updatedAt": "2026-03-23T00:00:00.000Z" -} +} \ No newline at end of file diff --git a/install_worker_service.sh b/install_worker_service.sh new file mode 100644 index 0000000..75fff1d --- /dev/null +++ b/install_worker_service.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +echo "Installing Momentry Worker as a system service..." + +# Copy worker plist to system LaunchDaemons +sudo cp /Users/accusys/momentry_core_0.1/momentry_runtime/plist/com.momentry.worker.plist /Library/LaunchDaemons/ + +# Load the service +sudo launchctl load /Library/LaunchDaemons/com.momentry.worker.plist + +echo "Worker service installed successfully." +echo "Checking service status..." +launchctl list | grep com.momentry.worker || echo "Service not listed in user domain; check system domain." diff --git a/migrations/004_fix_processor_results.sql b/migrations/004_fix_processor_results.sql new file mode 100644 index 0000000..4986e40 --- /dev/null +++ b/migrations/004_fix_processor_results.sql @@ -0,0 +1,61 @@ +-- ================================================================ +-- Migration 004: Fix Processor Results Schema +-- Version: 004 +-- Date: 2026-03-26 +-- Description: Add missing output_data column and fix worker integration +-- ================================================================ + +-- 4.1.1: Add output_data column (JSONB) to processor_results +ALTER TABLE processor_results ADD COLUMN IF NOT EXISTS output_data JSONB; + +-- 4.1.2: Update processor_results table - drop duration_secs column if exists (we'll compute it) +ALTER TABLE processor_results DROP COLUMN IF EXISTS duration_secs; + +-- 4.1.3: Add computed duration column (stored as integer seconds) +ALTER TABLE processor_results ADD COLUMN IF NOT EXISTS duration_secs INT GENERATED ALWAYS AS ( + CASE + WHEN completed_at IS NOT NULL AND started_at IS NOT NULL + THEN EXTRACT(EPOCH FROM (completed_at - started_at))::INT + ELSE NULL + END +) STORED; + +-- 4.1.4: Add check constraint for processor values +ALTER TABLE processor_results DROP CONSTRAINT IF EXISTS chk_processor_results_processor; +ALTER TABLE processor_results ADD CONSTRAINT chk_processor_results_processor + CHECK (processor IN ('asr', 'cut', 'yolo', 'ocr', 'face', 'pose', 'asrx')); + +-- 4.1.5: Create index on processor_results.output_data for JSON queries (optional) +CREATE INDEX IF NOT EXISTS idx_processor_results_output_data ON processor_results USING gin (output_data); + +-- 4.1.6: Add foreign key from processor_results.video_id to videos.id if not exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'processor_results' + AND constraint_name = 'processor_results_video_id_fkey' + ) THEN + ALTER TABLE processor_results ADD CONSTRAINT processor_results_video_id_fkey + FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE; + END IF; +END $$; + +-- 4.1.7: Update monitor_jobs table - ensure processors column is correct type +ALTER TABLE monitor_jobs ALTER COLUMN processors TYPE VARCHAR(20)[] USING processors::VARCHAR(20)[]; +ALTER TABLE monitor_jobs ALTER COLUMN completed_processors TYPE VARCHAR(20)[] USING completed_processors::VARCHAR(20)[]; +ALTER TABLE monitor_jobs ALTER COLUMN failed_processors TYPE VARCHAR(20)[] USING failed_processors::VARCHAR(20)[]; + +-- 4.1.8: Add default values for arrays +ALTER TABLE monitor_jobs ALTER COLUMN processors SET DEFAULT '{"asr","cut","yolo","ocr","face","pose","asrx"}'; +ALTER TABLE monitor_jobs ALTER COLUMN completed_processors SET DEFAULT '{}'; +ALTER TABLE monitor_jobs ALTER COLUMN failed_processors SET DEFAULT '{}'; + +-- 4.1.9: Update existing rows to have default processor array +UPDATE monitor_jobs SET processors = '{"asr","cut","yolo","ocr","face","pose","asrx"}' WHERE processors IS NULL; + +-- 4.1.10: Add index on monitor_jobs.processors for faster array operations +CREATE INDEX IF NOT EXISTS idx_monitor_jobs_processors ON monitor_jobs USING gin (processors); + +COMMENT ON COLUMN processor_results.output_data IS 'JSON output from processor execution'; +COMMENT ON COLUMN processor_results.duration_secs IS 'Computed duration in seconds (completed - started)'; \ No newline at end of file diff --git a/migrations/005_change_duration_float.sql b/migrations/005_change_duration_float.sql new file mode 100644 index 0000000..1d30e15 --- /dev/null +++ b/migrations/005_change_duration_float.sql @@ -0,0 +1,22 @@ +-- ================================================================ +-- Migration 005: Change duration_secs to FLOAT8 +-- Version: 005 +-- Date: 2026-03-26 +-- Description: Change processor_results.duration_secs from INT to FLOAT8 +-- to match Rust f64 type and preserve fractional seconds. +-- ================================================================ + +-- 5.1.1: Drop the existing generated column +ALTER TABLE processor_results DROP COLUMN IF EXISTS duration_secs; + +-- 5.1.2: Re-add as double precision (float8) computed column +ALTER TABLE processor_results ADD COLUMN duration_secs DOUBLE PRECISION GENERATED ALWAYS AS ( + CASE + WHEN completed_at IS NOT NULL AND started_at IS NOT NULL + THEN EXTRACT(EPOCH FROM (completed_at - started_at)) + ELSE NULL + END +) STORED; + +-- 5.1.3: Update comment +COMMENT ON COLUMN processor_results.duration_secs IS 'Computed duration in seconds (completed - started) as double precision'; \ No newline at end of file diff --git a/momentry_runtime/plist/com.momentry.api.plist b/momentry_runtime/plist/com.momentry.api.plist index 9b5e003..78a0b78 100644 --- a/momentry_runtime/plist/com.momentry.api.plist +++ b/momentry_runtime/plist/com.momentry.api.plist @@ -30,6 +30,12 @@ DATABASE_URL postgres://accusys@localhost:5432/momentry + DB_MAX_CONNECTIONS + 50 + + DB_ACQUIRE_TIMEOUT + 30 + REDIS_URL redis://:accusys@localhost:6379 @@ -40,7 +46,7 @@ http://localhost:11434 QDRANT_URL - http://localhost:6333 + http://127.0.0.1:6333 RunAtLoad diff --git a/momentry_runtime/plist/com.momentry.worker.plist b/momentry_runtime/plist/com.momentry.worker.plist.bak similarity index 88% rename from momentry_runtime/plist/com.momentry.worker.plist rename to momentry_runtime/plist/com.momentry.worker.plist.bak index a55520d..3ccb38b 100644 --- a/momentry_runtime/plist/com.momentry.worker.plist +++ b/momentry_runtime/plist/com.momentry.worker.plist.bak @@ -30,6 +30,12 @@ DATABASE_URL postgres://accusys@localhost:5432/momentry + DB_MAX_CONNECTIONS + 50 + + DB_ACQUIRE_TIMEOUT + 30 + REDIS_URL redis://:accusys@localhost:6379 @@ -40,7 +46,7 @@ http://localhost:11434 QDRANT_URL - http://localhost:6333 + http://127.0.0.1:6333 RunAtLoad diff --git a/scripts/__pycache__/redis_publisher.cpython-311.pyc b/scripts/__pycache__/redis_publisher.cpython-311.pyc index 61e667d5397d97baca09941866c3205a32e9e4bc..c7666d6dec3fbcb19124c255c15b5c76ef955040 100644 GIT binary patch delta 20 acmdn#zSEt1IWI340}zz99p1>jP8k3|U Result<()> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + // Database connection + let db_url = config::DATABASE_URL.clone(); + let db = PostgresDb::new(&db_url).await?; + + let uuid = "9760d0820f0cf9a7"; + + // Load OCR result + let ocr_json = + fs::read_to_string("/Users/accusys/momentry/output/job_2_ocr_1774475908877.json")?; + let ocr_result: OcrResult = serde_json::from_str(&ocr_json)?; + println!("Loaded OCR result with {} frames", ocr_result.frames.len()); + + // Load FACE result + let face_json = + fs::read_to_string("/Users/accusys/momentry/output/job_2_face_1774475908878.json")?; + let face_result: FaceResult = serde_json::from_str(&face_json)?; + println!( + "Loaded FACE result with {} frames", + face_result.frames.len() + ); + + // Load POSE result + let pose_json = + fs::read_to_string("/Users/accusys/momentry/output/job_2_pose_1774475908880.json")?; + let pose_result: PoseResult = serde_json::from_str(&pose_json)?; + println!( + "Loaded POSE result with {} frames", + pose_result.frames.len() + ); + + // Load ASRX result + let asrx_json = + fs::read_to_string("/Users/accusys/momentry/output/job_2_asrx_1774475908887.json")?; + let asrx_result: AsrxResult = serde_json::from_str(&asrx_json)?; + println!( + "Loaded ASRX result with {} segments", + asrx_result.segments.len() + ); + + // Load YOLO result + let yolo_json = + fs::read_to_string("/Users/accusys/momentry/output/job_2_yolo_1774475908875.json")?; + let python_result: YoloPythonResult = serde_json::from_str(&yolo_json)?; + let yolo_result = python_result.to_yolo_result(); + println!( + "Loaded YOLO result with {} frames", + yolo_result.frames.len() + ); + + // Store chunks using ProcessorPool's static methods + println!("Storing OCR chunks..."); + ProcessorPool::store_ocr_chunks(&db, uuid, &ocr_result).await?; + println!("Storing FACE chunks..."); + ProcessorPool::store_face_chunks(&db, uuid, &face_result).await?; + println!("Storing POSE chunks..."); + ProcessorPool::store_pose_chunks(&db, uuid, &pose_result).await?; + println!("Storing ASRX chunks..."); + ProcessorPool::store_asrx_chunks(&db, uuid, &asrx_result).await?; + println!("Storing YOLO chunks..."); + ProcessorPool::store_yolo_chunks(&db, uuid, &yolo_result).await?; + + println!("All trace chunks stored successfully!"); + + Ok(()) +} diff --git a/src/core/chunk/splitter.rs b/src/core/chunk/splitter.rs index 33a0061..196d8e1 100644 --- a/src/core/chunk/splitter.rs +++ b/src/core/chunk/splitter.rs @@ -20,7 +20,7 @@ impl ChunkSplitter { while current_time < duration { let end_time = (current_time + self.time_based_duration).min(duration); - chunks.push(Chunk::new( + chunks.push(Chunk::from_seconds( 0, // file_id uuid.to_string(), index, @@ -45,7 +45,7 @@ impl ChunkSplitter { let mut chunks = Vec::new(); for (index, segment) in asr_segments.iter().enumerate() { - chunks.push(Chunk::new( + chunks.push(Chunk::from_seconds( 0, // file_id uuid.to_string(), index as u32, diff --git a/src/core/chunk/types.rs b/src/core/chunk/types.rs index 92d0ea8..024b559 100644 --- a/src/core/chunk/types.rs +++ b/src/core/chunk/types.rs @@ -1,3 +1,4 @@ +use crate::core::time::FrameTime; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] @@ -46,10 +47,11 @@ pub struct Chunk { pub chunk_index: u32, pub chunk_type: ChunkType, pub rule: ChunkRule, - pub start_time: f64, - pub end_time: f64, + /// Frames per second (can be fractional, e.g., 29.97, 23.976) pub fps: f64, + /// Start frame (0-based) pub start_frame: i64, + /// End frame (exclusive) pub end_frame: i64, pub text_content: Option, pub content: serde_json::Value, @@ -62,6 +64,13 @@ pub struct Chunk { } impl Chunk { + /// Creates a new chunk from frame counts. + /// + /// # Arguments + /// + /// * `start_frame` - Start frame (0-based) + /// * `end_frame` - End frame (exclusive) + /// * `fps` - Frames per second (can be fractional) #[allow(clippy::too_many_arguments)] pub fn new( file_id: i32, @@ -69,13 +78,11 @@ impl Chunk { chunk_index: u32, chunk_type: ChunkType, rule: ChunkRule, - start_time: f64, - end_time: f64, + start_frame: i64, + end_frame: i64, fps: f64, content: serde_json::Value, ) -> Self { - let start_frame = (start_time * fps) as i64; - let end_frame = (end_time * fps) as i64; let chunk_id = format!("{}_{:04}", chunk_type.as_str(), chunk_index); Self { file_id, @@ -84,8 +91,6 @@ impl Chunk { chunk_index, chunk_type, rule, - start_time, - end_time, fps, start_frame, end_frame, @@ -100,6 +105,95 @@ impl Chunk { } } + /// Creates a new chunk from seconds (legacy conversion). + /// + /// This is useful for migrating from older systems that store time as seconds. + /// The frame counts are calculated by rounding `seconds * fps`. + #[allow(clippy::too_many_arguments)] + pub fn from_seconds( + file_id: i32, + uuid: String, + chunk_index: u32, + chunk_type: ChunkType, + rule: ChunkRule, + start_time: f64, + end_time: f64, + fps: f64, + content: serde_json::Value, + ) -> Self { + let start_frame = (start_time * fps).round() as i64; + let end_frame = (end_time * fps).round() as i64; + Self::new( + file_id, + uuid, + chunk_index, + chunk_type, + rule, + start_frame, + end_frame, + fps, + content, + ) + } + + /// Returns the start time as a `FrameTime`. + pub fn start_time(&self) -> FrameTime { + FrameTime::from_frames(self.start_frame, self.fps) + } + + /// Returns the end time as a `FrameTime`. + pub fn end_time(&self) -> FrameTime { + FrameTime::from_frames(self.end_frame, self.fps) + } + + /// Returns the duration in frames. + pub fn duration_frames(&self) -> i64 { + self.end_frame - self.start_frame + } + + /// Returns the duration in seconds. + pub fn duration_seconds(&self) -> f64 { + self.duration_frames() as f64 / self.fps + } + + /// Formats the start time as "seconds.frame" (e.g., "123.04"). + pub fn format_start_sec_frame(&self) -> String { + self.start_time().format_sec_frame() + } + + /// Formats the end time as "seconds.frame" (e.g., "456.15"). + pub fn format_end_sec_frame(&self) -> String { + self.end_time().format_sec_frame() + } + + /// Formats the start time as "HH:MM:SS". + pub fn format_start_hms(&self) -> String { + self.start_time().format_hms() + } + + /// Formats the end time as "HH:MM:SS". + pub fn format_end_hms(&self) -> String { + self.end_time().format_hms() + } + + /// Formats the start time as "HH:MM:SS.FF". + pub fn format_start_hms_frame(&self) -> String { + self.start_time().format_hms_frame() + } + + /// Formats the end time as "HH:MM:SS.FF". + pub fn format_end_hms_frame(&self) -> String { + self.end_time().format_hms_frame() + } + + /// Returns a tuple of (start_seconds, end_seconds) for compatibility. + /// + /// This is provided for backward compatibility during migration. + /// Prefer using `start_time()` and `end_time()` methods. + pub fn time_range_seconds(&self) -> (f64, f64) { + (self.start_time().seconds(), self.end_time().seconds()) + } + pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self { self.metadata = Some(metadata); self diff --git a/src/core/db/mongodb_db.rs b/src/core/db/mongodb_db.rs index 0c176f2..a087d3b 100644 --- a/src/core/db/mongodb_db.rs +++ b/src/core/db/mongodb_db.rs @@ -28,13 +28,15 @@ pub struct ChunkDocument { impl From for ChunkDocument { fn from(chunk: Chunk) -> Self { + let start_time = chunk.start_time().seconds(); + let end_time = chunk.end_time().seconds(); Self { uuid: chunk.uuid, chunk_id: chunk.chunk_id, chunk_index: chunk.chunk_index, chunk_type: chunk.chunk_type.as_str().to_string(), - start_time: chunk.start_time, - end_time: chunk.end_time, + start_time, + end_time, fps: chunk.fps, start_frame: chunk.start_frame, end_frame: chunk.end_frame, @@ -118,8 +120,6 @@ impl MongoDb { chunk_index: doc.chunk_index, chunk_type, rule: ChunkRule::Rule1, - start_time: doc.start_time, - end_time: doc.end_time, fps: doc.fps, start_frame: doc.start_frame, end_frame: doc.end_frame, @@ -178,8 +178,6 @@ impl MongoDb { chunk_index: doc.chunk_index, chunk_type, rule: ChunkRule::Rule1, - start_time: doc.start_time, - end_time: doc.end_time, fps: doc.fps, start_frame: doc.start_frame, end_frame: doc.end_frame, @@ -235,8 +233,6 @@ impl MongoDb { chunk_index: doc.chunk_index, chunk_type, rule: ChunkRule::Rule1, - start_time: doc.start_time, - end_time: doc.end_time, fps: doc.fps, start_frame: doc.start_frame, end_frame: doc.end_frame, diff --git a/src/core/db/postgres_db.rs b/src/core/db/postgres_db.rs index 6a736f2..137f7c3 100644 --- a/src/core/db/postgres_db.rs +++ b/src/core/db/postgres_db.rs @@ -126,8 +126,6 @@ pub struct PreChunk { pub source_type: String, pub source_file: Option, pub chunk_type: String, - pub start_time: f64, - pub end_time: f64, pub start_frame: i64, pub end_frame: i64, pub fps: f64, @@ -209,7 +207,7 @@ pub struct MonitorJobStats { pub failed: i32, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum ProcessorType { Asr, @@ -449,6 +447,12 @@ impl PostgresDb { .parse::() .unwrap_or(60); + tracing::info!( + "DB pool config: max_connections={}, acquire_timeout={}s", + max_connections, + acquire_timeout_secs + ); + let pool_options = PgPoolOptions::new() .max_connections(max_connections) .acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs)); @@ -1770,8 +1774,8 @@ impl PostgresDb { .bind(&chunk.chunk_id) .bind(chunk.chunk_index as i32) .bind(chunk.chunk_type.as_str()) - .bind(chunk.start_time) - .bind(chunk.end_time) + .bind(chunk.start_time().seconds()) + .bind(chunk.end_time().seconds()) .bind(chunk.fps) .bind(chunk.start_frame) .bind(chunk.end_frame) @@ -1791,7 +1795,7 @@ impl PostgresDb { pub async fn get_chunks_by_uuid(&self, uuid: &str) -> Result> { let rows = sqlx::query( - "SELECT COALESCE(file_id, 0) as file_id, uuid, chunk_id, chunk_index, chunk_type, start_time, end_time, COALESCE(fps, 24.0) as fps, COALESCE(start_frame, 0) as start_frame, COALESCE(end_frame, 0) as end_frame, text_content, content, metadata, vector_id, COALESCE(frame_count, 0) as frame_count, pre_chunk_ids, parent_chunk_id, child_chunk_ids FROM chunks WHERE uuid = $1 ORDER BY chunk_index" + "SELECT COALESCE(file_id, 0) as file_id, uuid, chunk_id, chunk_index, chunk_type, COALESCE(fps, 24.0) as fps, COALESCE(start_frame, 0) as start_frame, COALESCE(end_frame, 0) as end_frame, text_content, content, metadata, vector_id, COALESCE(frame_count, 0) as frame_count, pre_chunk_ids, parent_chunk_id, child_chunk_ids FROM chunks WHERE uuid = $1 ORDER BY chunk_index" ) .bind(uuid) .fetch_all(&self.pool) @@ -1811,12 +1815,12 @@ impl PostgresDb { _ => ChunkType::TimeBased, }; - let content: serde_json::Value = r.get(11); - let metadata: Option = r.get(12); + let content: serde_json::Value = r.get(9); + let metadata: Option = r.get(10); - let pre_chunk_ids: Vec = r.try_get(15).unwrap_or_default(); - let parent_chunk_id: Option = r.try_get(16).ok().flatten(); - let child_chunk_ids: Vec = r.try_get(17).unwrap_or_default(); + let pre_chunk_ids: Vec = r.try_get(13).unwrap_or_default(); + let parent_chunk_id: Option = r.try_get(14).ok().flatten(); + let child_chunk_ids: Vec = r.try_get(15).unwrap_or_default(); let (rule, content_data) = if content.get("rule").is_some() { let rule_str = content @@ -1844,8 +1848,7 @@ impl PostgresDb { chunk_index: chunk_index as u32, chunk_type, rule, - start_time: r.get("start_time"), - end_time: r.get("end_time"), + fps: r.get("fps"), start_frame: r.get("start_frame"), end_frame: r.get("end_frame"), @@ -1866,7 +1869,7 @@ impl PostgresDb { pub async fn get_chunk_by_chunk_id(&self, chunk_id: &str) -> Result> { let row = sqlx::query( - "SELECT COALESCE(file_id, 0) as file_id, uuid, chunk_id, chunk_index, chunk_type, start_time, end_time, COALESCE(fps, 24.0) as fps, COALESCE(start_frame, 0) as start_frame, COALESCE(end_frame, 0) as end_frame, text_content, content, metadata, vector_id, COALESCE(frame_count, 0) as frame_count, pre_chunk_ids, parent_chunk_id, child_chunk_ids FROM chunks WHERE chunk_id = $1" + "SELECT COALESCE(file_id, 0) as file_id, uuid, chunk_id, chunk_index, chunk_type, COALESCE(fps, 24.0) as fps, COALESCE(start_frame, 0) as start_frame, COALESCE(end_frame, 0) as end_frame, text_content, content, metadata, vector_id, COALESCE(frame_count, 0) as frame_count, pre_chunk_ids, parent_chunk_id, child_chunk_ids FROM chunks WHERE chunk_id = $1" ) .bind(chunk_id) .fetch_optional(&self.pool) @@ -1884,12 +1887,12 @@ impl PostgresDb { _ => ChunkType::TimeBased, }; - let content: serde_json::Value = r.get(11); - let metadata: Option = r.get(12); + let content: serde_json::Value = r.get(9); + let metadata: Option = r.get(10); - let pre_chunk_ids: Vec = r.try_get(15).unwrap_or_default(); - let parent_chunk_id: Option = r.try_get(16).ok().flatten(); - let child_chunk_ids: Vec = r.try_get(17).unwrap_or_default(); + let pre_chunk_ids: Vec = r.try_get(13).unwrap_or_default(); + let parent_chunk_id: Option = r.try_get(14).ok().flatten(); + let child_chunk_ids: Vec = r.try_get(15).unwrap_or_default(); let (rule, content_data) = if content.get("rule").is_some() { let rule_str = content @@ -1917,8 +1920,6 @@ impl PostgresDb { chunk_index: chunk_index as u32, chunk_type, rule, - start_time: r.get("start_time"), - end_time: r.get("end_time"), fps: r.get("fps"), start_frame: r.get("start_frame"), end_frame: r.get("end_frame"), @@ -1942,6 +1943,9 @@ impl PostgresDb { INSERT INTO pre_chunks (file_id, source_type, source_file, chunk_type, start_time, end_time, start_frame, end_frame, fps, raw_json, text_content, processed, chunk_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (file_id, source_type, start_frame, end_frame) DO UPDATE SET + start_time = EXCLUDED.start_time, + end_time = EXCLUDED.end_time, + fps = EXCLUDED.fps, raw_json = EXCLUDED.raw_json, text_content = EXCLUDED.text_content, processed = EXCLUDED.processed, @@ -1953,8 +1957,8 @@ impl PostgresDb { .bind(&pre_chunk.source_type) .bind(&pre_chunk.source_file) .bind(&pre_chunk.chunk_type) - .bind(pre_chunk.start_time) - .bind(pre_chunk.end_time) + .bind(pre_chunk.start_frame as f64 / pre_chunk.fps) + .bind(pre_chunk.end_frame as f64 / pre_chunk.fps) .bind(pre_chunk.start_frame) .bind(pre_chunk.end_frame) .bind(pre_chunk.fps) @@ -2108,8 +2112,7 @@ impl PostgresDb { chunk_index: chunk_index as u32, chunk_type, rule, - start_time: r.get("start_time"), - end_time: r.get("end_time"), + fps: r.get("fps"), start_frame: r.get("start_frame"), end_frame: r.get("end_frame"), @@ -2134,7 +2137,7 @@ impl PostgresDb { } let rows = sqlx::query( - "SELECT file_id, uuid, chunk_id, chunk_index, chunk_type, start_time, end_time, fps, start_frame, end_frame, text_content, content, metadata, vector_id, frame_count, pre_chunk_ids, parent_chunk_id, child_chunk_ids FROM chunks WHERE chunk_id = ANY($1) ORDER BY chunk_index", + "SELECT file_id, uuid, chunk_id, chunk_index, chunk_type, fps, start_frame, end_frame, text_content, content, metadata, vector_id, frame_count, pre_chunk_ids, parent_chunk_id, child_chunk_ids FROM chunks WHERE chunk_id = ANY($1) ORDER BY chunk_index", ) .bind(chunk_ids) .fetch_all(&self.pool) @@ -2154,12 +2157,12 @@ impl PostgresDb { _ => ChunkType::TimeBased, }; - let content: serde_json::Value = r.get(11); - let metadata: Option = r.get(12); + let content: serde_json::Value = r.get(9); + let metadata: Option = r.get(10); - let pre_chunk_ids: Vec = r.try_get(15).unwrap_or_default(); - let parent_chunk_id: Option = r.try_get(16).ok().flatten(); - let child_chunk_ids: Vec = r.try_get(17).unwrap_or_default(); + let pre_chunk_ids: Vec = r.try_get(13).unwrap_or_default(); + let parent_chunk_id: Option = r.try_get(14).ok().flatten(); + let child_chunk_ids: Vec = r.try_get(15).unwrap_or_default(); let (rule, content_data) = if content.get("rule").is_some() { let rule_str = content @@ -2187,8 +2190,7 @@ impl PostgresDb { chunk_index: chunk_index as u32, chunk_type, rule, - start_time: r.get("start_time"), - end_time: r.get("end_time"), + fps: r.get("fps"), start_frame: r.get("start_frame"), end_frame: r.get("end_frame"), @@ -2337,8 +2339,6 @@ impl PostgresDb { chunk_index: r.2 as u32, chunk_type, rule: ChunkRule::Rule1, - start_time: r.4, - end_time: r.5, fps: r.6, start_frame: r.7, end_frame: r.8, @@ -2497,8 +2497,8 @@ impl PostgresDb { chunk_type: chunk_data .map(|c| c.chunk_type.as_str().to_string()) .unwrap_or_default(), - start_time: chunk_data.map(|c| c.start_time).unwrap_or(0.0), - end_time: chunk_data.map(|c| c.end_time).unwrap_or(0.0), + start_time: chunk_data.map(|c| c.start_time().seconds()).unwrap_or(0.0), + end_time: chunk_data.map(|c| c.end_time().seconds()).unwrap_or(0.0), text: chunk_data .and_then(|c| c.text_content.clone()) .unwrap_or_default(), @@ -2584,6 +2584,7 @@ impl PostgresDb { error_count, last_error, started_at, updated_at, created_at FROM monitor_jobs WHERE status = 'pending' + OR (status = 'running' AND EXISTS (SELECT 1 FROM processor_results WHERE job_id = monitor_jobs.id AND status = 'pending')) ORDER BY created_at ASC LIMIT $1 FOR UPDATE SKIP LOCKED @@ -2619,6 +2620,77 @@ impl PostgresDb { Ok(jobs) } + pub async fn get_running_jobs_with_all_processors_done( + &self, + limit: i32, + ) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, uuid, video_path, status, current_processor, progress_total, progress_current, + error_count, last_error, started_at, updated_at, created_at + FROM monitor_jobs + WHERE status = 'running' + AND NOT EXISTS ( + SELECT 1 FROM processor_results pr + WHERE pr.job_id = monitor_jobs.id + AND pr.status IN ('pending', 'running') + ) + ORDER BY updated_at ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + "# + ) + .bind(limit) + .fetch_all(&self.pool) + .await?; + + let jobs: Vec = rows + .into_iter() + .map(|r| { + let status_str: String = r.get(3); + let status = + MonitorJobStatus::from_db_str(&status_str).unwrap_or(MonitorJobStatus::Pending); + MonitorJob { + id: r.get(0), + uuid: r.get(1), + video_path: r.get(2), + status, + current_processor: r.get(4), + progress_total: r.get(5), + progress_current: r.get(6), + error_count: r.get(7), + last_error: r.get(8), + started_at: r.get(9), + updated_at: r.get(10), + created_at: r.get(11), + } + }) + .collect(); + + Ok(jobs) + } + + pub async fn update_job_processors_arrays( + &self, + job_id: i32, + completed_processors: Vec, + failed_processors: Vec, + ) -> Result<()> { + sqlx::query( + "UPDATE monitor_jobs + SET completed_processors = $1, + failed_processors = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3", + ) + .bind(completed_processors) + .bind(failed_processors) + .bind(job_id) + .execute(&self.pool) + .await?; + Ok(()) + } + pub async fn update_job_status(&self, job_id: i32, status: MonitorJobStatus) -> Result<()> { sqlx::query( "UPDATE monitor_jobs SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2", @@ -2660,6 +2732,7 @@ impl PostgresDb { r#" INSERT INTO processor_results (job_id, processor, status) VALUES ($1, $2, 'pending') + ON CONFLICT (job_id, processor) DO UPDATE SET job_id = EXCLUDED.job_id RETURNING id "#, ) @@ -2685,8 +2758,8 @@ impl PostgresDb { SET status = $1, error_message = $2, output_data = $3, + started_at = CASE WHEN $1 = 'running' AND started_at IS NULL THEN CURRENT_TIMESTAMP ELSE started_at END, completed_at = CASE WHEN $1 IN ('completed', 'failed', 'skipped') THEN CURRENT_TIMESTAMP ELSE completed_at END, - duration_secs = CASE WHEN $1 IN ('completed', 'failed', 'skipped') THEN EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) ELSE duration_secs END, updated_at = CURRENT_TIMESTAMP WHERE id = $4 "#, @@ -2705,7 +2778,7 @@ impl PostgresDb { r#" SELECT id, job_id, processor, status, output_path, started_at, completed_at, error_message, progress_total, progress_current, last_checkpoint, - created_at, updated_at, duration_secs + created_at, updated_at, duration_secs FROM processor_results WHERE job_id = $1 ORDER BY created_at ASC diff --git a/src/core/db/sync_db.rs b/src/core/db/sync_db.rs index da7a185..c349b98 100644 --- a/src/core/db/sync_db.rs +++ b/src/core/db/sync_db.rs @@ -26,8 +26,8 @@ impl SyncDb { let uuid = chunk.uuid.clone(); let chunk_id = chunk.chunk_id.clone(); let chunk_type = chunk.chunk_type.as_str().to_string(); - let start_time = chunk.start_time; - let end_time = chunk.end_time; + let start_time = chunk.start_time().seconds(); + let end_time = chunk.end_time().seconds(); let vector = self.embed_text(text).await?; @@ -117,7 +117,7 @@ impl SyncDb { "language_probability": asr_result.language_probability, }); - let chunk = Chunk::new( + let chunk = Chunk::from_seconds( 0, // file_id - will be set later uuid.to_string(), i as u32, diff --git a/src/core/mod.rs b/src/core/mod.rs index f9be089..c950aad 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,3 +9,4 @@ pub mod probe; pub mod processor; pub mod storage; pub mod thumbnail; +pub mod time; diff --git a/src/core/processor/asr.rs b/src/core/processor/asr.rs index 88e2f4f..60d7ef7 100644 --- a/src/core/processor/asr.rs +++ b/src/core/processor/asr.rs @@ -4,7 +4,7 @@ use std::time::Duration; use super::executor::PythonExecutor; -const ASR_TIMEOUT: Duration = Duration::from_secs(3600); +const ASR_TIMEOUT: Duration = Duration::from_secs(1800); // 30 minutes #[derive(Debug, Serialize, Deserialize)] pub struct AsrResult { diff --git a/src/core/processor/executor.rs b/src/core/processor/executor.rs index 8671c42..ecee621 100644 --- a/src/core/processor/executor.rs +++ b/src/core/processor/executor.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use libc; use std::path::PathBuf; use std::process::Stdio; use std::time::Duration; @@ -159,12 +160,16 @@ impl PythonExecutor { cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); + cmd.kill_on_drop(true); + // Create new process group for clean termination + cmd.process_group(0); tracing::info!("[{}] Starting: {:?}", log_prefix, script_name); let mut child = cmd .spawn() .with_context(|| format!("Failed to run {}", script_name))?; + let child_pid = child.id(); let stdout = child.stdout.take().context("Failed to capture stdout")?; let stderr = child.stderr.take().context("Failed to capture stderr")?; @@ -220,6 +225,13 @@ impl PythonExecutor { Ok(Ok(())) => {} Ok(Err(e)) => return Err(e), Err(_) => { + // Try to kill the entire process group + if let Some(pid) = child_pid { + let pgid = pid as i32; + unsafe { + libc::killpg(pgid, libc::SIGKILL); + } + } child.kill().await.context("Failed to kill process")?; anyhow::bail!("{} timed out after {:?}", script_name, duration); } diff --git a/src/core/processor/yolo.rs b/src/core/processor/yolo.rs index 8a6b0c1..6443410 100644 --- a/src/core/processor/yolo.rs +++ b/src/core/processor/yolo.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::time::Duration; use super::executor::PythonExecutor; @@ -31,6 +32,90 @@ pub struct YoloObject { pub confidence: f32, } +// New structs for parsing Python YOLO output +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct YoloPythonMetadata { + video_path: String, + fps: f64, + width: i32, + height: i32, + total_frames: i64, + total_duration: f64, + processed_at: String, + auto_save_interval: i32, + auto_save_frames: i32, + status: String, + last_saved_at: String, + last_saved_frame: i64, + completed_at: Option, + processing_time: Option, + total_detections: Option, + auto_save_count: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct YoloPythonDetection { + class_name: String, + confidence: f32, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: i32, + height: i32, + class_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct YoloPythonFrame { + frame_number: u64, + time_seconds: f64, + time_formatted: String, + detections: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct YoloPythonResult { + metadata: YoloPythonMetadata, + frames: HashMap, +} + +impl YoloPythonResult { + pub fn to_yolo_result(&self) -> YoloResult { + let mut frames = Vec::new(); + + // Sort frames by frame number (key is string, but we parse as u64) + let mut frame_entries: Vec<_> = self.frames.iter().collect(); + frame_entries.sort_by_key(|(key, _)| key.parse::().unwrap_or(0)); + + for (_, frame) in frame_entries { + let mut objects = Vec::new(); + for detection in &frame.detections { + objects.push(YoloObject { + class_name: detection.class_name.clone(), + class_id: detection.class_id.unwrap_or(0), + x: detection.x1 as i32, + y: detection.y1 as i32, + width: detection.width, + height: detection.height, + confidence: detection.confidence, + }); + } + frames.push(YoloFrame { + frame: frame.frame_number, + timestamp: frame.time_seconds, + objects, + }); + } + + YoloResult { + frame_count: frames.len() as u64, + fps: self.metadata.fps, + frames, + } + } +} + pub async fn process_yolo( video_path: &str, output_path: &str, @@ -63,9 +148,11 @@ pub async fn process_yolo( let json_str = std::fs::read_to_string(output_path).context("Failed to read YOLO output")?; - let result: YoloResult = + let python_result: YoloPythonResult = serde_json::from_str(&json_str).context("Failed to parse YOLO output")?; + let result = python_result.to_yolo_result(); + tracing::info!( "[YOLO] Result: {} frames, {:.2} fps", result.frame_count, @@ -150,4 +237,75 @@ mod tests { }; assert!(result.frames.is_empty()); } + + #[test] + fn test_yolo_python_result_parsing() { + // Sample JSON matching Python script output + let json = r#"{ + "metadata": { + "video_path": "/test/video.mp4", + "fps": 22.0, + "width": 640, + "height": 360, + "total_frames": 3512, + "total_duration": 159.63636363636363, + "processed_at": "2026-03-26T05:20:48.230143", + "auto_save_interval": 30, + "auto_save_frames": 300, + "status": "completed", + "last_saved_at": "2026-03-26T05:23:22.791673", + "last_saved_frame": 0, + "completed_at": "2026-03-26T05:23:22.791666", + "processing_time": 154.5577518939972, + "total_detections": 12786, + "auto_save_count": 11 + }, + "frames": { + "13": { + "frame_number": 13, + "time_seconds": 0.545, + "time_formatted": "00:00:00", + "detections": [ + { + "class_id": 0, + "class_name": "person", + "confidence": 0.8424218893051147, + "x1": 473.4156494140625, + "y1": 79.5609359741211, + "x2": 639.77783203125, + "y2": 303.8714294433594, + "width": 166, + "height": 224 + } + ] + } + } + }"#; + + let python_result: YoloPythonResult = serde_json::from_str(json).unwrap(); + assert_eq!(python_result.metadata.fps, 22.0); + assert_eq!(python_result.frames.len(), 1); + let frame = python_result.frames.get("13").unwrap(); + assert_eq!(frame.frame_number, 13); + assert_eq!(frame.detections.len(), 1); + let detection = &frame.detections[0]; + assert_eq!(detection.class_id, Some(0)); + assert_eq!(detection.class_name, "person"); + assert!((detection.confidence - 0.8424218893051147).abs() < 0.0001); + assert!((detection.x1 - 473.4156494140625).abs() < 0.0001); + + // Convert to YoloResult + let yolo_result = python_result.to_yolo_result(); + assert_eq!(yolo_result.frames.len(), 1); + assert_eq!(yolo_result.frames[0].frame, 13); + assert_eq!(yolo_result.frames[0].objects.len(), 1); + let obj = &yolo_result.frames[0].objects[0]; + assert_eq!(obj.class_name, "person"); + assert_eq!(obj.class_id, 0); + assert_eq!(obj.x, 473); + assert_eq!(obj.y, 79); + assert_eq!(obj.width, 166); + assert_eq!(obj.height, 224); + assert!((obj.confidence - 0.842421889).abs() < 0.0001); + } } diff --git a/src/core/time.rs b/src/core/time.rs new file mode 100644 index 0000000..a037428 --- /dev/null +++ b/src/core/time.rs @@ -0,0 +1,383 @@ +//! Frame-based time representation for video processing. +//! +//! This module provides a `FrameTime` struct that stores time as frame count +//! with a given FPS (frames per second). This avoids floating-point precision +//! issues when converting between seconds and frames. +//! +//! # Examples +//! +//! ``` +//! use momentry_core::time::FrameTime; +//! +//! // Create a FrameTime from frames +//! let time = FrameTime::from_frames(1234, 30.0); +//! assert_eq!(time.seconds(), 41.13333333333333); +//! assert_eq!(time.format_sec_frame(), "41.04"); +//! +//! // Create from seconds (useful for migration) +//! let time = FrameTime::from_seconds(41.133333, 30.0); +//! assert_eq!(time.frames(), 1234); +//! ``` + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Frame-based time representation. +/// +/// Stores time as an integer frame count with a floating-point FPS. +/// All calculations are performed using integer frame counts to avoid +/// floating-point precision issues. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct FrameTime { + /// Frame count (0-based) + frames: i64, + /// Frames per second (can be fractional, e.g., 29.97, 23.976) + fps: f64, +} + +impl FrameTime { + /// Creates a new `FrameTime` from frame count and FPS. + /// + /// If `fps <= 0.0` or `fps.is_nan()`, defaults to 30.0 FPS. + pub fn from_frames(frames: i64, fps: f64) -> Self { + let fps = if fps <= 0.0 || !fps.is_finite() { + 30.0 + } else { + fps + }; + Self { frames, fps } + } + + /// Creates a new `FrameTime` from seconds and FPS. + /// + /// This is useful for migrating from existing time representations. + /// The frame count is calculated as `(seconds * fps).round() as i64` + /// to minimize precision loss. + /// + /// If `fps <= 0.0` or `fps.is_nan()`, defaults to 30.0 FPS. + pub fn from_seconds(seconds: f64, fps: f64) -> Self { + let fps = if fps <= 0.0 || !fps.is_finite() { + 30.0 + } else { + fps + }; + let frames = (seconds * fps).round() as i64; + Self { frames, fps } + } + + /// Returns the frame count. + pub fn frames(&self) -> i64 { + self.frames + } + + /// Returns the FPS (frames per second). + pub fn fps(&self) -> f64 { + self.fps + } + + /// Returns the time in seconds as a floating-point value. + /// + /// Note: This may have precision limitations for fractional FPS values. + /// For display purposes, use `format_sec_frame()` or `format_hms()` instead. + pub fn seconds(&self) -> f64 { + self.frames as f64 / self.fps + } + + /// Formats the time as "seconds.frame" with fixed two-digit frame number. + /// + /// The frame number is displayed as a zero-padded two-digit number + /// representing the frame within the current second. + /// + /// # Examples + /// + /// - `123.04` = 123 seconds, frame 4 (at 30 FPS, frame 4 = 0.133 seconds) + /// - `5.29` = 5 seconds, frame 29 (at 30 FPS, last frame of that second) + pub fn format_sec_frame(&self) -> String { + let total_seconds = self.frames as f64 / self.fps; + let seconds = total_seconds.floor() as i64; + // For fractional FPS, use ceil of fps for modulo operation + let fps_ceil = self.fps.ceil() as i64; + // Ensure fps_ceil > 0 + let frames_in_second = if fps_ceil == 0 { + 0 + } else { + self.frames % fps_ceil + }; + // Handle negative frames + let frames_in_second = if frames_in_second < 0 { + // This shouldn't happen in practice + 0 + } else { + frames_in_second + }; + format!("{}.{:02}", seconds, frames_in_second) + } + + /// Formats the time as "HH:MM:SS" (hours, minutes, seconds). + /// + /// This displays whole seconds only, without frame information. + /// Useful for human-readable time displays. + pub fn format_hms(&self) -> String { + let total_seconds = self.seconds(); + let hours = (total_seconds / 3600.0) as i64; + let minutes = ((total_seconds % 3600.0) / 60.0) as i64; + let seconds = (total_seconds % 60.0) as i64; + + format!("{:02}:{:02}:{:02}", hours, minutes, seconds) + } + + /// Formats the time as "HH:MM:SS.FF" (hours, minutes, seconds, frames). + /// + /// Displays full time with frame information. Frames are shown as + /// zero-padded two-digit numbers. + pub fn format_hms_frame(&self) -> String { + let total_seconds = self.seconds(); + let hours = (total_seconds / 3600.0) as i64; + let minutes = ((total_seconds % 3600.0) / 60.0) as i64; + let seconds = (total_seconds % 60.0) as i64; + // For fractional FPS, use ceil of fps for modulo operation + let fps_ceil = self.fps.ceil() as i64; + let frames_in_second = if fps_ceil == 0 { + 0 + } else { + self.frames % fps_ceil + }; + let frames_in_second = if frames_in_second < 0 { + 0 + } else { + frames_in_second + }; + + format!( + "{:02}:{:02}:{:02}.{:02}", + hours, minutes, seconds, frames_in_second + ) + } + + /// Adds frames to this time, returning a new `FrameTime`. + /// + /// # Panics + /// + /// Panics if the FPS doesn't match. + pub fn add_frames(&self, frames: i64) -> Self { + Self { + frames: self.frames + frames, + fps: self.fps, + } + } + + /// Subtracts frames from this time, returning a new `FrameTime`. + /// + /// # Panics + /// + /// Panics if the FPS doesn't match or if the result would be negative. + pub fn sub_frames(&self, frames: i64) -> Self { + assert!( + self.frames >= frames, + "Cannot subtract more frames than available" + ); + Self { + frames: self.frames - frames, + fps: self.fps, + } + } + + /// Adds seconds to this time, returning a new `FrameTime`. + /// + /// # Panics + /// + /// Panics if the FPS doesn't match. + pub fn add_seconds(&self, seconds: f64) -> Self { + let frames_to_add = (seconds * self.fps).round() as i64; + self.add_frames(frames_to_add) + } + + /// Subtracts seconds from this time, returning a new `FrameTime`. + /// + /// # Panics + /// + /// Panics if the FPS doesn't match or if the result would be negative. + pub fn sub_seconds(&self, seconds: f64) -> Self { + let frames_to_sub = (seconds * self.fps).round() as i64; + self.sub_frames(frames_to_sub) + } + + /// Returns the duration between two `FrameTime` instances. + /// + /// # Panics + /// + /// Panics if the FPS values don't match. + pub fn duration(&self, other: &FrameTime) -> FrameDuration { + assert!( + (self.fps - other.fps).abs() < f64::EPSILON * 2.0, + "FPS mismatch: {} != {}", + self.fps, + other.fps + ); + + let frame_diff = (self.frames - other.frames).abs(); + FrameDuration::from_frames(frame_diff, self.fps) + } +} + +impl fmt::Display for FrameTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.format_sec_frame()) + } +} + +/// Duration between two frame times. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FrameDuration { + frames: i64, + fps: f64, +} + +impl FrameDuration { + /// Creates a duration from frame count and FPS. + /// If `fps <= 0.0` or `fps.is_nan()`, defaults to 30.0 FPS. + pub fn from_frames(frames: i64, fps: f64) -> Self { + let fps = if fps <= 0.0 || !fps.is_finite() { + 30.0 + } else { + fps + }; + Self { frames, fps } + } + + /// Creates a duration from seconds and FPS. + /// If `fps <= 0.0` or `fps.is_nan()`, defaults to 30.0 FPS. + pub fn from_seconds(seconds: f64, fps: f64) -> Self { + let fps = if fps <= 0.0 || !fps.is_finite() { + 30.0 + } else { + fps + }; + let frames = (seconds * fps).round() as i64; + Self { frames, fps } + } + + /// Returns the duration in frames. + pub fn frames(&self) -> i64 { + self.frames + } + + /// Returns the duration in seconds. + pub fn seconds(&self) -> f64 { + self.frames as f64 / self.fps + } + + /// Formats the duration as "seconds.frame" (same as `FrameTime`). + pub fn format_sec_frame(&self) -> String { + let temp_time = FrameTime::from_frames(self.frames, self.fps); + temp_time.format_sec_frame() + } + + /// Formats the duration as "HH:MM:SS". + pub fn format_hms(&self) -> String { + let temp_time = FrameTime::from_frames(self.frames, self.fps); + temp_time.format_hms() + } +} + +impl fmt::Display for FrameDuration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.format_sec_frame()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_frames() { + let time = FrameTime::from_frames(150, 30.0); + assert_eq!(time.frames(), 150); + assert_eq!(time.fps(), 30.0); + assert_eq!(time.seconds(), 5.0); + } + + #[test] + fn test_from_seconds() { + let time = FrameTime::from_seconds(5.0, 30.0); + assert_eq!(time.frames(), 150); + assert_eq!(time.seconds(), 5.0); + } + + #[test] + fn test_format_sec_frame() { + let time = FrameTime::from_frames(123, 30.0); + assert_eq!(time.format_sec_frame(), "4.03"); + + let time = FrameTime::from_frames(29, 30.0); + assert_eq!(time.format_sec_frame(), "0.29"); + + let time = FrameTime::from_frames(60, 30.0); + assert_eq!(time.format_sec_frame(), "2.00"); + } + + #[test] + fn test_format_sec_frame_fractional_fps() { + // 29.97 fps (NTSC) + let time = FrameTime::from_frames(30, 29.97); + // 30 frames at 29.97 fps = 1.001 seconds = 1 second, frame 0 + assert_eq!(time.format_sec_frame(), "1.00"); + + let time = FrameTime::from_frames(60, 29.97); + // 60 frames at 29.97 fps = 2.002 seconds = 2 seconds, frame 0 + assert_eq!(time.format_sec_frame(), "2.00"); + } + + #[test] + fn test_format_hms() { + let time = FrameTime::from_frames(3661, 30.0); // 122.033 seconds = 2 minutes 2 seconds + assert_eq!(time.format_hms(), "00:02:02"); + + let time = FrameTime::from_frames(4500, 30.0); // 150 seconds = 2 minutes 30 seconds + assert_eq!(time.format_hms(), "00:02:30"); + } + + #[test] + fn test_format_hms_frame() { + let time = FrameTime::from_frames(123, 30.0); // 4 seconds, 3 frames + assert_eq!(time.format_hms_frame(), "00:00:04.03"); + } + + #[test] + fn test_add_sub_frames() { + let time = FrameTime::from_frames(100, 30.0); + let new_time = time.add_frames(50); + assert_eq!(new_time.frames(), 150); + + let new_time = time.sub_frames(30); + assert_eq!(new_time.frames(), 70); + } + + #[test] + fn test_add_sub_seconds() { + let time = FrameTime::from_frames(100, 30.0); + let new_time = time.add_seconds(2.0); + assert_eq!(new_time.frames(), 160); // 100 + 60 + + let new_time = time.sub_seconds(1.0); + assert_eq!(new_time.frames(), 70); // 100 - 30 + } + + #[test] + fn test_duration() { + let time1 = FrameTime::from_frames(200, 30.0); + let time2 = FrameTime::from_frames(150, 30.0); + let duration = time1.duration(&time2); + assert_eq!(duration.frames(), 50); + assert_eq!(duration.seconds(), 50.0 / 30.0); + } + + #[test] + fn test_frame_duration() { + let duration = FrameDuration::from_frames(90, 30.0); + assert_eq!(duration.seconds(), 3.0); + assert_eq!(duration.format_sec_frame(), "3.00"); + assert_eq!(duration.format_hms(), "00:00:03"); + } +} diff --git a/src/main.rs b/src/main.rs index 33fbb2d..942ffdf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; use momentry_core::core::api_key::{ApiKeyService, ApiKeyType}; use momentry_core::core::chunk::types::{Chunk, ChunkRule, ChunkType}; use momentry_core::core::db::Database; +use momentry_core::core::time::FrameTime; use momentry_core::ui::progress::{ProcessorType, ProgressState, ProgressUi}; use momentry_core::{ Embedder, OutputDir, PostgresDb, QdrantDb, RedisClient, VectorPayload, VideoRecord, VideoStatus, @@ -821,6 +822,7 @@ enum N8nAction { #[tokio::main] async fn main() -> Result<()> { + dotenv::dotenv().ok(); tracing_subscriber::fmt::init(); let cli = Cli::parse(); @@ -1808,16 +1810,14 @@ async fn main() -> Result<()> { // Store ASR sentence pre_chunks let mut asr_pre_chunk_ids = Vec::new(); for seg in asr_result.segments.iter() { - let start_frame = (seg.start * fps) as i64; - let end_frame = (seg.end * fps) as i64; + let start_frame = FrameTime::from_seconds(seg.start, fps).frames(); + let end_frame = FrameTime::from_seconds(seg.end, fps).frames(); let pre_chunk = momentry_core::core::db::postgres_db::PreChunk { id: 0, file_id, source_type: "asr".to_string(), source_file: Some(asr_path.clone()), chunk_type: "sentence".to_string(), - start_time: seg.start, - end_time: seg.end, start_frame, end_frame, fps, @@ -1840,8 +1840,6 @@ async fn main() -> Result<()> { source_type: "cut".to_string(), source_file: Some(cut_path.clone()), chunk_type: "cut".to_string(), - start_time: scene.start_time, - end_time: scene.end_time, start_frame: scene.start_frame as i64, end_frame: scene.end_frame as i64, fps, @@ -1863,8 +1861,8 @@ async fn main() -> Result<()> { let mut time_start = 0.0; while time_start < duration { let time_end = (time_start + 10.0).min(duration); - let start_frame = (time_start * fps) as i64; - let end_frame = (time_end * fps) as i64; + let start_frame = FrameTime::from_seconds(time_start, fps).frames(); + let end_frame = FrameTime::from_seconds(time_end, fps).frames(); let pre_chunk = momentry_core::core::db::postgres_db::PreChunk { id: 0, @@ -1872,8 +1870,6 @@ async fn main() -> Result<()> { source_type: "time".to_string(), source_file: None, chunk_type: "time".to_string(), - start_time: time_start, - end_time: time_end, start_frame, end_frame, fps, @@ -1965,7 +1961,7 @@ async fn main() -> Result<()> { let mut sentence_chunks = Vec::new(); for (i, seg) in asr_result.segments.iter().enumerate() { let pre_chunk_id = asr_pre_chunk_ids.get(i).copied().unwrap_or(0); - let chunk = Chunk::new( + let chunk = Chunk::from_seconds( file_id as i32, uuid.clone(), i as u32, @@ -1987,7 +1983,7 @@ async fn main() -> Result<()> { let mut cut_chunks = Vec::new(); for (i, scene) in cut_result.scenes.iter().enumerate() { let pre_chunk_id = cut_pre_chunk_ids.get(i).copied().unwrap_or(0); - let chunk = Chunk::new( + let chunk = Chunk::from_seconds( file_id as i32, uuid.clone(), i as u32, @@ -2016,8 +2012,8 @@ async fn main() -> Result<()> { i as u32, ChunkType::TimeBased, ChunkRule::Rule1, - tc.start_time, - tc.end_time, + tc.start_frame, + tc.end_frame, fps, serde_json::json!({"interval": 10.0}), ) @@ -2107,12 +2103,13 @@ async fn main() -> Result<()> { println!("\n=== Scene {} ===", i + 1); println!( "Time: {:.2}s - {:.2}s", - story_chunk.start_time, story_chunk.end_time + story_chunk.start_time().seconds(), + story_chunk.end_time().seconds() ); // Get context: expand time range by 5 seconds before and after - let context_start = (story_chunk.start_time - 5.0).max(0.0); - let context_end = (story_chunk.end_time + 5.0).min(duration); + let context_start = (story_chunk.start_time().seconds() - 5.0).max(0.0); + let context_end = (story_chunk.end_time().seconds() + 5.0).min(duration); // Get chunks in context range (sentence chunks with ASR text) let context_chunks = db @@ -2129,8 +2126,8 @@ async fn main() -> Result<()> { story.push_str(&format!( "Scene {} ({:.1}s - {:.1}s)\n\n", i + 1, - story_chunk.start_time, - story_chunk.end_time + story_chunk.start_time().seconds(), + story_chunk.end_time().seconds() )); // Add audio/text content @@ -2280,8 +2277,8 @@ async fn main() -> Result<()> { uuid: chunk.uuid.clone(), chunk_id: chunk.chunk_id.clone(), chunk_type: "sentence".to_string(), - start_time: chunk.start_time, - end_time: chunk.end_time, + start_time: chunk.start_time().seconds(), + end_time: chunk.end_time().seconds(), text: Some(text.to_string()), }; if let Err(e) = qdrant @@ -2408,13 +2405,16 @@ async fn main() -> Result<()> { } => { use momentry_core::worker::{JobWorker, WorkerConfig}; - let config = WorkerConfig { - max_concurrent: max_concurrent.unwrap_or(2), - poll_interval_secs: poll_interval.unwrap_or(5), - enabled: true, - batch_size: batch_size.unwrap_or(10), - processor_timeout_secs: 3600, - }; + let mut config = WorkerConfig::default(); + if let Some(max) = max_concurrent { + config.max_concurrent = max; + } + if let Some(interval) = poll_interval { + config.poll_interval_secs = interval; + } + if let Some(batch) = batch_size { + config.batch_size = batch; + } let db = PostgresDb::init().await?; let redis = RedisClient::new()?; diff --git a/src/playground.rs b/src/playground.rs index d6caad2..91d7597 100644 --- a/src/playground.rs +++ b/src/playground.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; use momentry_core::core::api_key::{ApiKeyService, ApiKeyType}; use momentry_core::core::chunk::types::{Chunk, ChunkRule, ChunkType}; use momentry_core::core::db::Database; +use momentry_core::core::time::FrameTime; use momentry_core::ui::progress::{ProcessorType, ProgressState, ProgressUi}; use momentry_core::{ Embedder, OutputDir, PostgresDb, QdrantDb, RedisClient, VectorPayload, VideoRecord, VideoStatus, @@ -1818,16 +1819,14 @@ async fn main() -> Result<()> { // Store ASR sentence pre_chunks let mut asr_pre_chunk_ids = Vec::new(); for seg in asr_result.segments.iter() { - let start_frame = (seg.start * fps) as i64; - let end_frame = (seg.end * fps) as i64; + let start_frame = FrameTime::from_seconds(seg.start, fps).frames(); + let end_frame = FrameTime::from_seconds(seg.end, fps).frames(); let pre_chunk = momentry_core::core::db::postgres_db::PreChunk { id: 0, file_id, source_type: "asr".to_string(), source_file: Some(asr_path.clone()), chunk_type: "sentence".to_string(), - start_time: seg.start, - end_time: seg.end, start_frame, end_frame, fps, @@ -1850,8 +1849,6 @@ async fn main() -> Result<()> { source_type: "cut".to_string(), source_file: Some(cut_path.clone()), chunk_type: "cut".to_string(), - start_time: scene.start_time, - end_time: scene.end_time, start_frame: scene.start_frame as i64, end_frame: scene.end_frame as i64, fps, @@ -1873,8 +1870,8 @@ async fn main() -> Result<()> { let mut time_start = 0.0; while time_start < duration { let time_end = (time_start + 10.0).min(duration); - let start_frame = (time_start * fps) as i64; - let end_frame = (time_end * fps) as i64; + let start_frame = FrameTime::from_seconds(time_start, fps).frames(); + let end_frame = FrameTime::from_seconds(time_end, fps).frames(); let pre_chunk = momentry_core::core::db::postgres_db::PreChunk { id: 0, @@ -1882,8 +1879,6 @@ async fn main() -> Result<()> { source_type: "time".to_string(), source_file: None, chunk_type: "time".to_string(), - start_time: time_start, - end_time: time_end, start_frame, end_frame, fps, @@ -1975,7 +1970,7 @@ async fn main() -> Result<()> { let mut sentence_chunks = Vec::new(); for (i, seg) in asr_result.segments.iter().enumerate() { let pre_chunk_id = asr_pre_chunk_ids.get(i).copied().unwrap_or(0); - let chunk = Chunk::new( + let chunk = Chunk::from_seconds( file_id as i32, uuid.clone(), i as u32, @@ -1997,7 +1992,7 @@ async fn main() -> Result<()> { let mut cut_chunks = Vec::new(); for (i, scene) in cut_result.scenes.iter().enumerate() { let pre_chunk_id = cut_pre_chunk_ids.get(i).copied().unwrap_or(0); - let chunk = Chunk::new( + let chunk = Chunk::from_seconds( file_id as i32, uuid.clone(), i as u32, @@ -2026,8 +2021,8 @@ async fn main() -> Result<()> { i as u32, ChunkType::TimeBased, ChunkRule::Rule1, - tc.start_time, - tc.end_time, + tc.start_frame, + tc.end_frame, fps, serde_json::json!({"interval": 10.0}), ) @@ -2117,12 +2112,13 @@ async fn main() -> Result<()> { println!("\n=== Scene {} ===", i + 1); println!( "Time: {:.2}s - {:.2}s", - story_chunk.start_time, story_chunk.end_time + story_chunk.start_time().seconds(), + story_chunk.end_time().seconds() ); // Get context: expand time range by 5 seconds before and after - let context_start = (story_chunk.start_time - 5.0).max(0.0); - let context_end = (story_chunk.end_time + 5.0).min(duration); + let context_start = (story_chunk.start_time().seconds() - 5.0).max(0.0); + let context_end = (story_chunk.end_time().seconds() + 5.0).min(duration); // Get chunks in context range (sentence chunks with ASR text) let context_chunks = db @@ -2139,8 +2135,8 @@ async fn main() -> Result<()> { story.push_str(&format!( "Scene {} ({:.1}s - {:.1}s)\n\n", i + 1, - story_chunk.start_time, - story_chunk.end_time + story_chunk.start_time().seconds(), + story_chunk.end_time().seconds() )); // Add audio/text content @@ -2290,8 +2286,8 @@ async fn main() -> Result<()> { uuid: chunk.uuid.clone(), chunk_id: chunk.chunk_id.clone(), chunk_type: "sentence".to_string(), - start_time: chunk.start_time, - end_time: chunk.end_time, + start_time: chunk.start_time().seconds(), + end_time: chunk.end_time().seconds(), text: Some(text.to_string()), }; if let Err(e) = qdrant diff --git a/src/worker/job_worker.rs b/src/worker/job_worker.rs index cbb2c39..90b7a8f 100644 --- a/src/worker/job_worker.rs +++ b/src/worker/job_worker.rs @@ -1,10 +1,13 @@ use anyhow::Result; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use tokio::time::sleep; use tracing::{error, info, warn}; -use crate::core::db::{MonitorJobStatus, PostgresDb, ProcessorType, RedisClient}; +use crate::core::db::{ + MonitorJobStatus, PostgresDb, ProcessorJobStatus, ProcessorType, RedisClient, VideoStatus, +}; use crate::worker::config::WorkerConfig; use crate::worker::processor::{ProcessorPool, ProcessorTask}; @@ -49,22 +52,33 @@ impl JobWorker { } async fn poll_and_process(&self) -> Result<()> { - let pending_jobs = self.db.get_pending_jobs(self.config.batch_size).await?; - - if pending_jobs.is_empty() { - return Ok(()); + // Always check for completion of running jobs first + // This ensures jobs with all processors in terminal states are marked complete/failed + let running_jobs_done = self + .db + .get_running_jobs_with_all_processors_done(self.config.batch_size) + .await?; + for job in running_jobs_done { + if let Err(e) = self.check_and_complete_job(job.id, &job.uuid).await { + error!("Failed to complete job {}: {}", job.uuid, e); + } } - info!("Found {} pending jobs", pending_jobs.len()); + // Process pending jobs if any + let pending_jobs = self.db.get_pending_jobs(self.config.batch_size).await?; - for job in pending_jobs { - if !self.processor_pool.can_start().await { - info!("Max concurrent processors reached, waiting..."); - break; - } + if !pending_jobs.is_empty() { + info!("Found {} pending jobs", pending_jobs.len()); - if let Err(e) = self.process_job(job).await { - error!("Failed to process job: {}", e); + for job in pending_jobs { + if !self.processor_pool.can_start().await { + info!("Max concurrent processors reached, waiting..."); + break; + } + + if let Err(e) = self.process_job(job).await { + error!("Failed to process job: {}", e); + } } } @@ -84,7 +98,50 @@ impl JobWorker { .update_worker_job_status(&job.uuid, job.id, "running", None, 0, total_processors) .await?; + // Get existing processor results for this job + let existing_results = self.db.get_processor_results_by_job(job.id).await?; + let mut result_map = HashMap::new(); + for result in existing_results { + result_map.insert(result.processor_type, result); + } + for processor_type in ProcessorType::all() { + // Check if processor already in terminal state + if let Some(result) = result_map.get(&processor_type) { + match result.status { + ProcessorJobStatus::Completed | ProcessorJobStatus::Skipped => { + info!( + "Processor {} already completed, skipping", + processor_type.as_str() + ); + continue; + } + ProcessorJobStatus::Failed => { + info!("Processor {} failed, skipping", processor_type.as_str()); + continue; + } + ProcessorJobStatus::Running => { + info!( + "Processor {} already running, skipping", + processor_type.as_str() + ); + continue; + } + ProcessorJobStatus::Pending => { + // Continue to start processor + } + } + } + + // Check capacity before starting processor + if !self.processor_pool.can_start().await { + info!( + "Max concurrent processors reached, skipping remaining processors for job {}", + job.uuid + ); + break; + } + let processor_result_id = self .db .create_processor_result(job.id, processor_type) @@ -134,11 +191,39 @@ impl JobWorker { }) .count() as i32; + // Compute completed and failed processor arrays + let completed_processors: Vec = results + .iter() + .filter(|r| { + matches!( + r.status, + crate::core::db::ProcessorJobStatus::Completed + | crate::core::db::ProcessorJobStatus::Skipped + ) + }) + .map(|r| r.processor_type.as_str().to_string()) + .collect(); + + let failed_processors: Vec = results + .iter() + .filter(|r| matches!(r.status, crate::core::db::ProcessorJobStatus::Failed)) + .map(|r| r.processor_type.as_str().to_string()) + .collect(); + + // Update processor arrays in job record + self.db + .update_job_processors_arrays(job_id, completed_processors, failed_processors) + .await?; + if all_completed && !any_failed { self.db .update_job_status(job_id, MonitorJobStatus::Completed) .await?; + self.db + .update_video_status(uuid, VideoStatus::Completed) + .await?; + self.redis .update_worker_job_status(uuid, job_id, "completed", None, completed_count, 7) .await?; @@ -151,6 +236,10 @@ impl JobWorker { .update_job_status(job_id, MonitorJobStatus::Failed) .await?; + self.db + .update_video_status(uuid, VideoStatus::Failed) + .await?; + self.redis .update_worker_job_status(uuid, job_id, "failed", None, completed_count, 7) .await?; diff --git a/src/worker/processor.rs b/src/worker/processor.rs index 9d1190e..a21efd9 100644 --- a/src/worker/processor.rs +++ b/src/worker/processor.rs @@ -1,11 +1,22 @@ use anyhow::{Context, Result}; use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{mpsc, RwLock}; use tracing::{error, info}; +use crate::core::chunk::types::{Chunk, ChunkRule, ChunkType}; +use crate::core::config::{OUTPUT_DIR, PYTHON_PATH, SCRIPTS_DIR}; use crate::core::db::RedisClient; use crate::core::db::{MonitorJob, PostgresDb, ProcessorJobStatus, ProcessorType}; +use crate::core::processor; +use crate::core::processor::asr::AsrResult; +use crate::core::processor::asrx::AsrxResult; +use crate::core::processor::cut::CutResult; +use crate::core::processor::face::FaceResult; +use crate::core::processor::ocr::OcrResult; +use crate::core::processor::pose::PoseResult; +use crate::core::processor::yolo::YoloResult; #[derive(Debug, Clone)] pub struct ProcessorTask { @@ -104,46 +115,58 @@ impl ProcessorPool { "Processor {} completed for job {}", processor_name, job.uuid ); - let _ = db + if let Err(e) = db .update_processor_result( processor_result_id, ProcessorJobStatus::Completed, None, Some(&output), ) - .await; + .await + { + error!("Failed to update processor result to completed: {}", e); + } - let _ = redis + if let Err(e) = redis .update_worker_processor_status( &job.uuid, &processor_name, "completed", None, ) - .await; + .await + { + error!("Failed to update Redis processor status: {}", e); + } } Err(e) => { error!( "Processor {} failed for job {}: {}", processor_name, job.uuid, e ); - let _ = db + if let Err(db_err) = db .update_processor_result( processor_result_id, ProcessorJobStatus::Failed, Some(&e.to_string()), None, ) - .await; + .await + { + error!("Failed to update processor result to failed: {}", db_err); + } - let _ = redis + if let Err(redis_err) = redis .update_worker_processor_status( &job.uuid, &processor_name, "failed", Some(&e.to_string()), ) - .await; + .await + { + error!("Failed to update Redis processor status: {}", redis_err); + } } } }); @@ -153,24 +176,136 @@ impl ProcessorPool { async fn run_processor( db: &PostgresDb, - redis: &RedisClient, + _redis: &RedisClient, job: &MonitorJob, processor_type: ProcessorType, - mut cancel_rx: mpsc::Receiver<()>, + _cancel_rx: mpsc::Receiver<()>, ) -> Result { let video_path = job.video_path.as_ref().context("No video path in job")?; + // Generate output path + let output_dir = PathBuf::from(OUTPUT_DIR.as_str()); + let output_path = output_dir.join(format!( + "job_{}_{}_{}.json", + job.id, + processor_type.as_str(), + chrono::Utc::now().timestamp_millis() + )); + + // Ensure output directory exists + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let uuid = Some(job.uuid.as_str()); + match processor_type { - ProcessorType::Asr => Self::run_asr(db, redis, video_path, &mut cancel_rx).await, - ProcessorType::Cut => Self::run_cut(db, redis, video_path, &mut cancel_rx).await, - ProcessorType::Yolo => Self::run_yolo(db, redis, video_path, &mut cancel_rx).await, - ProcessorType::Ocr => Self::run_ocr(db, redis, video_path, &mut cancel_rx).await, - ProcessorType::Face => Self::run_face(db, redis, video_path, &mut cancel_rx).await, - ProcessorType::Pose => Self::run_pose(db, redis, video_path, &mut cancel_rx).await, - ProcessorType::Asrx => Self::run_asrx(db, redis, video_path, &mut cancel_rx).await, + ProcessorType::Asr => { + let result = + processor::process_asr(video_path, output_path.to_str().unwrap(), uuid).await?; + // Store ASR chunks in database + tracing::info!( + "ASR completed, storing {} segments for {}", + result.segments.len(), + job.uuid + ); + if let Err(e) = Self::store_asr_chunks(db, &job.uuid, &result).await { + tracing::error!("Failed to store ASR chunks for {}: {}", job.uuid, e); + } + Ok(serde_json::to_value(result)?) + } + ProcessorType::Cut => { + let result = + processor::process_cut(video_path, output_path.to_str().unwrap(), uuid).await?; + // Store CUT chunks in database + tracing::info!( + "CUT completed, storing {} scenes for {}", + result.scenes.len(), + job.uuid + ); + if let Err(e) = Self::store_cut_chunks(db, &job.uuid, &result).await { + tracing::error!("Failed to store CUT chunks for {}: {}", job.uuid, e); + } + Ok(serde_json::to_value(result)?) + } + ProcessorType::Yolo => { + let result = + processor::process_yolo(video_path, output_path.to_str().unwrap(), uuid) + .await?; + // Store YOLO chunks in database + tracing::info!( + "YOLO completed, storing {} frames for {}", + result.frames.len(), + job.uuid + ); + if let Err(e) = Self::store_yolo_chunks(db, &job.uuid, &result).await { + tracing::error!("Failed to store YOLO chunks for {}: {}", job.uuid, e); + } + Ok(serde_json::to_value(result)?) + } + ProcessorType::Ocr => { + let result = + processor::process_ocr(video_path, output_path.to_str().unwrap(), uuid).await?; + // Store OCR chunks in database + tracing::info!( + "OCR completed, storing {} frames for {}", + result.frames.len(), + job.uuid + ); + if let Err(e) = Self::store_ocr_chunks(db, &job.uuid, &result).await { + tracing::error!("Failed to store OCR chunks for {}: {}", job.uuid, e); + } + Ok(serde_json::to_value(result)?) + } + ProcessorType::Face => { + let result = + processor::process_face(video_path, output_path.to_str().unwrap(), uuid) + .await?; + // Store FACE chunks in database + tracing::info!( + "FACE completed, storing {} frames for {}", + result.frames.len(), + job.uuid + ); + if let Err(e) = Self::store_face_chunks(db, &job.uuid, &result).await { + tracing::error!("Failed to store FACE chunks for {}: {}", job.uuid, e); + } + Ok(serde_json::to_value(result)?) + } + ProcessorType::Pose => { + let result = + processor::process_pose(video_path, output_path.to_str().unwrap(), uuid) + .await?; + // Store POSE chunks in database + tracing::info!( + "POSE completed, storing {} frames for {}", + result.frames.len(), + job.uuid + ); + if let Err(e) = Self::store_pose_chunks(db, &job.uuid, &result).await { + tracing::error!("Failed to store POSE chunks for {}: {}", job.uuid, e); + } + Ok(serde_json::to_value(result)?) + } + ProcessorType::Asrx => { + let result = + processor::process_asrx(video_path, output_path.to_str().unwrap(), uuid) + .await?; + // Store ASRX chunks in database + tracing::info!( + "ASRX completed, storing {} segments for {}", + result.segments.len(), + job.uuid + ); + if let Err(e) = Self::store_asrx_chunks(db, &job.uuid, &result).await { + tracing::error!("Failed to store ASRX chunks for {}: {}", job.uuid, e); + } + Ok(serde_json::to_value(result)?) + } } } + #[allow(dead_code)] async fn run_asr( _db: &PostgresDb, _redis: &RedisClient, @@ -178,9 +313,9 @@ impl ProcessorPool { _cancel_rx: &mut mpsc::Receiver<()>, ) -> Result { let script_path = std::env::var("MOMENTRY_ASR_SCRIPT") - .unwrap_or_else(|_| "/Users/accusys/momentry/scripts/asr.py".to_string()); + .unwrap_or_else(|_| format!("{}/asr_processor.py", SCRIPTS_DIR.as_str())); - let output = tokio::process::Command::new("/opt/homebrew/bin/python3.11") + let output = tokio::process::Command::new(PYTHON_PATH.as_str()) .arg(&script_path) .arg(video_path) .output() @@ -195,6 +330,7 @@ impl ProcessorPool { Ok(result) } + #[allow(dead_code)] async fn run_cut( _db: &PostgresDb, _redis: &RedisClient, @@ -202,9 +338,9 @@ impl ProcessorPool { _cancel_rx: &mut mpsc::Receiver<()>, ) -> Result { let script_path = std::env::var("MOMENTRY_CUT_SCRIPT") - .unwrap_or_else(|_| "/Users/accusys/momentry/scripts/cut.py".to_string()); + .unwrap_or_else(|_| format!("{}/cut_processor.py", SCRIPTS_DIR.as_str())); - let output = tokio::process::Command::new("/opt/homebrew/bin/python3.11") + let output = tokio::process::Command::new(PYTHON_PATH.as_str()) .arg(&script_path) .arg(video_path) .output() @@ -219,6 +355,7 @@ impl ProcessorPool { Ok(result) } + #[allow(dead_code)] async fn run_yolo( _db: &PostgresDb, _redis: &RedisClient, @@ -226,9 +363,9 @@ impl ProcessorPool { _cancel_rx: &mut mpsc::Receiver<()>, ) -> Result { let script_path = std::env::var("MOMENTRY_YOLO_SCRIPT") - .unwrap_or_else(|_| "/Users/accusys/momentry/scripts/yolo_processor.py".to_string()); + .unwrap_or_else(|_| format!("{}/yolo_processor.py", SCRIPTS_DIR.as_str())); - let output = tokio::process::Command::new("/opt/homebrew/bin/python3.11") + let output = tokio::process::Command::new(PYTHON_PATH.as_str()) .arg(&script_path) .arg(video_path) .output() @@ -243,6 +380,7 @@ impl ProcessorPool { Ok(result) } + #[allow(dead_code)] async fn run_ocr( _db: &PostgresDb, _redis: &RedisClient, @@ -250,9 +388,9 @@ impl ProcessorPool { _cancel_rx: &mut mpsc::Receiver<()>, ) -> Result { let script_path = std::env::var("MOMENTRY_OCR_SCRIPT") - .unwrap_or_else(|_| "/Users/accusys/momentry/scripts/ocr.py".to_string()); + .unwrap_or_else(|_| format!("{}/ocr_processor.py", SCRIPTS_DIR.as_str())); - let output = tokio::process::Command::new("/opt/homebrew/bin/python3.11") + let output = tokio::process::Command::new(PYTHON_PATH.as_str()) .arg(&script_path) .arg(video_path) .output() @@ -267,6 +405,7 @@ impl ProcessorPool { Ok(result) } + #[allow(dead_code)] async fn run_face( _db: &PostgresDb, _redis: &RedisClient, @@ -274,9 +413,9 @@ impl ProcessorPool { _cancel_rx: &mut mpsc::Receiver<()>, ) -> Result { let script_path = std::env::var("MOMENTRY_FACE_SCRIPT") - .unwrap_or_else(|_| "/Users/accusys/momentry/scripts/face.py".to_string()); + .unwrap_or_else(|_| format!("{}/face_processor.py", SCRIPTS_DIR.as_str())); - let output = tokio::process::Command::new("/opt/homebrew/bin/python3.11") + let output = tokio::process::Command::new(PYTHON_PATH.as_str()) .arg(&script_path) .arg(video_path) .output() @@ -291,6 +430,7 @@ impl ProcessorPool { Ok(result) } + #[allow(dead_code)] async fn run_pose( _db: &PostgresDb, _redis: &RedisClient, @@ -298,9 +438,9 @@ impl ProcessorPool { _cancel_rx: &mut mpsc::Receiver<()>, ) -> Result { let script_path = std::env::var("MOMENTRY_POSE_SCRIPT") - .unwrap_or_else(|_| "/Users/accusys/momentry/scripts/pose.py".to_string()); + .unwrap_or_else(|_| format!("{}/pose_processor.py", SCRIPTS_DIR.as_str())); - let output = tokio::process::Command::new("/opt/homebrew/bin/python3.11") + let output = tokio::process::Command::new(PYTHON_PATH.as_str()) .arg(&script_path) .arg(video_path) .output() @@ -315,6 +455,7 @@ impl ProcessorPool { Ok(result) } + #[allow(dead_code)] async fn run_asrx( _db: &PostgresDb, _redis: &RedisClient, @@ -322,9 +463,9 @@ impl ProcessorPool { _cancel_rx: &mut mpsc::Receiver<()>, ) -> Result { let script_path = std::env::var("MOMENTRY_ASRX_SCRIPT") - .unwrap_or_else(|_| "/Users/accusys/momentry/scripts/asrx.py".to_string()); + .unwrap_or_else(|_| format!("{}/asrx_processor.py", SCRIPTS_DIR.as_str())); - let output = tokio::process::Command::new("/opt/homebrew/bin/python3.11") + let output = tokio::process::Command::new(PYTHON_PATH.as_str()) .arg(&script_path) .arg(video_path) .output() @@ -339,6 +480,377 @@ impl ProcessorPool { Ok(result) } + pub async fn store_asr_chunks( + db: &PostgresDb, + uuid: &str, + asr_result: &AsrResult, + ) -> Result<()> { + // Get video record to obtain file_id and fps + let video = match db.get_video_by_uuid(uuid).await { + Ok(Some(video)) => video, + Ok(None) => { + tracing::error!("Video not found for uuid: {}", uuid); + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get video for uuid {}: {}", uuid, e); + return Ok(()); + } + }; + let file_id = video.id; + let fps = if video.fps > 0.0 { video.fps } else { 30.0 }; + + for (i, segment) in asr_result.segments.iter().enumerate() { + let chunk = Chunk::from_seconds( + file_id as i32, + uuid.to_string(), + i as u32, + ChunkType::Sentence, + ChunkRule::Rule1, + segment.start, + segment.end, + fps, + serde_json::json!({ + "text": segment.text, + "text_normalized": segment.text.to_lowercase(), + }), + ) + .with_metadata(serde_json::json!({ + "language": asr_result.language, + "language_probability": asr_result.language_probability, + })); + + match db.store_chunk(&chunk).await { + Ok(_) => { + tracing::info!("Stored ASR chunk {} for video {}", i, uuid); + } + Err(e) => { + tracing::error!("Failed to store ASR chunk {}: {}", i, e); + } + } + } + Ok(()) + } + + pub async fn store_cut_chunks( + db: &PostgresDb, + uuid: &str, + cut_result: &CutResult, + ) -> Result<()> { + // Get video record to obtain file_id and fps + let video = match db.get_video_by_uuid(uuid).await { + Ok(Some(video)) => video, + Ok(None) => { + tracing::error!("Video not found for uuid: {}", uuid); + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get video for uuid {}: {}", uuid, e); + return Ok(()); + } + }; + let file_id = video.id; + let fps = if video.fps > 0.0 { video.fps } else { 30.0 }; + + for (i, scene) in cut_result.scenes.iter().enumerate() { + let chunk = Chunk::from_seconds( + file_id as i32, + uuid.to_string(), + i as u32, + ChunkType::Cut, + ChunkRule::Rule1, + scene.start_time, + scene.end_time, + fps, + serde_json::json!({ + "scene_number": scene.scene_number, + "start_frame": scene.start_frame, + "end_frame": scene.end_frame, + }), + ); + + match db.store_chunk(&chunk).await { + Ok(_) => { + tracing::info!("Stored CUT chunk {} for video {}", i, uuid); + } + Err(e) => { + tracing::error!("Failed to store CUT chunk {}: {}", i, e); + } + } + } + Ok(()) + } + + pub async fn store_yolo_chunks( + db: &PostgresDb, + uuid: &str, + yolo_result: &YoloResult, + ) -> Result<()> { + // Get video record to obtain file_id and fps + let video = match db.get_video_by_uuid(uuid).await { + Ok(Some(video)) => video, + Ok(None) => { + tracing::error!("Video not found for uuid: {}", uuid); + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get video for uuid {}: {}", uuid, e); + return Ok(()); + } + }; + let file_id = video.id; + let fps = if video.fps > 0.0 { video.fps } else { 30.0 }; + + for (i, frame) in yolo_result.frames.iter().enumerate() { + let mut chunk = Chunk::new( + file_id as i32, + uuid.to_string(), + i as u32, + ChunkType::Trace, + ChunkRule::Rule1, + frame.frame as i64, + frame.frame as i64 + 1, + fps, + serde_json::json!({ + "objects": frame.objects, + "timestamp": frame.timestamp, + }), + ); + // Override chunk_id to include processor prefix for uniqueness + chunk.chunk_id = format!("trace_yolo_{:04}", i); + + match db.store_chunk(&chunk).await { + Ok(_) => { + tracing::info!( + "Stored YOLO chunk {} (frame {}) for video {}", + i, + frame.frame, + uuid + ); + } + Err(e) => { + tracing::error!("Failed to store YOLO chunk {}: {}", i, e); + } + } + } + Ok(()) + } + + pub async fn store_ocr_chunks( + db: &PostgresDb, + uuid: &str, + ocr_result: &OcrResult, + ) -> Result<()> { + // Get video record to obtain file_id and fps + let video = match db.get_video_by_uuid(uuid).await { + Ok(Some(video)) => video, + Ok(None) => { + tracing::error!("Video not found for uuid: {}", uuid); + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get video for uuid {}: {}", uuid, e); + return Ok(()); + } + }; + let file_id = video.id; + let fps = if video.fps > 0.0 { video.fps } else { 30.0 }; + + for (i, frame) in ocr_result.frames.iter().enumerate() { + let mut chunk = Chunk::new( + file_id as i32, + uuid.to_string(), + i as u32, + ChunkType::Trace, + ChunkRule::Rule1, + frame.frame as i64, + frame.frame as i64 + 1, + fps, + serde_json::json!({ + "texts": frame.texts, + "timestamp": frame.timestamp, + }), + ); + // Override chunk_id to include processor prefix for uniqueness + chunk.chunk_id = format!("trace_ocr_{:04}", i); + + match db.store_chunk(&chunk).await { + Ok(_) => { + tracing::info!( + "Stored OCR chunk {} (frame {}) for video {}", + i, + frame.frame, + uuid + ); + } + Err(e) => { + tracing::error!("Failed to store OCR chunk {}: {}", i, e); + } + } + } + Ok(()) + } + + pub async fn store_face_chunks( + db: &PostgresDb, + uuid: &str, + face_result: &FaceResult, + ) -> Result<()> { + // Get video record to obtain file_id and fps + let video = match db.get_video_by_uuid(uuid).await { + Ok(Some(video)) => video, + Ok(None) => { + tracing::error!("Video not found for uuid: {}", uuid); + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get video for uuid {}: {}", uuid, e); + return Ok(()); + } + }; + let file_id = video.id; + let fps = if video.fps > 0.0 { video.fps } else { 30.0 }; + + for (i, frame) in face_result.frames.iter().enumerate() { + let mut chunk = Chunk::new( + file_id as i32, + uuid.to_string(), + i as u32, + ChunkType::Trace, + ChunkRule::Rule1, + frame.frame as i64, + frame.frame as i64 + 1, + fps, + serde_json::json!({ + "faces": frame.faces, + "timestamp": frame.timestamp, + }), + ); + // Override chunk_id to include processor prefix for uniqueness + chunk.chunk_id = format!("trace_face_{:04}", i); + + match db.store_chunk(&chunk).await { + Ok(_) => { + tracing::info!( + "Stored FACE chunk {} (frame {}) for video {}", + i, + frame.frame, + uuid + ); + } + Err(e) => { + tracing::error!("Failed to store FACE chunk {}: {}", i, e); + } + } + } + Ok(()) + } + + pub async fn store_pose_chunks( + db: &PostgresDb, + uuid: &str, + pose_result: &PoseResult, + ) -> Result<()> { + // Get video record to obtain file_id and fps + let video = match db.get_video_by_uuid(uuid).await { + Ok(Some(video)) => video, + Ok(None) => { + tracing::error!("Video not found for uuid: {}", uuid); + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get video for uuid {}: {}", uuid, e); + return Ok(()); + } + }; + let file_id = video.id; + let fps = if video.fps > 0.0 { video.fps } else { 30.0 }; + + for (i, frame) in pose_result.frames.iter().enumerate() { + let mut chunk = Chunk::new( + file_id as i32, + uuid.to_string(), + i as u32, + ChunkType::Trace, + ChunkRule::Rule1, + frame.frame as i64, + frame.frame as i64 + 1, + fps, + serde_json::json!({ + "persons": frame.persons, + "timestamp": frame.timestamp, + }), + ); + // Override chunk_id to include processor prefix for uniqueness + chunk.chunk_id = format!("trace_pose_{:04}", i); + + match db.store_chunk(&chunk).await { + Ok(_) => { + tracing::info!( + "Stored POSE chunk {} (frame {}) for video {}", + i, + frame.frame, + uuid + ); + } + Err(e) => { + tracing::error!("Failed to store POSE chunk {}: {}", i, e); + } + } + } + Ok(()) + } + + pub async fn store_asrx_chunks( + db: &PostgresDb, + uuid: &str, + asrx_result: &AsrxResult, + ) -> Result<()> { + // Get video record to obtain file_id and fps + let video = match db.get_video_by_uuid(uuid).await { + Ok(Some(video)) => video, + Ok(None) => { + tracing::error!("Video not found for uuid: {}", uuid); + return Ok(()); + } + Err(e) => { + tracing::error!("Failed to get video for uuid {}: {}", uuid, e); + return Ok(()); + } + }; + let file_id = video.id; + let fps = if video.fps > 0.0 { video.fps } else { 30.0 }; + + for (i, segment) in asrx_result.segments.iter().enumerate() { + let mut chunk = Chunk::from_seconds( + file_id as i32, + uuid.to_string(), + i as u32, + ChunkType::Trace, + ChunkRule::Rule1, + segment.start, + segment.end, + fps, + serde_json::json!({ + "text": segment.text, + "timestamp": segment.start, + }), + ); + // Override chunk_id to include processor prefix for uniqueness + chunk.chunk_id = format!("trace_asrx_{:04}", i); + + match db.store_chunk(&chunk).await { + Ok(_) => { + tracing::info!("Stored ASRX chunk {} for video {}", i, uuid); + } + Err(e) => { + tracing::error!("Failed to store ASRX chunk {}: {}", i, e); + } + } + } + Ok(()) + } + pub async fn get_running_count(&self) -> usize { *self.running_count.read().await } diff --git a/update_all_workflows.py b/update_all_workflows.py new file mode 100644 index 0000000..1cb9418 --- /dev/null +++ b/update_all_workflows.py @@ -0,0 +1,142 @@ +import json + + +def update_file(filename, new_js_code): + with open(filename, "r", encoding="utf-8") as f: + data = json.load(f) + + for node in data["nodes"]: + if "parameters" in node and "jsCode" in node["parameters"]: + print(f"Updating jsCode in node: {node.get('name', 'unknown')}") + node["parameters"]["jsCode"] = new_js_code + break + + with open(filename, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + print(f"Updated {filename}") + + +# New jsCode for video search (already updated) +js_code_video_search = """const hits = $input.first().json.hits; + +if (!hits || hits.length === 0) { + return { + json: { + message: '找不到相關結果', + query: $input.first().json.query + } + }; +} + +const results = hits.map((hit, index) => { + return { + number: index + 1, + text: hit.text, + start: hit.start, + end: hit.end, + score: Math.round(hit.score * 100) + '%', + video_title: hit.title, + file_path: hit.file_path + }; +}); + +return { + json: { + query: $input.first().json.query, + count: $input.first().json.count, + results: results + } +};""" + +# New jsCode for simple workflow +js_code_simple = """// 處理 Momentry 搜尋結果 +const data = $input.first().json; +const hits = data.hits; + +if (!hits || hits.length === 0) { + return { + json: { + success: false, + message: '找不到相關結果', + query: data.query + } + }; +} + +// 格式化結果 +const formattedResults = hits.map((hit, idx) => { + return { + index: idx + 1, + id: hit.id, + title: hit.title, + text: hit.text, + startTime: hit.start, + endTime: hit.end, + relevance: Math.round(hit.score * 100) + '%', + file_path: hit.file_path + }; +}); + +return { + json: { + success: true, + query: data.query, + totalFound: data.count, + results: formattedResults + } +};""" + +# New jsCode for RAG MCP workflow +js_code_rag = """// Process Momentry Core search results +const data = $input.first().json; +const hits = data.hits || []; + +if (hits.length === 0) { + return { + json: { + success: false, + message: 'No relevant results found', + query: data.query, + results: [] + } + }; +} + +// Format results for RAG +const formattedResults = hits.map((hit, idx) => { + return { + index: idx + 1, + id: hit.id || hit.chunk_id, + title: hit.title || 'Unknown Video', + text: hit.text || hit.content || '', + startTime: hit.start_time || hit.start || 0, + endTime: hit.end_time || hit.end || 0, + relevance: Math.round((hit.score || 0) * 100) + '%', + videoUuid: hit.video_uuid || hit.uuid, + file_path: hit.file_path || '' + }; +}); + +// Build context for RAG +const context = formattedResults + .map(r => \`[\${r.index}] \${r.text} (Video: \${r.title}, Time: \${r.startTime}s-\${r.endTime}s)\`) + .join('\\n\\n'); + +return { + json: { + success: true, + query: data.query, + totalFound: data.count || hits.length, + context: context, + results: formattedResults + } +};""" + +if __name__ == "__main__": + # Update simple workflow + update_file("docs/n8n_workflow_simple.json", js_code_simple) + # Update RAG workflow + update_file("docs/n8n_workflow_video_rag_mcp.json", js_code_rag) + # Note: video search already updated, but we can re-update if needed + # update_file('docs/n8n_workflow_video_search.json', js_code_video_search) + print("All workflows updated.") diff --git a/update_api_service.sh b/update_api_service.sh new file mode 100644 index 0000000..2a6326d --- /dev/null +++ b/update_api_service.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +echo "Updating Momentry API service with DB connection pool settings..." + +# Backup existing plist +sudo cp /Library/LaunchDaemons/com.momentry.api.plist /Library/LaunchDaemons/com.momentry.api.plist.backup.$(date +%Y%m%d_%H%M%S) + +# Stop the service +sudo launchctl unload /Library/LaunchDaemons/com.momentry.api.plist 2>/dev/null || true + +# Copy updated plist +sudo cp /Users/accusys/momentry_core_0.1/com.momentry.api.updated.plist /Library/LaunchDaemons/com.momentry.api.plist + +# Start the service +sudo launchctl load /Library/LaunchDaemons/com.momentry.api.plist + +echo "API service updated successfully." +echo "Checking service status..." +launchctl list | grep com.momentry.api || echo "Service not listed in user domain; check system domain." diff --git a/update_asr.py b/update_asr.py new file mode 100644 index 0000000..8837964 --- /dev/null +++ b/update_asr.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import json +import subprocess +import os + + +def main(): + # Find latest ASR file for job 10 + output_dir = "/Users/accusys/momentry/output" + files = [f for f in os.listdir(output_dir) if f.startswith("job_10_asr_")] + if not files: + print("No ASR files found") + return + + # Sort by timestamp (numeric suffix) + def extract_timestamp(fname): + # job_10_asr_1774505428450.json + parts = fname.split("_") + timestamp = parts[3].split(".")[0] + return int(timestamp) + + files.sort(key=extract_timestamp, reverse=True) + latest_file = os.path.join(output_dir, files[0]) + print(f"Using ASR file: {latest_file}") + + with open(latest_file, "r") as f: + data = json.load(f) + + # Convert to JSON string, escape single quotes for SQL + json_str = json.dumps(data).replace("'", "''") + + # Update processor_results + sql = f""" + UPDATE processor_results + SET status = 'completed', + output_data = '{json_str}'::jsonb, + completed_at = NOW() + WHERE job_id = 10 AND processor = 'asr'; + """ + + # Execute with psql + db_url = "postgres://accusys@localhost:5432/momentry" + cmd = ["psql", db_url, "-c", sql] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"Error updating database: {result.stderr}") + else: + print("Successfully updated ASR processor result to completed") + + # Also need to store ASR chunks in database via Rust logic + # For now, we'll trust that the worker will do it when restarted + # (the store_asr_chunks method will be called on completion) + + +if __name__ == "__main__": + main() diff --git a/update_workflow.py b/update_workflow.py new file mode 100644 index 0000000..6f8fc90 --- /dev/null +++ b/update_workflow.py @@ -0,0 +1,59 @@ +import json +import sys + + +def update_workflow(filename): + with open(filename, "r", encoding="utf-8") as f: + data = json.load(f) + + # Find the code node (index 2) + for node in data["nodes"]: + if ( + node.get("name") == "處理結果" + or "parameters" in node + and "jsCode" in node["parameters"] + ): + old_code = node["parameters"]["jsCode"] + print("Old code length:", len(old_code)) + # New code without URL generation + new_code = """const hits = $input.first().json.hits; + +if (!hits || hits.length === 0) { + return { + json: { + message: '找不到相關結果', + query: $input.first().json.query + } + }; +} + +const results = hits.map((hit, index) => { + return { + number: index + 1, + text: hit.text, + start: hit.start, + end: hit.end, + score: Math.round(hit.score * 100) + '%', + video_title: hit.title, + file_path: hit.file_path + }; +}); + +return { + json: { + query: $input.first().json.query, + count: $input.first().json.count, + results: results + } +};""" + node["parameters"]["jsCode"] = new_code + print("Updated jsCode") + break + + with open(filename, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + print("File updated:", filename) + + +if __name__ == "__main__": + update_workflow(sys.argv[1]) diff --git a/worker.pid b/worker.pid new file mode 100644 index 0000000..e6e6014 --- /dev/null +++ b/worker.pid @@ -0,0 +1 @@ +65327