feat: 新增 Job Worker 系統與 API 文檔全面更新

This commit is contained in:
Warren
2026-03-26 16:16:34 +08:00
parent 80399b1c12
commit 82955504f3
70 changed files with 3460 additions and 376 deletions

43
.env
View File

@@ -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

3
.gitignore vendored
View File

@@ -38,3 +38,6 @@ id_*
*.swp
*.swo
*~
# Documentation backups
docs_v1.0/

View File

@@ -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"

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.momentry.api</string>
<key>UserName</key>
<string>accusys</string>
<key>GroupName</key>
<string>staff</string>
<key>WorkingDirectory</key>
<string>/Users/accusys/momentry_core_0.1</string>
<key>ProgramArguments</key>
<array>
<string>/Users/accusys/momentry_core_0.1/target/release/momentry</string>
<string>server</string>
<string>--port</string>
<string>3002</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>DATABASE_URL</key>
<string>postgres://accusys@localhost:5432/momentry</string>
<key>DB_MAX_CONNECTIONS</key>
<string>50</string>
<key>DB_ACQUIRE_TIMEOUT</key>
<string>30</string>
<key>REDIS_URL</key>
<string>redis://:accusys@localhost:6379</string>
<key>REDIS_PASSWORD</key>
<string>accusys</string>
<key>OLLAMA_HOST</key>
<string>http://localhost:11434</string>
<key>QDRANT_URL</key>
<string>http://127.0.0.1:6333</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/accusys/momentry/log/momentry_api.log</string>
<key>StandardErrorPath</key>
<string>/Users/accusys/momentry/log/momentry_api.error.log</string>
</dict>
</plist>

View File

@@ -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`

View File

@@ -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 <uuid1> <uuid2> <uuid3>
### 3.5 取得處理進度 ✅
```bash
curl http://localhost:3002/api/v1/progress/<uuid>
curl -H "X-API-Key: YOUR_API_KEY" http://localhost:3002/api/v1/progress/<uuid>
```
**回應範例**:
@@ -247,6 +274,67 @@ curl http://localhost:3002/api/v1/progress/<uuid>
}
```
### 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/<uuid>
```
**任務列表回應範例**:
```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/<uuid>
```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'
```
---

View File

@@ -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"
}
]
}

View File

@@ -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
<?php
// 將文件路徑轉換為可訪問的 URL
function convert_file_path_to_url($file_path) {
// 範例: 將 SFTPGo 文件路徑轉換為 web URL
// /Users/accusys/momentry/var/sftpgo/data/demo/video.mp4
// → https://sftpgo.example.com/demo/video.mp4
// 移除基本路徑
$base_path = '/Users/accusys/momentry/var/sftpgo/data/';
if (strpos($file_path, $base_path) === 0) {
$relative_path = substr($file_path, strlen($base_path));
// 替換為實際的 SFTPGo web URL
return 'https://sftpgo.example.com/' . $relative_path;
}
// 如果無法轉換,返回原始路徑
return $file_path;
}
// 註冊短碼
add_shortcode('momentry_search', function($atts) {
$atts = shortcode_atts([
@@ -472,7 +509,10 @@ add_shortcode('momentry_search', function($atts) {
}
$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' // 替換為實際的 API Key
],
'body' => json_encode([
'query' => $atts['query'],
'limit' => (int)$atts['limit']
@@ -492,10 +532,15 @@ add_shortcode('momentry_search', function($atts) {
$output = '<ul class="momentry-results">';
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(
'<li>%s <a href="%s?start=%s">播放</a></li>',
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"
}
]
}

View File

@@ -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 |
---

View File

@@ -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 |
---
**狀態**: 開發中
---

View File

@@ -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}'
```

532
docs/API_QUICK_REFERENCE.md Normal file
View File

@@ -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 工作流程整合

View File

@@ -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
```
---

View File

@@ -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 |

View File

@@ -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)) {
<?php
$api_url = 'https://api.momentry.ddns.net/api/v1/videos';
$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)) {
$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
<?php
// 將文件路徑轉換為可訪問的 URL
function convert_file_path_to_url($file_path) {
// 範例: 將 SFTPGo 文件路徑轉換為 web URL
// /Users/accusys/momentry/var/sftpgo/data/demo/video.mp4
// → https://sftpgo.example.com/demo/video.mp4
// 移除基本路徑
$base_path = '/Users/accusys/momentry/var/sftpgo/data/';
if (strpos($file_path, $base_path) === 0) {
$relative_path = substr($file_path, strlen($base_path));
// 替換為實際的 SFTPGo web URL
return 'https://sftpgo.example.com/' . $relative_path;
}
// 如果無法轉換,返回原始路徑
return $file_path;
}
// 註冊短碼
add_shortcode('momentry_search', function($atts) {
$atts = shortcode_atts([
@@ -144,7 +191,10 @@ add_shortcode('momentry_search', function($atts) {
}
$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' // 替換為實際的 API Key
],
'body' => json_encode([
'query' => $atts['query'],
'limit' => (int)$atts['limit']
@@ -164,10 +214,15 @@ add_shortcode('momentry_search', function($atts) {
$output = '<ul class="momentry-results">';
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(
'<li>%s <a href="%s?start=%s">播放</a></li>',
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)

View File

@@ -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 |
---
**狀態**: 完成
---

View File

@@ -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.

View File

@@ -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
- 使用無痕/私密瀏覽視窗

View File

@@ -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
```
---

View File

@@ -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
```

View File

@@ -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,

View File

@@ -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 測試資料

View File

@@ -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}'
```

View File

@@ -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
---

View File

@@ -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

View File

@@ -1,5 +1,21 @@
# n8n MCP 整合測試報告
| 項目 | 內容 |
|------|------|
| 建立者 | Warren |
| 建立時間 | 2026-03-23 |
| 文件版本 | V1.0 |
---
## 版本歷史
| 版本 | 日期 | 目的 | 操作人 | 工具/模型 |
|------|------|------|--------|-----------|
| V1.0 | 2026-03-23 | 創建測試報告 | Warren | OpenCode |
---
## 測試日期
2026-03-23

View File

@@ -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 \

View File

@@ -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).

View File

@@ -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. **模型管理** - 新增、選擇、預覽模型

View File

@@ -1,5 +1,21 @@
# Momentry 系統測試與驗證計劃
| 項目 | 內容 |
|------|------|
| 建立者 | Warren |
| 建立時間 | 2026-03-23 |
| 文件版本 | V1.0 |
---
## 版本歷史
| 版本 | 日期 | 目的 | 操作人 | 工具/模型 |
|------|------|------|--------|-----------|
| V1.0 | 2026-03-23 | 創建測試與驗證計劃 | Warren | OpenCode |
---
> **計劃階段** - 僅供討論,尚未執行
> **建立時間**: 2026-03-23
> **目標**: 安裝後測試、跑分、燒機

View File

@@ -2,9 +2,21 @@
| 項目 | 內容 |
|------|------|
| 版本 | V1.0 |
| 日期 | 2026-03-21 |
| 目標讀者 | 系統管理員、開發者 |
| 建立者 | Warren |
| 建立時間 | 2026-03-21 |
| 文件版本 | V1.0 |
---
## 版本歷史
| 版本 | 日期 | 目的 | 操作人 | 工具/模型 |
|------|------|------|--------|-----------|
| V1.0 | 2026-03-21 | 創建使用手冊 | Warren | OpenCode |
---
**目標讀者**: 系統管理員、開發者
---

View File

@@ -1,5 +1,21 @@
# Momentry Core 版本管理規範
| 項目 | 內容 |
|------|------|
| 建立者 | Warren |
| 建立時間 | 2026-03-23 |
| 文件版本 | V1.0 |
---
## 版本歷史
| 版本 | 日期 | 目的 | 操作人 | 工具/模型 |
|------|------|------|--------|-----------|
| V1.0 | 2026-03-23 | 創建版本管理規範 | Warren | OpenCode |
---
## 1. 版本與通訊埠對照表
| 版本 | Binary | Port | Redis Prefix | 用途 |

View File

@@ -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 說明 |

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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
}
],

14
install_worker_service.sh Normal file
View File

@@ -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."

View File

@@ -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)';

View File

@@ -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';

View File

@@ -30,6 +30,12 @@
<key>DATABASE_URL</key>
<string>postgres://accusys@localhost:5432/momentry</string>
<key>DB_MAX_CONNECTIONS</key>
<string>50</string>
<key>DB_ACQUIRE_TIMEOUT</key>
<string>30</string>
<key>REDIS_URL</key>
<string>redis://:accusys@localhost:6379</string>
@@ -40,7 +46,7 @@
<string>http://localhost:11434</string>
<key>QDRANT_URL</key>
<string>http://localhost:6333</string>
<string>http://127.0.0.1:6333</string>
</dict>
<key>RunAtLoad</key>

View File

@@ -30,6 +30,12 @@
<key>DATABASE_URL</key>
<string>postgres://accusys@localhost:5432/momentry</string>
<key>DB_MAX_CONNECTIONS</key>
<string>50</string>
<key>DB_ACQUIRE_TIMEOUT</key>
<string>30</string>
<key>REDIS_URL</key>
<string>redis://:accusys@localhost:6379</string>
@@ -40,7 +46,7 @@
<string>http://localhost:11434</string>
<key>QDRANT_URL</key>
<string>http://localhost:6333</string>
<string>http://127.0.0.1:6333</string>
</dict>
<key>RunAtLoad</key>

54
scripts/asr_processor.py Normal file → Executable file
View File

@@ -3,17 +3,65 @@ import sys
import json
import os
import argparse
import signal
import subprocess
from faster_whisper import WhisperModel
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from redis_publisher import RedisPublisher
def signal_handler(signum, frame):
print(f"ASR: Received signal {signum}, exiting...")
sys.exit(1)
def has_audio_stream(video_path):
"""Check if video file has audio stream using ffprobe."""
try:
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"a",
"-show_entries",
"stream=codec_type",
"-of",
"csv=p=0",
video_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return bool(result.stdout.strip())
except subprocess.CalledProcessError:
return False
except FileNotFoundError:
print("WARNING: ffprobe not found, assuming audio exists")
return True
def run_asr(video_path, output_path, uuid: str = ""):
# Set up signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
publisher = RedisPublisher(uuid) if uuid else None
if publisher:
publisher.info("asr", "ASR_START")
# Check for audio stream
if not has_audio_stream(video_path):
if publisher:
publisher.info("asr", "No audio stream detected, skipping transcription")
output = {"language": "", "language_probability": 0.0, "segments": []}
with open(output_path, "w") as f:
json.dump(output, f, indent=2)
if publisher:
publisher.complete("asr", "0 segments (no audio)")
sys.stderr.write("ASR: No audio stream, skipping transcription\n")
sys.stderr.flush()
sys.exit(0)
if publisher:
publisher.info("asr", "Loading Whisper model...")
@@ -53,6 +101,12 @@ def run_asr(video_path, output_path, uuid: str = ""):
if publisher:
publisher.complete("asr", f"{len(results)} segments")
sys.stderr.write(
f"ASR: Transcription complete, {len(results)} segments written to {output_path}\n"
)
sys.stderr.flush()
sys.exit(0)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="ASR Transcription")

0
scripts/caption_processor.py Normal file → Executable file
View File

View File

@@ -8,14 +8,24 @@ import sys
import json
import argparse
import os
import signal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from redis_publisher import RedisPublisher
def signal_handler(signum, frame):
print(f"OCR: Received signal {signum}, exiting...")
sys.exit(1)
def process_ocr(video_path: str, output_path: str, uuid: str = ""):
"""Process video for OCR using EasyOCR"""
# Set up signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
publisher = RedisPublisher(uuid) if uuid else None
if publisher:
publisher.info("ocr", "OCR_START")

View File

@@ -8,14 +8,24 @@ import sys
import json
import argparse
import os
import signal
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from redis_publisher import RedisPublisher
def signal_handler(signum, frame):
print(f"POSE: Received signal {signum}, exiting...")
sys.exit(1)
def process_pose(video_path: str, output_path: str, uuid: str = ""):
"""Process video for pose estimation using YOLOv8 Pose"""
# Set up signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
publisher = RedisPublisher(uuid) if uuid else None
if publisher:
publisher.info("pose", "POSE_START")

0
scripts/story_processor.py Normal file → Executable file
View File

View File

@@ -791,8 +791,8 @@ async fn search(
uuid: chunk.uuid.clone(),
chunk_id: chunk.chunk_id.clone(),
chunk_type: chunk.chunk_type.as_str().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,
score: r.score,
});
@@ -868,8 +868,8 @@ async fn n8n_search(
hits.push(N8nSearchHit {
id: chunk.chunk_id.clone(),
vid: chunk.uuid.clone(),
start: chunk.start_time,
end: chunk.end_time,
start: chunk.start_time().seconds(),
end: chunk.end_time().seconds(),
title: if title.is_empty() {
format!("Chunk {}", chunk.chunk_id)
} else {

82
src/bin/fix_chunks.rs Normal file
View File

@@ -0,0 +1,82 @@
use anyhow::Result;
use momentry_core::core::config;
use momentry_core::core::db::PostgresDb;
use momentry_core::core::processor::asrx::AsrxResult;
use momentry_core::core::processor::face::FaceResult;
use momentry_core::core::processor::ocr::OcrResult;
use momentry_core::core::processor::pose::PoseResult;
use momentry_core::core::processor::yolo::{YoloPythonResult, YoloResult};
use momentry_core::worker::processor::ProcessorPool;
use serde_json;
use std::fs;
#[tokio::main]
async fn main() -> 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(())
}

View File

@@ -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,

View File

@@ -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<String>,
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

View File

@@ -28,13 +28,15 @@ pub struct ChunkDocument {
impl From<Chunk> 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,

View File

@@ -126,8 +126,6 @@ pub struct PreChunk {
pub source_type: String,
pub source_file: Option<String>,
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::<u64>()
.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<Vec<Chunk>> {
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<serde_json::Value> = r.get(12);
let content: serde_json::Value = r.get(9);
let metadata: Option<serde_json::Value> = r.get(10);
let pre_chunk_ids: Vec<i32> = r.try_get(15).unwrap_or_default();
let parent_chunk_id: Option<String> = r.try_get(16).ok().flatten();
let child_chunk_ids: Vec<String> = r.try_get(17).unwrap_or_default();
let pre_chunk_ids: Vec<i32> = r.try_get(13).unwrap_or_default();
let parent_chunk_id: Option<String> = r.try_get(14).ok().flatten();
let child_chunk_ids: Vec<String> = 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<Option<Chunk>> {
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<serde_json::Value> = r.get(12);
let content: serde_json::Value = r.get(9);
let metadata: Option<serde_json::Value> = r.get(10);
let pre_chunk_ids: Vec<i32> = r.try_get(15).unwrap_or_default();
let parent_chunk_id: Option<String> = r.try_get(16).ok().flatten();
let child_chunk_ids: Vec<String> = r.try_get(17).unwrap_or_default();
let pre_chunk_ids: Vec<i32> = r.try_get(13).unwrap_or_default();
let parent_chunk_id: Option<String> = r.try_get(14).ok().flatten();
let child_chunk_ids: Vec<String> = 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<serde_json::Value> = r.get(12);
let content: serde_json::Value = r.get(9);
let metadata: Option<serde_json::Value> = r.get(10);
let pre_chunk_ids: Vec<i32> = r.try_get(15).unwrap_or_default();
let parent_chunk_id: Option<String> = r.try_get(16).ok().flatten();
let child_chunk_ids: Vec<String> = r.try_get(17).unwrap_or_default();
let pre_chunk_ids: Vec<i32> = r.try_get(13).unwrap_or_default();
let parent_chunk_id: Option<String> = r.try_get(14).ok().flatten();
let child_chunk_ids: Vec<String> = 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<Vec<MonitorJob>> {
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<MonitorJob> = 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<String>,
failed_processors: Vec<String>,
) -> 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
"#,

View File

@@ -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,

View File

@@ -9,3 +9,4 @@ pub mod probe;
pub mod processor;
pub mod storage;
pub mod thumbnail;
pub mod time;

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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<String>,
processing_time: Option<f64>,
total_detections: Option<i64>,
auto_save_count: Option<i32>,
}
#[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<u32>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct YoloPythonFrame {
frame_number: u64,
time_seconds: f64,
time_formatted: String,
detections: Vec<YoloPythonDetection>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct YoloPythonResult {
metadata: YoloPythonMetadata,
frames: HashMap<String, YoloPythonFrame>,
}
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::<u64>().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);
}
}

383
src/core/time.rs Normal file
View File

@@ -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");
}
}

View File

@@ -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()?;

View File

@@ -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

View File

@@ -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,12 +52,22 @@ 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);
}
}
// Process pending jobs if any
let pending_jobs = self.db.get_pending_jobs(self.config.batch_size).await?;
if !pending_jobs.is_empty() {
info!("Found {} pending jobs", pending_jobs.len());
for job in pending_jobs {
@@ -67,6 +80,7 @@ impl JobWorker {
error!("Failed to process job: {}", e);
}
}
}
Ok(())
}
@@ -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<String> = 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<String> = 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?;

View File

@@ -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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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
}

142
update_all_workflows.py Normal file
View File

@@ -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.")

20
update_api_service.sh Normal file
View File

@@ -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."

56
update_asr.py Normal file
View File

@@ -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()

59
update_workflow.py Normal file
View File

@@ -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])

1
worker.pid Normal file
View File

@@ -0,0 +1 @@
65327