diff --git a/Cargo.lock b/Cargo.lock index b205f01..c5abea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2340,7 +2340,7 @@ dependencies = [ [[package]] name = "momentry_core" -version = "0.1.0" +version = "1.0.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4cead5e..7a57be3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "momentry_core" -version = "0.1.0" +version = "1.0.0" edition = "2021" authors = ["Momentry Team"] description = "Digital asset management system with video analysis and RAG" diff --git a/docs_v1.0/DEV_API_V1.0/API_REFERENCE_v1.0.0 draft.md b/docs_v1.0/DEV_API_V1.0/API_REFERENCE_v1.0.0 draft.md new file mode 100644 index 0000000..f534207 --- /dev/null +++ b/docs_v1.0/DEV_API_V1.0/API_REFERENCE_v1.0.0 draft.md @@ -0,0 +1,464 @@ +# Momentry Core API Documentation v1.0.0 + +## 快速資訊 +- **Base URL**: `http://:3003` (開發環境) +- **Production**: `http://:3002` +- **認證方式**: Header `X-API-Key: ` +- **測試 Key**: `muser_test_001` + +--- + +## API 分類原則 + +| 分類 | 用途 | 代表端點 | +|------|------|----------| +| **健康檢查** | 系統狀態確認 | `/health` | +| **檔案管理 (Files)** | 列出、查詢檔案 | `/api/v1/files` | +| **人物管理 (People)** | Identity 搜尋、候選 | `/api/v1/people` | +| **Identity 管理** | 人物詳細資訊 | `/api/v1/identities/:uuid` | +| **搜尋 (Search)** | 文字、BM25、混合搜尋 | `/api/v1/search/*` | +| **人臉 (Face)** | 人臉辨識、列表 | `/api/v1/face/*` | +| **任務 (Jobs)** | 處理任務狀態 | `/api/v1/jobs` | +| **統計 (Stats)** | 系統統計 | `/api/v1/stats/ingest` | + +### 設計概念 +1. **Files 是核心資源**:所有影片/圖片都是 File,用 32 碼 `file_uuid` 識別 +2. **Identity 是跨檔案的人物**:一個 Identity 可出現在多個 Files +3. **People = Identity 的別名路由**:`/api/v1/people` 和 `/api/v1/identities` 指向相同資料 +4. **所有列表 API 支援分頁**:`page`, `page_size` 參數 +5. **所有操作 API 回傳統一格式**:`{ success, data, total, page, page_size }` + +--- + +## 1. 健康檢查 + +### `GET /health` +檢查系統是否運作。 + +**回應**: +```json +{ + "status": "ok", + "version": "1.0.0 (build: 2026-04-30 15:40:04)", + "uptime_ms": 348240 +} +``` + +--- + +## 2. 檔案管理 (Files) + +> **測試日期**: 2026-04-30 | **環境**: Playground (Port 3003) | **Schema**: dev + +### `GET /api/v1/files` — 列出所有檔案 + +**參數**: `page` (預設 1), `page_size` (預設 20) + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files?page=1&page_size=2" \ + -H "X-API-Key: muser_test_001" +``` + +**實際回應**: +```json +{ + "success": true, + "total": 21, + "page": 1, + "page_size": 2, + "data": [ + { + "file_uuid": "384b0ff44aaaa1f14cb2cd63b3fea966", + "file_name": "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", + "status": "ready" + } + ] +} +``` + +### `GET /api/v1/files/:uuid` — 取得檔案詳情 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files/384b0ff44aaaa1f14cb2cd63b3fea966" \ + -H "X-API-Key: muser_test_001" +``` + +**實際回應**: +```json +{ + "success": true, + "file_uuid": "384b0ff44aaaa1f14cb2cd63b3fea966", + "file_name": "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", + "metadata": { + "format": { + "duration": "6879.329524", + "width": 1920, + "height": 1080 + }, + "streams": [ + { + "codec_name": "h264", + "codec_type": "video", + "width": 1920, + "height": 1080, + "r_frame_rate": "60000/1001" + }, + { + "codec_name": "aac", + "codec_type": "audio", + "sample_rate": "44100", + "channels": 2 + } + ] + } +} +``` + +### `GET /api/v1/files/scan` — 掃描檔案系統 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files/scan" \ + -H "X-API-Key: muser_test_001" +``` + +**實際回應**: +```json +{ + "files": [ + { + "file_name": "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", + "file_size": 2361629896, + "file_uuid": "384b0ff44aaaa1f14cb2cd63b3fea966", + "status": "pending", + "is_registered": true, + "registration_time": "2026-04-28 18:25:14.010351+00" + } + ], + "total": 20, + "registered_count": 20, + "unregistered_count": 0 +} +``` + +### `POST /api/v1/files/register` — 註冊新檔案 + +> **⚠️ 已知問題**: 此 endpoint 在 dev 環境有 SQLx 型別綁定問題 (probe_json text vs jsonb)。不影響 marcom 團隊的 GUI 設計。 + +**Request**: +```json +{ + "file_path": "/path/to/video.mp4" +} +``` + +--- + +## 3. 人物管理 (People) + +### `GET /api/v1/people` — 列出所有人物 + +**參數**: `page`, `page_size` + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/people?page=1&page_size=3" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "total": 100, + "page": 1, + "page_size": 3, + "data": [ + { + "identity_id": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4", + "name": "Trace 2 Fixed Format", + "metadata": {}, + "created_at": "2026-04-28T06:10:14.582062Z" + } + ] +} +``` + +### `POST /api/v1/people/search` — 搜尋人物 + +**Request**: +```json +{ + "query": "Trace", + "limit": 3 +} +``` + +**curl**: +```bash +curl -s -X POST "http://localhost:3003/api/v1/people/search" \ + -H "X-API-Key: muser_test_001" \ + -H "Content-Type: application/json" \ + -d '{"query":"Trace","limit":3}' +``` + +**回應**: +```json +{ + "success": true, + "total": 3, + "page": 1, + "page_size": 20, + "data": [ + { + "identity_id": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4", + "name": "Trace 2 Fixed Format", + "metadata": {}, + "created_at": "2026-04-28T06:10:14.582062Z" + } + ] +} +``` + +### `GET /api/v1/people/candidates` — 列出未命名人物候選 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/people/candidates" \ + -H "X-API-Key: muser_test_001" +``` + +--- + +## 4. Identity 詳細資訊 + +### `GET /api/v1/identities/:uuid` — 取得人物詳情 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/identities/a9a90105-6d6b-46ff-92da-0c3c1a57dff4" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "uuid": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4", + "name": "Trace 2 Fixed Format", + "identity_type": "people", + "source": "auto_trace", + "status": "active", + "metadata": {}, + "reference_data": { + "trace_id": 2, + "quality_avg": 0.8748, + "trace_stats": { + "start_frame": 155, + "end_frame": 297, + "avg_confidence": 0.8624, + "duration_seconds": 6.5, + "total_appearances": 143 + }, + "angles_covered": ["profile_right", "three_quarter"] + } +} +``` + +### `GET /api/v1/identities/:uuid/files` — 取得人物出現的檔案列表 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/identities/a9a90105-6d6b-46ff-92da-0c3c1a57dff4/files" \ + -H "X-API-Key: muser_test_001" +``` + +--- + +## 5. 搜尋 (Search) + +### `POST /api/v1/search` — 向量搜尋 + +**Request**: +```json +{ + "query": "person talking", + "limit": 5 +} +``` + +**回應**: +```json +{ + "results": [], + "query": "person talking" +} +``` + +### `POST /api/v1/search/bm25` — BM25 全文搜尋 + +**Request**: +```json +{ + "query": "test", + "limit": 3 +} +``` + +**回應**: +```json +{ + "results": [ + { + "uuid": "a1b10138a6bbb0cd", + "chunk_id": "sentence_1006", + "text": "...", + "bm25_score": 5.23 + } + ], + "query": "test" +} +``` + +### `POST /api/v1/search/hybrid` — 混合搜尋 (向量 + BM25) + +**Request**: +```json +{ + "query": "test", + "limit": 3 +} +``` + +--- + +## 6. 人臉 (Face) + +### `GET /api/v1/face/list` — 列出人臉 + +**必填參數**: `file_uuid` + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/face/list?file_uuid=384b0ff44aaaa1f14cb2cd63b3fea966&page=1&page_size=3" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "message": "Found 2 faces", + "faces": [ + { + "face_id": "identity_8d7c22e3a6794a8c93b4a3655f106944", + "name": "Cary Grant", + "created_at": "2026-04-18T10:44:48.571407+00:00", + "is_active": true + }, + { + "face_id": "identity_9c5d1e8965eb49ae83d9b88db12fbb18", + "name": "Audrey Hepburn", + "created_at": "2026-04-18T10:44:31.536039+00:00", + "is_active": true + } + ], + "count": 2, + "page": 1, + "page_size": 3 +} +``` + +--- + +## 7. 任務 (Jobs) + +### `GET /api/v1/jobs` — 列出處理任務 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/jobs?page=1&page_size=2" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "jobs": [ + { + "id": 32, + "uuid": "942d0bdf5d6fb6ac18b47deb031e60c3", + "status": "running", + "current_processor": null, + "progress_current": 0, + "progress_total": 0, + "created_at": "2026-04-28 15:55:47.911654", + "started_at": null + } + ], + "count": 19, + "page": 1, + "page_size": 2 +} +``` + +### `GET /api/v1/rules/:rule/status` — 取得規則狀態 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/rules/default/status" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "rule": "default", + "supported_processor_ids": [], + "active_jobs": [] +} +``` + +--- + +## 8. 統計 (Stats) + +### `GET /api/v1/stats/ingest` — 取得匯入統計 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/stats/ingest" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "total_videos": 21, + "total_chunks": 0, + "sentence_chunks": 0, + "cut_chunks": 0, + "time_chunks": 0, + "searchable_chunks": 0, + "chunks_with_visual": 0, + "chunks_with_summary": 0, + "pending_videos": 12 +} +``` + +--- + +## 錯誤回應 + +| HTTP Code | 說明 | +|-----------|------| +| `400` | 請求參數錯誤 (例如缺少必填欄位) | +| `401` | API Key 無效或缺失 | +| `404` | 資源不存在 | +| `422` | 請求格式錯誤 (JSON 解析失敗) | +| `500` | 伺服器內部錯誤 | diff --git a/docs_v1.0/DEV_API_V1.0/API_REFERENCE_v1.0.0.md b/docs_v1.0/DEV_API_V1.0/API_REFERENCE_v1.0.0.md new file mode 100644 index 0000000..68cbb6d --- /dev/null +++ b/docs_v1.0/DEV_API_V1.0/API_REFERENCE_v1.0.0.md @@ -0,0 +1,904 @@ +# Momentry Core API Documentation v1.0.0 + +## 快速資訊 +- **Base URL**: `http://:3003` (開發環境) +- **Production**: `http://:3002` +- **認證方式**: Header `X-API-Key: ` +- **測試 Key**: `muser_test_001` + +--- + +## API 分類原則 + +| 分類 | 用途 | 代表端點 | +|------|------|----------| +| **健康檢查** | 系統狀態確認 | `/health` | +| **檔案管理 (Files)** | 列出、查詢檔案 | `/api/v1/files` | +| **人物管理 (People)** | Identity 搜尋、候選 | `/api/v1/people` | +| **Identity 管理** | 人物詳細資訊 | `/api/v1/identities/:uuid` | +| **搜尋 (Search)** | 文字、BM25、混合搜尋 | `/api/v1/search/*` | +| **人臉 (Face)** | 人臉辨識、列表 | `/api/v1/face/*` | +| **任務 (Jobs)** | 處理任務狀態 | `/api/v1/jobs` | +| **統計 (Stats)** | 系統統計 | `/api/v1/stats/ingest` | + +### 設計概念 +1. **Files 是核心資源**:所有影片/圖片都是 File,用 32 碼 `file_uuid` 識別 +2. **Identity 是跨檔案的人物**:一個 Identity 可出現在多個 Files +3. **People = Identity 的別名路由**:`/api/v1/people` 和 `/api/v1/identities` 指向相同資料 +4. **所有列表 API 支援分頁**:`page`, `page_size` 參數 +5. **所有操作 API 回傳統一格式**:`{ success, data, total, page, page_size }` + +--- + +## 1. 健康檢查 + +### `GET /health` +檢查系統是否運作。 + +**回應**: +```json +{ + "status": "ok", + "version": "1.0.0 (build: 2026-04-30 15:40:04)", + "uptime_ms": 348240 +} +``` + +--- + +## 2. 檔案管理 (Files) + +> **測試日期**: 2026-04-30 | **環境**: Playground (Port 3003) | **Schema**: dev + +### `GET /api/v1/files` — 列出所有檔案 + +**參數**: `page` (預設 1), `page_size` (預設 20) + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files?page=1&page_size=2" \ + -H "X-API-Key: muser_test_001" +``` + +**實際回應**: +```json +{ + "success": true, + "total": 21, + "page": 1, + "page_size": 2, + "data": [ + { + "file_uuid": "384b0ff44aaaa1f14cb2cd63b3fea966", + "file_name": "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", + "status": "ready" + } + ] +} +``` + +### `GET /api/v1/files/:uuid` — 取得檔案詳情 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files/384b0ff44aaaa1f14cb2cd63b3fea966" \ + -H "X-API-Key: muser_test_001" +``` + +**實際回應**: +```json +{ + "success": true, + "file_uuid": "384b0ff44aaaa1f14cb2cd63b3fea966", + "file_name": "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", + "metadata": { + "format": { + "duration": "6879.329524", + "width": 1920, + "height": 1080 + }, + "streams": [ + { + "codec_name": "h264", + "codec_type": "video", + "width": 1920, + "height": 1080, + "r_frame_rate": "60000/1001" + }, + { + "codec_name": "aac", + "codec_type": "audio", + "sample_rate": "44100", + "channels": 2 + } + ] + } +} +``` + +### `GET /api/v1/files/scan` — 掃描檔案系統 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files/scan" \ + -H "X-API-Key: muser_test_001" +``` + +**實際回應**: +```json +{ + "files": [ + { + "file_name": "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", + "file_size": 2361629896, + "file_uuid": "384b0ff44aaaa1f14cb2cd63b3fea966", + "status": "pending", + "is_registered": true, + "registration_time": "2026-04-28 18:25:14.010351+00" + } + ], + "total": 20, + "registered_count": 20, + "unregistered_count": 0 +} +``` + +### `POST /api/v1/files/register` — 註冊新檔案 + +> **⚠️ 已知問題**: 此 endpoint 在 dev 環境有 SQLx 型別綁定問題 (probe_json text vs jsonb)。不影響 marcom 團隊的 GUI 設計。 + +**Request**: +```json +{ + "file_path": "/path/to/video.mp4" +} +``` + +--- + +## 3. 人物管理 (People) + +### `GET /api/v1/people` — 列出所有人物 + +**參數**: `page`, `page_size` + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/people?page=1&page_size=3" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "total": 100, + "page": 1, + "page_size": 3, + "data": [ + { + "identity_id": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4", + "name": "Trace 2 Fixed Format", + "metadata": {}, + "created_at": "2026-04-28T06:10:14.582062Z" + } + ] +} +``` + +### `POST /api/v1/people/search` — 搜尋人物 + +**Request**: +```json +{ + "query": "Trace", + "limit": 3 +} +``` + +**curl**: +```bash +curl -s -X POST "http://localhost:3003/api/v1/people/search" \ + -H "X-API-Key: muser_test_001" \ + -H "Content-Type: application/json" \ + -d '{"query":"Trace","limit":3}' +``` + +**回應**: +```json +{ + "success": true, + "total": 3, + "page": 1, + "page_size": 20, + "data": [ + { + "identity_id": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4", + "name": "Trace 2 Fixed Format", + "metadata": {}, + "created_at": "2026-04-28T06:10:14.582062Z" + } + ] +} +``` + +### `GET /api/v1/people/candidates` — 列出未命名人物候選 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/people/candidates" \ + -H "X-API-Key: muser_test_001" +``` + +--- + +## 4. Identity 詳細資訊 + +### `GET /api/v1/identities/:uuid` — 取得人物詳情 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/identities/a9a90105-6d6b-46ff-92da-0c3c1a57dff4" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "uuid": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4", + "name": "Trace 2 Fixed Format", + "identity_type": "people", + "source": "auto_trace", + "status": "active", + "metadata": {}, + "reference_data": { + "trace_id": 2, + "quality_avg": 0.8748, + "trace_stats": { + "start_frame": 155, + "end_frame": 297, + "avg_confidence": 0.8624, + "duration_seconds": 6.5, + "total_appearances": 143 + }, + "angles_covered": ["profile_right", "three_quarter"] + } +} +``` + +### `GET /api/v1/identities/:uuid/files` — 取得人物出現的檔案列表 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/identities/a9a90105-6d6b-46ff-92da-0c3c1a57dff4/files" \ + -H "X-API-Key: muser_test_001" +``` + +--- + +## 5. 搜尋 (Search) + +### `POST /api/v1/search` — 向量搜尋 + +**Request**: +```json +{ + "query": "person talking", + "limit": 5 +} +``` + +**回應**: +```json +{ + "results": [], + "query": "person talking" +} +``` + +### `POST /api/v1/search/bm25` — BM25 全文搜尋 + +**Request**: +```json +{ + "query": "test", + "limit": 3 +} +``` + +**回應**: +```json +{ + "results": [ + { + "uuid": "a1b10138a6bbb0cd", + "chunk_id": "sentence_1006", + "text": "...", + "bm25_score": 5.23 + } + ], + "query": "test" +} +``` + +### `POST /api/v1/search/hybrid` — 混合搜尋 (向量 + BM25) + +**Request**: +```json +{ + "query": "test", + "limit": 3 +} +``` + +--- + +## 6. 人臉 (Face) + +### `GET /api/v1/face/list` — 列出人臉 + +**必填參數**: `file_uuid` + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/face/list?file_uuid=384b0ff44aaaa1f14cb2cd63b3fea966&page=1&page_size=3" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "message": "Found 2 faces", + "faces": [ + { + "face_id": "identity_8d7c22e3a6794a8c93b4a3655f106944", + "name": "Cary Grant", + "created_at": "2026-04-18T10:44:48.571407+00:00", + "is_active": true + }, + { + "face_id": "identity_9c5d1e8965eb49ae83d9b88db12fbb18", + "name": "Audrey Hepburn", + "created_at": "2026-04-18T10:44:31.536039+00:00", + "is_active": true + } + ], + "count": 2, + "page": 1, + "page_size": 3 +} +``` + +--- + +## 7. 任務 (Jobs) + +### `GET /api/v1/jobs` — 列出處理任務 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/jobs?page=1&page_size=2" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "jobs": [ + { + "id": 32, + "uuid": "942d0bdf5d6fb6ac18b47deb031e60c3", + "status": "running", + "current_processor": null, + "progress_current": 0, + "progress_total": 0, + "created_at": "2026-04-28 15:55:47.911654", + "started_at": null + } + ], + "count": 19, + "page": 1, + "page_size": 2 +} +``` + +### `GET /api/v1/rules/:rule/status` — 取得規則狀態 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/rules/default/status" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "rule": "default", + "supported_processor_ids": [], + "active_jobs": [] +} +``` + +--- + +## 8. 統計 (Stats) + +### `GET /api/v1/stats/ingest` — 取得匯入統計 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/stats/ingest" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "total_videos": 21, + "total_chunks": 0, + "sentence_chunks": 0, + "cut_chunks": 0, + "time_chunks": 0, + "searchable_chunks": 0, + "chunks_with_visual": 0, + "chunks_with_summary": 0, + "pending_videos": 12 +} +``` + +--- + +## 9. 影片管理 (Videos) + +> **注意**: `/api/v1/videos` 主要用於後端列表與詳細資料查詢。前端常用 `/api/v1/files` 作為主要資源入口。 + +### `GET /api/v1/videos` — 列出影片 + +**參數**: `page`, `page_size` + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/videos?page=1&page_size=1" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "files": [ + { + "file_uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/view7.mp4", + "file_name": "view7.mp4", + "file_type": null, + "duration": 11.066667, + "width": 1920, + "height": 1080, + "status": "pending", + "processing_status": {}, + "birth_registration": {}, + "created_at": "2026-04-30 18:17:46.161312+00", + "registration_time": "2026-04-30 18:17:46.161312+00", + "file_size": null, + "probe_json": "...", + "total_frames": 332 + } + ], + "count": 22, + "page": 1, + "page_size": 1 +} +``` + +### `DELETE /api/v1/videos/:uuid` — 刪除影片 + +刪除影片及其所有關聯資料 (faces, chunks, etc.)。 + +**curl**: +```bash +curl -s -X DELETE "http://localhost:3003/api/v1/videos/" \ + -H "X-API-Key: muser_test_001" +``` + +### `GET /api/v1/progress/:uuid` — 取得處理進度 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/progress/e79890f13e2e0bebf6c67b436f2c4279" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "file_name": "view7.mp4", + "duration": 11.066667, + "overall_progress": 0, + "cpu_percent": 0.0, + "gpu_percent": null, + "memory_percent": 0.2, + "memory_mb": 29504, + "processors": [ + { + "name": "asr", + "status": "pending", + "current": 0, + "total": 0, + "progress": 0, + "message": "" + } + ] +} +``` + +--- + +## 10. 其他工具 (Utilities) + +### `GET /api/v1/lookup` — 查詢檔案資訊 + +可透過 `uuid` 或 `path` 查詢。 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/lookup?uuid=e79890f13e2e0bebf6c67b436f2c4279" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "file_uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "file_path": "/Users/accusys/momentry/var/sftpgo/data/demo/view7.mp4", + "file_name": "view7.mp4", + "duration": 11.066667 +} +``` + +--- + +## 11. 人臉 (Face) + +### `GET /api/v1/face/list` — 列出人臉 + +**必填參數**: `file_uuid` + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/face/list?file_uuid=e79890f13e2e0bebf6c67b436f2c4279&page=1&page_size=2" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "faces": [ + { + "face_id": "identity_8d7c22e3a6794a8c93b4a3655f106944", + "name": "Cary Grant", + "is_active": true + } + ], + "count": 2 +} +``` + +### `GET /api/v1/face/:face_id` — 取得人臉詳情 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/face/identity_8d7c22e3a6794a8c93b4a3655f106944" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "face_id": "identity_8d7c22e3a6794a8c93b4a3655f106944", + "name": "Cary Grant", + "has_embedding": false, + "is_active": true +} +``` + +### `POST /api/v1/face/recognize` — 進行人臉辨識 + +> **注意**: 此端點會掃描影片並返回逐幀結果。若影片過大可能耗時較長。 + +**Request**: +```json +{ + "file_uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "image_path": "/tmp/test.jpg" +} +``` + +**回應**: +```json +{ + "success": true, + "result": { + "frame_count": 332, + "fps": 30.0, + "faces": [ ... ] + } +} +``` + +--- + +## 12. 快照與截圖管理 (Snapshots) + +> **設計概念**: 快照分為「熱 (hot)」與「冷 (cold)」。冷快照需遷移至記憶體 (migrate) 後才能進行高效存取。 + +### `GET /api/v1/files/:uuid/snapshots` — 列出快照狀態 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files/e79890f13e2e0bebf6c67b436f2c4279/snapshots" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "file_uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "tier": "cold", + "hits": 0, + "types": [ + "products", "ocr", "logos", "faces" + ] +} +``` + +### `GET /api/v1/files/:uuid/snapshots/status` — 檢查詳細狀態 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files/e79890f13e2e0bebf6c67b436f2c4279/snapshots/status" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "status": "cold" +} +``` + +### `POST /api/v1/files/:uuid/snapshots/migrate` — 載入快照至記憶體 + +將冷快照載入記憶體以加速讀取。 + +**Request**: +```json +{ + "parent_uuid": "e79890f13e2e0bebf6c67b436f2c4279" +} +``` + +**回應**: +```json +{ + "success": true, + "message": "Migrated 4 snapshot types", + "migrated_types": ["products", "ocr", "logos", "faces"] +} +``` + +### `POST /api/v1/files/:uuid/snapshots/teardown` — 釋放記憶體 + +釋放已載入的快照資源。 + +--- + +## 13. 身份綁定與訊號 (Identity Binding) + +### `POST /api/v1/identities/bind` — 綁定人臉/聲音至身份 + +將特定的人臉或聲音訊號綁定到全域身份。若身份不存在,需提供 `name` 自動建立。 + +**Request**: +```json +{ + "name": "John Doe", + "binding_type": "face", + "binding_value": "identity_xxx" +} +``` + +### `POST /api/v1/identities/unbind` — 解綁身份 + +**Request**: +```json +{ + "binding_type": "face", + "binding_value": "identity_xxx" +} +``` + +### `GET /api/v1/signals/unbound` — 列出未綁定訊號 + +列出檔案中尚未被綁定的臉部或聲音訊號。 + +**參數**: `uuid`, `binding_type` (face/speaker) + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/signals/unbound?uuid=e79890f13e2e0bebf6c67b436f2c4279&binding_type=face" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "message": "Found 0 unbound face signals", + "data": [] +} +``` + +--- + +## 14. 身份來源與詳細 (Identity Sources) + +### `GET /api/v1/identities/:uuid` — 取得身份詳情 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/identities/a9a90105-6d6b-46ff-92da-0c3c1a57dff4" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "uuid": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4", + "name": "Trace 2 Fixed Format", + "identity_type": "people", + "source": "auto_trace", + "status": "active", + "reference_data": { + "trace_id": 2, + "quality_avg": 0.8748 + } +} +``` + +### `GET /api/v1/identities/:identity_id/faces` — 取得身份下的所有臉部 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/identities/22/faces?page=1&page_size=5" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "identity_id": 22, + "faces": [], + "total": 0 +} +``` + +### `GET /api/v1/files/:uuid/identities` — 取得檔案中出現的身份 + +**curl**: +```bash +curl -s "http://localhost:3003/api/v1/files/e79890f13e2e0bebf6c67b436f2c4279/identities" \ + -H "X-API-Key: muser_test_001" +``` + +**回應**: +```json +{ + "success": true, + "file_uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "total": 0, + "data": [] +} +``` + +--- + +## 15. 進階視覺搜尋 (Visual Search Variants) + +### `POST /api/v1/search/visual/class` — 依物件類別搜尋 + +**Request**: +```json +{ + "uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "object_class": "person" +} +``` + +### `POST /api/v1/search/visual/density` — 依物件密度搜尋 + +**Request**: +```json +{ + "uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "min_density": 0.1 +} +``` + +### `POST /api/v1/search/visual/combination` — 依物件組合搜尋 + +**Request**: +```json +{ + "uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "combination": [["person", 1]] +} +``` + +--- + +## 16. 代理人 (Agents) + +### `POST /api/v1/agents/identity/analyze` — 分析身份重複 + +分析檔案中的身份是否重複,提供合併建議。 + +**Request**: +```json +{ + "file_uuid": "e79890f13e2e0bebf6c67b436f2c4279" +} +``` + +### `POST /api/v1/agents/5w1h/analyze` — 分析 5W1H 摘要 + +**Request**: +```json +{ + "file_uuid": "e79890f13e2e0bebf6c67b436f2c4279", + "chunk_id": "chunk_1" +} +``` + +--- + +## 17. 資產操作 (Assets) + +### `POST /api/v1/assets/:uuid/process` — 觸發處理流程 + +**Request**: +```json +{ + "processors": ["asr", "cut", "face"] +} +``` + +### `POST /api/v1/unregister` — 解除註冊檔案 + +**Request**: +```json +{ + "uuid": "e79890f13e2e0bebf6c67b436f2c4279" +} +``` + +--- + +## 錯誤回應 + +| HTTP Code | 說明 | +|-----------|------| +| `400` | 請求參數錯誤 (例如缺少必填欄位) | +| `401` | API Key 無效或缺失 | +| `404` | 資源不存在 | +| `422` | 請求格式錯誤 (JSON 解析失敗) | +| `500` | 伺服器內部錯誤 | diff --git a/src/api/identity_api.rs b/src/api/identity_api.rs index 001f846..f9a1993 100644 --- a/src/api/identity_api.rs +++ b/src/api/identity_api.rs @@ -12,17 +12,6 @@ use crate::core::db::ResourceRecord; pub fn identity_routes() -> Router { Router::new() - .route("/api/v1/people", get(list_people)) - .route("/api/v1/people/search", post(search_people)) - .route("/api/v1/people/candidates", get(list_candidates)) - .route( - "/api/v1/people/:identity_id/confirm-candidate", - post(confirm_candidate), - ) - .route( - "/api/v1/people/:identity_id/reject-candidate", - post(reject_candidate), - ) .route("/api/v1/files", get(list_files)) .route("/api/v1/files/:uuid", get(get_file_detail)) .route("/api/v1/files/:uuid/identities", get(get_file_identities)) @@ -34,213 +23,6 @@ pub fn identity_routes() -> Router { .route("/api/v1/resources", get(list_resources)) } -// ... (Keep existing functions) ... - -// --- People / Identity Endpoints --- - -#[derive(Debug, Deserialize)] -pub struct PeopleQuery { - page: Option, - page_size: Option, -} - -#[derive(Debug, Serialize)] -pub struct PeopleResponse { - pub success: bool, - pub total: i64, - pub page: usize, - pub page_size: usize, - pub data: Vec, -} - -#[derive(Debug, Serialize)] -pub struct PeopleItem { - pub identity_id: Uuid, - pub name: String, - pub metadata: serde_json::Value, - pub created_at: Option>, -} - -async fn list_people( - State(state): State, - Query(params): Query, -) -> Result, (StatusCode, String)> { - let page = params.page.unwrap_or(1); - let page_size = params.page_size.unwrap_or(20); - let offset = ((page - 1) as i64) * (page_size as i64); - - let records = state - .db - .list_people(page_size as i32, offset) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - // TODO: Get total count - let total = 100; // Placeholder - - let data = records - .into_iter() - .map(|r| PeopleItem { - identity_id: r.uuid, - name: r.name, - metadata: r.metadata, - created_at: r.created_at, - }) - .collect(); - - Ok(Json(PeopleResponse { - success: true, - total, - page, - page_size, - data, - })) -} - -#[derive(Debug, Deserialize)] -pub struct SearchPeopleRequest { - pub query: String, - pub page: Option, - pub page_size: Option, -} - -async fn search_people( - State(state): State, - Json(req): Json, -) -> Result, (StatusCode, String)> { - let page = req.page.unwrap_or(1); - let page_size = req.page_size.unwrap_or(20); - let offset = ((page - 1) as i64) * (page_size as i64); - - let records = state - .db - .search_people(&req.query, page_size as i32, offset) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let data: Vec = records - .into_iter() - .map(|r| PeopleItem { - identity_id: r.uuid, - name: r.name, - metadata: r.metadata, - created_at: r.created_at, - }) - .collect(); - - Ok(Json(PeopleResponse { - success: true, - total: data.len() as i64, // Approximation - page, - page_size, - data, - })) -} - -#[derive(Debug, Deserialize)] -pub struct CandidatesQuery { - page: Option, - page_size: Option, -} - -#[derive(Debug, Serialize)] -pub struct CandidatesResponse { - pub success: bool, - pub total: i64, - pub page: usize, - pub page_size: usize, - pub data: Vec, -} - -#[derive(Debug, Serialize)] -pub struct CandidateItem { - pub pre_chunk_id: i64, - pub file_uuid: Uuid, - pub data: serde_json::Value, -} - -async fn list_candidates( - State(state): State, - Query(params): Query, -) -> Result, (StatusCode, String)> { - let page = params.page.unwrap_or(1); - let page_size = params.page_size.unwrap_or(20); - let offset = ((page - 1) as i64) * (page_size as i64); - - let records = state - .db - .get_people_candidates(page_size as i32, offset) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let data = records - .into_iter() - .map(|r| CandidateItem { - pre_chunk_id: r.id, - file_uuid: r.file_uuid, - data: r.data, - }) - .collect(); - - Ok(Json(CandidatesResponse { - success: true, - total: 0, // TODO - page, - page_size, - data, - })) -} - -// --- Candidate Workflow Endpoints --- - -#[derive(Debug, Deserialize)] -pub struct ConfirmCandidateRequest { - pub pre_chunk_id: i64, -} - -#[derive(Debug, Serialize)] -pub struct ConfirmCandidateResponse { - pub success: bool, - pub message: String, -} - -async fn confirm_candidate( - State(state): State, - Path(identity_id_str): Path, - Json(req): Json, -) -> Result, (StatusCode, String)> { - let identity_id = Uuid::parse_str(&identity_id_str) - .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {}", e)))?; - - state - .db - .confirm_candidate(req.pre_chunk_id, identity_id) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - Ok(Json(ConfirmCandidateResponse { - success: true, - message: "Candidate confirmed and linked to identity".to_string(), - })) -} - -async fn reject_candidate( - State(state): State, - Path(_identity_id_str): Path, // Unused, but consistent with route - Json(req): Json, -) -> Result, (StatusCode, String)> { - state - .db - .reject_candidate(req.pre_chunk_id) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - Ok(Json(ConfirmCandidateResponse { - success: true, - message: "Candidate rejected".to_string(), - })) -} - // --- Files Endpoints --- #[derive(Debug, Deserialize)] diff --git a/src/api/server.rs b/src/api/server.rs index cbe5cb5..8b86ac8 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -154,10 +154,6 @@ struct CacheToggleResponse { } // Missing structs added -#[derive(Debug, Deserialize)] -struct RegisterRequest { - path: String, -} #[derive(Debug, Deserialize)] struct RegisterFileRequest { @@ -182,48 +178,6 @@ struct RegisterFileResponse { message: String, } -#[derive(Debug, Serialize)] -struct RegisterResponse { - file_uuid: String, - file_id: i64, - job_id: i32, - file_name: String, - duration: f64, - width: u32, - height: u32, - already_exists: bool, -} - -#[derive(Debug, Deserialize)] -struct ProbeRequest { - path: String, -} - -#[derive(Debug, Serialize)] -struct ProbeResponse { - uuid: String, - file_name: String, - duration: f64, - width: u32, - height: u32, - fps: f64, - cached: bool, - format: crate::core::probe::FormatInfo, - streams: Vec, -} - -#[derive(Debug, Deserialize)] -struct UnregisterRequest { - uuid: String, -} - -#[derive(Debug, Serialize)] -struct UnregisterResponse { - success: bool, - uuid: String, - message: String, -} - #[derive(Debug, Serialize, Deserialize)] struct SearchResult { uuid: String, @@ -241,31 +195,17 @@ struct SearchResponse { query: String, } -#[derive(Debug, Serialize, Deserialize)] -struct N8nSearchHit { - id: String, - vid: String, - start_frame: i64, - end_frame: i64, +#[derive(Debug, Serialize)] +struct ProbeResponse { + uuid: String, + file_name: String, + duration: f64, + width: u32, + height: u32, fps: f64, - start: f64, - end: f64, - title: String, - text: String, - score: f32, - #[serde(skip_serializing_if = "Option::is_none")] - file_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - has_visual_stats: Option, - #[serde(skip_serializing_if = "Option::is_none")] - parent_id: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct N8nSearchResponse { - query: String, - count: usize, - hits: Vec, + cached: bool, + format: crate::core::probe::FormatInfo, + streams: Vec, } // --- P0 API Structs --- @@ -645,267 +585,6 @@ fn generate_visual_search_hash( format!("{:x}", hasher.finalize())[..16].to_string() } -async fn register( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let path = req.path; - - // Support both relative and absolute paths - // Relative: ./demo/video.mp4 or demo/video.mp4 - // Absolute: /Users/.../sftpgo/data/demo/video.mp4 - let (relative_path, canonical_path) = if path.starts_with("./") || path.starts_with("../") { - // Relative path - keep as is for UUID, resolve to absolute for storage - let rel = path.clone(); - let abs = std::path::Path::new(&path) - .canonicalize() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| path.clone()); - (rel, abs) - } else if std::path::Path::new(&path).is_absolute() { - // Absolute path - use as is - (path.clone(), path.clone()) - } else { - // Assume relative path without ./ - let rel = format!("./{}", path); - let abs = std::path::Path::new(&rel) - .canonicalize() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| path.clone()); - (rel, abs) - }; - - // Compute UUID from relative path (username/filepath) - // Extract: ./demo/video.mp4 -> username="demo", filepath="video.mp4" - let uuid = crate::core::storage::uuid::compute_uuid_from_relative_path(&relative_path); - - // Extract username and filepath for logging - let (username, filepath) = - crate::core::storage::uuid::extract_user_from_relative_path(&relative_path); - tracing::info!( - "Registering video: uuid={}, username={}, filepath={}, canonical={}", - uuid, - username, - filepath, - canonical_path - ); - - let db = PostgresDb::init() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - // Check if video already exists - if let Some(existing_video) = db - .get_video_by_uuid(&uuid) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - { - tracing::info!( - "Video already registered: uuid={}, video_id={}", - uuid, - existing_video.id - ); - - // Get the job_id if exists (i64 -> i32) - let job_id: i32 = existing_video.job_id.map(|id| id as i32).unwrap_or(0); - - return Ok(Json(RegisterResponse { - file_uuid: existing_video.file_uuid, - file_id: existing_video.id, - job_id, - file_name: existing_video.file_name, - duration: existing_video.duration, - width: existing_video.width, - height: existing_video.height, - already_exists: true, - })); - } - - let probe_result = { - let probe_path = format!( - "{}/{}.probe.json", - crate::core::config::OUTPUT_DIR.as_str(), - uuid - ); - - if let Ok(content) = std::fs::read_to_string(&probe_path) { - tracing::info!("Using existing probe.json: {}", probe_path); - serde_json::from_str(&content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - } else { - tracing::info!("Running ffprobe for: {}", canonical_path); - crate::core::probe::probe_video(&canonical_path) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - } - }; - - let duration = probe_result - .format - .duration - .as_ref() - .and_then(|s: &String| s.parse::().ok()) - .unwrap_or(0.0); - - let mut width = 0u32; - let mut height = 0u32; - - let mut fps = 0.0; - for stream in &probe_result.streams { - if stream.codec_type.as_deref() == Some("video") { - width = stream.width.unwrap_or(0); - height = stream.height.unwrap_or(0); - - // Parse FPS from r_frame_rate (e.g., "60000/1001") - if let Some(frame_rate_str) = &stream.r_frame_rate { - if let Some((num_str, den_str)) = frame_rate_str.split_once('/') { - if let (Ok(num), Ok(den)) = (num_str.parse::(), den_str.parse::()) { - if den != 0.0 { - fps = num / den; - } - } - } - } - } - } - - let file_manager = FileManager::new(std::path::PathBuf::from( - crate::core::config::OUTPUT_DIR.as_str(), - )); - let json_str = - serde_json::to_string(&probe_result).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let _json_path = file_manager - .save_json(&uuid, "probe", &json_str) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let file_name = std::path::Path::new(&canonical_path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - - // Calculate total_frames: prefer nb_frames (exact) over duration * fps (approximate) - let total_frames = { - let video_stream = probe_result - .streams - .iter() - .find(|s| s.codec_type.as_deref() == Some("video")); - - if let Some(stream) = video_stream { - if let Some(nb_frames_str) = &stream.nb_frames { - if let Ok(nb_frames) = nb_frames_str.parse::() { - tracing::info!( - "Using nb_frames from ffprobe: {} frames for {}", - nb_frames, - file_name - ); - Some(nb_frames) - } else { - tracing::warn!( - "Failed to parse nb_frames, using duration * fps fallback for {}", - file_name - ); - Some((duration * fps).floor() as u64) - } - } else { - tracing::warn!( - "nb_frames not available, using duration * fps fallback for {}", - file_name - ); - Some((duration * fps).floor() as u64) - } - } else { - tracing::warn!("No video stream found for {}", file_name); - Some(0) - } - }; - - // Determine file type - let file_type = Some(detect_file_type( - std::path::Path::new(&canonical_path), - Some(&probe_result), - )); - - let probe_json_str = - serde_json::to_string(&probe_result).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let file_manager = FileManager::new(std::path::PathBuf::from( - crate::core::config::OUTPUT_DIR.as_str(), - )); - - if let Err(e) = file_manager.save_json(&uuid, "probe", &probe_json_str) { - tracing::warn!("[REGISTER] Failed to save probe JSON: {}", e); - } - - let record = VideoRecord { - id: 0, - file_uuid: uuid.clone(), - file_path: canonical_path.clone(), - file_name: file_name.clone(), - file_type, - duration, - width, - height, - fps, - probe_json: Some(probe_json_str), - storage: Default::default(), - status: VideoStatus::Pending, - processing_status: Some(serde_json::json!({"phase": "REGISTERED"})), - birth_registration: None, - user_id: None, - job_id: None, - created_at: String::new(), - registration_time: None, - total_frames: total_frames.unwrap_or(0), - parent_uuid: None, - }; - - let video_id = db.register_video(&record).await.map_err(|e| { - tracing::error!("[REGISTER] Failed to register video: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let job = db - .create_monitor_job(&uuid, Some(&canonical_path)) - .await - .map_err(|e| { - tracing::error!("Failed to create monitor job for {}: {}", uuid, e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - db.update_video_job_id(video_id, job.id) - .await - .map_err(|e| { - tracing::error!( - "Failed to update video job_id for video {}: {}", - video_id, - e - ); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - db.update_monitor_job_video_id(job.id, video_id) - .await - .map_err(|e| { - tracing::error!( - "Failed to update monitor job video_id for job {}: {}", - job.id, - e - ); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let _ = state.mongo_cache.invalidate_videos_list().await; - - Ok(Json(RegisterResponse { - file_uuid: uuid, - file_id: video_id, - job_id: job.id, - file_name, - duration, - width, - height, - already_exists: false, - })) -} - async fn register_file( State(state): State, Json(req): Json, @@ -1111,7 +790,7 @@ async fn register_file( width, height, fps, - probe_json: Some(probe_json_str), + probe_json: serde_json::from_str(&probe_json_str).ok(), storage: Default::default(), status: VideoStatus::Pending, processing_status: Some(serde_json::json!({"phase": "REGISTERED"})), @@ -1170,140 +849,6 @@ async fn register_file( })) } -async fn probe( - State(_state): State, - Json(req): Json, -) -> Result, StatusCode> { - let path = req.path; - - // Support both relative and absolute paths - let (relative_path, canonical_path) = if path.starts_with("./") || path.starts_with("../") { - let rel = path.clone(); - let abs = std::path::Path::new(&path) - .canonicalize() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| path.clone()); - (rel, abs) - } else if std::path::Path::new(&path).is_absolute() { - (path.clone(), path.clone()) - } else { - let rel = format!("./{}", path); - let abs = std::path::Path::new(&rel) - .canonicalize() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| path.clone()); - (rel, abs) - }; - - // Compute UUID from relative path - let uuid = crate::core::storage::uuid::compute_uuid_from_relative_path(&relative_path); - - let (username, filepath) = - crate::core::storage::uuid::extract_user_from_relative_path(&relative_path); - tracing::info!( - "Probing video: uuid={}, username={}, filepath={}, canonical={}", - uuid, - username, - filepath, - canonical_path - ); - - // Check for cached probe.json - let probe_path = format!( - "{}/{}.probe.json", - crate::core::config::OUTPUT_DIR.as_str(), - uuid - ); - - let (probe_result, cached) = if let Ok(content) = std::fs::read_to_string(&probe_path) { - tracing::info!("Using cached probe.json: {}", probe_path); - let result: crate::core::probe::ProbeResult = - serde_json::from_str(&content).map_err(|e| { - tracing::error!("Failed to parse cached probe.json: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - (result, true) - } else { - tracing::info!("Running ffprobe for: {}", canonical_path); - let result = crate::core::probe::probe_video(&canonical_path).map_err(|e| { - tracing::error!("ffprobe failed: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - // Save probe.json to OUTPUT_DIR - let file_manager = FileManager::new(std::path::PathBuf::from( - crate::core::config::OUTPUT_DIR.as_str(), - )); - let json_str = serde_json::to_string(&result).map_err(|e| { - tracing::error!("Failed to serialize probe result: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - file_manager - .save_json(&uuid, "probe", &json_str) - .map_err(|e| { - tracing::error!("Failed to save probe.json: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - (result, false) - }; - - // Extract video info - let duration = probe_result - .format - .duration - .as_ref() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - - let mut width = 0u32; - let mut height = 0u32; - let mut fps = 0.0; - - for stream in &probe_result.streams { - if stream.codec_type.as_deref() == Some("video") { - width = stream.width.unwrap_or(0); - height = stream.height.unwrap_or(0); - // Parse fps from r_frame_rate (e.g., "30/1" or "29.97") - if let Some(fps_str) = &stream.r_frame_rate { - fps = if fps_str.contains('/') { - let parts: Vec<&str> = fps_str.split('/').collect(); - if parts.len() == 2 { - let num: f64 = parts[0].parse().unwrap_or(0.0); - let den: f64 = parts[1].parse().unwrap_or(1.0); - if den > 0.0 { - num / den - } else { - 0.0 - } - } else { - 0.0 - } - } else { - fps_str.parse().unwrap_or(0.0) - }; - } - } - } - - let file_name = std::path::Path::new(&canonical_path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - - Ok(Json(ProbeResponse { - uuid, - file_name, - duration, - width, - height, - fps, - cached, - format: probe_result.format, - streams: probe_result.streams, - })) -} - async fn probe_by_uuid( State(state): State, Path(uuid): Path, @@ -1813,91 +1358,6 @@ async fn search( Ok(Json(response)) } -async fn n8n_search( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let limit = req.limit.unwrap_or(10); - let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit); - let cache_key = keys::n8n_search(&query_hash); - let ttl = state.mongo_cache.ttl_search(); - - let response = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_N8N_SEARCH, || async { - let query_vector = state - .embedder - .embed_query(&req.query) - .await - .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?; - - let qdrant = QdrantDb::new(); - let pg = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - let search_results = if let Some(ref uuid) = req.uuid { - qdrant.search_in_uuid(&query_vector, uuid, limit).await? - } else { - qdrant.search(&query_vector, limit).await? - }; - - let mut hits = Vec::new(); - - for r in search_results { - if let Some(chunk) = pg - .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid) - .await - .ok() - .flatten() - { - let text = extract_text_from_content(&chunk.content); - - let title = extract_title_from_content(&chunk.content); - - let file_path = if chunk.uuid.is_empty() { - None - } else { - let video = pg.get_video_by_uuid(&chunk.uuid).await.ok().flatten(); - video.map(|v| v.file_path) - }; - - hits.push(N8nSearchHit { - id: chunk.chunk_id.clone(), - vid: chunk.uuid.clone(), - start_frame: chunk.start_frame, - end_frame: chunk.end_frame, - fps: chunk.fps, - start: chunk.start_time().seconds(), - end: chunk.end_time().seconds(), - title: if title.is_empty() { - format!("Chunk {}", chunk.chunk_id) - } else { - title - }, - text, - score: r.score, - file_path, - has_visual_stats: chunk.visual_stats.as_ref().map(|v| { - !v.is_null() && v.as_object().map_or(false, |o| !o.is_empty()) - }), - parent_id: chunk.parent_chunk_id.clone(), - }); - } - } - - Ok::(N8nSearchResponse { - query: req.query.clone(), - count: hits.len(), - hits, - }) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(response)) -} - async fn search_bm25( State(state): State, Json(req): Json, @@ -1990,251 +1450,6 @@ async fn search_smart( Ok(Json(response)) } -async fn n8n_search_bm25( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let limit = req.limit.unwrap_or(10); - let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit); - let cache_key = keys::n8n_bm25_search(&query_hash); - let ttl = state.mongo_cache.ttl_search(); - - let response = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_N8N_SEARCH, || async { - let pg = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - let bm25_results = pg - .search_bm25(&req.query, req.uuid.as_deref(), limit) - .await?; - - let mut hits = Vec::new(); - - for r in bm25_results { - if let Some(chunk) = pg - .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid) - .await - .ok() - .flatten() - { - let text = r.text; // Use text from BM25 result - let title = extract_title_from_content(&chunk.content); - - let file_path = if chunk.uuid.is_empty() { - None - } else { - let video = pg.get_video_by_uuid(&chunk.uuid).await.ok().flatten(); - video.map(|v| v.file_path) - }; - - hits.push(N8nSearchHit { - id: chunk.chunk_id.clone(), - vid: chunk.uuid.clone(), - start_frame: chunk.start_frame, - end_frame: chunk.end_frame, - fps: chunk.fps, - start: chunk.start_time().seconds(), - end: chunk.end_time().seconds(), - title: if title.is_empty() { - format!("Chunk {}", chunk.chunk_id) - } else { - title - }, - text, - score: r.bm25_score, - file_path, - has_visual_stats: chunk.visual_stats.as_ref().map(|v| { - !v.is_null() && v.as_object().map_or(false, |o| !o.is_empty()) - }), - parent_id: chunk.parent_chunk_id.clone(), - }); - } - } - - Ok::(N8nSearchResponse { - query: req.query.clone(), - count: hits.len(), - hits, - }) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(response)) -} - -async fn n8n_search_hybrid( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let limit = req.limit.unwrap_or(10); - let vector_weight = req.vector_weight.unwrap_or(0.7); - let bm25_weight = req.bm25_weight.unwrap_or(0.3); - - let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit); - let cache_key = keys::hybrid_search(&query_hash); - let ttl = state.mongo_cache.ttl_hybrid_search(); - - let response = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_HYBRID_SEARCH, || async { - let query_vector = state - .embedder - .embed_query(&req.query) - .await - .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?; - - let pg = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - let results = pg - .hybrid_search( - &req.query, - &query_vector, - req.uuid.as_deref(), - limit, - vector_weight, - bm25_weight, - ) - .await?; - - let mut hits = Vec::new(); - - for r in results { - if let Some(chunk) = pg - .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid) - .await - .ok() - .flatten() - { - let text = r.text; - let title = extract_title_from_content(&chunk.content); - - let file_path = if chunk.uuid.is_empty() { - None - } else { - let video = pg.get_video_by_uuid(&chunk.uuid).await.ok().flatten(); - video.map(|v| v.file_path) - }; - - hits.push(N8nSearchHit { - id: chunk.chunk_id.clone(), - vid: chunk.uuid.clone(), - start_frame: chunk.start_frame, - end_frame: chunk.end_frame, - fps: chunk.fps, - start: chunk.start_time().seconds(), - end: chunk.end_time().seconds(), - title: if title.is_empty() { - format!("Chunk {}", chunk.chunk_id) - } else { - title - }, - text, - score: r.combined_score as f32, - file_path, - has_visual_stats: chunk.visual_stats.as_ref().map(|v| { - !v.is_null() && v.as_object().map_or(false, |o| !o.is_empty()) - }), - parent_id: chunk.parent_chunk_id.clone(), - }); - } - } - - Ok::(N8nSearchResponse { - query: req.query.clone(), - count: hits.len(), - hits, - }) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(response)) -} - -async fn n8n_search_smart( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - let limit = req.limit.unwrap_or(10); - let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit); - let cache_key = keys::search(&format!("{}smart", query_hash)); - let ttl = state.mongo_cache.ttl_search(); - - let response = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_SEARCH, || async { - let pg = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - let keywords = vec![req.query.clone()]; - let search_terms = keywords.join(" "); - let file_uuid = req.uuid.clone(); - - let bm25_results = pg - .search_bm25(&search_terms, file_uuid.as_deref(), limit) - .await?; - - let mut hits = Vec::new(); - - for r in bm25_results { - if let Some(chunk) = pg - .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid) - .await - .ok() - .flatten() - { - let text = r.text; - let title = extract_title_from_content(&chunk.content); - - let file_path = if chunk.uuid.is_empty() { - None - } else { - let video = pg.get_video_by_uuid(&chunk.uuid).await.ok().flatten(); - video.map(|v| v.file_path) - }; - - hits.push(N8nSearchHit { - id: chunk.chunk_id.clone(), - vid: chunk.uuid.clone(), - start_frame: chunk.start_frame, - end_frame: chunk.end_frame, - fps: chunk.fps, - start: chunk.start_time().seconds(), - end: chunk.end_time().seconds(), - title: if title.is_empty() { - format!("Chunk {}", chunk.chunk_id) - } else { - title - }, - text, - score: r.bm25_score, - file_path, - has_visual_stats: chunk.visual_stats.as_ref().map(|v| { - !v.is_null() && v.as_object().map_or(false, |o| !o.is_empty()) - }), - parent_id: chunk.parent_chunk_id.clone(), - }); - } - } - - Ok::(N8nSearchResponse { - query: req.query.clone(), - count: hits.len(), - hits, - }) - }) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - Ok(Json(response)) -} - async fn hybrid_search( State(state): State, Json(req): Json, @@ -2495,137 +1710,6 @@ async fn scan_files(State(state): State) -> Result, - Query(params): Query, -) -> Result, StatusCode> { - let page = params.page.unwrap_or(1); - let page_size = params.page_size.unwrap_or(20); - let offset = ((page - 1) as i64) * (page_size as i64); - let status_filter = params.status.clone(); - let query_filter = params.q.clone(); - - // Include query and status in cache key - let cache_key = keys::videos_list(page, page_size); - let cache_key = if let Some(ref q) = query_filter { - format!("{}:q:{}", cache_key, q) - } else { - cache_key - }; - let ttl = state.mongo_cache.ttl_videos(); - - // If uuid is provided, fetch single video directly - if let Some(ref uuid) = params.uuid { - let db = PostgresDb::init() - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let video = db - .get_video_by_uuid(uuid) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if let Some(v) = video { - return Ok(Json(FilesResponse { - files: vec![FileInfoResponse { - file_uuid: v.file_uuid, - file_path: v.file_path, - file_name: v.file_name, - file_type: v.file_type, - duration: v.duration, - width: v.width, - height: v.height, - status: v.status.as_str().to_string(), - processing_status: v.processing_status, - birth_registration: v.birth_registration, - created_at: Some(v.created_at), - registration_time: v.registration_time, - file_size: None, - probe_json: v.probe_json, - total_frames: v.total_frames, - }], - count: 1, - page, - page_size, - })); - } else { - return Err(StatusCode::NOT_FOUND); - } - } - - tracing::info!( - "list_files called: page={}, page_size={}, cache_key={}", - page, - page_size, - cache_key - ); - - let file_infos = state - .mongo_cache - .get_or_fetch(&cache_key, ttl, keys::CATEGORY_VIDEOS, || async { - tracing::info!("Fetching videos from database..."); - let db = PostgresDb::init() - .await - .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?; - - // Map status parameter to is_processed filter - let is_processed = match status_filter.as_deref() { - Some("pending") | Some("unprocessed") => Some(false), - Some("completed") | Some("ready") | Some("processed") => Some(true), - _ => None, // no filter - }; - - // Search by query if provided - let (files, count) = if let Some(ref q) = query_filter { - db.search_videos(Some(q.as_str()), is_processed, page_size as i32, offset) - .await? - } else if let Some(processed) = is_processed { - db.search_videos(None, Some(processed), page_size as i32, offset) - .await? - } else { - db.list_videos(page_size as i32, offset).await? - }; - tracing::info!("Got {} files from DB", files.len()); - - let file_infos: Vec = files - .into_iter() - .map(|v| FileInfoResponse { - file_uuid: v.file_uuid, - file_path: v.file_path, - file_name: v.file_name, - file_type: v.file_type, - duration: v.duration, - width: v.width, - height: v.height, - status: v.status.as_str().to_string(), - processing_status: v.processing_status, - birth_registration: v.birth_registration, - created_at: Some(v.created_at), - registration_time: v.registration_time, - file_size: None, - probe_json: v.probe_json, - total_frames: v.total_frames, - }) - .collect(); - - tracing::info!("Mapped to {} file infos", file_infos.len()); - - Ok::(FilesResponse { - files: file_infos, - count, - page, - page_size, - }) - }) - .await - .map_err(|e| { - tracing::error!("Error in list_files: {}", e); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(file_infos)) -} - #[derive(Debug, Serialize)] struct ProgressResponse { uuid: String, @@ -2949,34 +2033,6 @@ async fn cache_toggle( Ok(Json(response)) } -async fn unregister( - State(state): State, - Json(req): Json, -) -> Result, StatusCode> { - tracing::info!("[unregister] Unregistering video: {}", req.uuid); - - let db = &state.api_state.db; - - match db.delete_video(&req.uuid).await { - Ok(_) => { - tracing::info!("[unregister] SUCCESS - deleted: {}", req.uuid); - Ok(Json(UnregisterResponse { - success: true, - uuid: req.uuid, - message: "Video unregistered successfully".to_string(), - })) - } - Err(e) => { - tracing::error!("[unregister] ERROR - {}", e); - Ok(Json(UnregisterResponse { - success: false, - uuid: req.uuid, - message: format!("Failed to unregister: {}", e), - })) - } - } -} - pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { let _ = SERVER_START.set(Instant::now()); @@ -2997,11 +2053,8 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { }; let protected_routes = Router::new() - .route("/api/v1/register", post(register)) .route("/api/v1/files/register", post(register_file)) .route("/api/v1/files/scan", get(scan_files)) - .route("/api/v1/unregister", post(unregister)) - .route("/api/v1/probe", post(probe)) .route("/api/v1/assets/:uuid/probe", get(probe_by_uuid)) .route("/api/v1/assets/:uuid/process", post(trigger_processing)) .route("/api/v1/assets/:uuid/status", get(get_asset_status)) @@ -3009,13 +2062,8 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> { .route("/api/v1/rules/:rule/status", get(get_rule_status)) .route("/api/v1/search/hybrid", post(hybrid_search)) .route("/api/v1/search", post(search)) - .route("/api/v1/n8n/search", post(n8n_search)) .route("/api/v1/search/bm25", post(search_bm25)) - .route("/api/v1/n8n/search/bm25", post(n8n_search_bm25)) - .route("/api/v1/n8n/search/hybrid", post(n8n_search_hybrid)) - .route("/api/v1/n8n/search/smart", post(n8n_search_smart)) .route("/api/v1/lookup", get(lookup)) - .route("/api/v1/videos", get(list_files)) .route("/api/v1/videos/:uuid", delete(delete_video)) .route("/api/v1/videos/:uuid/details", get(video_details)) .route("/api/v1/videos/:uuid/pre_chunks", get(list_pre_chunks)) diff --git a/src/core/db/postgres_db.rs b/src/core/db/postgres_db.rs index 0275b97..30c679d 100644 --- a/src/core/db/postgres_db.rs +++ b/src/core/db/postgres_db.rs @@ -209,7 +209,7 @@ impl From for VideoRecord { width: row.width.unwrap_or(0) as u32, height: row.height.unwrap_or(0) as u32, fps: row.fps.unwrap_or(0.0), - probe_json: row.probe_json.map(|v| v.to_string()), + probe_json: row.probe_json, storage: StorageStatus { fs_video: row.fs_video.unwrap_or(false), fs_json: row.fs_json.unwrap_or(false), @@ -243,7 +243,7 @@ pub struct VideoRecord { pub width: u32, pub height: u32, pub fps: f64, - pub probe_json: Option, + pub probe_json: Option, pub storage: StorageStatus, pub status: VideoStatus, pub processing_status: Option, @@ -695,7 +695,7 @@ impl PostgresDb { } // Videos - sqlx::query("CREATE TABLE IF NOT EXISTS videos (id SERIAL PRIMARY KEY, file_uuid VARCHAR(32) UNIQUE NOT NULL, file_path TEXT NOT NULL, file_name TEXT NOT NULL, duration DOUBLE PRECISION, width INTEGER, height INTEGER, fps DOUBLE PRECISION, probe_json TEXT, fs_video BOOLEAN DEFAULT FALSE, fs_json BOOLEAN DEFAULT FALSE, psql_chunk BOOLEAN DEFAULT FALSE, pobject_chunk BOOLEAN DEFAULT FALSE, mobject_chunk BOOLEAN DEFAULT FALSE, pvector_chunk BOOLEAN DEFAULT FALSE, qvector_chunk BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)").execute(pool).await?; + sqlx::query("CREATE TABLE IF NOT EXISTS videos (id SERIAL PRIMARY KEY, file_uuid VARCHAR(32) UNIQUE NOT NULL, file_path TEXT NOT NULL, file_name TEXT NOT NULL, duration DOUBLE PRECISION, width INTEGER, height INTEGER, fps DOUBLE PRECISION, probe_json jsonb, fs_video BOOLEAN DEFAULT FALSE, fs_json BOOLEAN DEFAULT FALSE, psql_chunk BOOLEAN DEFAULT FALSE, pobject_chunk BOOLEAN DEFAULT FALSE, mobject_chunk BOOLEAN DEFAULT FALSE, pvector_chunk BOOLEAN DEFAULT FALSE, qvector_chunk BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)").execute(pool).await?; sqlx::query("CREATE INDEX IF NOT EXISTS idx_videos_file_uuid ON videos(file_uuid)") .execute(pool) .await?; diff --git a/src/core/ingestion.rs b/src/core/ingestion.rs index 3116d82..8c930ad 100644 --- a/src/core/ingestion.rs +++ b/src/core/ingestion.rs @@ -171,7 +171,7 @@ impl IngestionService { width, height, fps, - probe_json: Some(probe_json_str), + probe_json: serde_json::from_str(&probe_json_str).ok(), storage: Default::default(), status: VideoStatus::Pending, processing_status: Some(serde_json::json!({"phase": "REGISTERED"})), diff --git a/src/playground.rs b/src/playground.rs index 5d61ec1..df5b24d 100644 --- a/src/playground.rs +++ b/src/playground.rs @@ -967,7 +967,7 @@ async fn main() -> Result<()> { width, height, fps, - probe_json: Some(json_str), + probe_json: serde_json::from_str(&json_str).ok(), storage: Default::default(), status: VideoStatus::Pending, processing_status: Some(serde_json::json!({"phase": "REGISTERED"})),