diff --git a/.env b/.env new file mode 100644 index 0000000..589609d --- /dev/null +++ b/.env @@ -0,0 +1,39 @@ +# 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 (not installed, comment out for now) +# QDRANT_URL=http://localhost:6333 +# QDRANT_COLLECTION=momentry_chunks + +# 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 diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..47cea38 --- /dev/null +++ b/.env.development @@ -0,0 +1,55 @@ +# Development Environment Configuration +# Used by: momentry_playground binary +# +# This file is loaded BEFORE the main .env file +# Settings here override defaults but can be overridden by CLI flags + +# Server Configuration +MOMENTRY_SERVER_PORT=3003 +MOMENTRY_REDIS_PREFIX=momentry_dev: + +# Worker Configuration (disabled by default for development) +MOMENTRY_WORKER_ENABLED=false +MOMENTRY_MAX_CONCURRENT=1 +MOMENTRY_POLL_INTERVAL=10 +MOMENTRY_WORKER_BATCH_SIZE=5 + +# Database (same as production, but could use separate dev database) +DATABASE_URL=postgres://accusys@localhost:5432/momentry + +# MongoDB +MONGODB_URL=mongodb://accusys:Test3200Test3200@localhost:27017/admin +MONGODB_DATABASE=momentry + +# Redis +REDIS_URL=redis://:accusys@localhost:6379 +REDIS_PASSWORD=accusys + +# Paths +MOMENTRY_OUTPUT_DIR=/Users/accusys/momentry/output_dev +MOMENTRY_BACKUP_DIR=/Users/accusys/momentry/backup/momentry_dev + +# Python (for processing scripts) +MOMENTRY_PYTHON_PATH=/opt/homebrew/bin/python3.11 +MOMENTRY_SCRIPTS_DIR=/Users/accusys/momentry_core_0.1/scripts + +# Logging +RUST_LOG=debug +MOMENTRY_LOG_LEVEL=debug + +# Media +MOMENTRY_MEDIA_BASE_URL=https://wp.momentry.ddns.net + +# Processor Timeouts +MOMENTRY_ASR_TIMEOUT=3600 +MOMENTRY_CUT_TIMEOUT=3600 +MOMENTRY_DEFAULT_TIMEOUT=7200 + +# Cache Settings +MONGODB_CACHE_ENABLED=true +MONGODB_CACHE_TTL_VIDEOS=300 +MONGODB_CACHE_TTL_SEARCH=300 +MONGODB_CACHE_TTL_HYBRID_SEARCH=600 +MONGODB_CACHE_TTL_VIDEO_META=3600 +REDIS_CACHE_TTL_HEALTH=30 +REDIS_CACHE_TTL_VIDEO_META=3600 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fc59b95 --- /dev/null +++ b/.env.example @@ -0,0 +1,70 @@ +# Momentry Core Configuration Template +# Copy this file to .env and customize for your environment +# DO NOT commit .env with real credentials to version control + +# =========================================== +# Database Configuration +# =========================================== +DATABASE_URL=postgres://user:password@localhost:5432/momentry + +# =========================================== +# Redis Configuration +# =========================================== +REDIS_URL=redis://user:password@localhost:6379 +REDIS_PASSWORD=your_redis_password + +# =========================================== +# MongoDB Configuration +# =========================================== +MONGODB_URL=mongodb://user:password@localhost:27017/admin +MONGODB_DATABASE=momentry + +# =========================================== +# Qdrant Configuration +# =========================================== +QDRANT_URL=http://localhost:6333 +QDRANT_API_KEY=your_qdrant_api_key +QDRANT_COLLECTION=chunks_v3 + +# =========================================== +# API Server Configuration +# =========================================== +API_HOST=127.0.0.1 +API_PORT=3000 + +# =========================================== +# Directory Paths +# =========================================== +MOMENTRY_OUTPUT_DIR=/path/to/output +MOMENTRY_BACKUP_DIR=/path/to/backup +MOMENTRY_SCRIPTS_DIR=/path/to/momentry_core/scripts +MOMENTRY_PYTHON_PATH=/opt/homebrew/bin/python3.11 + +# =========================================== +# Processor Timeouts (seconds) +# =========================================== +MOMENTRY_ASR_TIMEOUT=3600 +MOMENTRY_CUT_TIMEOUT=3600 +MOMENTRY_DEFAULT_TIMEOUT=7200 + +# =========================================== +# Watch Directories (comma separated) +# =========================================== +WATCH_DIRECTORIES=~/Videos,~/Downloads + +# =========================================== +# Logging +# =========================================== +RUST_LOG=info +# Options: trace, debug, info, warn, error + +# =========================================== +# Ollama (for LLM integration) +# =========================================== +OLLAMA_HOST=http://localhost:11434 + +# =========================================== +# Model Paths +# =========================================== +# EMBEDDING_MODEL_PATH=./models/embedding +# LLM_MODEL_PATH=./models/llm diff --git a/.gitignore b/.gitignore index 2f8823c..567d49a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,40 @@ +# Environment - Local configs (NEVER commit these) .env .env.local +.env.*.local + +# Build artifacts target/ venv/ + +# Generated files thumbnails/ *.asr.json *.probe.json test_asr.json -.DS_Store -*.log + +# Local output (machine learning results) +output/ +*.pt + +# Cache .ruff_cache/ + +# OS files +.DS_Store +.Spotlight-V100 +.Trashes + +# Logs +*.log + +# SSH keys (NEVER commit) +id_* +!id_*.pub + +# IDE and editor +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/AGENTS.md b/AGENTS.md index 9749602..fb61024 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,13 +9,29 @@ Rust-based digital asset management system with video analysis and RAG capabilit cargo build cargo build --release cargo build --bin momentry +cargo build --bin momentry_playground + +# Build all binaries +cargo build --bins # Run CLI cargo run -- --help cargo run -- register /path/to/video.mp4 -cargo run -- server --host 0.0.0.0 --port 3000 +cargo run -- server --host 0.0.0.0 --port 3002 + +# Run playground (development binary) +cargo run --bin momentry_playground -- server +cargo run --bin momentry_playground -- --help ``` +## Binaries + +| Binary | Purpose | Port | Redis Prefix | Environment | +|--------|---------|------|--------------|-------------| +| `momentry` | Production | 3002 | `momentry:` | `.env` | +| `momentry_playground` | Development | 3003 | `momentry_dev:` | `.env.development` | +| `momentry_player` | Video player | - | - | - | + ## Testing ```bash @@ -133,16 +149,20 @@ src/ ├── main.rs # CLI entry point ├── lib.rs # Library exports ├── core/ +│ ├── api_key/ # API key management (anomaly, blacklist, encryption, etc.) │ ├── chunk/ # Chunking logic +│ ├── config.rs # Centralized configuration (env vars) │ ├── db/ # Database (PostgreSQL, MongoDB, Redis, Qdrant) │ ├── embedding/ # Vector embeddings │ ├── overlay/ # Video overlay │ ├── probe/ # ffprobe integration -│ ├── processor/ # ASR, OCR, YOLO, Face, Pose +│ ├── processor/ # ASR, OCR, YOLO, Face, Pose, CUT, ASRX +│ │ └── executor.rs # Unified Python script executor │ ├── storage/ # File management │ └── thumbnail/ # Thumbnail extraction ├── api/ # HTTP API (axum) ├── player/ # Video player +├── ui/ # TUI components └── watcher/ # File system watcher ``` @@ -151,18 +171,243 @@ src/ - **Error handling**: `anyhow`, `thiserror` - **Async**: `tokio` (full features), `async-trait` - **CLI**: `clap` (derive) -- **Serialization**: `serde`, `serde_json` -- **Database**: `sqlx`, `mongodb`, `redis`, `qdrant-client` +- **Serialization**: `serde`, `serde_json`, `chrono` +- **Database**: `sqlx`, `mongodb`, `redis` (1.0), `qdrant-client` - **HTTP**: `axum`, `tower` - **Logging**: `tracing`, `tracing-subscriber` +- **Config**: `once_cell` (lazy static config) ## Environment Variables +### Server +- `MOMENTRY_SERVER_PORT` - API server port (default: `3002` for production, `3003` for playground) +- `MOMENTRY_REDIS_PREFIX` - Redis key prefix (default: `momentry:` for production, `momentry_dev:` for playground) + +### Database - `DATABASE_URL` - PostgreSQL (default: `postgres://accusys@localhost:5432/momentry`) +### Redis +- `REDIS_URL` - Redis URL (default: `redis://:accusys@localhost:6379`) +- `REDIS_PASSWORD` - Redis password (default: `accusys`) + +### Paths +- `MOMENTRY_OUTPUT_DIR` - Output directory (default: `/Users/accusys/momentry/output`) +- `MOMENTRY_BACKUP_DIR` - Backup directory +- `MOMENTRY_PYTHON_PATH` - Python path (default: `/opt/homebrew/bin/python3.11`) +- `MOMENTRY_SCRIPTS_DIR` - Scripts directory + +### Processor Timeouts +- `MOMENTRY_ASR_TIMEOUT` - ASR timeout in seconds (default: 3600) +- `MOMENTRY_CUT_TIMEOUT` - CUT timeout in seconds (default: 3600) +- `MOMENTRY_DEFAULT_TIMEOUT` - Default timeout (default: 7200) + +### Logging +- `RUST_LOG` or `MOMENTRY_LOG_LEVEL` - Log level (default: `info`) + ## Notes -- No unit tests exist - add tests when implementing features +- Unit tests exist (86 library tests) - Video processing uses external tools (ffprobe, Python scripts) - Multi-database architecture (PostgreSQL, MongoDB, Redis, Qdrant) - Monitor directory is a separate system (not Rust) +- PythonExecutor provides unified script execution with timeout support +- Redis 1.0.x for improved performance + +## Task Management + +### 使用 todowrite 追蹤任務 +```bash +# 創建任務清單 +/todo 建立配置模組 [in_progress] +/todo 添加單元測試 [pending] + +# 更新狀態 +/todo 完成標記 [completed] +``` + +### 任務批次建議 +- 一次處理 1-2 個功能 +- 每個功能完成後驗證 (clippy + test) +- 驗證通過後再繼續下一個 + +## Code Review Checklist + +完成任務後檢查: +- [ ] `cargo clippy --lib` 通過 +- [ ] `cargo test --lib` 通過 +- [ ] `cargo fmt -- --check` 通過 +- [ ] 文檔已更新 (如需要) +- [ ] 新功能有單元測試 + +## Commit Guidelines + +```bash +# feat: 新功能 +git commit -m "feat: add monitor_jobs table" + +# fix: 錯誤修復 +git commit -m "fix: resolve SQL injection in store_vector" + +# refactor: 重構 +git commit -m "refactor: use parameterized queries" + +# docs: 文檔更新 +git commit -m "docs: update AGENTS.md with new modules" +``` + +## Pre-commit Hook + +專案已配置 `.git/hooks/pre-commit`,提交前自動檢查: + +```bash +# 檢查內容 +1. cargo fmt --check # Rust 格式化檢查 +2. cargo clippy --lib # Rust Lint 檢查 +3. cargo test --lib # Rust 單元測試 +4. ruff check # Python Lint 檢查 +5. ruff format --check # Python 格式化檢查 +6. markdownlint # Markdown 格式檢查 +7. shellcheck # Shell 腳本檢查 + +# 跳過檢查(不建議) +git commit --no-verify + +# 跳過特定檢查 +git commit --skip-checks +``` + +**注意**: Hook 僅檢查已暫存的 Rust/Python/Markdown 文件。 + +### Python 環境設置 +```bash +# 安裝 ruff +pip install ruff==0.11.2 + +# 格式化 Python 文件 +ruff format scripts/ + +# Lint Python 文件 +ruff check scripts/ +``` + +### Markdown 環境設置 +```bash +# 安裝 markdownlint-cli (使用系統 Node.js) +npm install -g markdownlint-cli + +# 檢查 Markdown 文件 +markdownlint docs/ + +# 配置檔案 +.markdownlint.json +``` + +### Shell 環境設置 +```bash +# 安裝 shellcheck +brew install shellcheck + +# 檢查 Shell 腳本 +shellcheck scripts/*.sh monitor/**/*.sh +``` + +**注意**: Hook 只檢查 error 等級的 shellcheck 問題,style 警告會顯示但不阻擋提交。 + +## Reference Documents + +| 文件 | 用途 | +|------|------| +| `docs/OPENCODE_GUIDE.md` | OpenCode 使用規範 | +| `docs/ARCHITECTURE_EVALUATION.md` | 架構優化待評估項目 (含 GraphRAG) | +| `docs/PENDING_ISSUES.md` | 待解決問題追蹤 | +| `docs/MOMENTRY_CORE_MONITORING.md` | 監控系統規範 | +| `docs/MOMENTRY_CORE_REDIS_KEYS.md` | Redis Key 設計規範 | +| `docs/PYTHON.md` | Python 腳本規範 | +| `docs/FILE_CHANGE_MANAGEMENT.md` | 文件修改管理規範 | +| `docs/YOLO_RESUME_INTEGRATION.md` | YOLO Resume 功能整合記錄 | +| `docs/DOCUMENT_EMBEDDING_STRATEGY.md` | Parent-Child 嵌入策略 | +| `docs/PROCESSING_PIPELINE.md` | 處理流程文檔 | +| `docs/N8N_DEMO_WORKFLOW.md` | n8n 工作流文檔 | +| `docs/FRESH_MAC_INSTALLATION.md` | 全新 Mac 安裝指南 | +| `docs/SERVICES.md` | 服務總覽與管理 | +| `docs/SFTPGO_DEMO_USER.md` | SFTPGo 用戶指南 | + +## Document Change Workflow + +修改文件前請參考 `docs/FILE_CHANGE_MANAGEMENT.md`,確保: + +1. **修改前**:完整閱讀文件、執行預檢清單 +2. **修改中**:提供變更計畫、取得確認 +3. **修改後**:展示 diff、更新版本歷史 +4. **驗證**:執行 lint/test、提交前審查 + +### AI 工具修改規範 + +AI 工具修改文件時: +- 必須先完整閱讀文件(不可只讀取部分章節) +- 修改前先提出變更計畫供確認 +- 修改後展示 diff 內容 +- 更新版本歷史表 + +## PHP Development + +WordPress 作為 Momentry Portal,負責 n8n 自動化與 sftpgo 檔案服務的頁面整合。 + +### 編輯器設定 + +| 編輯器 | LSP 方案 | 安裝方式 | +|--------|----------|----------| +| VS Code | Intelephense | Extension Marketplace (推薦) | +| Cursor | Intelephense | Extension Marketplace (推薦) | +| CLI | phpactor | `~/bin/phpactor` | + +### Intelephense (VS Code/Cursor) + +1. 安裝 Extension: 搜尋 "Intelephense" +2. 設定: +```json +{ + "intelephense.stubs": ["wordpress"] +} +``` + +### phpactor (CLI) + +```bash +# 安裝方式 +brew install composer +curl -sSL https://github.com/phpactor/phpactor/releases/latest/download/phpactor.phar -o ~/bin/phpactor +chmod +x ~/bin/phpactor + +# 安裝 WordPress Stubs +cd /Users/accusys/wordpress/web +composer require --dev php-stubs/wordpress-stubs + +# 建立 WordPress 索引 +cd /Users/accusys/wordpress/web +~/bin/phpactor index:build --reset + +# 常用指令 +~/bin/phpactor class:search "WP_User" # 搜尋類別 +~/bin/phpactor index:query WP_User # 查看類別資訊 +~/bin/phpactor navigate /path/to/file.php # 導航到定義 +``` + +### WordPress 程式碼位置 +| 類型 | 路徑 | +|------|------| +| 主題 | `/Users/accusys/wordpress/web/wp-content/themes/` | +| 插件 | `/Users/accusys/wordpress/web/wp-content/plugins/` | + +### 與 marcom 團隊協作 +| 角色 | 負責 | +|------|------| +| marcom 團隊 | Figma 設計 / Elementor 建構 | +| OpenCode | 程式碼實作 / 重構 | + +### 開發時程 +``` +Phase 1: marcom 建構 (現在) → Elementor 頁面建構 +Phase 2: 交付審視 (TBD) → 功能確認 / 重構評估 +Phase 3: OpenCode 重構 → 純程式碼實作,交付無 Elementor 依賴版本 +``` diff --git a/Cargo.lock b/Cargo.lock index 099dd5a..792740d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -21,6 +56,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -36,6 +80,21 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstream" version = "1.0.0" @@ -43,7 +102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -53,9 +112,18 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] [[package]] name = "anstyle-parse" @@ -92,6 +160,41 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -140,6 +243,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -201,6 +315,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "base64" version = "0.13.1" @@ -270,6 +393,7 @@ dependencies = [ "ahash", "base64 0.22.1", "bitvec", + "chrono", "getrandom 0.2.17", "getrandom 0.3.4", "hex", @@ -303,10 +427,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "cc" -version = "1.2.56" +name = "cassowary" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -331,10 +470,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -351,7 +503,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "clap_lex", "strsim 0.11.1", @@ -377,9 +529,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -395,6 +547,20 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -416,6 +582,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -474,6 +650,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -489,6 +674,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio 1.1.1", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -496,9 +706,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.13.4" @@ -519,6 +739,16 @@ dependencies = [ "darling_macro 0.20.11", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.13.4" @@ -547,6 +777,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.13.4" @@ -569,6 +812,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -606,6 +860,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -694,6 +959,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-as-inner" version = "0.4.0" @@ -706,6 +980,29 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream 0.6.21", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -744,6 +1041,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filetime" version = "0.2.27" @@ -794,6 +1107,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -967,6 +1295,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "h2" version = "0.4.13" @@ -1030,6 +1368,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hex" version = "0.4.3" @@ -1161,6 +1508,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1179,9 +1542,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.3", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1355,6 +1720,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.9.6" @@ -1375,6 +1749,28 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -1409,6 +1805,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1424,6 +1829,30 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -1510,6 +1939,18 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1531,6 +1972,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-cache" version = "0.1.2" @@ -1568,6 +2018,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -1580,6 +2036,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1609,34 +2075,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "momentry_core" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", "async-trait", + "atty", "axum", + "base64 0.22.1", + "bson", + "chrono", "clap", + "crossterm", "dotenv", + "env_logger", + "futures-util", "hex", + "libc", + "md5", + "moka", "mongodb", "notify", + "once_cell", "qdrant-client", + "ratatui", "redis", - "sdl2", + "reqwest", "serde", "serde_json", "sha2", "sqlx", + "subtle", "thiserror 1.0.69", "tokio", "tower 0.4.13", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", + "uuid", ] [[package]] @@ -1686,6 +2190,23 @@ dependencies = [ "webpki-roots 0.25.4", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "notify" version = "6.1.1" @@ -1714,6 +2235,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1766,6 +2297,28 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1778,12 +2331,56 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking" version = "2.2.1" @@ -1813,6 +2410,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.11.0" @@ -1902,6 +2505,33 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1936,6 +2566,39 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1962,7 +2625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -2141,24 +2804,51 @@ dependencies = [ ] [[package]] -name = "redis" -version = "0.25.4" +name = "ratatui" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0d7a6955c7511f60f3ba9e86c6d02b3c3f144f8c24b288d1f4e18074ab8bbec" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "async-trait", + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redis" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b36964393906eb775b89b25b05b7b95685b8dd14062f1663a31ff93e75c452e5" +dependencies = [ + "arc-swap", + "arcstr", + "backon", "bytes", + "cfg-if", "combine", + "futures-channel", "futures-util", "itoa", + "num-bigint", "percent-encoding", "pin-project-lite", "ryu", "sha1_smol", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tokio-util", "url", + "xxhash-rust", ] [[package]] @@ -2179,6 +2869,35 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "reqwest" version = "0.12.28" @@ -2187,6 +2906,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -2195,9 +2916,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2208,6 +2932,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.3", @@ -2261,6 +2986,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2295,6 +3054,32 @@ dependencies = [ "semver 0.9.0", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.21.12" @@ -2429,29 +3214,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "sdl2" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" -dependencies = [ - "bitflags 1.3.2", - "lazy_static", - "libc", - "sdl2-sys", -] - -[[package]] -name = "sdl2-sys" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" -dependencies = [ - "cfg-if", - "libc", - "version-compare", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -2459,7 +3221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2649,6 +3411,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 1.1.1", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2760,6 +3543,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64 0.22.1", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -2835,6 +3619,7 @@ dependencies = [ "bitflags 2.11.0", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -2876,6 +3661,7 @@ dependencies = [ "base64 0.22.1", "bitflags 2.11.0", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -2910,6 +3696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -2933,6 +3720,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" @@ -2956,6 +3749,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3004,6 +3819,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take_mut" version = "0.2.2" @@ -3016,6 +3858,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3108,9 +3963,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3149,6 +4004,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -3194,6 +4059,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow", +] + [[package]] name = "tonic" version = "0.12.3" @@ -3340,9 +4235,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -3420,6 +4315,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3447,12 +4348,45 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -3483,6 +4417,50 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "uuid", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943e0ff606c6d57d410fd5663a4d7c074ab2c5f14ab903b9514565e59fa1189e" +dependencies = [ + "axum", + "mime_guess", + "regex", + "reqwest", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.22.0" @@ -3507,12 +4485,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version-compare" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" - [[package]] name = "version_check" version = "0.9.5" @@ -3797,6 +4769,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3833,6 +4816,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -4037,6 +5029,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.50.0" @@ -4150,6 +5151,12 @@ dependencies = [ "tap", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yoke" version = "0.8.1" @@ -4253,6 +5260,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cc23c04387f4da0374be4533ad1208cbb091d5c11d070dfef13676ad6497164" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.13.0", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index e51f889..b3b9486 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,48 +12,85 @@ thiserror = "1.0" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = "0.3" +once_cell = "1.19" +dotenv = "0.15" # CLI clap = { version = "4", features = ["derive"] } # Async async-trait = "0.1" +futures-util = "0.3" # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } # UUID sha2 = "0.10" hex = "0.4" +uuid = { version = "1.0", features = ["v4"] } + +# Security +subtle = "2.5" +aes-gcm = "0.10" +base64 = "0.22" + +# Cache +moka = { version = "0.12", features = ["future"] } # Database -redis = { version = "0.25", features = ["tokio-comp"] } -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "json"] } -mongodb = { version = "2", features = ["tokio-sync"] } +redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "json", "chrono"] } +mongodb = { version = "2", features = ["tokio-runtime"] } +bson = { version = "2", features = ["chrono-0_4"] } qdrant-client = "1.7" +reqwest = { version = "0.12", features = ["json"] } # HTTP Server axum = "0.7" tower = "0.4" +# API Documentation +utoipa = { version = "4", features = ["axum_extras", "chrono", "uuid"] } +utoipa-swagger-ui = { version = "7", features = ["axum"] } + # File watching notify = "6" -# Video/Audio -sdl2 = "0.38" +# Logging +env_logger = "0.11" -# Configuration -dotenv = "0.15" +# Hash +md5 = "0.7" -[features] -default = [] -dev = [] +# TUI +ratatui = "0.28" +crossterm = "0.28" + +# Terminal +atty = "0.2" + +# System +libc = "0.2" [lib] name = "momentry_core" path = "src/lib.rs" +[features] +default = [] +player = [] + [[bin]] name = "momentry" path = "src/main.rs" + +[[bin]] +name = "momentry_player" +path = "src/player/main.rs" + +[[bin]] +name = "momentry_playground" +path = "src/playground.rs" diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..4306b33 --- /dev/null +++ b/config/README.md @@ -0,0 +1,105 @@ +# Momentry Core 配置管理 + +## 目錄結構 + +``` +momentry_core_0.1/ +├── .env.example # 配置模板(已納入版本控制) +├── .env # 本地配置(已從版本控制排除) +├── .env.local # 本地覆蓋配置(已從版本控制排除) +├── config/ +│ └── README.md # 本文件 +└── src/core/config.rs # 配置代碼 +``` + +## 配置加載順序 + +1. `.env` - 默認本地配置 +2. `.env.local` - 本地覆蓋(最高優先級) + +## 環境變數列表 + +### 數據庫配置 + +| 變數 | 說明 | 默認值 | +|------|------|--------| +| `DATABASE_URL` | PostgreSQL 連接字串 | `postgres://accusys@localhost:5432/momentry` | + +### Redis 配置 + +| 變數 | 說明 | 默認值 | +|------|------|--------| +| `REDIS_URL` | Redis 連接字串 | `redis://:accusys@localhost:6379` | +| `REDIS_PASSWORD` | Redis 密碼 | `accusys` | + +### 存儲路徑 + +| 變數 | 說明 | 默認值 | +|------|------|--------| +| `MOMENTRY_OUTPUT_DIR` | 輸出目錄 | `/Users/accusys/momentry/output` | +| `MOMENTRY_BACKUP_DIR` | 備份目錄 | `/Users/accusys/momentry/backup/momentry` | +| `MOMENTRY_SCRIPTS_DIR` | 腳本目錄 | `/Users/accusys/momentry_core_0.1/scripts` | +| `MOMENTRY_PYTHON_PATH` | Python 路徑 | `/opt/homebrew/bin/python3.11` | + +### 處理器超時(秒) + +| 變數 | 說明 | 默認值 | +|------|------|--------| +| `MOMENTRY_ASR_TIMEOUT` | ASR 處理超時 | `3600` | +| `MOMENTRY_CUT_TIMEOUT` | CUT 處理超時 | `3600` | +| `MOMENTRY_DEFAULT_TIMEOUT` | 默認超時 | `7200` | + +### 日誌 + +| 變數 | 說明 | 默認值 | +|------|------|--------| +| `RUST_LOG` | 日誌級別 | `info` | +| `MOMENTRY_LOG_LEVEL` | 日誌級別(備選) | `info` | + +## 使用方式 + +### 1. 首次設置 + +```bash +# 複製模板 +cp .env.example .env + +# 編輯配置 +nano .env +``` + +### 2. 本地覆蓋 + +創建 `.env.local` 設置僅本地適用的配置: + +```bash +# .env.local 示例 +DATABASE_URL=postgres://local:password@localhost:5432/momentry_dev +MOMENTRY_LOG_LEVEL=debug +``` + +### 3. 運行應用 + +```bash +# 加載配置並運行 +source .env && cargo run + +# 或使用 direnv +direnv allow +``` + +## 版本控制策略 + +| 文件 | 版本控制 | 說明 | +|------|---------|------| +| `.env.example` | ✅ 追蹤 | 模板,包含所有選項 | +| `.env` | ❌ 忽略 | 本地敏感配置 | +| `.env.local` | ❌ 忽略 | 本地覆蓋配置 | + +## 部署檢查清單 + +- [ ] 複製 `.env.example` 到 `.env` +- [ ] 設置數據庫連接 +- [ ] 設置 Redis 密碼 +- [ ] 配置目錄路徑 +- [ ] 確認日誌級別 diff --git a/docs/PLAYGROUND_BINARY_IMPLEMENTATION.md b/docs/PLAYGROUND_BINARY_IMPLEMENTATION.md new file mode 100644 index 0000000..66f5798 --- /dev/null +++ b/docs/PLAYGROUND_BINARY_IMPLEMENTATION.md @@ -0,0 +1,376 @@ +# Playground Binary Implementation Plan + +## Overview + +Create separate `momentry_playground` binary with distinct configuration from `momentry` (production). + +| Aspect | Production (`momentry`) | Development (`momentry_playground`) | +|--------|------------------------|-------------------------------------| +| **Port** | 3002 | 3003 | +| **Redis Prefix** | `momentry:` | `momentry_dev:` | +| **Worker** | Enabled | Disabled | +| **Purpose** | Production deployment | Testing/Development | + +--- + +## Files to Modify + +``` +Files Changed: 6 files (+1 new) +├── src/core/config.rs ← Add server_port(), redis_key_prefix() +├── src/core/db/redis_client.rs ← Replace hardcoded prefixes +├── src/core/cache/redis_cache.rs ← Use configurable prefix +├── src/main.rs ← Update CLI defaults +├── src/playground.rs ← NEW: Development binary +├── Cargo.toml ← Add new binary +└── .env.development ← NEW: Dev environment config +``` + +--- + +## Implementation Steps + +### Step 1: Update `src/core/config.rs` + +Add after line 51 (after `MEDIA_BASE_URL`): + +```rust +pub static SERVER_PORT: Lazy = Lazy::new(|| { + env::var("MOMENTRY_SERVER_PORT") + .unwrap_or_else(|_| "3002".to_string()) + .parse() + .unwrap_or(3002) +}); + +pub static REDIS_KEY_PREFIX: Lazy = Lazy::new(|| { + env::var("MOMENTRY_REDIS_PREFIX") + .unwrap_or_else(|_| "momentry:".to_string()) +}); +``` + +--- + +### Step 2: Update `src/core/db/redis_client.rs` + +Replace all hardcoded `momentry:` prefixes with configurable prefix. + +**Import at top:** +```rust +use crate::core::config::REDIS_KEY_PREFIX; +``` + +**Pattern for each method:** +```rust +let prefix = REDIS_KEY_PREFIX.as_str(); +let key = format!("{}job:{}", prefix, uuid); +``` + +**Affected lines:** + +| Line | Key Pattern | +|------|-------------| +| 47 | `job:{uuid}` | +| 81, 109 | `job:{uuid}:processor:{processor}` | +| 136, 146 | `progress:{uuid}` | +| 172 | `jobs:active` | +| 179 | `jobs:active` → `jobs:completed` | +| 187 | `jobs:active` → `jobs:failed` | +| 194 | `jobs:active` | +| 201, 208 | `health:momentry_core` | +| 214 | `monitor:job:{uuid}` | +| 242, 300 | `errors:{uuid}` | +| 258, 281 | `anomaly:alerts`, `anomaly:key:{key_id}` | +| 317, 346, 364, 392, 397 | `worker:job:{uuid}...` | +| 406, 410 | `worker:job:*` | + +--- + +### Step 3: Update `src/core/cache/redis_cache.rs` + +**Import:** +```rust +use crate::core::config::REDIS_KEY_PREFIX; +``` + +**Replace line 10:** +```rust +// Remove: const KEY_PREFIX: &str = "momentry:cache:"; +``` + +**Update `prefixed_key` method (line 24):** +```rust +fn prefixed_key(&self, key: &str) -> String { + format!("{}cache:{}", REDIS_KEY_PREFIX.as_str(), key) +} +``` + +**Update tests (lines 161-162):** +```rust +#[test] +fn test_prefixed_key() { + // Note: This test will use the configured prefix + let cache = RedisCache::new().unwrap(); + // With default prefix "momentry:" + assert_eq!(cache.prefixed_key("test"), "momentry:cache:test"); + assert_eq!(cache.prefixed_key("video:abc"), "momentry:cache:video:abc"); +} +``` + +--- + +### Step 4: Update `src/main.rs` + +**Change CLI defaults (Lines 691-695):** + +```rust +// Before: +#[arg(long, default_value = "3000")] +port: u16, + +// After: +#[arg(long)] +port: Option, +``` + +**Update Server match arm (around line 2398):** + +```rust +Commands::Server { host, port } => { + let port = port.unwrap_or_else(|| *crate::core::config::SERVER_PORT); + momentry_core::api::start_server(&host, port).await?; + Ok(()) +} +``` + +**Update Redis key usage (Line 1098):** + +```rust +// Before: +let key = format!("momentry:job:{}:processor:{}", uuid, processor); + +// After: +let key = format!( + "{}job:{}:processor:{}", + crate::core::config::REDIS_KEY_PREFIX.as_str(), + uuid, + processor +); +``` + +--- + +### Step 5: Create `src/playground.rs` + +```rust +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +// ... same imports as main.rs ... + +fn main() -> Result<()> { + // Load development environment first + dotenv::from_filename(".env.development").ok(); + + tracing_subscriber::fmt::init(); + tracing::info!("Starting momentry_playground (development binary)"); + tracing::info!("Port: {}", *momentry_core::core::config::SERVER_PORT); + tracing::info!("Redis prefix: {}", *momentry_core::core::config::REDIS_KEY_PREFIX); + + let cli = Cli::parse(); + // ... rest identical to main.rs ... +} +``` + +--- + +### Step 6: Update `Cargo.toml` + +**Add after line 90:** + +```toml +[[bin]] +name = "momentry_playground" +path = "src/playground.rs" +``` + +**Add dependency (if not present):** + +```toml +dotenv = "0.15" +``` + +--- + +### Step 7: Create `.env.development` + +```bash +# Development Environment Configuration +# Used by: momentry_playground binary + +# Server Configuration +MOMENTRY_SERVER_PORT=3003 +MOMENTRY_REDIS_PREFIX=momentry_dev: + +# Worker Configuration (disabled for development) +MOMENTRY_WORKER_ENABLED=false +MOMENTRY_MAX_CONCURRENT=1 +MOMENTRY_POLL_INTERVAL=10 + +# Database (can use separate dev database) +DATABASE_URL=postgres://accusys@localhost:5432/momentry +MONGODB_URL=mongodb://accusys:Test3200Test3200@localhost:27017/admin + +# Redis +REDIS_URL=redis://:accusys@localhost:6379 +``` + +--- + +### Step 8: Update `.env` (Production) + +Add these lines: + +```bash +# Production Environment Configuration +# Used by: momentry binary + +# Server Configuration +MOMENTRY_SERVER_PORT=3002 +MOMENTRY_REDIS_PREFIX=momentry: + +# Worker Configuration +MOMENTRY_WORKER_ENABLED=true +MOMENTRY_MAX_CONCURRENT=2 +MOMENTRY_POLL_INTERVAL=5 +``` + +--- + +## Testing Checklist + +### 1. Build and Run Production Binary + +```bash +cargo build --release --bin momentry +cargo run --bin momentry -- server +# Expected: Listening on http://127.0.0.1:3002 + +cargo run --bin momentry -- worker +# Expected: Worker started with momentry: prefix +``` + +### 2. Build and Run Development Binary + +```bash +cargo build --bin momentry_playground +cargo run --bin momentry_playground -- server +# Expected: Listening on http://127.0.0.1:3003 +``` + +### 3. Verify Redis Key Isolation + +```bash +# Production data +redis-cli KEYS "momentry:*" +# Development data +redis-cli KEYS "momentry_dev:*" +# Should be separate +``` + +### 4. Run Both Simultaneously + +```bash +# Terminal 1: Production +cargo run --bin momentry -- server + +# Terminal 2: Development +cargo run --bin momentry_playground -- server + +# Both should run without port conflicts +``` + +### 5. Unit Tests + +```bash +cargo test --lib +# All tests should pass +``` + +--- + +## Redis Key Structure + +### Production (`momentry:`) + +``` +momentry:job:{uuid} # Job status +momentry:job:{uuid}:processor:{name} # Processor progress +momentry:progress:{uuid} # Progress pub/sub +momentry:jobs:active # Active job set +momentry:jobs:completed # Completed job set +momentry:jobs:failed # Failed job set +momentry:health:momentry_core # Health status +momentry:cache:{key} # Cache entries +momentry:worker:job:{uuid} # Worker job +momentry:worker:job:{uuid}:processor:{name} +``` + +### Development (`momentry_dev:`) + +``` +momentry_dev:job:{uuid} +momentry_dev:job:{uuid}:processor:{name} +momentry_dev:progress:{uuid} +momentry_dev:jobs:active +momentry_dev:jobs:completed +momentry_dev:jobs:failed +momentry_dev:health:momentry_core +momentry_dev:cache:{key} +momentry_dev:worker:job:{uuid} +momentry_dev:worker:job:{uuid}:processor:{name} +``` + +--- + +## Potential Issues & Solutions + +| Issue | Solution | +|-------|----------| +| `dotenv` crate not in dependencies | Add to Cargo.toml | +| Tests use hardcoded prefix | Update tests to use config, or use `#[cfg(test)]` defaults | +| Worker starts in playground | Check `MOMENTRY_WORKER_ENABLED=false` in `.env.development` | +| Port already in use | Graceful error message with suggestion to use `--port` flag | +| Mixed data in Redis | Ensure prefix is loaded before any Redis operations | + +--- + +## Files Summary + +| File | Lines Changed | Purpose | +|------|---------------|---------| +| `src/core/config.rs` | +15 | Add SERVER_PORT and REDIS_KEY_PREFIX | +| `src/core/db/redis_client.rs` | ~50 | Replace hardcoded prefixes | +| `src/core/cache/redis_cache.rs` | ~10 | Use configurable prefix | +| `src/main.rs` | ~15 | Update CLI defaults, Redis key usage | +| `src/playground.rs` | NEW (~2800) | Development binary | +| `Cargo.toml` | +4 | Add binary definition | +| `.env.development` | NEW (~20) | Development environment | + +**Total**: ~60 lines modified + ~2800 lines new file + +--- + +## Reference Documents + +| Document | Purpose | +|----------|---------| +| `docs/SERVICES.md` | Port allocations | +| `docs/MOMENTRY_CORE_REDIS_KEYS.md` | Redis key design | +| `AGENTS.md` | Code style and conventions | + +--- + +## Version History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-03-25 | OpenCode | Initial implementation plan | diff --git a/docs/SERVICES.md b/docs/SERVICES.md index 40ce439..0b5a555 100644 --- a/docs/SERVICES.md +++ b/docs/SERVICES.md @@ -1,31 +1,167 @@ # Momentry 系統服務安裝與管理指南 +| 項目 | 內容 | +|------|------| +| 建立者 | Warren | +| 建立時間 | 2026-03-18 | +| 更新時間 | 2026-03-24 | +| 文件版本 | V1.1 | + +--- + +## 版本歷史 + +| 版本 | 日期 | 目的 | 操作人 | 工具/模型 | +|------|------|------|--------|-----------| +| V1.0 | 2026-03-18 | 創建文件 | Warren | OpenCode / MiniMax M2.5 | +| V1.1 | 2026-03-24 | 更新所有服務 plist 狀態,統一使用自定義 plist | OpenCode | OpenCode / big-pickle | + +--- + ## 概述 本文檔記錄 momentry 系統所需的所有服務,包括安裝步驟、健康檢查和管理命令。 **重要**: 請勿使用 `brew services` 命令管理服務,否則可能導致 .plist 檔案還原為預設狀態,造成系統異常。請使用 `launchctl` 命令進行管理。 +**2026-03-24 更新**: 所有服務已統一使用自定義 plist,存在於 `/Library/LaunchDaemons/` 目錄。Reboot 後會自動啟動。 + --- ## 服務清單 | 服務名稱 | 安裝方式 | 用途 | 狀態 | |----------|----------|------|-------| -| PostgreSQL | Homebrew | 影片元資料儲存 | 已安裝 | -| Redis | Homebrew | 快取與工作佇列 | 已安裝 | -| Ollama | Homebrew | 本地 LLM 推論 | 已安裝 | -| Caddy | Homebrew | 網頁伺服器 (可選) | 已安裝 | -| Gitea | 手動安裝 | Git 服務 | 已安裝 | -| Grafana | Homebrew | 監控儀表板 | 已安裝 | -| Kafka | 手動安裝 | 訊息佇列 (可選) | 已安裝 | -| MariaDB | Homebrew | 資料庫 (可選) | 已安裝 | -| Netdata | Homebrew | 系統監控 | 已安裝 | -| PHP | Homebrew | Web 後端 | 已安裝 | -| Prometheus | Homebrew | 指標收集 | 已安裝 | -| SeaweedFS | 手動安裝 | 分散式儲存 (可選) | 已安裝 | -| SFTPGo | 手動安裝 | SFTP 服務 | 已安裝 | -| n8n | Homebrew | 工作流自動化 | 已安裝 | +| PostgreSQL | 自定義 plist | 影片元資料儲存 | ✅ 正常 | +| Redis | 自定義 plist | 快取與工作佇列 | ✅ 正常 | +| Ollama | 自定義 plist | 本地 LLM 推論 | ✅ 正常 | +| Caddy | 自定義 plist | 網頁伺服器 | ✅ 正常 | +| Gitea | 自定義 plist | Git 服務 | ✅ 正常 | +| Gitea MCP Server | 自定義 plist | Gitea MCP 整合 | ✅ 正常 | +| Grafana | Homebrew | 監控儀表板 | ⚠️ Homebrew | +| Kafka | 手動安裝 | 訊息佇列 (可選) | ⚠️ 未遷移 | +| MariaDB | 自定義 plist | 資料庫 (可選) | ✅ 正常 | +| Netdata | Homebrew | 系統監控 | ⚠️ Homebrew | +| PHP | 自定義 plist | Web 後端 | ✅ 正常 | +| Prometheus | Homebrew | 指標收集 | ⚠️ Homebrew | +| SeaweedFS | 手動安裝 | 分散式儲存 (可選) | ⚠️ 未遷移 | +| SFTPGo | 自定義 plist | SFTP 服務 | ✅ 正常 | +| n8n | 自定義 plist | 工作流自動化 | ✅ 正常 | +| n8n Worker | 自定義 plist | 工作流 Worker | ✅ 正常 | +| MongoDB | 自定義 plist | 文件資料庫 | ✅ 正常 | +| Qdrant | 自定義 plist | 向量資料庫 | ✅ 正常 | +| Momentry API | 自定義 plist | 影片管理 API | ✅ 正常 | +| RustDesk HBBR | 自定義 plist | 遠端桌面橋接 | ✅ 正常 | +| RustDesk HBBS | 自定義 plist | 遠端桌面服務器 | ✅ 正常 | + +--- + +## 服務健康檢查結果 (2026-03-24) + +### ✅ 正常運行的服務 + +| 服務 | 版本 | Port | 測試命令 | 結果 | +|------|------|------|----------|------| +| **PostgreSQL** | 18.1 | 5432 | `pg_isready -h 127.0.0.1 -p 5432` | ✅ 接受連線 | +| **Redis** | 7.4.x | 6379 | `redis-cli -a accusys ping` | ✅ PONG | +| **MongoDB** | 8.2.6 | 27017 | `mongosh --eval "db.adminCommand('ping')"` | ✅ { ok: 1 } | +| **Ollama** | - | 11434 | `curl -s http://localhost:11434/api/tags` | ✅ 模型可用 | +| **n8n** | 2.12.3 | 5678/5681/5682 | `curl -s http://localhost:5678/healthz` | ✅ {"status":"ok"} | +| **n8n Worker** | 2.12.3 | 5681/5690/5691 | - | ✅ 運行中 | +| **Momentry API** | 0.1.0 | 3002 | `curl -s http://localhost:3002/health` | ✅ OK | +| **Qdrant** | 1.17.0 | 6333 | `curl -s http://localhost:6333/` | ✅ 版本資訊 | +| **Caddy** | 2.10.x | 443 | `curl -sI https://momentry.ddns.net` | ✅ HTTP 200 | +| **SFTPGo** | 2.7.x | 8080 | `curl -s http://localhost:8080/sftpgo/` | ✅ JSON 響應 | +| **Gitea** | - | 3000 | `curl -s http://localhost:3000/` | ✅ HTML 響應 | +| **Gitea MCP** | - | 8787 | `curl -s http://localhost:8787/` | ✅ 運行中 | +| **MariaDB** | 12.1.x | 3306 | `mariadb -u root -e "SELECT 1;"` | ✅ 正常 | +| **PHP-FPM** | 8.5.x | 9000 | `ps aux \| grep php-fpm` | ✅ 運行中 | + +### ⚠️ 需要配置的服務 + +| 服務 | 問題 | 解決方案 | +|------|------|----------| +| Grafana | 使用 Homebrew | 考慮遷移到自定義 plist | +| Prometheus | 使用 Homebrew | 考慮遷移到自定義 plist | +| Kafka | 未遷移 | 可選服務 | +| SeaweedFS | 未遷移 | 可選服務 | +| Netdata | 使用 Homebrew | 考慮遷移到自定義 plist | + +### ⚠️ Homebrew 管理的服務 (建議遷移) + +| 服務 | 風險 | +|------|------| +| homebrew.mxcl.grafana | Reboot 後可能自動啟動但使用預設設定 | +| homebrew.mxcl.prometheus | Reboot 後可能自動啟動但使用預設設定 | +| homebrew.mxcl.openwebui | 需要確認是否需要 | +| homebrew.mxcl.kafka | 需要確認是否需要 | +| homebrew.mxcl.seaweedfs | 需要確認是否需要 | +| homebrew.mxcl.netdata | 需要確認是否需要 | +| homebrew.mxcl.ddclient | 動態 DNS,可能需要 | +| homebrew.mxcl.shadowsocks-rust | VPN,可能需要 | + +### MCP Servers (2026-03-24) + +| Server | 安裝方式 | 路徑 | 狀態 | +|--------|----------|------|------| +| gitea | Homebrew | /opt/homebrew/bin/gitea-mcp-server | ✅ Connected | +| n8n | NPM | /opt/homebrew/bin/mcp-n8n | ✅ Connected | +| postgres | NPM | /opt/homebrew/bin/mcp-server-postgres | ✅ Connected | +| redis | NPM | /opt/homebrew/bin/mcp-server-redis | ✅ Connected | +| mongodb | NPM | /opt/homebrew/bin/mongodb-mcp-server | ✅ Connected | +| qdrant | Python | /opt/homebrew/bin/mcp-server-qdrant | ✅ Connected | +| filesystem | NPM | /opt/homebrew/bin/mcp-server-filesystem | ✅ Connected | +| sentry | NPM | /opt/homebrew/bin/sentry-mcp | ⏳ Pending Config | +| context7 | NPM | /opt/homebrew/bin/context7-mcp | ✅ Connected | +| playwright | NPM | /opt/homebrew/bin/playwright-mcp | ✅ Connected | + +**配置文件**: `~/.config/opencode/opencode.json` + +**驗證命令**: +```bash +opencode mcp ls +``` + +**詳細文檔**: [OpenCode MCP 安裝指南](./OPENCODE_MCP_INSTALL.md) + +### 測試腳本 + +```bash +#!/bin/bash +# services_health_check.sh + +echo "=== Momentry 服務健康檢查 ===" +echo "" + +# PostgreSQL +pg_isready -h 127.0.0.1 -p 5432 -U accusys > /dev/null 2>&1 && echo "✅ PostgreSQL" || echo "❌ PostgreSQL" + +# Redis +redis-cli -a accusys ping > /dev/null 2>&1 && echo "✅ Redis" || echo "❌ Redis" + +# MongoDB +mongosh --quiet --eval "db.adminCommand('ping')" > /dev/null 2>&1 && echo "✅ MongoDB" || echo "❌ MongoDB" + +# Ollama +curl -s http://localhost:11434/api/tags > /dev/null 2>&1 && echo "✅ Ollama" || echo "❌ Ollama" + +# n8n +curl -s http://localhost:5678/ > /dev/null 2>&1 && echo "✅ n8n" || echo "❌ n8n" + +# Momentry API +curl -s http://localhost:3002/health > /dev/null 2>&1 && echo "✅ Momentry API" || echo "❌ Momentry API" + +# Qdrant +curl -s http://localhost:6333/ > /dev/null 2>&1 && echo "✅ Qdrant" || echo "❌ Qdrant" + +# Caddy +curl -sI https://momentry.ddns.net > /dev/null 2>&1 && echo "✅ Caddy" || echo "❌ Caddy" + +# SFTPGo +curl -s http://localhost:8080/api/v2/healthz > /dev/null 2>&1 && echo "✅ SFTPGo" || echo "⚠️ SFTPGo (需配置)" +``` + +--- --- @@ -42,14 +178,12 @@ brew list postgresql@18 2>/dev/null || echo "Not installed" brew install postgresql@18 ``` -#### 初始化資料庫 -```bash -# 初始化資料庫 (如尚未初始化) -initdb /usr/local/var/postgresql@18 - -# 建立 momentry 資料庫 -createdb -U accusys momentry +#### 資料目錄 ``` +/Users/accusys/momentry/var/postgresql +``` + +**重要**: 確保使用統一的資料目錄,避免與 homebrew plist 衝突。 #### 開機自動啟動 ```bash @@ -61,42 +195,51 @@ sudo tee /Library/LaunchDaemons/com.momentry.postgresql.plist > /dev/null <<'EOF Label com.momentry.postgresql + UserName + accusys + EnvironmentVariables + + LC_ALL + en_US.UTF-8 + + WorkingDirectory + /Users/accusys/momentry/var/postgresql ProgramArguments - /opt/homebrew/opt/postgresql@18/bin/pg_ctl + /opt/homebrew/opt/postgresql@18/bin/postgres -D - /opt/homebrew/var/postgresql@18 - -l - /opt/homebrew/var/postgresql@18/logfile - start + /Users/accusys/momentry/var/postgresql RunAtLoad KeepAlive - WorkingDirectory - /opt/homebrew/var/postgresql@18 - UserName - accusys + StandardErrorPath + /Users/accusys/momentry/log/postgresql.error.log + StandardOutPath + /Users/accusys/momentry/log/postgresql.log EOF # 載入服務 -sudo launchctl load /Library/LaunchDaemons/com.momentry.postgresql.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.postgresql.plist ``` #### 管理命令 ```bash # 啟動 -sudo launchctl load /Library/LaunchDaemons/com.momentry.postgresql.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.postgresql.plist # 停止 -sudo launchctl unload /Library/LaunchDaemons/com.momentry.postgresql.plist +sudo launchctl bootout system/com.momentry.postgresql.plist # 重新載入 -sudo launchctl unload /Library/LaunchDaemons/com.momentry.postgresql.plist -sudo launchctl load /Library/LaunchDaemons/com.momentry.postgresql.plist +sudo launchctl bootout system/com.momentry.postgresql.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.postgresql.plist + +# 查看狀態 +launchctl list | grep com.momentry.postgresql ``` #### 健康檢查 @@ -105,10 +248,13 @@ sudo launchctl load /Library/LaunchDaemons/com.momentry.postgresql.plist pg_isready -h localhost -p 5432 -U accusys # 方法 2: 連線測試 -psql -U accusys -h localhost -d momentry -c "SELECT 1;" +PGPASSWORD=n8n1234 psql -h localhost -U n8n -d n8n -c "SELECT 1;" # 方法 3: 檢查程序 -pgrep -f "postgres.*postgresql@18" +pgrep -a postgres + +# 方法 4: 檢查資料庫 +PGPASSWORD=n8n1234 psql -h localhost -U n8n -d n8n -c "SELECT COUNT(*) FROM workflow_entity;" ``` --- @@ -356,7 +502,7 @@ ps aux | grep n8n | grep -v grep # 方法 3: 檢查端口 lsof -i :5678 -lsof -i :5690 +lsof -i :5679 ``` --- @@ -512,41 +658,54 @@ chmod +x scripts/health_check.sh ## 服務管理速查表 -### 啟動服務 +### 啟動服務 (使用 launchctl bootstrap) ```bash -# PostgreSQL (需要 root 權限) -sudo launchctl load /Library/LaunchDaemons/com.momentry.postgresql.plist +# 所有服務 +for plist in /Library/LaunchDaemons/com.momentry.*.plist; do + sudo launchctl bootstrap system "$plist" +done -# Redis, Ollama (需要 root 權限) -sudo launchctl load /Library/LaunchDaemons/com.momentry.redis.plist -sudo launchctl load /Library/LaunchDaemons/com.momentry.ollama.plist +# PostgreSQL +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.postgresql.plist -# n8n (需要 root 權限) -sudo launchctl load /Library/LaunchDaemons/com.momentry.n8n.main.plist -sudo launchctl load /Library/LaunchDaemons/com.momentry.n8n.worker.plist +# Redis, Ollama, Qdrant +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.redis.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.ollama.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.qdrant.plist + +# n8n (main + worker) +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.n8n.main.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/com.momentry.n8n.worker.plist ``` -### 停止服務 +### 停止服務 (使用 launchctl bootout) ```bash -# PostgreSQL -sudo launchctl unload /Library/LaunchDaemons/com.momentry.postgresql.plist +# 所有 Momentry 服務 +for svc in $(launchctl list | grep com.momentry | awk '{print $3}'); do + sudo launchctl bootout system/$svc 2>/dev/null +done -# Redis, Ollama -sudo launchctl unload /Library/LaunchDaemons/com.momentry.redis.plist -sudo launchctl unload /Library/LaunchDaemons/com.momentry.ollama.plist +# PostgreSQL +sudo launchctl bootout system/com.momentry.postgresql.plist + +# Redis, Ollama, Qdrant +sudo launchctl bootout system/com.momentry.redis.plist +sudo launchctl bootout system/com.momentry.ollama.plist +sudo launchctl bootout system/com.momentry.qdrant.plist # n8n -sudo launchctl unload /Library/LaunchDaemons/com.momentry.n8n.main.plist -sudo launchctl unload /Library/LaunchDaemons/com.momentry.n8n.worker.plist +sudo launchctl bootout system/com.momentry.n8n.main.plist +sudo launchctl bootout system/com.momentry.n8n.worker.plist ``` ### 查詢服務狀態 ```bash -# 查看所有服務 -launchctl list | grep -E "(postgres|redis|ollama|n8n|grafana|prometheus)" +# 查看所有 Momentry 服務 +launchctl list | grep com.momentry # 查看特定服務 -launchctl list | grep com.momentry +launchctl list | grep com.momentry.postgresql +launchctl list | grep com.momentry.n8n ``` --- @@ -711,21 +870,160 @@ esac --- +## 服務快速參照 + +### Port 對照表 + +| Port | 服務 | 說明 | +|------|------|------| +| 11434 | Ollama | LLM API | +| 19999 | Netdata | 系統監控 | +| 2019 | Caddy | 管理 API | +| 21115-21119 | RustDesk | 遠端桌面 | +| 27017 | MongoDB | 文件資料庫 | +| 3000 | Gitea | Git 服務 | +| 3001 | Grafana | 監控儀表板 | +| 3002 | Momentry API | Rust API 伺服器 (生產環境) | +| 3003 | Momentry Playground | Rust API 伺服器 (開發環境) | +| 3306 | MariaDB | MySQL 相容資料庫 | +| 4096 | OpenCode | CLI 工具 | +| 5000-7000 | ControlCenter | 控制中心 | +| 5678 | n8n | 工作流自動化 (Main) | +| 5681 | n8n | Worker HTTP | +| 5682 | n8n | Worker Health Check | +| 5690 | n8n | Task Broker | +| 5691 | n8n | Runner Health | +| 6333-6334 | Qdrant | 向量資料庫 | +| 6379 | Redis | 快取/佇列 | +| 8080 | SFTPGo | HTTP/WebDAV | +| 8081 | Trunk | 轉發服務 | +| 8082 | SeaweedFS | Master | +| 8090 | SFTPGo | WebDAV | +| 8333 | SeaweedFS | Volume | +| 8388 | Shadowsocks | VPN | +| 8888 | SeaweedFS | Filer | +| 9000 | PHP-FPM | PHP 處理器 | +| 9090 | Prometheus | 指標收集 | +| 9092-9093 | Kafka | 訊息佇列 | +| 9333 | SeaweedFS | Volume | +| 18082 | SeaweedFS | Volume | +| 18333 | SeaweedFS | Volume | +| 18888 | SeaweedFS | Filer | +| 19333 | SeaweedFS | Volume | + +## Caddy 反向代理 URL 對照表 + +| URL | 內部服務 | 說明 | +|-----|----------|------| +| `n8n.momentry.ddns.net` | :5678 | n8n 工作流自動化 | +| `wp.momentry.ddns.net` | :9000 | WordPress 網站 | +| `seaweed.momentry.ddns.net` | :8888 | SeaweedFS Filer | +| `sftpgo.momentry.ddns.net` | :8080 | SFTPGo HTTP | +| `webdav.momentry.ddns.net` | :8090 | SFTPGo WebDAV | +| `qdrant.momentry.ddns.net` | :6333 | Qdrant 向量資料庫 | +| `gitea.momentry.ddns.net` | :3000 | Gitea Git 服務 | +| `chat.momentry.ddns.net` | :8085 | Open WebUI | +| `netdata.momentry.ddns.net` | :19999 | Netdata 監控 | +| `grafana.momentry.ddns.net` | :3001 | Grafana 儀表板 | +| `router5.momentry.ddns.net` | 192.168.5.1:80 | Router 5 | +| `router110.momentry.ddns.net` | 192.168.110.1:80 | Router 110 | +| `router0.momentry.ddns.net/admin/*` | 192.168.0.1:80 | Router 0 | +| `truenas.momentry.ddns.net` | 192.168.0.219:80 | TrueNAS | +| `:3200` | :3002 | Momentry Dashboard + API | + +**Caddy 管理**: https://localhost:2019/ + +--- + +## 目錄對照表 + +### /Users/accusys/momentry/var/ + +| 目錄 | 服務 | 說明 | +|------|------|------| +| `caddy/` | Caddy | 資料目錄 | +| `gitea/` | Gitea | Git 資料庫 | +| `mariadb/` | MariaDB | 資料庫檔案 | +| `mongodb/` | MongoDB | 文件資料庫 (自定義 plist) | +| `n8n/` | n8n | 工作流資料 | +| `ollama/` | Ollama | 模型快取 | +| `php/` | PHP | FastCGI 進程 | +| `postgresql/` | PostgreSQL | 主資料庫 (自定義 plist) | +| `qdrant/` | Qdrant | 向量資料庫 | +| `redis/` | Redis | 持久化檔案 | +| `rustdesk/` | RustDesk | ID 資料庫 | +| `sftpgo/` | SFTPGo | 使用者資料 | +| `sftpgo_backup/` | SFTPGo | 備份 | + +### /opt/homebrew/var/ (Homebrew 管理) + +| 目錄 | 服務 | 說明 | +|------|------|------| +| `mongodb/` | MongoDB | (舊) 文件資料庫 | +| `postgresql@18/` | PostgreSQL | (舊) 主資料庫 | + +### /Users/accusys/momentry/etc/ + +| 目錄 | 服務 | 說明 | +|------|------|------| +| `caddy/` | Caddy | 配置 | +| `gitea/` | Gitea | 配置 | +| `php/` | PHP | 配置 | +| `sftpgo/` | SFTPGo | 配置 | + +### /Users/accusys/momentry/log/ + +| 檔案 | 服務 | 說明 | +|------|------|------| +| `backup.log` | Backup | 備份日誌 | +| `redis.log` | Redis | 執行日誌 | +| `redis.error.log` | Redis | 錯誤日誌 | +| `ollama.log` | Ollama | 執行日誌 | +| `ollama.error.log` | Ollama | 錯誤日誌 | + +### /Users/accusys/momentry/backup/ + +| 目錄 | 說明 | +|------|------| +| `daily/` | 每日備份 | +| `weekly/` | 每週備份 | +| `monthly/` | 每月備份 | + +--- + ## 附錄: 服務版本對應表 -| 服務 | 版本 | Port | 使用者 | plist 位置 | -|------|------|------|--------|-------------| -| PostgreSQL | 18.1 | 5432 | accusys | /Library/LaunchDaemons/ | -| Redis | 7.4.x | 6379 | accusys | /Library/LaunchDaemons/ | -| Ollama | - | 11434 | accusys | /Library/LaunchDaemons/ | -| n8n | 2.3.5 | 5678/5690 | accusys | /Library/LaunchDaemons/ | -| Node.js (n8n) | 22.22.1 | - | - | /opt/homebrew/opt/node@22/ | -| Python (Momentry) | 3.11.14 | - | - | venv/bin/python | -| Caddy | 2.10.x | 2019 | root | /Library/LaunchDaemons/ | -| Gitea | - | 3000 | accusys | /Library/LaunchDaemons/ | -| SFTPGo | 2.7.x | 8080 | accusys | /Library/LaunchDaemons/ | -| Qdrant | 1.17.x | 6333 | accusys | /Library/LaunchDaemons/ | -| MongoDB | - | 27017 | accusys | /Library/LaunchDaemons/ | -| MariaDB | 12.1.x | 3306 | accusys | /Library/LaunchDaemons/ | -| RustDesk | - | 21115-21119 | accusys | /Library/LaunchDaemons/ | -| PHP | 8.3.x | - | - | /opt/homebrew/ | +| 服務 | 版本 | Port | 使用者 | plist 位置 | 資料目錄 | +|------|------|------|--------|-------------|----------| +| PostgreSQL | 18.1 | 5432 | accusys | /Library/LaunchDaemons/ | /Users/accusys/momentry/var/postgresql | +| Redis | 7.4.x | 6379 | accusys | /Library/LaunchDaemons/ | /opt/homebrew/var/redis | +| Ollama | 0.13.5 | 11434 | accusys | /Library/LaunchDaemons/ | /Users/accusys/momentry/var/ollama/models | +| n8n | 2.12.3 | 5678/5681 | accusys | /Library/LaunchDaemons/ | /Users/accusys/momentry/var/n8n | +| Node.js (n8n) | 22.22.1 | - | - | /opt/homebrew/opt/node@22/ | - | +| Python (Momentry) | 3.11.14 | - | - | venv/bin/python | - | +| Caddy | 2.10.x | 2019/443 | root | /Library/LaunchDaemons/ | /Users/accusys/momentry/var/caddy | +| Gitea | - | 3000 | accusys | /Library/LaunchDaemons/ | /Users/accusys/momentry/var/gitea | +| Gitea MCP | - | 8787 | accusys | /Library/LaunchDaemons/ | - | +| SFTPGo | 2.7.x | 8080/2022 | accusys | /Library/LaunchDaemons/ | /Users/accusys/momentry/var/sftpgo | +| Qdrant | 1.17.0 | 6333 | accusys | /Library/LaunchDaemons/ | /Users/accusys/.local/share/qdrant | +| MongoDB | 8.2.6 | 27017 | root | /Library/LaunchDaemons/ | /opt/homebrew/var/mongodb | +| MariaDB | 12.1.x | 3306 | accusys | /Library/LaunchDaemons/ | /Users/accusys/momentry/var/mariadb | +| RustDesk HBBR | - | 21115 | accusys | /Library/LaunchDaemons/ | - | +| RustDesk HBBS | - | 21115-21119 | accusys | /Library/LaunchDaemons/ | /Users/accusys/momentry/var/rustdesk | +| PHP | 8.5.x | 9000 | - | /Library/LaunchDaemons/ | - | +| Momentry API | 0.1.0 | 3002 | accusys | /Library/LaunchDaemons/ | - | +| Momentry Playground | 0.1.0 | 3003 | accusys | - | - | + +--- + +## 重要密碼與金鑰 + +| 服務 | 用途 | 預設值 | +|------|------|--------| +| **PostgreSQL** | 連線密碼 (accusys) | `accusys` | +| **Redis** | 認證密碼 | `accusys` | +| **Qdrant** | API Key | `Test3200Test3200Test3200` | +| **n8n** | 資料庫密碼 | `n8n` (PostgreSQL) | +| **SFTPGo** | 安裝碼 | `Test3200Test3200` | +| **SFTPGo** | DB 密碼 | `sftpgo_pass_2026` | +| **Momentry API** | 資料庫 | `postgres://accusys:accusys@127.0.0.1:5432/momentry` | diff --git a/scripts/check_config.sh b/scripts/check_config.sh new file mode 100755 index 0000000..8906bbc --- /dev/null +++ b/scripts/check_config.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Config Check Script +# 驗證配置是否正確設置 + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "==========================================" +echo "Momentry Core 配置檢查" +echo "==========================================" + +# 檢查 .env 文件 +if [ -f ".env" ]; then + echo -e "${GREEN}✅ .env 文件存在${NC}" +else + if [ -f ".env.example" ]; then + echo -e "${YELLOW}⚠️ .env 文件不存在,使用模板創建...${NC}" + cp .env.example .env + echo -e "${YELLOW}⚠️ 已創建 .env,請編輯並設置正確的憑據${NC}" + else + echo -e "${RED}❌ .env 和 .env.example 都不存在${NC}" + fi +fi + +# 檢查必要配置 +check_var() { + local var_name="$1" + local description="$2" + + if grep -q "^${var_name}=" .env 2>/dev/null; then + echo -e "${GREEN}✅ ${var_name}${NC} - $description" + else + echo -e "${YELLOW}⚠️ ${var_name}${NC} - $description (使用默認值)" + fi +} + +if [ -f ".env" ]; then + echo "" + echo "檢查環境變數..." + check_var "DATABASE_URL" "PostgreSQL 連接" + check_var "REDIS_URL" "Redis 連接" + check_var "REDIS_PASSWORD" "Redis 密碼" + check_var "MOMENTRY_OUTPUT_DIR" "輸出目錄" + check_var "MOMENTRY_PYTHON_PATH" "Python 路徑" + check_var "RUST_LOG" "日誌級別" +fi + +# 檢查目錄權限 +echo "" +echo "檢查目錄權限..." +check_dir() { + local dir="$1" + local description="$2" + + if [ -d "$dir" ]; then + if [ -w "$dir" ]; then + echo -e "${GREEN}✅ ${dir}${NC} - $description (可寫)" + else + echo -e "${RED}❌ ${dir}${NC} - $description (不可寫)" + fi + else + echo -e "${YELLOW}⚠️ ${dir}${NC} - $description (目錄不存在)" + fi +} + +check_dir "/Users/accusys/momentry/output" "輸出目錄" +check_dir "/Users/accusys/momentry/backup" "備份目錄" + +# 檢查 Python +echo "" +echo "檢查 Python..." +if command -v python3.11 &> /dev/null; then + version=$(python3.11 --version 2>&1) + echo -e "${GREEN}✅ Python 3.11 可用${NC} ($version)" +else + echo -e "${RED}❌ Python 3.11 不可用${NC}" +fi + +# 檢查 Rust +echo "" +echo "檢查 Rust..." +if command -v cargo &> /dev/null; then + version=$(cargo --version 2>&1) + echo -e "${GREEN}✅ Cargo 可用${NC} ($version)" +else + echo -e "${RED}❌ Cargo 不可用${NC}" +fi + +echo "" +echo "==========================================" +echo "配置檢查完成" +echo "==========================================" diff --git a/src/core/cache/redis_cache.rs b/src/core/cache/redis_cache.rs new file mode 100644 index 0000000..97acd19 --- /dev/null +++ b/src/core/cache/redis_cache.rs @@ -0,0 +1,162 @@ +use anyhow::Result; +use redis::AsyncCommands; +use serde::{de::DeserializeOwned, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::core::config::{cache as cache_config, REDIS_KEY_PREFIX}; +use crate::core::db::RedisClient; + +pub struct RedisCache { + client: Arc>, +} + +impl RedisCache { + pub fn new() -> Result { + let client = RedisClient::new()?; + Ok(Self { + client: Arc::new(RwLock::new(client)), + }) + } + + fn prefixed_key(&self, key: &str) -> String { + format!("{}cache:{}", REDIS_KEY_PREFIX.as_str(), key) + } + + pub async fn get(&self, key: &str) -> Result> { + let client = self.client.read().await; + let mut conn = client.get_conn_internal().await?; + let prefixed = self.prefixed_key(key); + let value: Option = conn.get(&prefixed).await?; + + match value { + Some(json) => { + let result = serde_json::from_str(&json)?; + Ok(Some(result)) + } + None => Ok(None), + } + } + + pub async fn set(&self, key: &str, value: &T, ttl_secs: u64) -> Result<()> { + let client = self.client.read().await; + let mut conn = client.get_conn_internal().await?; + let prefixed = self.prefixed_key(key); + let json = serde_json::to_string(value)?; + let _: String = conn.set_ex(&prefixed, json, ttl_secs).await?; + Ok(()) + } + + pub async fn delete(&self, key: &str) -> Result { + let client = self.client.read().await; + let mut conn = client.get_conn_internal().await?; + let prefixed = self.prefixed_key(key); + let _: () = conn.del(&prefixed).await?; + Ok(true) + } + + pub async fn exists(&self, key: &str) -> Result { + let client = self.client.read().await; + let mut conn = client.get_conn_internal().await?; + let prefixed = self.prefixed_key(key); + let exists: bool = conn.exists(&prefixed).await?; + Ok(exists) + } + + pub async fn invalidate_pattern(&self, pattern: &str) -> Result { + let client = self.client.read().await; + let mut conn = client.get_conn_internal().await?; + let prefixed_pattern = self.prefixed_key(pattern); + let keys: Vec = redis::cmd("KEYS") + .arg(&prefixed_pattern) + .query_async(&mut conn) + .await?; + let count = keys.len() as u64; + + if !keys.is_empty() { + let _: () = conn.del(&keys).await?; + } + + tracing::debug!("Invalidated {} keys matching pattern: {}", count, pattern); + Ok(count) + } + + pub async fn get_or_fetch(&self, key: &str, ttl_secs: u64, fetcher: F) -> Result + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + T: DeserializeOwned + Serialize, + { + if let Some(cached) = self.get::(key).await? { + tracing::debug!("Redis cache hit for key: {}", key); + return Ok(cached); + } + + tracing::debug!("Redis cache miss for key: {}", key); + let value = fetcher().await?; + if let Err(e) = self.set(key, &value, ttl_secs).await { + tracing::warn!("Failed to cache value in Redis: {}", e); + } + Ok(value) + } + + pub async fn get_health(&self) -> Result> { + let client = self.client.read().await; + let mut conn = client.get_conn_internal().await?; + let key = self.prefixed_key("health"); + let value: Option = conn.get(&key).await?; + Ok(value) + } + + pub async fn set_health(&self, status: &str) -> Result<()> { + let ttl = *cache_config::REDIS_CACHE_TTL_HEALTH; + let client = self.client.read().await; + let mut conn = client.get_conn_internal().await?; + let key = self.prefixed_key("health"); + let _: String = conn.set_ex(&key, status, ttl).await?; + Ok(()) + } + + pub async fn get_video_meta(&self, uuid: &str) -> Result> { + self.get(uuid).await + } + + pub async fn set_video_meta(&self, uuid: &str, value: &serde_json::Value) -> Result<()> { + let ttl = *cache_config::REDIS_CACHE_TTL_VIDEO_META; + self.set(uuid, value, ttl).await + } + + pub async fn invalidate_video_meta(&self, uuid: &str) -> Result { + self.delete(uuid).await + } + + pub async fn invalidate_videos_list(&self) -> Result { + self.invalidate_pattern("videos:*").await + } +} + +impl Clone for RedisCache { + fn clone(&self) -> Self { + Self { + client: Arc::clone(&self.client), + } + } +} + +impl Default for RedisCache { + fn default() -> Self { + Self::new().expect("Failed to create Redis cache") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prefixed_key() { + let cache = RedisCache::new().unwrap(); + assert_eq!(cache.prefixed_key("test"), "momentry:cache:test"); + assert_eq!(cache.prefixed_key("video:abc"), "momentry:cache:video:abc"); + } +} diff --git a/src/core/config.rs b/src/core/config.rs new file mode 100644 index 0000000..2933c76 --- /dev/null +++ b/src/core/config.rs @@ -0,0 +1,139 @@ +use once_cell::sync::Lazy; +use std::env; + +pub static DATABASE_URL: Lazy = Lazy::new(|| { + env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://accusys@localhost:5432/momentry".to_string()) +}); + +pub static MONGODB_URL: Lazy = Lazy::new(|| { + env::var("MONGODB_URL").unwrap_or_else(|_| "mongodb://localhost:27017".to_string()) +}); + +pub static REDIS_URL: Lazy = Lazy::new(|| { + env::var("REDIS_URL").unwrap_or_else(|_| { + let password = env::var("REDIS_PASSWORD").unwrap_or_else(|_| "accusys".to_string()); + // Format: redis://[:password]@host:port (use default user) + format!("redis://:{}@localhost:6379", password) + }) +}); + +pub static REDIS_PASSWORD: Lazy = + Lazy::new(|| env::var("REDIS_PASSWORD").unwrap_or_else(|_| "accusys".to_string())); + +pub static OUTPUT_DIR: Lazy = Lazy::new(|| { + env::var("MOMENTRY_OUTPUT_DIR").unwrap_or_else(|_| "/Users/accusys/momentry/output".to_string()) +}); + +pub static BACKUP_DIR: Lazy = Lazy::new(|| { + env::var("MOMENTRY_BACKUP_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry/backup/momentry".to_string()) +}); + +pub static PYTHON_PATH: Lazy = Lazy::new(|| { + env::var("MOMENTRY_PYTHON_PATH").unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string()) +}); + +pub static SCRIPTS_DIR: Lazy = Lazy::new(|| { + env::var("MOMENTRY_SCRIPTS_DIR") + .unwrap_or_else(|_| "/Users/accusys/momentry_core_0.1/scripts".to_string()) +}); + +pub static LOG_LEVEL: Lazy = Lazy::new(|| { + env::var("RUST_LOG") + .or_else(|_| env::var("MOMENTRY_LOG_LEVEL")) + .unwrap_or_else(|_| "info".to_string()) +}); + +pub static MEDIA_BASE_URL: Lazy = Lazy::new(|| { + env::var("MOMENTRY_MEDIA_BASE_URL") + .unwrap_or_else(|_| "https://wp.momentry.ddns.net".to_string()) +}); + +pub static SERVER_PORT: Lazy = Lazy::new(|| { + env::var("MOMENTRY_SERVER_PORT") + .unwrap_or_else(|_| "3002".to_string()) + .parse() + .unwrap_or(3002) +}); + +pub static REDIS_KEY_PREFIX: Lazy = + Lazy::new(|| env::var("MOMENTRY_REDIS_PREFIX").unwrap_or_else(|_| "momentry:".to_string())); + +pub mod processor { + use super::*; + + pub static ASR_TIMEOUT_SECS: Lazy = Lazy::new(|| { + env::var("MOMENTRY_ASR_TIMEOUT") + .unwrap_or_else(|_| "3600".to_string()) + .parse() + .unwrap_or(3600) + }); + + pub static CUT_TIMEOUT_SECS: Lazy = Lazy::new(|| { + env::var("MOMENTRY_CUT_TIMEOUT") + .unwrap_or_else(|_| "3600".to_string()) + .parse() + .unwrap_or(3600) + }); + + pub static DEFAULT_TIMEOUT_SECS: Lazy = Lazy::new(|| { + env::var("MOMENTRY_DEFAULT_TIMEOUT") + .unwrap_or_else(|_| "7200".to_string()) + .parse() + .unwrap_or(7200) + }); +} + +pub mod cache { + use super::*; + + pub static MONGODB_CACHE_ENABLED: Lazy = Lazy::new(|| { + env::var("MONGODB_CACHE_ENABLED") + .unwrap_or_else(|_| "true".to_string()) + .parse() + .unwrap_or(true) + }); + + pub static MONGODB_CACHE_TTL_VIDEOS: Lazy = Lazy::new(|| { + env::var("MONGODB_CACHE_TTL_VIDEOS") + .unwrap_or_else(|_| "300".to_string()) + .parse() + .unwrap_or(300) + }); + + pub static MONGODB_CACHE_TTL_SEARCH: Lazy = Lazy::new(|| { + env::var("MONGODB_CACHE_TTL_SEARCH") + .unwrap_or_else(|_| "300".to_string()) + .parse() + .unwrap_or(300) + }); + + pub static MONGODB_CACHE_TTL_HYBRID_SEARCH: Lazy = Lazy::new(|| { + env::var("MONGODB_CACHE_TTL_HYBRID_SEARCH") + .unwrap_or_else(|_| "600".to_string()) + .parse() + .unwrap_or(600) + }); + + pub static MONGODB_CACHE_TTL_VIDEO_META: Lazy = Lazy::new(|| { + env::var("MONGODB_CACHE_TTL_VIDEO_META") + .unwrap_or_else(|_| "3600".to_string()) + .parse() + .unwrap_or(3600) + }); + + pub static REDIS_CACHE_TTL_HEALTH: Lazy = Lazy::new(|| { + env::var("REDIS_CACHE_TTL_HEALTH") + .unwrap_or_else(|_| "30".to_string()) + .parse() + .unwrap_or(30) + }); + + pub static REDIS_CACHE_TTL_VIDEO_META: Lazy = Lazy::new(|| { + env::var("REDIS_CACHE_TTL_VIDEO_META") + .unwrap_or_else(|_| "3600".to_string()) + .parse() + .unwrap_or(3600) + }); +} diff --git a/src/core/db/redis_client.rs b/src/core/db/redis_client.rs index f5f32c8..98b0069 100644 --- a/src/core/db/redis_client.rs +++ b/src/core/db/redis_client.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; +use crate::core::config::REDIS_KEY_PREFIX; + pub struct RedisClient { client: Client, state: Arc>, @@ -18,13 +20,8 @@ pub struct RedisState { impl RedisClient { pub fn new() -> Result { - let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| { - let password = - std::env::var("REDIS_PASSWORD").unwrap_or_else(|_| "accusys".to_string()); - format!("redis://:{}@localhost:6379", password) - }); - - let client = Client::open(redis_url.as_str()).context("Failed to connect to Redis")?; + let client = Client::open(crate::core::config::REDIS_URL.as_str()) + .context("Failed to connect to Redis")?; Ok(Self { client, @@ -49,7 +46,8 @@ impl RedisClient { pub async fn get_job_status(&self, uuid: &str) -> Result> { let mut conn = self.get_conn_internal().await?; - let key = format!("momentry:job:{}", uuid); + let prefix = REDIS_KEY_PREFIX.as_str(); + let key = format!("{}job:{}", prefix, uuid); let status: Option = conn.hget(&key, "status").await?; if status.is_none() { @@ -83,7 +81,8 @@ impl RedisClient { status: &ProcessorStatus, ) -> Result<()> { let mut conn = self.get_conn_internal().await?; - let key = format!("momentry:job:{}:processor:{}", uuid, processor); + let prefix = REDIS_KEY_PREFIX.as_str(); + let key = format!("{}job:{}:processor:{}", prefix, uuid, processor); let _: Option = conn .hset_multiple( @@ -111,7 +110,8 @@ impl RedisClient { processor: &str, ) -> Result> { let mut conn = self.get_conn_internal().await?; - let key = format!("momentry:job:{}:processor:{}", uuid, processor); + let prefix = REDIS_KEY_PREFIX.as_str(); + let key = format!("{}job:{}:processor:{}", prefix, uuid, processor); let status: Option = conn.hget(&key, "status").await?; if status.is_none() { @@ -138,7 +138,8 @@ impl RedisClient { pub async fn publish_progress(&self, uuid: &str, message: &ProgressMessage) -> Result<()> { let mut conn = self.get_conn_internal().await?; - let channel = format!("momentry:progress:{}", uuid); + let prefix = REDIS_KEY_PREFIX.as_str(); + let channel = format!("{}progress:{}", prefix, uuid); let json = serde_json::to_string(message)?; let _: usize = conn.publish(&channel, json).await?; @@ -148,7 +149,8 @@ impl RedisClient { pub async fn subscribe_progress(&self, uuid: &str) -> Result { let mut pubsub = self.client.get_async_pubsub().await?; - let channel = format!("momentry:progress:{}", uuid); + let prefix = REDIS_KEY_PREFIX.as_str(); + let channel = format!("{}progress:{}", prefix, uuid); pubsub.subscribe(channel).await?; @@ -174,45 +176,285 @@ impl RedisClient { pub async fn add_to_active_jobs(&self, uuid: &str) -> Result<()> { let mut conn = self.get_conn_internal().await?; - let _: usize = conn.sadd("momentry:jobs:active", uuid).await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let _: usize = conn.sadd(format!("{}jobs:active", prefix), uuid).await?; Ok(()) } pub async fn move_to_completed_jobs(&self, uuid: &str) -> Result<()> { let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); let _: bool = conn - .smove("momentry:jobs:active", "momentry:jobs:completed", uuid) + .smove( + format!("{}jobs:active", prefix), + format!("{}jobs:completed", prefix), + uuid, + ) .await?; Ok(()) } pub async fn move_to_failed_jobs(&self, uuid: &str) -> Result<()> { let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); let _: bool = conn - .smove("momentry:jobs:active", "momentry:jobs:failed", uuid) + .smove( + format!("{}jobs:active", prefix), + format!("{}jobs:failed", prefix), + uuid, + ) .await?; Ok(()) } pub async fn get_active_jobs(&self) -> Result> { let mut conn = self.get_conn_internal().await?; - let jobs: Vec = conn.smembers("momentry:jobs:active").await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let jobs: Vec = conn.smembers(format!("{}jobs:active", prefix)).await?; Ok(jobs) } pub async fn set_health(&self, status: &str) -> Result<()> { let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); let _: String = conn - .set_ex("momentry:health:momentry_core", status, 60) + .set_ex(format!("{}health:momentry_core", prefix), status, 60) .await?; Ok(()) } pub async fn get_health(&self) -> Result> { let mut conn = self.get_conn_internal().await?; - let health: Option = conn.get("momentry:health:momentry_core").await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let health: Option = conn.get(format!("{}health:momentry_core", prefix)).await?; Ok(health) } + + pub async fn sync_monitor_job(&self, job: &MonitorJobRedis) -> Result<()> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let key = format!("{}monitor:job:{}", prefix, job.uuid); + + let _: Option = conn + .hset_multiple( + &key, + &[ + ("uuid", job.uuid.as_str()), + ("status", job.status.as_str()), + ("current_processor", job.current_processor.as_str()), + ("progress_total", job.progress_total.to_string().as_str()), + ( + "progress_current", + job.progress_current.to_string().as_str(), + ), + ("error_count", job.error_count.to_string().as_str()), + ("started_at", job.started_at.as_str()), + ("updated_at", job.updated_at.as_str()), + ], + ) + .await?; + + let _: bool = conn.expire(&key, 86400).await?; + + Ok(()) + } + + pub async fn publish_job_error(&self, uuid: &str, error: &str) -> Result<()> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let channel = format!("{}errors:{}", prefix, uuid); + + let error_msg = JobErrorMessage { + uuid: uuid.to_string(), + error: error.to_string(), + timestamp: chrono::Utc::now().timestamp(), + }; + + let json = serde_json::to_string(&error_msg)?; + let _: usize = conn.publish(&channel, json).await?; + + Ok(()) + } + + pub async fn publish_anomaly_alert(&self, alert: &AnomalyAlertMessage) -> Result<()> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let channel = format!("{}anomaly:alerts", prefix); + + let json = serde_json::to_string(alert)?; + let _: usize = conn.publish(&channel, json).await?; + + let key = format!("{}anomaly:key:{}", prefix, alert.key_id); + let alert_data = serde_json::json!({ + "key_id": alert.key_id, + "anomaly_type": alert.anomaly_type, + "severity": alert.severity, + "timestamp": alert.timestamp, + "message": alert.message, + }); + let _: Option = conn + .hset(&key, "latest", &serde_json::to_string(&alert_data)?) + .await?; + let _: bool = conn.expire(&key, 86400).await?; + + Ok(()) + } + + pub async fn subscribe_anomaly_alerts(&self) -> Result { + let mut pubsub = self.client.get_async_pubsub().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + pubsub + .subscribe(format!("{}anomaly:alerts", prefix)) + .await?; + Ok(pubsub) + } + + pub async fn get_latest_anomaly(&self, key_id: &str) -> Result> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let key = format!("{}anomaly:key:{}", prefix, key_id); + let latest: Option = conn.hget(&key, "latest").await?; + + if let Some(json) = latest { + let alert: AnomalyAlertMessage = serde_json::from_str(&json)?; + Ok(Some(alert)) + } else { + Ok(None) + } + } + + pub async fn subscribe_job_errors(&self, uuid: &str) -> Result { + let mut pubsub = self.client.get_async_pubsub().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let channel = format!("{}errors:{}", prefix, uuid); + + pubsub.subscribe(channel).await?; + + Ok(pubsub) + } + + pub async fn update_worker_job_status( + &self, + uuid: &str, + job_id: i64, + status: &str, + current_processor: Option<&str>, + progress: i32, + total: i32, + ) -> Result<()> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let key = format!("{}worker:job:{}", prefix, uuid); + + let _: Option = conn + .hset_multiple( + &key, + &[ + ("job_id", job_id.to_string().as_str()), + ("status", status), + ("current_processor", current_processor.unwrap_or("")), + ("progress_current", progress.to_string().as_str()), + ("progress_total", total.to_string().as_str()), + ("updated_at", &chrono::Utc::now().to_rfc3339()), + ], + ) + .await?; + + let _: bool = conn.expire(&key, 86400).await?; + + Ok(()) + } + + pub async fn update_worker_processor_status( + &self, + uuid: &str, + processor: &str, + status: &str, + error: Option<&str>, + ) -> Result<()> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let key = format!("{}worker:job:{}:processor:{}", prefix, uuid, processor); + + let now = chrono::Utc::now().to_rfc3339(); + + let mut fields: Vec<(&str, &str)> = vec![("status", status), ("updated_at", &now)]; + + if let Some(err) = error { + fields.push(("error", err)); + } + + let _: Option = conn.hset_multiple(&key, &fields).await?; + let _: bool = conn.expire(&key, 86400).await?; + + Ok(()) + } + + pub async fn get_worker_job_status(&self, uuid: &str) -> Result> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let key = format!("{}worker:job:{}", prefix, uuid); + + let exists: bool = conn.exists(&key).await?; + if !exists { + return Ok(None); + } + + let status: String = conn.hget(&key, "status").await?; + let job_id: i64 = conn.hget(&key, "job_id").await?; + let current_processor: String = conn.hget(&key, "current_processor").await?; + let progress_current: i32 = conn.hget(&key, "progress_current").await?; + let progress_total: i32 = conn.hget(&key, "progress_total").await?; + let updated_at: String = conn.hget(&key, "updated_at").await?; + + Ok(Some(WorkerJobStatus { + uuid: uuid.to_string(), + job_id, + status, + current_processor, + progress_current, + progress_total, + updated_at, + })) + } + + pub async fn delete_worker_job(&self, uuid: &str) -> Result<()> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + + let key = format!("{}worker:job:{}", prefix, uuid); + let _: i32 = conn.del(&key).await?; + + let processor_types = ["asr", "cut", "yolo", "ocr", "face", "pose", "asrx"]; + for ptype in processor_types { + let proc_key = format!("{}worker:job:{}:processor:{}", prefix, uuid, ptype); + let _: i32 = conn.del(&proc_key).await?; + } + + Ok(()) + } + + pub async fn get_all_worker_jobs(&self) -> Result> { + let mut conn = self.get_conn_internal().await?; + let prefix = REDIS_KEY_PREFIX.as_str(); + let keys: Vec = conn.keys(format!("{}worker:job:*", prefix)).await?; + + let mut jobs = Vec::new(); + for key in keys { + let uuid = key.replace(&format!("{}worker:job:", prefix), ""); + if let Some(status) = self.get_worker_job_status(&uuid).await? { + jobs.push(WorkerJobInfo { + uuid, + job_id: status.job_id, + status: status.status, + progress_current: status.progress_current, + progress_total: status.progress_total, + }); + } + } + + Ok(jobs) + } } impl Default for RedisClient { @@ -260,3 +502,55 @@ pub struct ProgressData { pub current: Option, pub total: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitorJobRedis { + pub uuid: String, + pub status: String, + pub current_processor: String, + pub progress_total: i32, + pub progress_current: i32, + pub error_count: i32, + pub started_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobErrorMessage { + pub uuid: String, + pub error: String, + pub timestamp: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnomalyAlertMessage { + pub key_id: String, + pub anomaly_type: String, + pub severity: String, + pub ip_address: Option, + pub request_count: Option, + pub error_rate: Option, + pub unique_ips: Option, + pub timestamp: i64, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkerJobStatus { + pub uuid: String, + pub job_id: i64, + pub status: String, + pub current_processor: String, + pub progress_current: i32, + pub progress_total: i32, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkerJobInfo { + pub uuid: String, + pub job_id: i64, + pub status: String, + pub progress_current: i32, + pub progress_total: i32, +} diff --git a/src/main.rs b/src/main.rs index 617e80c..33fbb2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,10 +5,620 @@ use std::path::Path; use std::str; 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::ui::progress::{ProcessorType, ProgressState, ProgressUi}; -use momentry_core::{Embedder, OutputDir, PostgresDb, QdrantDb, VectorPayload, VideoRecord}; +use momentry_core::{ + Embedder, OutputDir, PostgresDb, QdrantDb, RedisClient, VectorPayload, VideoRecord, VideoStatus, +}; + +fn parse_key_type(s: Option<&str>) -> ApiKeyType { + match s.map(|s| s.to_lowercase()).as_deref() { + Some("system") => ApiKeyType::System, + Some("user") => ApiKeyType::User, + Some("service") => ApiKeyType::Service, + Some("integration") => ApiKeyType::Integration, + Some("emergency") => ApiKeyType::Emergency, + _ => ApiKeyType::User, + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ProcessingDecision { + Process, + SkipComplete, + ResumePartial, + ForceReprocess, +} + +impl ProcessingDecision { + pub fn should_process(&self) -> bool { + matches!( + self, + ProcessingDecision::Process + | ProcessingDecision::ResumePartial + | ProcessingDecision::ForceReprocess + ) + } + + pub fn should_resume(&self) -> bool { + matches!(self, ProcessingDecision::ResumePartial) + } +} + +#[derive(Debug, Clone)] +pub struct SystemResources { + pub cpu_idle_percent: f64, + pub memory_available_mb: u64, + pub memory_total_mb: u64, + pub memory_used_percent: f64, + pub gpu_available: bool, + pub gpu_type: GpuType, + pub gpu_utilization: Option, +} + +#[derive(Debug, Clone, Copy)] +pub enum GpuType { + Nvidia, + AppleMps, +} + +impl SystemResources { + pub fn check() -> Self { + let cpu_idle = Self::get_cpu_idle(); + let (mem_available, mem_total) = Self::get_memory_info(); + let mem_used_pct = if mem_total > 0 && mem_available <= mem_total { + ((mem_total - mem_available) as f64 / mem_total as f64) * 100.0 + } else if mem_total > 0 { + 100.0 + } else { + 0.0 + }; + let (gpu_available, gpu_type, gpu_util) = Self::get_gpu_info(); + + Self { + cpu_idle_percent: cpu_idle, + memory_available_mb: mem_available, + memory_total_mb: mem_total, + memory_used_percent: mem_used_pct, + gpu_available, + gpu_type, + gpu_utilization: gpu_util, + } + } + + pub fn can_parallel(&self, required_memory_mb: u64) -> bool { + const MIN_CPU_IDLE: f64 = 30.0; + const MIN_MEMORY_MB: u64 = 4096; + + self.cpu_idle_percent >= MIN_CPU_IDLE + && self.memory_available_mb >= required_memory_mb + && self.memory_available_mb >= MIN_MEMORY_MB + } + + pub fn recommend_parallel_modules(&self) -> Vec<&'static str> { + let mut recommended = Vec::new(); + + if self.gpu_available { + recommended.push("yolo"); + } + + if self.memory_available_mb >= 8192 { + recommended.push("ocr"); + recommended.push("face"); + recommended.push("pose"); + } + + recommended + } + + fn get_cpu_idle() -> f64 { + use std::process::Command; + let output = Command::new("top").args(["-l", "1", "-n", "1"]).output(); + match output { + Ok(o) => { + let s = String::from_utf8_lossy(&o.stdout); + if let Some(line) = s.lines().find(|l| l.contains("idle")) { + if let Some(pct) = line + .split_whitespace() + .find_map(|s| s.strip_suffix("%idle")) + { + pct.trim().parse().ok().unwrap_or(50.0) + } else { + 50.0 + } + } else { + 50.0 + } + } + Err(_) => 50.0, + } + } + + fn get_memory_info() -> (u64, u64) { + use std::process::Command; + let output = Command::new("sysctl").args(["hw.memsize"]).output(); + match output { + Ok(o) => { + let s = String::from_utf8_lossy(&o.stdout); + let total = s + .split_whitespace() + .nth(1) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0) + / 1024 + / 1024; + + let vm_stat = Command::new("vm_stat").output(); + let available = match vm_stat { + Ok(v) => { + let vs = String::from_utf8_lossy(&v.stdout); + let mut free_pages: u64 = 0; + let mut inactive_pages: u64 = 0; + + for line in vs.lines() { + if line.contains("Pages free:") { + free_pages = line + .split_whitespace() + .last() + .and_then(|v| v.trim_end_matches('.').parse().ok()) + .unwrap_or(0); + } else if line.contains("Pages inactive:") { + inactive_pages = line + .split_whitespace() + .last() + .and_then(|v| v.trim_end_matches('.').parse().ok()) + .unwrap_or(0); + } + } + + // Pages * 4096 bytes / 1024 / 1024 = MB + (free_pages + inactive_pages) * 4096 / 1024 / 1024 + } + Err(_) => total / 4, + }; + + (available, total) + } + Err(_) => (0, 0), + } + } + + fn get_gpu_info() -> (bool, GpuType, Option) { + use std::process::Command; + + // Check NVIDIA GPU + let nvidia_output = Command::new("nvidia-smi") + .args([ + "--query-gpu=utilization.gpu", + "--format=csv,noheader,nounits", + ]) + .output(); + + if let Ok(o) = nvidia_output { + if o.status.success() { + let s = String::from_utf8_lossy(&o.stdout); + let util = s.trim().parse::().ok(); + return (true, GpuType::Nvidia, util); + } + } + + // Check Apple MPS (Metal Performance Shaders) + let mps_output = Command::new("system_profiler") + .args(["SPDisplaysDataType", "-detailLevel", "mini"]) + .output(); + + if let Ok(o) = mps_output { + let s = String::from_utf8_lossy(&o.stdout); + if s.contains("Metal") || s.contains("Apple") { + return (true, GpuType::AppleMps, Some(0.0)); + } + } + + (false, GpuType::Nvidia, None) + } +} + +impl std::fmt::Display for SystemResources { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "CPU: {:.1}% idle, Memory: {:.1}GB/{:.1}GB ({:.0}% used), GPU: {}", + self.cpu_idle_percent, + self.memory_available_mb as f64 / 1024.0, + self.memory_total_mb as f64 / 1024.0, + self.memory_used_percent, + if self.gpu_available { + format!("{:.0}% utilized", self.gpu_utilization.unwrap_or(0.0)) + } else { + "N/A".to_string() + } + ) + } +} + +fn decide_processing(json_path: &Path, force: bool, resume: bool) -> ProcessingDecision { + if !json_path.exists() { + return ProcessingDecision::Process; + } + + if force { + return ProcessingDecision::ForceReprocess; + } + + if resume { + return ProcessingDecision::ResumePartial; + } + + match check_json_completeness(json_path) { + JsonCompleteness::Complete => ProcessingDecision::SkipComplete, + JsonCompleteness::Partial { current, total } => { + eprintln!("\n⚠️ Found incomplete JSON file: {}", json_path.display()); + eprintln!( + " Progress: {}/{} ({:.1}%)", + current, + total, + (current as f64 / total as f64) * 100.0 + ); + eprintln!(" Use --resume to continue from checkpoint"); + eprintln!(" Use --force to reprocess from scratch"); + ProcessingDecision::SkipComplete + } + JsonCompleteness::Empty => ProcessingDecision::Process, + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum JsonCompleteness { + Complete, + Partial { current: u32, total: u32 }, + Empty, +} + +fn check_json_completeness(json_path: &Path) -> JsonCompleteness { + let content = match std::fs::read_to_string(json_path) { + Ok(c) => c, + Err(_) => return JsonCompleteness::Empty, + }; + + if content.trim().is_empty() { + return JsonCompleteness::Empty; + } + + let json: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return JsonCompleteness::Empty, + }; + + match json.get("segments") { + Some(serde_json::Value::Array(arr)) if !arr.is_empty() => JsonCompleteness::Complete, + Some(serde_json::Value::Object(obj)) => { + let current = obj.get("current").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let total = obj.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + if total > 0 && current < total { + JsonCompleteness::Partial { current, total } + } else { + JsonCompleteness::Complete + } + } + _ => JsonCompleteness::Complete, + } +} + +async fn process_asr_module( + asr_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Asr).start(1); + } + let asr_result = momentry_core::core::processor::process_asr( + video_path, + asr_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let asr_json = serde_json::to_string_pretty(&asr_result)?; + std::fs::write(asr_path, &asr_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "asr.json"); + println!(" ✓ ASR saved: {} segments", asr_result.segments.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Asr) + .complete(&format!("{} segments", asr_result.segments.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_cut_module( + cut_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Cut).start(1); + } + let cut_result = momentry_core::core::processor::process_cut( + video_path, + cut_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let cut_json = serde_json::to_string_pretty(&cut_result)?; + std::fs::write(cut_path, &cut_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "cut.json"); + println!(" ✓ CUT saved: {} scenes", cut_result.scenes.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Cut) + .complete(&format!("{} scenes", cut_result.scenes.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_asrx_module( + asrx_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Asrx).start(1); + } + let asrx_result = momentry_core::core::processor::process_asrx( + video_path, + asrx_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let asrx_json = serde_json::to_string_pretty(&asrx_result)?; + std::fs::write(asrx_path, &asrx_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "asrx.json"); + println!(" ✓ ASRX saved: {} segments", asrx_result.segments.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Asrx) + .complete(&format!("{} segments", asrx_result.segments.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_yolo_module( + yolo_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Yolo).start(1); + } + let yolo_result = momentry_core::core::processor::process_yolo( + video_path, + yolo_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let yolo_json = serde_json::to_string_pretty(&yolo_result)?; + std::fs::write(yolo_path, &yolo_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "yolo.json"); + println!(" ✓ YOLO saved: {} frames", yolo_result.frame_count); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Yolo) + .complete(&format!("{} frames", yolo_result.frame_count)); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_ocr_module( + ocr_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Ocr).start(1); + } + let ocr_result = momentry_core::core::processor::process_ocr( + video_path, + ocr_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let ocr_json = serde_json::to_string_pretty(&ocr_result)?; + std::fs::write(ocr_path, &ocr_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "ocr.json"); + println!( + " ✓ OCR saved: {} frames with text", + ocr_result.frames.len() + ); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Ocr) + .complete(&format!("{} frames", ocr_result.frames.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_face_module( + face_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Face).start(1); + } + let face_result = momentry_core::core::processor::process_face( + video_path, + face_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let face_json = serde_json::to_string_pretty(&face_result)?; + std::fs::write(face_path, &face_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "face.json"); + println!(" ✓ Face saved: {} frames", face_result.frames.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Face) + .complete(&format!("{} frames", face_result.frames.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_pose_module( + pose_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Pose).start(1); + } + let pose_result = momentry_core::core::processor::process_pose( + video_path, + pose_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let pose_json = serde_json::to_string_pretty(&pose_result)?; + std::fs::write(pose_path, &pose_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "pose.json"); + println!(" ✓ Pose saved: {} frames", pose_result.frames.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Pose) + .complete(&format!("{} frames", pose_result.frames.len())); + state.stop(); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_story_module( + story_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Story).start(1); + } + let story_result = momentry_core::core::processor::process_story( + video_path, + story_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let story_json = serde_json::to_string_pretty(&story_result)?; + std::fs::write(story_path, &story_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "story.json"); + println!( + " ✓ Story saved: {} parent chunks, {} child chunks", + story_result.stats.total_parent_chunks, story_result.stats.total_child_chunks + ); + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Story).complete(&format!( + "{} parents, {} children", + story_result.stats.total_parent_chunks, story_result.stats.total_child_chunks + )); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_caption_module( + caption_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Caption).start(1); + } + let caption_result = momentry_core::core::processor::process_caption( + video_path, + caption_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let caption_json = serde_json::to_string_pretty(&caption_result)?; + std::fs::write(caption_path, &caption_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "caption.json"); + println!(" ✓ Caption saved: {} frames", caption_result.total_frames); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Caption) + .complete(&format!("{} frames", caption_result.total_frames)); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} #[derive(Parser)] #[command(name = "momentry")] @@ -29,6 +639,20 @@ enum Commands { Process { /// UUID or path target: String, + /// Modules to process (comma separated: asr,cut,asrx,yolo,ocr,face,pose,story,caption) + /// If not specified, processes all modules + #[arg(short, long, value_delimiter = ',')] + modules: Option>, + /// Modules to process via cloud (comma separated) + /// Example: --cloud asr,yolo + #[arg(long, value_delimiter = ',')] + cloud: Option>, + /// Force reprocess even if JSON exists (skip completeness check) + #[arg(long, default_value = "false")] + force: bool, + /// Resume from last checkpoint if processing was interrupted + #[arg(long, default_value = "false")] + resume: bool, }, /// Generate chunks and store in database Chunk { @@ -55,14 +679,32 @@ enum Commands { /// Directories to watch (comma separated) directories: Option, }, + /// Check system resources and recommend processing strategy + System { + /// Show detailed GPU info (NVIDIA/MPS) + #[arg(long)] + gpu: bool, + }, /// Start API server Server { /// Host #[arg(long, default_value = "127.0.0.1")] host: String, - /// Port - #[arg(long, default_value = "3000")] - port: u16, + /// Port (defaults to MOMENTRY_SERVER_PORT env var, or3002 for production) + #[arg(long)] + port: Option, + }, + /// Start job worker + Worker { + /// Max concurrent processors + #[arg(long)] + max_concurrent: Option, + /// Poll interval in seconds + #[arg(long)] + poll_interval: Option, + /// Batch size + #[arg(long)] + batch_size: Option, }, /// Query using RAG Query { @@ -99,6 +741,82 @@ enum Commands { /// Days to keep (for cleanup) days: Option, }, + /// Manage API keys + ApiKey { + /// Action: create, list, validate, revoke, rotate, stats + #[arg(value_enum)] + action: ApiKeyAction, + /// Key name (for create) + name: Option, + /// Key type (system, user, service, integration, emergency) + #[arg(long)] + key_type: Option, + /// TTL in days (for create) + #[arg(long)] + ttl: Option, + /// API key to validate/revoke + #[arg(long)] + key: Option, + }, + /// Manage Gitea API tokens + Gitea { + /// Action: create, list, delete, verify + #[arg(value_enum)] + action: GiteaAction, + /// Gitea username + #[arg(long)] + username: Option, + /// Gitea password (for create/list/delete) + #[arg(long)] + password: Option, + /// Token name (for create/delete) + #[arg(long)] + token_name: Option, + /// Token scopes (comma separated: read:repository,write:issue) + #[arg(long)] + scopes: Option, + }, + /// Manage n8n API keys + N8n { + /// Action: create, list, delete, verify + #[arg(value_enum)] + action: N8nAction, + /// n8n API key (for create/list/delete) + #[arg(long)] + api_key: Option, + /// API key label (for create/delete) + #[arg(long)] + label: Option, + /// Expiration days (for create) + #[arg(long)] + expires_in_days: Option, + }, +} + +#[derive(clap::ValueEnum, Clone)] +enum ApiKeyAction { + Create, + List, + Validate, + Revoke, + Rotate, + Stats, +} + +#[derive(clap::ValueEnum, Clone)] +enum GiteaAction { + Create, + List, + Delete, + Verify, +} + +#[derive(clap::ValueEnum, Clone)] +enum N8nAction { + Create, + List, + Delete, + Verify, } #[tokio::main] @@ -189,6 +907,9 @@ async fn main() -> Result<()> { fps, probe_json: Some(json_str), storage: Default::default(), + status: VideoStatus::Pending, + user_id: None, + job_id: None, created_at: String::new(), }; @@ -197,8 +918,98 @@ async fn main() -> Result<()> { Ok(()) } - Commands::Process { target } => { + Commands::Process { + target, + modules, + cloud, + force, + resume, + } => { println!("Processing: {}", target); + println!(" force: {}, resume: {}", force, resume); + + // Parse selected modules + let selected_modules: Option> = modules.as_ref().map(|m| { + m.iter() + .filter_map(|name| { + let name_lower = name.to_lowercase(); + match name_lower.as_str() { + "asr" => Some(ProcessorType::Asr), + "cut" => Some(ProcessorType::Cut), + "asrx" => Some(ProcessorType::Asrx), + "yolo" => Some(ProcessorType::Yolo), + "ocr" => Some(ProcessorType::Ocr), + "face" => Some(ProcessorType::Face), + "pose" => Some(ProcessorType::Pose), + "story" => Some(ProcessorType::Story), + "caption" => Some(ProcessorType::Caption), + _ => { + eprintln!("Unknown module: {}", name); + None + } + } + }) + .collect() + }); + + // Parse cloud modules + let cloud_modules: Vec = cloud + .as_ref() + .map(|c| { + c.iter() + .filter_map(|name| { + let name_lower = name.to_lowercase(); + match name_lower.as_str() { + "asr" => Some(ProcessorType::Asr), + "cut" => Some(ProcessorType::Cut), + "asrx" => Some(ProcessorType::Asrx), + "yolo" => Some(ProcessorType::Yolo), + "ocr" => Some(ProcessorType::Ocr), + "face" => Some(ProcessorType::Face), + "pose" => Some(ProcessorType::Pose), + "story" => Some(ProcessorType::Story), + "caption" => Some(ProcessorType::Caption), + _ => { + eprintln!("Unknown cloud module: {}", name); + None + } + } + }) + .collect() + }) + .unwrap_or_default(); + + if let Some(ref mods) = selected_modules { + println!( + " Modules: {}", + mods.iter() + .map(|m| m.to_string()) + .collect::>() + .join(", ") + ); + } else { + println!(" Modules: ALL"); + } + + if !cloud_modules.is_empty() { + println!( + " Cloud: {}", + cloud_modules + .iter() + .map(|m| m.to_string()) + .collect::>() + .join(", ") + ); + } + + let processing_mode = if force { + "FORCE (reprocess all)" + } else if resume { + "RESUME (continue from checkpoint)" + } else { + "SMART (skip complete, resume partial)" + }; + println!(" Mode: {}", processing_mode); // Compute UUID if path is given let uuid = if target.len() == 16 && !target.contains('/') { @@ -227,6 +1038,17 @@ async fn main() -> Result<()> { let progress_state = Arc::new(Mutex::new(ProgressState::new(&video_name))); progress_state.lock().unwrap().start(); + // Helper closure to check if a module should be processed + let should_process = |module: ProcessorType| -> bool { + selected_modules + .as_ref() + .map(|mods| mods.contains(&module)) + .unwrap_or(true) + }; + + // Helper closure to check if a module should run in the cloud + let is_cloud = |module: ProcessorType| -> bool { cloud_modules.contains(&module) }; + // Create UI and wrap in Arc for sharing with Redis subscriber let ui = Arc::new(Mutex::new(ProgressUi::new(&video_name).ok())); if let Some(ref mut ui) = *ui.lock().unwrap() { @@ -272,9 +1094,10 @@ async fn main() -> Result<()> { { if let Ok(mut conn) = redis_client.get_conn().await { + let prefix = momentry_core::core::config::REDIS_KEY_PREFIX.as_str(); let key = format!( - "momentry:job:{}:processor:{}", - uuid, processor + "{}job:{}:processor:{}", + prefix, uuid, processor ); let _: () = redis::cmd("HSET") .arg(&key) @@ -333,204 +1156,484 @@ async fn main() -> Result<()> { } }); - // Process ASR - println!("\nRunning ASR..."); - { - let mut state = progress_state.lock().unwrap(); - state.get_processor(ProcessorType::Asr).start(1); - } - let asr_path = output_dir.get_output_path(&uuid, "asr.json"); - let asr_result = momentry_core::core::processor::process_asr( - video_path, - asr_path.to_str().unwrap(), - Some(&uuid), - ) - .await?; - let asr_json = serde_json::to_string_pretty(&asr_result)?; - std::fs::write(&asr_path, &asr_json)?; - let _ = output_dir.backup_file(&uuid, "asr.json"); - println!("ASR saved to: {}", asr_path.display()); - println!(" {} segments found", asr_result.segments.len()); - { - let mut state = progress_state.lock().unwrap(); - state - .get_processor(ProcessorType::Asr) - .complete(&format!("{} segments", asr_result.segments.len())); - } - if let Some(ref mut ui) = *ui.lock().unwrap() { - let _ = ui.render(); + // Process ASR (Automatic Speech Recognition) + if should_process(ProcessorType::Asr) { + let asr_path = output_dir.get_output_path(&uuid, "asr.json"); + let decision = decide_processing(&asr_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nASR: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nASR: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&asr_path).ok(); + if is_cloud(ProcessorType::Asr) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_asr_module(&asr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nASR: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Asr) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_asr_module(&asr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Asr) { + println!("\nASR: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nASR: ⚙️ Processing..."); + process_asr_module(&asr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + } } // Update storage status db.update_storage_status(&uuid, "fs_json", true).await?; // Process CUT (scene detection) - println!("\nRunning CUT (scene detection)..."); - { - let mut state = progress_state.lock().unwrap(); - state.get_processor(ProcessorType::Cut).start(1); - } - let cut_path = output_dir.get_output_path(&uuid, "cut.json"); - let _cut_result = momentry_core::core::processor::process_cut( - video_path, - cut_path.to_str().unwrap(), - Some(&uuid), - ) - .await?; - let cut_json = serde_json::to_string_pretty(&_cut_result)?; - std::fs::write(&cut_path, &cut_json)?; - let _ = output_dir.backup_file(&uuid, "cut.json"); - println!("CUT saved to: {}", cut_path.display()); - println!(" {} scenes found", _cut_result.scenes.len()); - { - let mut state = progress_state.lock().unwrap(); - state - .get_processor(ProcessorType::Cut) - .complete(&format!("{} scenes", _cut_result.scenes.len())); - } - if let Some(ref mut ui) = *ui.lock().unwrap() { - let _ = ui.render(); + if should_process(ProcessorType::Cut) { + let cut_path = output_dir.get_output_path(&uuid, "cut.json"); + let decision = decide_processing(&cut_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nCUT: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nCUT: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&cut_path).ok(); + if is_cloud(ProcessorType::Cut) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_cut_module(&cut_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nCUT: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Cut) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_cut_module(&cut_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Cut) { + println!("\nCUT: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nCUT: ⚙️ Processing..."); + process_cut_module(&cut_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + } } // Process ASRX (speaker diarization) - println!("\nRunning ASRX (speaker diarization)..."); - { - let mut state = progress_state.lock().unwrap(); - state.get_processor(ProcessorType::Asrx).start(1); - } - let asrx_path = output_dir.get_output_path(&uuid, "asrx.json"); - let _asrx_result = momentry_core::core::processor::process_asrx( - video_path, - asrx_path.to_str().unwrap(), - Some(&uuid), - ) - .await?; - let asrx_json = serde_json::to_string_pretty(&_asrx_result)?; - std::fs::write(&asrx_path, &asrx_json)?; - let _ = output_dir.backup_file(&uuid, "asrx.json"); - println!("ASRX saved to: {}", asrx_path.display()); - println!(" {} segments found", _asrx_result.segments.len()); - { - let mut state = progress_state.lock().unwrap(); - state - .get_processor(ProcessorType::Asrx) - .complete(&format!("{} segments", _asrx_result.segments.len())); - } - if let Some(ref mut ui) = *ui.lock().unwrap() { - let _ = ui.render(); + if should_process(ProcessorType::Asrx) { + let asrx_path = output_dir.get_output_path(&uuid, "asrx.json"); + let decision = decide_processing(&asrx_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nASRX: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nASRX: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&asrx_path).ok(); + if is_cloud(ProcessorType::Asrx) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_asrx_module( + &asrx_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nASRX: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Asrx) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_asrx_module( + &asrx_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Asrx) { + println!("\nASRX: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nASRX: ⚙️ Processing..."); + process_asrx_module( + &asrx_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } } // Process YOLO (object detection) - println!("\nRunning YOLO (object detection)..."); - { - let mut state = progress_state.lock().unwrap(); - state.get_processor(ProcessorType::Yolo).start(1); - } - let yolo_path = output_dir.get_output_path(&uuid, "yolo.json"); - let _yolo_result = momentry_core::core::processor::process_yolo( - video_path, - yolo_path.to_str().unwrap(), - Some(&uuid), - ) - .await?; - let yolo_json = serde_json::to_string_pretty(&_yolo_result)?; - std::fs::write(&yolo_path, &yolo_json)?; - let _ = output_dir.backup_file(&uuid, "yolo.json"); - println!("YOLO saved to: {}", yolo_path.display()); - println!(" {} frames processed", _yolo_result.frame_count); - { - let mut state = progress_state.lock().unwrap(); - state - .get_processor(ProcessorType::Yolo) - .complete(&format!("{} frames", _yolo_result.frame_count)); - } - if let Some(ref mut ui) = *ui.lock().unwrap() { - let _ = ui.render(); + if should_process(ProcessorType::Yolo) { + let yolo_path = output_dir.get_output_path(&uuid, "yolo.json"); + let decision = decide_processing(&yolo_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nYOLO: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nYOLO: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&yolo_path).ok(); + if is_cloud(ProcessorType::Yolo) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_yolo_module( + &yolo_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nYOLO: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Yolo) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_yolo_module( + &yolo_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Yolo) { + println!("\nYOLO: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nYOLO: ⚙️ Processing..."); + process_yolo_module( + &yolo_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } } // Process OCR (text recognition) - println!("\nRunning OCR (text recognition)..."); - { - let mut state = progress_state.lock().unwrap(); - state.get_processor(ProcessorType::Ocr).start(1); - } - let ocr_path = output_dir.get_output_path(&uuid, "ocr.json"); - let _ocr_result = momentry_core::core::processor::process_ocr( - video_path, - ocr_path.to_str().unwrap(), - Some(&uuid), - ) - .await?; - let ocr_json = serde_json::to_string_pretty(&_ocr_result)?; - std::fs::write(&ocr_path, &ocr_json)?; - let _ = output_dir.backup_file(&uuid, "ocr.json"); - println!("OCR saved to: {}", ocr_path.display()); - println!(" {} frames with text", _ocr_result.frames.len()); - { - let mut state = progress_state.lock().unwrap(); - state - .get_processor(ProcessorType::Ocr) - .complete(&format!("{} frames", _ocr_result.frames.len())); - } - if let Some(ref mut ui) = *ui.lock().unwrap() { - let _ = ui.render(); + if should_process(ProcessorType::Ocr) { + let ocr_path = output_dir.get_output_path(&uuid, "ocr.json"); + let decision = decide_processing(&ocr_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nOCR: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nOCR: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&ocr_path).ok(); + if is_cloud(ProcessorType::Ocr) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_ocr_module(&ocr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nOCR: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Ocr) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_ocr_module(&ocr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Ocr) { + println!("\nOCR: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nOCR: ⚙️ Processing..."); + process_ocr_module(&ocr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + } } // Process Face (face detection) - println!("\nRunning Face detection..."); - { - let mut state = progress_state.lock().unwrap(); - state.get_processor(ProcessorType::Face).start(1); - } - let face_path = output_dir.get_output_path(&uuid, "face.json"); - let _face_result = momentry_core::core::processor::process_face( - video_path, - face_path.to_str().unwrap(), - Some(&uuid), - ) - .await?; - let face_json = serde_json::to_string_pretty(&_face_result)?; - std::fs::write(&face_path, &face_json)?; - let _ = output_dir.backup_file(&uuid, "face.json"); - println!("Face saved to: {}", face_path.display()); - println!(" {} frames with faces", _face_result.frames.len()); - { - let mut state = progress_state.lock().unwrap(); - state - .get_processor(ProcessorType::Face) - .complete(&format!("{} frames", _face_result.frames.len())); - } - if let Some(ref mut ui) = *ui.lock().unwrap() { - let _ = ui.render(); + if should_process(ProcessorType::Face) { + let face_path = output_dir.get_output_path(&uuid, "face.json"); + let decision = decide_processing(&face_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nFace: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nFace: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&face_path).ok(); + if is_cloud(ProcessorType::Face) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_face_module( + &face_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nFace: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Face) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_face_module( + &face_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Face) { + println!("\nFace: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nFace: ⚙️ Processing..."); + process_face_module( + &face_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } } // Process Pose (pose estimation) - println!("\nRunning Pose estimation..."); - { - let mut state = progress_state.lock().unwrap(); - state.get_processor(ProcessorType::Pose).start(1); + if should_process(ProcessorType::Pose) { + let pose_path = output_dir.get_output_path(&uuid, "pose.json"); + let decision = decide_processing(&pose_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nPose: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nPose: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&pose_path).ok(); + if is_cloud(ProcessorType::Pose) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_pose_module( + &pose_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nPose: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Pose) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_pose_module( + &pose_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Pose) { + println!("\nPose: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nPose: ⚙️ Processing..."); + process_pose_module( + &pose_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } } - let pose_path = output_dir.get_output_path(&uuid, "pose.json"); - let pose_result = momentry_core::core::processor::process_pose( - video_path, - pose_path.to_str().unwrap(), - Some(&uuid), - ) - .await?; - let pose_json = serde_json::to_string_pretty(&pose_result)?; - std::fs::write(&pose_path, &pose_json)?; - let _ = output_dir.backup_file(&uuid, "pose.json"); - println!("Pose saved to: {}", pose_path.display()); - println!(" {} frames with poses", pose_result.frames.len()); - { - let mut state = progress_state.lock().unwrap(); - state - .get_processor(ProcessorType::Pose) - .complete(&format!("{} frames", pose_result.frames.len())); - state.stop(); + + // Process Story (video narrative) + if should_process(ProcessorType::Story) { + let story_path = output_dir.get_output_path(&uuid, "story.json"); + let decision = decide_processing(&story_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nStory: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nStory: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&story_path).ok(); + if is_cloud(ProcessorType::Story) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_story_module( + &story_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nStory: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Story) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_story_module( + &story_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Story) { + println!("\nStory: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nStory: ⚙️ Processing..."); + process_story_module( + &story_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } } - if let Some(ref mut ui) = *ui.lock().unwrap() { - let _ = ui.render(); + + // Process Caption (image captions) + if should_process(ProcessorType::Caption) { + let caption_path = output_dir.get_output_path(&uuid, "caption.json"); + let decision = decide_processing(&caption_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nCaption: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nCaption: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&caption_path).ok(); + if is_cloud(ProcessorType::Caption) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_caption_module( + &caption_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nCaption: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Caption) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_caption_module( + &caption_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Caption) { + println!("\nCaption: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nCaption: ⚙️ Processing..."); + process_caption_module( + &caption_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } } // TODO: Store pre_chunks and frames to database @@ -539,13 +1642,42 @@ async fn main() -> Result<()> { redis_handle.abort(); println!("\n✓ Process stage completed!"); - println!(" - ASR JSON saved: {}", asr_path.display()); - println!(" - CUT JSON saved: {}", cut_path.display()); - println!(" - ASRX JSON saved: {}", asrx_path.display()); - println!(" - YOLO JSON saved: {}", yolo_path.display()); - println!(" - OCR JSON saved: {}", ocr_path.display()); - println!(" - Face JSON saved: {}", face_path.display()); - println!(" - Pose JSON saved: {}", pose_path.display()); + if should_process(ProcessorType::Asr) { + let path = output_dir.get_output_path(&uuid, "asr.json"); + println!(" - ASR JSON: {}", path.display()); + } + if should_process(ProcessorType::Cut) { + let path = output_dir.get_output_path(&uuid, "cut.json"); + println!(" - CUT JSON: {}", path.display()); + } + if should_process(ProcessorType::Asrx) { + let path = output_dir.get_output_path(&uuid, "asrx.json"); + println!(" - ASRX JSON: {}", path.display()); + } + if should_process(ProcessorType::Yolo) { + let path = output_dir.get_output_path(&uuid, "yolo.json"); + println!(" - YOLO JSON: {}", path.display()); + } + if should_process(ProcessorType::Ocr) { + let path = output_dir.get_output_path(&uuid, "ocr.json"); + println!(" - OCR JSON: {}", path.display()); + } + if should_process(ProcessorType::Face) { + let path = output_dir.get_output_path(&uuid, "face.json"); + println!(" - Face JSON: {}", path.display()); + } + if should_process(ProcessorType::Pose) { + let path = output_dir.get_output_path(&uuid, "pose.json"); + println!(" - Pose JSON: {}", path.display()); + } + if should_process(ProcessorType::Story) { + let path = output_dir.get_output_path(&uuid, "story.json"); + println!(" - Story JSON: {}", path.display()); + } + if should_process(ProcessorType::Caption) { + let path = output_dir.get_output_path(&uuid, "caption.json"); + println!(" - Caption JSON: {}", path.display()); + } Ok(()) } @@ -579,29 +1711,95 @@ async fn main() -> Result<()> { serde_json::from_str(&cut_json)?; println!("Loaded CUT: {} scenes", cut_result.scenes.len()); - // Read YOLO JSON + // Read YOLO JSON (optional) let yolo_path = format!("{}.yolo.json", uuid); - let yolo_json = std::fs::read_to_string(&yolo_path) - .context("YOLO file not found. Run 'process' first.")?; - let yolo_result: momentry_core::core::processor::yolo::YoloResult = - serde_json::from_str(&yolo_json)?; - println!("Loaded YOLO: {} frames", yolo_result.frames.len()); + let yolo_result = match std::fs::read_to_string(&yolo_path) { + Ok(yolo_json) => match serde_json::from_str::< + momentry_core::core::processor::yolo::YoloResult, + >(&yolo_json) + { + Ok(result) => { + println!("Loaded YOLO: {} frames", result.frames.len()); + result + } + Err(e) => { + println!("Warning: Failed to parse YOLO JSON: {}. Skipping YOLO.", e); + momentry_core::core::processor::yolo::YoloResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }, + Err(_) => { + println!("Warning: YOLO file not found. Skipping YOLO."); + momentry_core::core::processor::yolo::YoloResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }; - // Read OCR JSON + // Read OCR JSON (optional) let ocr_path = format!("{}.ocr.json", uuid); - let ocr_json = std::fs::read_to_string(&ocr_path) - .context("OCR file not found. Run 'process' first.")?; - let ocr_result: momentry_core::core::processor::ocr::OcrResult = - serde_json::from_str(&ocr_json)?; - println!("Loaded OCR: {} frames", ocr_result.frames.len()); + let ocr_result = match std::fs::read_to_string(&ocr_path) { + Ok(ocr_json) => match serde_json::from_str::< + momentry_core::core::processor::ocr::OcrResult, + >(&ocr_json) + { + Ok(result) => { + println!("Loaded OCR: {} frames", result.frames.len()); + result + } + Err(e) => { + println!("Warning: Failed to parse OCR JSON: {}. Skipping OCR.", e); + momentry_core::core::processor::ocr::OcrResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }, + Err(_) => { + println!("Warning: OCR file not found. Skipping OCR."); + momentry_core::core::processor::ocr::OcrResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }; - // Read Face JSON + // Read Face JSON (optional) let face_path = format!("{}.face.json", uuid); - let face_json = std::fs::read_to_string(&face_path) - .context("Face file not found. Run 'process' first.")?; - let face_result: momentry_core::core::processor::face::FaceResult = - serde_json::from_str(&face_json)?; - println!("Loaded Face: {} frames", face_result.frames.len()); + let face_result = match std::fs::read_to_string(&face_path) { + Ok(face_json) => match serde_json::from_str::< + momentry_core::core::processor::face::FaceResult, + >(&face_json) + { + Ok(result) => { + println!("Loaded Face: {} frames", result.frames.len()); + result + } + Err(e) => { + println!("Warning: Failed to parse Face JSON: {}. Skipping Face.", e); + momentry_core::core::processor::face::FaceResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }, + Err(_) => { + println!("Warning: Face file not found. Skipping Face."); + momentry_core::core::processor::face::FaceResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }; // ========== Store pre_chunks (from ASR, CUT) ========== @@ -1139,10 +2337,102 @@ async fn main() -> Result<()> { // TODO: Implement watch Ok(()) } + Commands::System { gpu } => { + let resources = SystemResources::check(); + println!("╔══════════════════════════════════════════════════════════════╗"); + println!("║ System Resources Report ║"); + println!("╠══════════════════════════════════════════════════════════════╣"); + println!( + "║ CPU: {:.1}% idle ║", + resources.cpu_idle_percent + ); + println!( + "║ Memory: {:.1}GB / {:.1}GB available ({:.0}% used) ║", + resources.memory_available_mb as f64 / 1024.0, + resources.memory_total_mb as f64 / 1024.0, + resources.memory_used_percent + ); + + if resources.gpu_available { + match resources.gpu_type { + GpuType::Nvidia => { + let util = resources.gpu_utilization.unwrap_or(0.0); + println!( + "║ GPU: NVIDIA - {:.0}% utilized ║", + util + ); + } + GpuType::AppleMps => { + println!( + "║ GPU: Apple MPS (Metal) - available ║" + ); + } + } + } else { + println!("║ GPU: None detected ║"); + } + println!("╠══════════════════════════════════════════════════════════════╣"); + + if resources.can_parallel(4096) { + println!("║ Mode: PARALLEL - Can run multiple modules together ║"); + println!( + "║ Recommended modules: {} ║", + resources.recommend_parallel_modules().join(", ") + ); + } else { + println!("║ Mode: SEQUENTIAL - Low resources, run one at a time ║"); + } + println!("╚══════════════════════════════════════════════════════════════╝"); + + if gpu { + println!("\n=== GPU Details ==="); + let output = std::process::Command::new("system_profiler") + .args(["SPDisplaysDataType", "-detailLevel", "mini"]) + .output(); + if let Ok(o) = output { + println!("{}", String::from_utf8_lossy(&o.stdout)); + } + } + + Ok(()) + } Commands::Server { host, port } => { + let port = port.unwrap_or_else(|| *momentry_core::core::config::SERVER_PORT); momentry_core::api::start_server(&host, port).await?; Ok(()) } + Commands::Worker { + max_concurrent, + poll_interval, + batch_size, + } => { + 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 db = PostgresDb::init().await?; + let redis = RedisClient::new()?; + + let worker = JobWorker::new( + std::sync::Arc::new(db), + std::sync::Arc::new(redis), + config.clone(), + ); + + println!( + "Starting worker with max_concurrent={}, poll_interval={}s", + config.max_concurrent, config.poll_interval_secs + ); + + worker.run().await?; + Ok(()) + } Commands::Query { query } => { println!("Query: {}", query); // TODO: Implement query @@ -1355,5 +2645,554 @@ async fn main() -> Result<()> { } Ok(()) } + Commands::ApiKey { + action, + name, + key_type, + ttl, + key, + } => { + let db = PostgresDb::init().await?; + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://accusys@localhost:5432/momentry".to_string()); + + let service = ApiKeyService::new(db_url); + + match action { + ApiKeyAction::Create => { + let name = name.unwrap_or_else(|| "unnamed-key".to_string()); + let kt = parse_key_type(key_type.as_deref()); + let request = momentry_core::core::api_key::CreateApiKeyRequest { + name: name.clone(), + key_type: kt, + user_id: None, + service_name: None, + permissions: vec!["read".to_string(), "write".to_string()], + ttl_days: ttl, + }; + + match service.create_key(request) { + Ok(response) => { + let key_hash = service.hash_key(&response.key); + let key_type_str = + serde_json::to_string(&kt).unwrap_or_else(|_| "user".to_string()); + let permissions = serde_json::json!(["read", "write"]); + + let config = momentry_core::core::db::CreateApiKeyConfig::new( + &response.key_id, + &key_hash, + kt.prefix(), + &name, + &key_type_str, + ) + .with_permissions(&permissions) + .with_expires_at(response.expires_at); + + if let Err(e) = db.create_api_key(config).await { + eprintln!( + "\n⚠️ Key generated but failed to store in database: {}", + e + ); + } + + println!("\n✅ API Key created successfully!"); + println!("\n┌─────────────────────────────────────────────────────────────────────────────┐"); + println!("│ ⚠️ IMPORTANT: Save this key now - it will not be shown again! │"); + println!("└─────────────────────────────────────────────────────────────────────────────┘"); + println!("\nKey ID: {}", response.key_id); + println!("API Key: {}", response.key); + println!("Expires: {}", response.expires_at); + if !response.warning.is_empty() { + println!("\n⚠️ {}", response.warning); + } + } + Err(e) => { + eprintln!("\n❌ Failed to create API key: {}", e); + } + } + } + ApiKeyAction::List => match db.list_api_keys().await { + Ok(keys) => { + println!("\n📋 API Key List"); + if keys.is_empty() { + println!(" (no API keys found)"); + } else { + println!("\n┌────────────────────────────────────────────────────────────────────────────┐"); + println!( + "│ {:8} │ {:20} │ {:12} │ {:8} │ {:15} │", + "Status", "Name", "Type", "Usage", "Last Used" + ); + println!("├────────────────────────────────────────────────────────────────────────────┤"); + for k in &keys { + let status = if k.status == "active" { + "✓ active" + } else { + &k.status + }; + let last_used = k + .last_used_at + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "never".to_string()); + println!( + "│ {:8} │ {:20} │ {:12} │ {:8} │ {:15} │", + status, + if k.name.len() > 20 { + &k.name[..17] + } else { + &k.name + }, + k.key_type, + k.usage_count, + last_used + ); + } + println!("└────────────────────────────────────────────────────────────────────────────┘"); + println!("\nTotal: {} key(s)", keys.len()); + } + } + Err(e) => { + eprintln!("\n❌ Failed to list API keys: {}", e); + } + }, + ApiKeyAction::Validate => { + let api_key = + key.ok_or_else(|| anyhow::anyhow!("--key required for validate"))?; + let key_hash = service.hash_key(&api_key); + + match db.get_api_key_by_hash(&key_hash).await { + Ok(Some(record)) => { + if record.status == "active" { + db.update_api_key_usage(&record.key_id, None).await.ok(); + println!("\n✅ API Key is valid"); + println!("Key ID: {}", record.key_id); + println!("Name: {}", record.name); + println!("Type: {}", record.key_type); + println!("Usage: {} times", record.usage_count + 1); + if record.rotation_required { + println!( + "⚠️ Rotation required: {}", + record.rotation_reason.as_deref().unwrap_or("unknown") + ); + } + } else { + println!("\n❌ API Key is {}", record.status); + } + } + Ok(None) => { + println!("\n❌ API Key is invalid or not found"); + } + Err(e) => { + eprintln!("\n❌ Validation error: {}", e); + } + } + } + ApiKeyAction::Revoke => { + let key = key.ok_or_else(|| anyhow::anyhow!("--key required for revoke"))?; + let key_id = service.extract_key_id(&key); + match db.revoke_api_key(&key_id).await { + Ok(_) => { + println!("\n🔴 API Key {} revoked successfully", key_id); + } + Err(e) => { + eprintln!("\n❌ Failed to revoke API key: {}", e); + } + } + } + ApiKeyAction::Rotate => { + let key = key.ok_or_else(|| anyhow::anyhow!("--key required for rotate"))?; + let key_id = service.extract_key_id(&key); + let grace_period_end = + service.calculate_grace_period_end(parse_key_type(key_type.as_deref())); + match db + .require_api_key_rotation( + &key_id, + "manual rotation requested", + grace_period_end, + ) + .await + { + Ok(_) => { + println!("\n🔄 Rotation requested for key: {}", key_id); + println!("Grace period ends: {}", grace_period_end); + } + Err(e) => { + eprintln!("\n❌ Rotation request failed: {}", e); + } + } + } + ApiKeyAction::Stats => { + match db.get_api_key_stats().await { + Ok(stats) => { + println!("\n📊 API Key Statistics"); + println!("\n┌─────────────────────────────────────────┐"); + println!("│ Total Keys: {:5} │", stats.total_keys); + println!( + "│ Active Keys: {:5} │", + stats.active_keys + ); + println!( + "│ Expired Keys: {:5} │", + stats.expired_keys + ); + println!( + "│ Rotation Required: {:4} │", + stats.rotation_required + ); + println!( + "│ Anomalies (24h): {:5} │", + stats.anomalies_last_24h + ); + println!("└─────────────────────────────────────────┘"); + } + Err(e) => { + eprintln!("\n⚠️ Failed to get stats: {}", e); + } + } + + let config = service.get_config(); + println!("\n┌─────────────────────────────────────────┐"); + println!("│ Anomaly Detection Thresholds │"); + println!("├─────────────────────────────────────────┤"); + println!( + "│ Requests/minute: {:5} │", + config.requests_per_minute_threshold + ); + println!( + "│ Requests/hour: {:5} │", + config.requests_per_hour_threshold + ); + println!( + "│ Error rate: {:5.1}% │", + config.error_rate_threshold * 100.0 + ); + println!( + "│ Unique IPs/hour: {:5} │", + config.unique_ips_per_hour_threshold + ); + println!( + "│ Lockout threshold: {:5} │", + config.lockout_threshold + ); + println!("└─────────────────────────────────────────┘"); + } + } + Ok(()) + } + Commands::Gitea { + action, + username, + password, + token_name, + scopes, + } => { + use momentry_core::core::api_key::gitea::{ + CreateGiteaTokenRequest, GiteaClient, GiteaScope, + }; + + let db = PostgresDb::init().await?; + let gitea = GiteaClient::new()?; + + match action { + GiteaAction::Create => { + let username = username + .ok_or_else(|| anyhow::anyhow!("--username required for create"))?; + let password = password + .ok_or_else(|| anyhow::anyhow!("--password required for create"))?; + let token_name = token_name + .ok_or_else(|| anyhow::anyhow!("--token-name required for create"))?; + + let scopes_vec: Vec = scopes + .map(|s| { + s.split(',') + .filter_map(|scope| scope.trim().parse::().ok()) + .collect() + }) + .unwrap_or_else(|| { + vec![GiteaScope::ReadRepository, GiteaScope::WriteRepository] + }); + + let request = CreateGiteaTokenRequest { + username: username.clone(), + password, + token_name: token_name.clone(), + scopes: scopes_vec.clone(), + }; + + match gitea.create_token(&request).await { + Ok(response) => { + if let Err(e) = db + .create_gitea_token( + response.id, + &username, + &token_name, + &response.token_last_eight, + &serde_json::json!(scopes_vec + .iter() + .map(|s| s.as_str()) + .collect::>()), + None, + ) + .await + { + eprintln!("\n⚠️ Token created but failed to store: {}", e); + } + + println!("\n✅ Gitea Token created successfully!"); + println!("\n┌─────────────────────────────────────────────────────────────────────────────┐"); + println!("│ ⚠️ IMPORTANT: Save this token now - it will not be shown again! │"); + println!("└─────────────────────────────────────────────────────────────────────────────┘"); + println!("\nToken ID: {}", response.id); + println!("Token Name: {}", response.name); + println!("SHA1: {}", response.sha1); + println!("Last 8: {}", response.token_last_eight); + println!("\nAuthorization Header:"); + println!(" Authorization: token {}", response.sha1); + } + Err(e) => { + eprintln!("\n❌ Failed to create Gitea token: {}", e); + } + } + } + GiteaAction::List => { + let username = + username.ok_or_else(|| anyhow::anyhow!("--username required for list"))?; + let password = + password.ok_or_else(|| anyhow::anyhow!("--password required for list"))?; + + match gitea.list_tokens(&username, &password).await { + Ok(tokens) => { + println!("\n📋 Gitea Tokens for user: {}", username); + if tokens.is_empty() { + println!(" (no tokens found)"); + } else { + println!("\n┌────────────────────────────────────────────────────────────────────────────┐"); + println!("│ ID │ Name │ Last 8 │ Registered │"); + println!("├────────────────────────────────────────────────────────────────────────────┤"); + for token in &tokens { + let registered = db + .get_gitea_token_by_name(&username, &token.name) + .await + .ok() + .flatten() + .map(|_| "✓") + .unwrap_or("-"); + println!( + "│ {:8} │ {:20} │ {:9} │ {:27} │", + token.id, + if token.name.len() > 20 { + &token.name[..17] + } else { + &token.name + }, + token.token_last_eight, + registered + ); + } + println!("└────────────────────────────────────────────────────────────────────────────┘"); + println!("\nTotal: {} token(s)", tokens.len()); + } + } + Err(e) => { + eprintln!("\n❌ Failed to list Gitea tokens: {}", e); + } + } + } + GiteaAction::Delete => { + let username = username + .ok_or_else(|| anyhow::anyhow!("--username required for delete"))?; + let password = password + .ok_or_else(|| anyhow::anyhow!("--password required for delete"))?; + let token_name = token_name + .ok_or_else(|| anyhow::anyhow!("--token-name required for delete"))?; + + match gitea.delete_token(&username, &password, &token_name).await { + Ok(_) => { + let _ = db.delete_gitea_token(&username, &token_name).await; + println!("\n🗑️ Token '{}' deleted successfully", token_name); + } + Err(e) => { + eprintln!("\n❌ Failed to delete Gitea token: {}", e); + } + } + } + GiteaAction::Verify => { + let token_name = token_name + .ok_or_else(|| anyhow::anyhow!("--token-name required for verify"))?; + + let record = db + .get_gitea_token_by_name( + &username.unwrap_or_else(|| "unknown".to_string()), + &token_name, + ) + .await?; + + match record { + Some(r) => { + println!("\n📋 Gitea Token: {}", r.token_name); + println!(" User: {}", r.gitea_user); + println!(" Token ID: {}", r.gitea_token_id); + println!(" Last 8: {}", r.token_last_eight); + println!(" Scopes: {}", r.scopes); + println!(" Created: {}", r.created_at); + if let Some(verified) = r.last_verified { + println!(" Last Verified: {}", verified); + } else { + println!(" Last Verified: never"); + } + } + None => { + println!("\n❌ Token not found in local database"); + } + } + } + } + Ok(()) + } + Commands::N8n { + action, + api_key, + label, + expires_in_days, + } => { + use momentry_core::core::api_key::n8n::{ + extract_last_eight, CreateN8nApiKeyRequest, N8nClient, + }; + + let db = PostgresDb::init().await?; + + match action { + N8nAction::Create => { + let api_key_value = api_key.ok_or_else(|| { + anyhow::anyhow!("--api-key required for create (existing n8n API key)") + })?; + let label = + label.ok_or_else(|| anyhow::anyhow!("--label required for create"))?; + + let n8n = N8nClient::new(api_key_value)?; + + let expires_at = expires_in_days + .map(|days| chrono::Utc::now() + chrono::Duration::days(days)); + + let request = CreateN8nApiKeyRequest { + label: label.clone(), + expires_at, + }; + + match n8n.create_api_key(&request).await { + Ok(response) => { + if let Err(e) = db + .create_n8n_api_key( + &response.id, + &label, + &extract_last_eight(&response.api_key), + None, + response.expires_at, + ) + .await + { + eprintln!("\n⚠️ API key created but failed to store: {}", e); + } + + println!("\n✅ n8n API Key created successfully!"); + println!("\n┌─────────────────────────────────────────────────────────────────────────────┐"); + println!("│ ⚠️ IMPORTANT: Save this API key now - it will not be shown again! │"); + println!("└─────────────────────────────────────────────────────────────────────────────┘"); + println!("\nKey ID: {}", response.id); + println!("Label: {}", response.label); + println!("API Key: {}", response.api_key); + println!("\nUsage:"); + println!(" curl -H 'X-N8N-API-KEY: {}' https://n8n.momentry.ddns.net/api/v1/workflows", response.api_key); + } + Err(e) => { + eprintln!("\n❌ Failed to create n8n API key: {}", e); + } + } + } + N8nAction::List => { + let api_key_value = + api_key.ok_or_else(|| anyhow::anyhow!("--api-key required for list"))?; + + let n8n = N8nClient::new(api_key_value)?; + + match n8n.list_api_keys().await { + Ok(keys) => { + println!("\n📋 n8n API Keys"); + if keys.is_empty() { + println!(" (no API keys found)"); + } else { + println!("\n┌────────────────────────────────────────────────────────────────────────────┐"); + println!("│ Label │ ID │"); + println!("├────────────────────────────────────────────────────────────────────────────┤"); + for key in &keys { + println!( + "│ {:27} │ {:39} │", + if key.label.len() > 27 { + &key.label[..24] + } else { + &key.label + }, + key.id + ); + } + println!("└────────────────────────────────────────────────────────────────────────────┘"); + println!("\nTotal: {} key(s)", keys.len()); + } + } + Err(e) => { + eprintln!("\n❌ Failed to list n8n API keys: {}", e); + } + } + } + N8nAction::Delete => { + let api_key_value = + api_key.ok_or_else(|| anyhow::anyhow!("--api-key required for delete"))?; + let label = + label.ok_or_else(|| anyhow::anyhow!("--label required for delete"))?; + + let record = db.get_n8n_api_key_by_label(&label).await?; + if let Some(r) = record { + let n8n = N8nClient::new(api_key_value)?; + match n8n.delete_api_key(&r.n8n_key_id).await { + Ok(_) => { + let _ = db.delete_n8n_api_key(&label).await; + println!("\n🗑️ API key '{}' deleted successfully", label); + } + Err(e) => { + eprintln!("\n❌ Failed to delete n8n API key: {}", e); + } + } + } else { + println!("\n❌ API key '{}' not found in local database", label); + } + } + N8nAction::Verify => { + let label = + label.ok_or_else(|| anyhow::anyhow!("--label required for verify"))?; + + let record = db.get_n8n_api_key_by_label(&label).await?; + + match record { + Some(r) => { + println!("\n📋 n8n API Key: {}", r.label); + println!(" Key ID: {}", r.n8n_key_id); + println!(" Last 8: {}", r.api_key_last_eight); + println!(" Created: {}", r.created_at); + if let Some(expires) = r.expires_at { + println!(" Expires: {}", expires); + } + if let Some(verified) = r.last_verified { + println!(" Last Verified: {}", verified); + } else { + println!(" Last Verified: never"); + } + } + None => { + println!("\n❌ API key not found in local database"); + } + } + } + } + Ok(()) + } } } diff --git a/src/playground.rs b/src/playground.rs new file mode 100644 index 0000000..d6caad2 --- /dev/null +++ b/src/playground.rs @@ -0,0 +1,3208 @@ +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use futures_util::StreamExt; +use std::path::Path; +use std::str; +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::ui::progress::{ProcessorType, ProgressState, ProgressUi}; +use momentry_core::{ + Embedder, OutputDir, PostgresDb, QdrantDb, RedisClient, VectorPayload, VideoRecord, VideoStatus, +}; + +fn parse_key_type(s: Option<&str>) -> ApiKeyType { + match s.map(|s| s.to_lowercase()).as_deref() { + Some("system") => ApiKeyType::System, + Some("user") => ApiKeyType::User, + Some("service") => ApiKeyType::Service, + Some("integration") => ApiKeyType::Integration, + Some("emergency") => ApiKeyType::Emergency, + _ => ApiKeyType::User, + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ProcessingDecision { + Process, + SkipComplete, + ResumePartial, + ForceReprocess, +} + +impl ProcessingDecision { + pub fn should_process(&self) -> bool { + matches!( + self, + ProcessingDecision::Process + | ProcessingDecision::ResumePartial + | ProcessingDecision::ForceReprocess + ) + } + + pub fn should_resume(&self) -> bool { + matches!(self, ProcessingDecision::ResumePartial) + } +} + +#[derive(Debug, Clone)] +pub struct SystemResources { + pub cpu_idle_percent: f64, + pub memory_available_mb: u64, + pub memory_total_mb: u64, + pub memory_used_percent: f64, + pub gpu_available: bool, + pub gpu_type: GpuType, + pub gpu_utilization: Option, +} + +#[derive(Debug, Clone, Copy)] +pub enum GpuType { + Nvidia, + AppleMps, +} + +impl SystemResources { + pub fn check() -> Self { + let cpu_idle = Self::get_cpu_idle(); + let (mem_available, mem_total) = Self::get_memory_info(); + let mem_used_pct = if mem_total > 0 && mem_available <= mem_total { + ((mem_total - mem_available) as f64 / mem_total as f64) * 100.0 + } else if mem_total > 0 { + 100.0 + } else { + 0.0 + }; + let (gpu_available, gpu_type, gpu_util) = Self::get_gpu_info(); + + Self { + cpu_idle_percent: cpu_idle, + memory_available_mb: mem_available, + memory_total_mb: mem_total, + memory_used_percent: mem_used_pct, + gpu_available, + gpu_type, + gpu_utilization: gpu_util, + } + } + + pub fn can_parallel(&self, required_memory_mb: u64) -> bool { + const MIN_CPU_IDLE: f64 = 30.0; + const MIN_MEMORY_MB: u64 = 4096; + + self.cpu_idle_percent >= MIN_CPU_IDLE + && self.memory_available_mb >= required_memory_mb + && self.memory_available_mb >= MIN_MEMORY_MB + } + + pub fn recommend_parallel_modules(&self) -> Vec<&'static str> { + let mut recommended = Vec::new(); + + if self.gpu_available { + recommended.push("yolo"); + } + + if self.memory_available_mb >= 8192 { + recommended.push("ocr"); + recommended.push("face"); + recommended.push("pose"); + } + + recommended + } + + fn get_cpu_idle() -> f64 { + use std::process::Command; + let output = Command::new("top").args(["-l", "1", "-n", "1"]).output(); + match output { + Ok(o) => { + let s = String::from_utf8_lossy(&o.stdout); + if let Some(line) = s.lines().find(|l| l.contains("idle")) { + if let Some(pct) = line + .split_whitespace() + .find_map(|s| s.strip_suffix("%idle")) + { + pct.trim().parse().ok().unwrap_or(50.0) + } else { + 50.0 + } + } else { + 50.0 + } + } + Err(_) => 50.0, + } + } + + fn get_memory_info() -> (u64, u64) { + use std::process::Command; + let output = Command::new("sysctl").args(["hw.memsize"]).output(); + match output { + Ok(o) => { + let s = String::from_utf8_lossy(&o.stdout); + let total = s + .split_whitespace() + .nth(1) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0) + / 1024 + / 1024; + + let vm_stat = Command::new("vm_stat").output(); + let available = match vm_stat { + Ok(v) => { + let vs = String::from_utf8_lossy(&v.stdout); + let mut free_pages: u64 = 0; + let mut inactive_pages: u64 = 0; + + for line in vs.lines() { + if line.contains("Pages free:") { + free_pages = line + .split_whitespace() + .last() + .and_then(|v| v.trim_end_matches('.').parse().ok()) + .unwrap_or(0); + } else if line.contains("Pages inactive:") { + inactive_pages = line + .split_whitespace() + .last() + .and_then(|v| v.trim_end_matches('.').parse().ok()) + .unwrap_or(0); + } + } + + // Pages * 4096 bytes / 1024 / 1024 = MB + (free_pages + inactive_pages) * 4096 / 1024 / 1024 + } + Err(_) => total / 4, + }; + + (available, total) + } + Err(_) => (0, 0), + } + } + + fn get_gpu_info() -> (bool, GpuType, Option) { + use std::process::Command; + + // Check NVIDIA GPU + let nvidia_output = Command::new("nvidia-smi") + .args([ + "--query-gpu=utilization.gpu", + "--format=csv,noheader,nounits", + ]) + .output(); + + if let Ok(o) = nvidia_output { + if o.status.success() { + let s = String::from_utf8_lossy(&o.stdout); + let util = s.trim().parse::().ok(); + return (true, GpuType::Nvidia, util); + } + } + + // Check Apple MPS (Metal Performance Shaders) + let mps_output = Command::new("system_profiler") + .args(["SPDisplaysDataType", "-detailLevel", "mini"]) + .output(); + + if let Ok(o) = mps_output { + let s = String::from_utf8_lossy(&o.stdout); + if s.contains("Metal") || s.contains("Apple") { + return (true, GpuType::AppleMps, Some(0.0)); + } + } + + (false, GpuType::Nvidia, None) + } +} + +impl std::fmt::Display for SystemResources { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "CPU: {:.1}% idle, Memory: {:.1}GB/{:.1}GB ({:.0}% used), GPU: {}", + self.cpu_idle_percent, + self.memory_available_mb as f64 / 1024.0, + self.memory_total_mb as f64 / 1024.0, + self.memory_used_percent, + if self.gpu_available { + format!("{:.0}% utilized", self.gpu_utilization.unwrap_or(0.0)) + } else { + "N/A".to_string() + } + ) + } +} + +fn decide_processing(json_path: &Path, force: bool, resume: bool) -> ProcessingDecision { + if !json_path.exists() { + return ProcessingDecision::Process; + } + + if force { + return ProcessingDecision::ForceReprocess; + } + + if resume { + return ProcessingDecision::ResumePartial; + } + + match check_json_completeness(json_path) { + JsonCompleteness::Complete => ProcessingDecision::SkipComplete, + JsonCompleteness::Partial { current, total } => { + eprintln!("\n⚠️ Found incomplete JSON file: {}", json_path.display()); + eprintln!( + " Progress: {}/{} ({:.1}%)", + current, + total, + (current as f64 / total as f64) * 100.0 + ); + eprintln!(" Use --resume to continue from checkpoint"); + eprintln!(" Use --force to reprocess from scratch"); + ProcessingDecision::SkipComplete + } + JsonCompleteness::Empty => ProcessingDecision::Process, + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum JsonCompleteness { + Complete, + Partial { current: u32, total: u32 }, + Empty, +} + +fn check_json_completeness(json_path: &Path) -> JsonCompleteness { + let content = match std::fs::read_to_string(json_path) { + Ok(c) => c, + Err(_) => return JsonCompleteness::Empty, + }; + + if content.trim().is_empty() { + return JsonCompleteness::Empty; + } + + let json: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return JsonCompleteness::Empty, + }; + + match json.get("segments") { + Some(serde_json::Value::Array(arr)) if !arr.is_empty() => JsonCompleteness::Complete, + Some(serde_json::Value::Object(obj)) => { + let current = obj.get("current").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let total = obj.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + if total > 0 && current < total { + JsonCompleteness::Partial { current, total } + } else { + JsonCompleteness::Complete + } + } + _ => JsonCompleteness::Complete, + } +} + +async fn process_asr_module( + asr_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Asr).start(1); + } + let asr_result = momentry_core::core::processor::process_asr( + video_path, + asr_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let asr_json = serde_json::to_string_pretty(&asr_result)?; + std::fs::write(asr_path, &asr_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "asr.json"); + println!(" ✓ ASR saved: {} segments", asr_result.segments.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Asr) + .complete(&format!("{} segments", asr_result.segments.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_cut_module( + cut_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Cut).start(1); + } + let cut_result = momentry_core::core::processor::process_cut( + video_path, + cut_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let cut_json = serde_json::to_string_pretty(&cut_result)?; + std::fs::write(cut_path, &cut_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "cut.json"); + println!(" ✓ CUT saved: {} scenes", cut_result.scenes.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Cut) + .complete(&format!("{} scenes", cut_result.scenes.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_asrx_module( + asrx_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Asrx).start(1); + } + let asrx_result = momentry_core::core::processor::process_asrx( + video_path, + asrx_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let asrx_json = serde_json::to_string_pretty(&asrx_result)?; + std::fs::write(asrx_path, &asrx_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "asrx.json"); + println!(" ✓ ASRX saved: {} segments", asrx_result.segments.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Asrx) + .complete(&format!("{} segments", asrx_result.segments.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_yolo_module( + yolo_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Yolo).start(1); + } + let yolo_result = momentry_core::core::processor::process_yolo( + video_path, + yolo_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let yolo_json = serde_json::to_string_pretty(&yolo_result)?; + std::fs::write(yolo_path, &yolo_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "yolo.json"); + println!(" ✓ YOLO saved: {} frames", yolo_result.frame_count); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Yolo) + .complete(&format!("{} frames", yolo_result.frame_count)); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_ocr_module( + ocr_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Ocr).start(1); + } + let ocr_result = momentry_core::core::processor::process_ocr( + video_path, + ocr_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let ocr_json = serde_json::to_string_pretty(&ocr_result)?; + std::fs::write(ocr_path, &ocr_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "ocr.json"); + println!( + " ✓ OCR saved: {} frames with text", + ocr_result.frames.len() + ); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Ocr) + .complete(&format!("{} frames", ocr_result.frames.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_face_module( + face_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Face).start(1); + } + let face_result = momentry_core::core::processor::process_face( + video_path, + face_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let face_json = serde_json::to_string_pretty(&face_result)?; + std::fs::write(face_path, &face_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "face.json"); + println!(" ✓ Face saved: {} frames", face_result.frames.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Face) + .complete(&format!("{} frames", face_result.frames.len())); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_pose_module( + pose_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Pose).start(1); + } + let pose_result = momentry_core::core::processor::process_pose( + video_path, + pose_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let pose_json = serde_json::to_string_pretty(&pose_result)?; + std::fs::write(pose_path, &pose_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "pose.json"); + println!(" ✓ Pose saved: {} frames", pose_result.frames.len()); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Pose) + .complete(&format!("{} frames", pose_result.frames.len())); + state.stop(); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_story_module( + story_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Story).start(1); + } + let story_result = momentry_core::core::processor::process_story( + video_path, + story_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let story_json = serde_json::to_string_pretty(&story_result)?; + std::fs::write(story_path, &story_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "story.json"); + println!( + " ✓ Story saved: {} parent chunks, {} child chunks", + story_result.stats.total_parent_chunks, story_result.stats.total_child_chunks + ); + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Story).complete(&format!( + "{} parents, {} children", + story_result.stats.total_parent_chunks, story_result.stats.total_child_chunks + )); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +async fn process_caption_module( + caption_path: &Path, + video_path: &str, + uuid: &str, + progress_state: &Arc>, + ui: &Arc>>, +) -> anyhow::Result<()> { + { + let mut state = progress_state.lock().unwrap(); + state.get_processor(ProcessorType::Caption).start(1); + } + let caption_result = momentry_core::core::processor::process_caption( + video_path, + caption_path.to_str().unwrap(), + Some(uuid), + ) + .await?; + let caption_json = serde_json::to_string_pretty(&caption_result)?; + std::fs::write(caption_path, &caption_json)?; + let output_dir = OutputDir::new(); + let _ = output_dir.backup_file(uuid, "caption.json"); + println!(" ✓ Caption saved: {} frames", caption_result.total_frames); + { + let mut state = progress_state.lock().unwrap(); + state + .get_processor(ProcessorType::Caption) + .complete(&format!("{} frames", caption_result.total_frames)); + } + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + Ok(()) +} + +#[derive(Parser)] +#[command(name = "momentry")] +#[command(about = "Digital asset management system with video analysis and RAG")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Register a video file + Register { + /// Video file path or URL + path: String, + }, + /// Process video (generate all JSON files) + Process { + /// UUID or path + target: String, + /// Modules to process (comma separated: asr,cut,asrx,yolo,ocr,face,pose,story,caption) + /// If not specified, processes all modules + #[arg(short, long, value_delimiter = ',')] + modules: Option>, + /// Modules to process via cloud (comma separated) + /// Example: --cloud asr,yolo + #[arg(long, value_delimiter = ',')] + cloud: Option>, + /// Force reprocess even if JSON exists (skip completeness check) + #[arg(long, default_value = "false")] + force: bool, + /// Resume from last checkpoint if processing was interrupted + #[arg(long, default_value = "false")] + resume: bool, + }, + /// Generate chunks and store in database + Chunk { + /// UUID + uuid: String, + }, + /// Generate story for cut scenes + Story { + /// UUID + uuid: String, + }, + /// Vectorize chunks + Vectorize { + /// UUID (or 'all' for all) + uuid: String, + }, + /// Play video with overlays + Play { + /// Video path or UUID + target: String, + }, + /// Start watching directories + Watch { + /// Directories to watch (comma separated) + directories: Option, + }, + /// Check system resources and recommend processing strategy + System { + /// Show detailed GPU info (NVIDIA/MPS) + #[arg(long)] + gpu: bool, + }, + /// Start API server + Server { + /// Host + #[arg(long, default_value = "127.0.0.1")] + host: String, + /// Port (defaults to MOMENTRY_SERVER_PORT env var, or 3003 for development) + #[arg(long)] + port: Option, + }, + /// Start job worker + Worker { + /// Max concurrent processors + #[arg(long)] + max_concurrent: Option, + /// Poll interval in seconds + #[arg(long)] + poll_interval: Option, + /// Batch size + #[arg(long)] + batch_size: Option, + }, + /// Query using RAG + Query { + /// Query text + query: String, + }, + /// Lookup UUID from path + Lookup { + /// File path + path: String, + }, + /// Resolve path from UUID + Resolve { + /// UUID + uuid: String, + }, + /// Generate thumbnails for videos + Thumbnails { + /// UUID (optional, generates for all if not specified) + uuid: Option, + /// Number of thumbnails per video + #[arg(short, long, default_value = "6")] + count: u32, + }, + /// Show storage status report + Status { + /// UUID (optional, shows all if not specified) + uuid: Option, + }, + /// Manage output backups + Backup { + /// Action: list, cleanup + action: String, + /// Days to keep (for cleanup) + days: Option, + }, + /// Manage API keys + ApiKey { + /// Action: create, list, validate, revoke, rotate, stats + #[arg(value_enum)] + action: ApiKeyAction, + /// Key name (for create) + name: Option, + /// Key type (system, user, service, integration, emergency) + #[arg(long)] + key_type: Option, + /// TTL in days (for create) + #[arg(long)] + ttl: Option, + /// API key to validate/revoke + #[arg(long)] + key: Option, + }, + /// Manage Gitea API tokens + Gitea { + /// Action: create, list, delete, verify + #[arg(value_enum)] + action: GiteaAction, + /// Gitea username + #[arg(long)] + username: Option, + /// Gitea password (for create/list/delete) + #[arg(long)] + password: Option, + /// Token name (for create/delete) + #[arg(long)] + token_name: Option, + /// Token scopes (comma separated: read:repository,write:issue) + #[arg(long)] + scopes: Option, + }, + /// Manage n8n API keys + N8n { + /// Action: create, list, delete, verify + #[arg(value_enum)] + action: N8nAction, + /// n8n API key (for create/list/delete) + #[arg(long)] + api_key: Option, + /// API key label (for create/delete) + #[arg(long)] + label: Option, + /// Expiration days (for create) + #[arg(long)] + expires_in_days: Option, + }, +} + +#[derive(clap::ValueEnum, Clone)] +enum ApiKeyAction { + Create, + List, + Validate, + Revoke, + Rotate, + Stats, +} + +#[derive(clap::ValueEnum, Clone)] +enum GiteaAction { + Create, + List, + Delete, + Verify, +} + +#[derive(clap::ValueEnum, Clone)] +enum N8nAction { + Create, + List, + Delete, + Verify, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Load development environment first + dotenv::from_filename(".env.development").ok(); + + tracing_subscriber::fmt::init(); + + tracing::info!("Starting momentry_playground (development binary)"); + tracing::info!("Port: {}", *momentry_core::core::config::SERVER_PORT); + tracing::info!( + "Redis prefix: {}", + *momentry_core::core::config::REDIS_KEY_PREFIX + ); + + let cli = Cli::parse(); + + match cli.command { + Commands::Register { path } => { + println!("Registering: {}", path); + + // Compute UUID + let uuid = momentry_core::uuid::compute_uuid_from_path(&path); + println!("UUID: {}", uuid); + + // Run ffprobe + let probe_result = momentry_core::core::probe::probe_video(&path)?; + + println!("\nVideo probe results:"); + let duration = probe_result + .format + .duration + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.0); + println!(" Duration: {}s", duration); + if let Some(size) = &probe_result.format.size { + println!(" Size: {}", size); + } + + let mut width = 0u32; + let mut height = 0u32; + let mut fps = 0.0; + + for stream in &probe_result.streams { + if stream.codec_type.as_deref() == Some("video") { + width = stream.width.unwrap_or(0); + height = stream.height.unwrap_or(0); + if let Some(fps_str) = &stream.r_frame_rate { + if let Some((num, den)) = fps_str.split_once('/') { + if let (Ok(n), Ok(d)) = (num.parse::(), den.parse::()) { + if d > 0.0 { + fps = n / d; + } + } + } + } + println!(" Video: {}x{}", width, height); + if let Some(fps) = &stream.r_frame_rate { + println!(" FPS: {}", fps); + } + } + if stream.codec_type.as_deref() == Some("audio") { + println!(" Audio: {} channels", stream.channels.unwrap_or(0)); + if let Some(sr) = &stream.sample_rate { + println!(" Sample Rate: {}", sr); + } + } + } + + // Save probe JSON to file + let file_manager = momentry_core::FileManager::new(std::path::PathBuf::from(".")); + let json_str = serde_json::to_string_pretty(&probe_result)?; + let json_path = file_manager.save_json(&uuid, "probe", &json_str)?; + println!("\nProbe JSON saved to: {:?}", json_path); + + // Store in PostgreSQL + println!("\nStoring in database..."); + let db = PostgresDb::init().await?; + let file_path = Path::new(&path) + .canonicalize() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.clone()); + let file_name = Path::new(&path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let record = VideoRecord { + id: 0, + uuid: uuid.clone(), + file_path, + file_name, + duration, + width, + height, + fps, + probe_json: Some(json_str), + storage: Default::default(), + status: VideoStatus::Pending, + user_id: None, + job_id: None, + created_at: String::new(), + }; + + let video_id = db.register_video(&record).await?; + println!("Video registered with ID: {}", video_id); + + Ok(()) + } + Commands::Process { + target, + modules, + cloud, + force, + resume, + } => { + println!("Processing: {}", target); + println!(" force: {}, resume: {}", force, resume); + + // Parse selected modules + let selected_modules: Option> = modules.as_ref().map(|m| { + m.iter() + .filter_map(|name| { + let name_lower = name.to_lowercase(); + match name_lower.as_str() { + "asr" => Some(ProcessorType::Asr), + "cut" => Some(ProcessorType::Cut), + "asrx" => Some(ProcessorType::Asrx), + "yolo" => Some(ProcessorType::Yolo), + "ocr" => Some(ProcessorType::Ocr), + "face" => Some(ProcessorType::Face), + "pose" => Some(ProcessorType::Pose), + "story" => Some(ProcessorType::Story), + "caption" => Some(ProcessorType::Caption), + _ => { + eprintln!("Unknown module: {}", name); + None + } + } + }) + .collect() + }); + + // Parse cloud modules + let cloud_modules: Vec = cloud + .as_ref() + .map(|c| { + c.iter() + .filter_map(|name| { + let name_lower = name.to_lowercase(); + match name_lower.as_str() { + "asr" => Some(ProcessorType::Asr), + "cut" => Some(ProcessorType::Cut), + "asrx" => Some(ProcessorType::Asrx), + "yolo" => Some(ProcessorType::Yolo), + "ocr" => Some(ProcessorType::Ocr), + "face" => Some(ProcessorType::Face), + "pose" => Some(ProcessorType::Pose), + "story" => Some(ProcessorType::Story), + "caption" => Some(ProcessorType::Caption), + _ => { + eprintln!("Unknown cloud module: {}", name); + None + } + } + }) + .collect() + }) + .unwrap_or_default(); + + if let Some(ref mods) = selected_modules { + println!( + " Modules: {}", + mods.iter() + .map(|m| m.to_string()) + .collect::>() + .join(", ") + ); + } else { + println!(" Modules: ALL"); + } + + if !cloud_modules.is_empty() { + println!( + " Cloud: {}", + cloud_modules + .iter() + .map(|m| m.to_string()) + .collect::>() + .join(", ") + ); + } + + let processing_mode = if force { + "FORCE (reprocess all)" + } else if resume { + "RESUME (continue from checkpoint)" + } else { + "SMART (skip complete, resume partial)" + }; + println!(" Mode: {}", processing_mode); + + // Compute UUID if path is given + let uuid = if target.len() == 16 && !target.contains('/') { + target.clone() + } else { + momentry_core::uuid::compute_uuid_from_path(&target) + }; + + // Get video from database + let db = PostgresDb::init().await?; + let video = db + .get_video_by_uuid(&uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("Video not found: {}", uuid))?; + + let video_path = &video.file_path; + let video_name = video.file_name.clone(); + let _file_manager = momentry_core::FileManager::new(std::path::PathBuf::from(".")); + + // Initialize output directory + let output_dir = OutputDir::new(); + output_dir.ensure_dir()?; + println!("Output directory: {:?}", output_dir.get_base_path()); + + // Initialize progress UI + let progress_state = Arc::new(Mutex::new(ProgressState::new(&video_name))); + progress_state.lock().unwrap().start(); + + // Helper closure to check if a module should be processed + let should_process = |module: ProcessorType| -> bool { + selected_modules + .as_ref() + .map(|mods| mods.contains(&module)) + .unwrap_or(true) + }; + + // Helper closure to check if a module should run in the cloud + let is_cloud = |module: ProcessorType| -> bool { cloud_modules.contains(&module) }; + + // Create UI and wrap in Arc for sharing with Redis subscriber + let ui = Arc::new(Mutex::new(ProgressUi::new(&video_name).ok())); + if let Some(ref mut ui) = *ui.lock().unwrap() { + let _ = ui.render(); + } + + // Spawn Redis subscriber for real-time progress updates + let redis_progress_state = progress_state.clone(); + let redis_ui = ui.clone(); + let redis_uuid = uuid.clone(); + let redis_handle = tokio::spawn(async move { + if let Ok(redis_client) = momentry_core::core::db::RedisClient::new() { + loop { + if let Ok(mut pubsub) = redis_client.subscribe_progress(&redis_uuid).await { + let mut stream = pubsub.on_message(); + while let Some(msg) = stream.next().await { + if let Ok(payload) = msg.get_payload::() { + if let Ok(progress_msg) = + serde_json::from_str::< + momentry_core::core::db::ProgressMessage, + >(&payload) + { + let mut state = redis_progress_state.lock().unwrap(); + state.update_from_redis( + &progress_msg.msg_type, + &progress_msg.processor, + progress_msg.data.current, + progress_msg.data.total, + progress_msg.data.message.as_deref(), + ); + + // Store progress in Redis Hash for HTTP API + let uuid = progress_msg.uuid.clone(); + let processor = progress_msg.processor.clone(); + let msg_type = progress_msg.msg_type.clone(); + let current = progress_msg.data.current; + let total = progress_msg.data.total; + let message = progress_msg.data.message.clone(); + + tokio::spawn(async move { + if let Ok(redis_client) = + momentry_core::core::db::RedisClient::new() + { + if let Ok(mut conn) = redis_client.get_conn().await + { + let prefix = momentry_core::core::config::REDIS_KEY_PREFIX.as_str(); + let key = format!( + "{}job:{}:processor:{}", + prefix, uuid, processor + ); + let _: () = redis::cmd("HSET") + .arg(&key) + .arg("status") + .arg(&msg_type) + .query_async(&mut conn) + .await + .unwrap_or(()); + if let Some(c) = current { + let _: () = redis::cmd("HSET") + .arg(&key) + .arg("current") + .arg(c) + .query_async(&mut conn) + .await + .unwrap_or(()); + } + if let Some(t) = total { + let _: () = redis::cmd("HSET") + .arg(&key) + .arg("total") + .arg(t) + .query_async(&mut conn) + .await + .unwrap_or(()); + } + if let Some(ref m) = message { + let _: () = redis::cmd("HSET") + .arg(&key) + .arg("message") + .arg(m) + .query_async(&mut conn) + .await + .unwrap_or(()); + } + let _: () = redis::cmd("EXPIRE") + .arg(&key) + .arg(86400i64) + .query_async(&mut conn) + .await + .unwrap_or(()); + } + } + }); + + // Trigger UI render on progress update + if let Some(ref mut ui) = *redis_ui.lock().unwrap() { + let _ = ui.render(); + } + } + } + } + } + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + } + }); + + // Process ASR (Automatic Speech Recognition) + if should_process(ProcessorType::Asr) { + let asr_path = output_dir.get_output_path(&uuid, "asr.json"); + let decision = decide_processing(&asr_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nASR: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nASR: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&asr_path).ok(); + if is_cloud(ProcessorType::Asr) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_asr_module(&asr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nASR: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Asr) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_asr_module(&asr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Asr) { + println!("\nASR: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nASR: ⚙️ Processing..."); + process_asr_module(&asr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + } + } + + // Update storage status + db.update_storage_status(&uuid, "fs_json", true).await?; + + // Process CUT (scene detection) + if should_process(ProcessorType::Cut) { + let cut_path = output_dir.get_output_path(&uuid, "cut.json"); + let decision = decide_processing(&cut_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nCUT: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nCUT: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&cut_path).ok(); + if is_cloud(ProcessorType::Cut) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_cut_module(&cut_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nCUT: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Cut) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_cut_module(&cut_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Cut) { + println!("\nCUT: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nCUT: ⚙️ Processing..."); + process_cut_module(&cut_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + } + } + + // Process ASRX (speaker diarization) + if should_process(ProcessorType::Asrx) { + let asrx_path = output_dir.get_output_path(&uuid, "asrx.json"); + let decision = decide_processing(&asrx_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nASRX: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nASRX: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&asrx_path).ok(); + if is_cloud(ProcessorType::Asrx) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_asrx_module( + &asrx_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nASRX: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Asrx) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_asrx_module( + &asrx_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Asrx) { + println!("\nASRX: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nASRX: ⚙️ Processing..."); + process_asrx_module( + &asrx_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } + } + + // Process YOLO (object detection) + if should_process(ProcessorType::Yolo) { + let yolo_path = output_dir.get_output_path(&uuid, "yolo.json"); + let decision = decide_processing(&yolo_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nYOLO: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nYOLO: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&yolo_path).ok(); + if is_cloud(ProcessorType::Yolo) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_yolo_module( + &yolo_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nYOLO: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Yolo) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_yolo_module( + &yolo_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Yolo) { + println!("\nYOLO: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nYOLO: ⚙️ Processing..."); + process_yolo_module( + &yolo_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } + } + + // Process OCR (text recognition) + if should_process(ProcessorType::Ocr) { + let ocr_path = output_dir.get_output_path(&uuid, "ocr.json"); + let decision = decide_processing(&ocr_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nOCR: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nOCR: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&ocr_path).ok(); + if is_cloud(ProcessorType::Ocr) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_ocr_module(&ocr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nOCR: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Ocr) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_ocr_module(&ocr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Ocr) { + println!("\nOCR: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nOCR: ⚙️ Processing..."); + process_ocr_module(&ocr_path, video_path, &uuid, &progress_state, &ui) + .await?; + } + } + } + } + + // Process Face (face detection) + if should_process(ProcessorType::Face) { + let face_path = output_dir.get_output_path(&uuid, "face.json"); + let decision = decide_processing(&face_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nFace: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nFace: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&face_path).ok(); + if is_cloud(ProcessorType::Face) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_face_module( + &face_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nFace: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Face) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_face_module( + &face_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Face) { + println!("\nFace: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nFace: ⚙️ Processing..."); + process_face_module( + &face_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } + } + + // Process Pose (pose estimation) + if should_process(ProcessorType::Pose) { + let pose_path = output_dir.get_output_path(&uuid, "pose.json"); + let decision = decide_processing(&pose_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nPose: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nPose: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&pose_path).ok(); + if is_cloud(ProcessorType::Pose) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_pose_module( + &pose_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nPose: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Pose) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_pose_module( + &pose_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Pose) { + println!("\nPose: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nPose: ⚙️ Processing..."); + process_pose_module( + &pose_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } + } + + // Process Story (video narrative) + if should_process(ProcessorType::Story) { + let story_path = output_dir.get_output_path(&uuid, "story.json"); + let decision = decide_processing(&story_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nStory: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nStory: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&story_path).ok(); + if is_cloud(ProcessorType::Story) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_story_module( + &story_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nStory: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Story) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_story_module( + &story_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Story) { + println!("\nStory: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nStory: ⚙️ Processing..."); + process_story_module( + &story_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } + } + + // Process Caption (image captions) + if should_process(ProcessorType::Caption) { + let caption_path = output_dir.get_output_path(&uuid, "caption.json"); + let decision = decide_processing(&caption_path, force, resume); + + match decision { + ProcessingDecision::SkipComplete => { + println!("\nCaption: ✓ Already complete, skipping"); + } + ProcessingDecision::ForceReprocess => { + println!("\nCaption: ⟳ Force reprocessing from scratch..."); + std::fs::remove_file(&caption_path).ok(); + if is_cloud(ProcessorType::Caption) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_caption_module( + &caption_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::ResumePartial => { + println!("\nCaption: ↻ Resuming from checkpoint..."); + if is_cloud(ProcessorType::Caption) { + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + process_caption_module( + &caption_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + ProcessingDecision::Process => { + if is_cloud(ProcessorType::Caption) { + println!("\nCaption: ☁️ Running via cloud..."); + println!(" [Cloud processing not implemented yet - run locally]"); + } else { + println!("\nCaption: ⚙️ Processing..."); + process_caption_module( + &caption_path, + video_path, + &uuid, + &progress_state, + &ui, + ) + .await?; + } + } + } + } + + // TODO: Store pre_chunks and frames to database + + // Stop Redis subscriber + redis_handle.abort(); + + println!("\n✓ Process stage completed!"); + if should_process(ProcessorType::Asr) { + let path = output_dir.get_output_path(&uuid, "asr.json"); + println!(" - ASR JSON: {}", path.display()); + } + if should_process(ProcessorType::Cut) { + let path = output_dir.get_output_path(&uuid, "cut.json"); + println!(" - CUT JSON: {}", path.display()); + } + if should_process(ProcessorType::Asrx) { + let path = output_dir.get_output_path(&uuid, "asrx.json"); + println!(" - ASRX JSON: {}", path.display()); + } + if should_process(ProcessorType::Yolo) { + let path = output_dir.get_output_path(&uuid, "yolo.json"); + println!(" - YOLO JSON: {}", path.display()); + } + if should_process(ProcessorType::Ocr) { + let path = output_dir.get_output_path(&uuid, "ocr.json"); + println!(" - OCR JSON: {}", path.display()); + } + if should_process(ProcessorType::Face) { + let path = output_dir.get_output_path(&uuid, "face.json"); + println!(" - Face JSON: {}", path.display()); + } + if should_process(ProcessorType::Pose) { + let path = output_dir.get_output_path(&uuid, "pose.json"); + println!(" - Pose JSON: {}", path.display()); + } + if should_process(ProcessorType::Story) { + let path = output_dir.get_output_path(&uuid, "story.json"); + println!(" - Story JSON: {}", path.display()); + } + if should_process(ProcessorType::Caption) { + let path = output_dir.get_output_path(&uuid, "caption.json"); + println!(" - Caption JSON: {}", path.display()); + } + + Ok(()) + } + Commands::Chunk { uuid } => { + println!("Chunking: {}", uuid); + + let db = PostgresDb::init().await?; + let video = db + .get_video_by_uuid(&uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("Video not found: {}", uuid))?; + + let file_id = video.id; + let fps = video.fps; + + // ========== Read all JSON files ========== + + // Read ASR JSON + let asr_path = format!("{}.asr.json", uuid); + let asr_json = std::fs::read_to_string(&asr_path) + .context("ASR file not found. Run 'process' first.")?; + let asr_result: momentry_core::core::processor::asr::AsrResult = + serde_json::from_str(&asr_json)?; + println!("Loaded ASR: {} segments", asr_result.segments.len()); + + // Read CUT JSON + let cut_path = format!("{}.cut.json", uuid); + let cut_json = std::fs::read_to_string(&cut_path) + .context("CUT file not found. Run 'process' first.")?; + let cut_result: momentry_core::core::processor::cut::CutResult = + serde_json::from_str(&cut_json)?; + println!("Loaded CUT: {} scenes", cut_result.scenes.len()); + + // Read YOLO JSON (optional) + let yolo_path = format!("{}.yolo.json", uuid); + let yolo_result = match std::fs::read_to_string(&yolo_path) { + Ok(yolo_json) => match serde_json::from_str::< + momentry_core::core::processor::yolo::YoloResult, + >(&yolo_json) + { + Ok(result) => { + println!("Loaded YOLO: {} frames", result.frames.len()); + result + } + Err(e) => { + println!("Warning: Failed to parse YOLO JSON: {}. Skipping YOLO.", e); + momentry_core::core::processor::yolo::YoloResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }, + Err(_) => { + println!("Warning: YOLO file not found. Skipping YOLO."); + momentry_core::core::processor::yolo::YoloResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }; + + // Read OCR JSON (optional) + let ocr_path = format!("{}.ocr.json", uuid); + let ocr_result = match std::fs::read_to_string(&ocr_path) { + Ok(ocr_json) => match serde_json::from_str::< + momentry_core::core::processor::ocr::OcrResult, + >(&ocr_json) + { + Ok(result) => { + println!("Loaded OCR: {} frames", result.frames.len()); + result + } + Err(e) => { + println!("Warning: Failed to parse OCR JSON: {}. Skipping OCR.", e); + momentry_core::core::processor::ocr::OcrResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }, + Err(_) => { + println!("Warning: OCR file not found. Skipping OCR."); + momentry_core::core::processor::ocr::OcrResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }; + + // Read Face JSON (optional) + let face_path = format!("{}.face.json", uuid); + let face_result = match std::fs::read_to_string(&face_path) { + Ok(face_json) => match serde_json::from_str::< + momentry_core::core::processor::face::FaceResult, + >(&face_json) + { + Ok(result) => { + println!("Loaded Face: {} frames", result.frames.len()); + result + } + Err(e) => { + println!("Warning: Failed to parse Face JSON: {}. Skipping Face.", e); + momentry_core::core::processor::face::FaceResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }, + Err(_) => { + println!("Warning: Face file not found. Skipping Face."); + momentry_core::core::processor::face::FaceResult { + frame_count: 0, + fps: 0.0, + frames: vec![], + } + } + }; + + // ========== Store pre_chunks (from ASR, CUT) ========== + + println!("\nStoring pre_chunks..."); + + // 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 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, + raw_json: serde_json::json!({"text": seg.text}), + text_content: Some(seg.text.clone()), + processed: false, + chunk_id: None, + created_at: String::new(), + }; + let pre_chunk_id = db.store_pre_chunk(&pre_chunk).await?; + asr_pre_chunk_ids.push(pre_chunk_id); + } + + // Store CUT scene pre_chunks + let mut cut_pre_chunk_ids = Vec::new(); + for scene in &cut_result.scenes { + let pre_chunk = momentry_core::core::db::postgres_db::PreChunk { + id: 0, + file_id, + 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, + raw_json: serde_json::json!({ + "scene_number": scene.scene_number, + }), + text_content: None, + processed: false, + chunk_id: None, + created_at: String::new(), + }; + let pre_chunk_id = db.store_pre_chunk(&pre_chunk).await?; + cut_pre_chunk_ids.push(pre_chunk_id); + } + + // Store time-based pre_chunks (every 10 seconds) + let duration = video.duration; + let mut time_pre_chunk_ids = Vec::new(); + 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 pre_chunk = momentry_core::core::db::postgres_db::PreChunk { + id: 0, + file_id, + 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, + raw_json: serde_json::json!({"interval": 10.0}), + text_content: None, + processed: false, + chunk_id: None, + created_at: String::new(), + }; + let pre_chunk_id = db.store_pre_chunk(&pre_chunk).await?; + time_pre_chunk_ids.push(pre_chunk_id); + time_start = time_end; + } + + println!( + "Stored pre_chunks: {} asr + {} cut + {} time", + asr_result.segments.len(), + cut_result.scenes.len(), + time_pre_chunk_ids.len() + ); + + // ========== Store frames (from YOLO, OCR, Face) ========== + + println!("\nStoring frames..."); + + // Group YOLO, OCR, Face results by frame_number + let mut frame_data: std::collections::HashMap< + u64, + momentry_core::core::processor::yolo::YoloFrame, + > = std::collections::HashMap::new(); + for frame in &yolo_result.frames { + frame_data.insert(frame.frame, frame.clone()); + } + + let mut ocr_by_frame: std::collections::HashMap< + u64, + momentry_core::core::processor::ocr::OcrFrame, + > = std::collections::HashMap::new(); + for frame in &ocr_result.frames { + ocr_by_frame.insert(frame.frame, frame.clone()); + } + + let mut face_by_frame: std::collections::HashMap< + u64, + momentry_core::core::processor::face::FaceFrame, + > = std::collections::HashMap::new(); + for frame in &face_result.frames { + face_by_frame.insert(frame.frame, frame.clone()); + } + + // Store frames (merge data from YOLO, OCR, Face) + let mut all_frames: Vec = frame_data + .keys() + .cloned() + .chain(ocr_by_frame.keys().cloned()) + .chain(face_by_frame.keys().cloned()) + .collect(); + all_frames.sort(); + all_frames.dedup(); + + for frame_num in &all_frames { + let timestamp = (*frame_num as f64) / fps; + let yolo_frame = frame_data.get(frame_num); + let ocr_frame = ocr_by_frame.get(frame_num); + let face_frame = face_by_frame.get(frame_num); + + let frame = momentry_core::core::db::postgres_db::Frame { + id: 0, + file_id, + frame_number: *frame_num as i64, + timestamp, + fps, + yolo_objects: yolo_frame.map(|f| serde_json::json!(&f.objects)), + ocr_results: ocr_frame.map(|f| serde_json::json!(&f.texts)), + face_results: face_frame.map(|f| serde_json::json!(&f.faces)), + frame_path: None, + created_at: String::new(), + }; + db.store_frame(&frame).await?; + } + + println!("Stored {} frames", all_frames.len()); + + // ========== Create chunks ========== + + println!("\nCreating chunks..."); + + // Rule 1: Direct conversion (sentence pre_chunk -> sentence chunk) + 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( + file_id as i32, + uuid.clone(), + i as u32, + ChunkType::Sentence, + ChunkRule::Rule1, + seg.start, + seg.end, + fps, + serde_json::json!({ + "text": seg.text, + }), + ) + .with_text_content(seg.text.clone()) + .with_pre_chunk_ids(vec![pre_chunk_id as i32]); + sentence_chunks.push(chunk); + } + + // Rule 1: CUT chunks + 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( + file_id as i32, + uuid.clone(), + i as u32, + ChunkType::Cut, + ChunkRule::Rule1, + scene.start_time, + scene.end_time, + fps, + serde_json::json!({ + "scene_number": scene.scene_number, + }), + ) + .with_pre_chunk_ids(vec![pre_chunk_id as i32]); + cut_chunks.push(chunk); + } + + // Rule 1: Time-based chunks + let splitter = momentry_core::core::chunk::ChunkSplitter::new(10.0); + let mut time_chunks = Vec::new(); + let time_chunk_list = splitter.split_time_based(&uuid, video.duration); + for (i, tc) in time_chunk_list.iter().enumerate() { + let pre_chunk_id = time_pre_chunk_ids.get(i).copied().unwrap_or(0); + let chunk = Chunk::new( + file_id as i32, + uuid.clone(), + i as u32, + ChunkType::TimeBased, + ChunkRule::Rule1, + tc.start_time, + tc.end_time, + fps, + serde_json::json!({"interval": 10.0}), + ) + .with_pre_chunk_ids(vec![pre_chunk_id as i32]); + time_chunks.push(chunk); + } + + // Store chunks + println!( + "Storing {} sentence chunks (rule_1)...", + sentence_chunks.len() + ); + for chunk in &sentence_chunks { + db.store_chunk(chunk).await?; + } + + println!("Storing {} cut chunks (rule_1)...", cut_chunks.len()); + for chunk in &cut_chunks { + db.store_chunk(chunk).await?; + } + + println!( + "Storing {} time-based chunks (rule_1)...", + time_chunks.len() + ); + for chunk in &time_chunks { + db.store_chunk(chunk).await?; + } + + let total_chunks = sentence_chunks.len() + cut_chunks.len() + time_chunks.len(); + + // Update storage status + db.update_storage_status(&uuid, "psql_chunk", true).await?; + + println!("\n✓ Chunk stage completed!"); + println!( + " - pre_chunks: {} (asr + cut + time)", + asr_result.segments.len() + cut_result.scenes.len() + time_pre_chunk_ids.len() + ); + println!(" - frames: {}", all_frames.len()); + println!(" - chunks: {} (sentence + cut + time_based)", total_chunks); + + Ok(()) + } + Commands::Story { uuid } => { + println!("Generating story for: {}", uuid); + + let db = PostgresDb::init().await?; + let video = db + .get_video_by_uuid(&uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("Video not found: {}", uuid))?; + + let file_id = video.id; + let _fps = video.fps; + let duration = video.duration; + + // Get all chunks + let all_chunks = db.get_chunks_by_uuid(&uuid).await?; + + // Try cut chunks first, fall back to sentence chunks + let mut story_chunks: Vec<&Chunk> = all_chunks + .iter() + .filter(|c| c.chunk_type == ChunkType::Cut) + .collect(); + + let story_type = if story_chunks.is_empty() { + // Fall back to sentence chunks + story_chunks = all_chunks + .iter() + .filter(|c| c.chunk_type == ChunkType::Sentence && c.text_content.is_some()) + .collect(); + "sentence" + } else { + "cut" + }; + + if story_chunks.is_empty() { + println!("No story chunks found. Run 'chunk' command first."); + return Ok(()); + } + + println!("Found {} {} scenes", story_chunks.len(), story_type); + + // Generate story for each scene + for (i, story_chunk) in story_chunks.iter().enumerate() { + println!("\n=== Scene {} ===", i + 1); + println!( + "Time: {:.2}s - {:.2}s", + story_chunk.start_time, story_chunk.end_time + ); + + // 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); + + // Get chunks in context range (sentence chunks with ASR text) + let context_chunks = db + .get_chunks_by_time_range(file_id, context_start, context_end) + .await?; + + // Get frames in context range + let context_frames = db + .get_frames_by_time_range(file_id, context_start, context_end) + .await?; + + // Build story + let mut story = String::new(); + story.push_str(&format!( + "Scene {} ({:.1}s - {:.1}s)\n\n", + i + 1, + story_chunk.start_time, + story_chunk.end_time + )); + + // Add audio/text content + let sentence_chunks: Vec<&Chunk> = context_chunks + .iter() + .filter(|c| c.chunk_type == ChunkType::Sentence) + .collect(); + + if !sentence_chunks.is_empty() { + story.push_str("【Speech】\n"); + for sc in &sentence_chunks { + if let Some(text) = &sc.text_content { + story.push_str(&format!(" - {}\n", text)); + } + } + story.push('\n'); + } + + // Aggregate YOLO objects + let mut all_objects: std::collections::HashMap = + std::collections::HashMap::new(); + for frame in &context_frames { + if let Some(objects) = &frame.yolo_objects { + if let Some(arr) = objects.as_array() { + for obj in arr { + if let Some(class_name) = + obj.get("class_name").and_then(|v| v.as_str()) + { + *all_objects.entry(class_name.to_string()).or_insert(0) += 1; + } + } + } + } + } + + if !all_objects.is_empty() { + story.push_str("【Objects】\n"); + let mut sorted_objects: Vec<_> = all_objects.iter().collect(); + sorted_objects.sort_by(|a, b| b.1.cmp(a.1)); + for (obj, count) in sorted_objects.iter().take(10) { + story.push_str(&format!(" - {} ({} frames)\n", obj, count)); + } + story.push('\n'); + } + + // Aggregate OCR text + let mut all_texts: Vec = Vec::new(); + for frame in &context_frames { + if let Some(texts) = &frame.ocr_results { + if let Some(arr) = texts.as_array() { + for txt in arr { + if let Some(text) = txt.get("text").and_then(|v| v.as_str()) { + if !text.is_empty() && text.len() > 2 { + all_texts.push(text.to_string()); + } + } + } + } + } + } + + if !all_texts.is_empty() { + story.push_str("【Text in video】\n"); + for txt in all_texts.iter().take(10) { + story.push_str(&format!(" - {}\n", txt)); + } + story.push('\n'); + } + + // Aggregate faces + let mut face_count = 0; + for frame in &context_frames { + if let Some(faces) = &frame.face_results { + if let Some(arr) = faces.as_array() { + face_count += arr.len(); + } + } + } + + if face_count > 0 { + story.push_str(&format!( + "【Faces】\n - {} face(s) detected\n\n", + face_count + )); + } + + println!("{}", story); + } + + Ok(()) + } + Commands::Vectorize { uuid } => { + println!("Vectorizing: {}", uuid); + + let pg = PostgresDb::init() + .await + .context("Failed to init PostgreSQL")?; + let qdrant = QdrantDb::init().await.context("Failed to init Qdrant")?; + let embedder = Embedder::new("nomic-embed-text:v1.5".to_string()); + + let target_uuid = if uuid == "all" { + None + } else { + Some(uuid.as_str()) + }; + + let mut stored_count = 0usize; + + if let Some(target) = target_uuid { + let chunks = pg.get_chunks_by_uuid(target).await?; + let sentence_chunks: Vec<_> = chunks + .into_iter() + .filter(|c| c.chunk_type == ChunkType::Sentence) + .collect(); + + println!( + "Found {} sentence chunks for {}", + sentence_chunks.len(), + target + ); + + for chunk in sentence_chunks { + let text = chunk + .content + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if text.is_empty() { + continue; + } + + print!("Embedding chunk {}... ", chunk.chunk_id); + + match embedder.embed_document(text).await { + Ok(vector) => { + let vector_id = format!("{}_{}", chunk.uuid, chunk.chunk_id); + + if let Err(e) = + pg.store_vector(&chunk.chunk_id, &vector, &chunk.uuid).await + { + eprintln!("store_vector error for {}: {}", chunk.chunk_id, e); + continue; + } + + let qdrant_payload = VectorPayload { + 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, + text: Some(text.to_string()), + }; + if let Err(e) = qdrant + .upsert_vector(&chunk.chunk_id, &vector, qdrant_payload) + .await + { + eprintln!("upsert_vector error for {}: {}", chunk.chunk_id, e); + continue; + } + + if let Err(e) = pg.update_vector_id(&chunk.chunk_id, &vector_id).await { + eprintln!("update_vector_id error for {}: {}", chunk.chunk_id, e); + continue; + } + + stored_count += 1; + println!("done ({} dims)", vector.len()); + } + Err(e) => { + println!("failed: {}", e); + } + } + } + + // Only update storage status if vectors were actually stored + if stored_count > 0 { + pg.update_storage_status(target, "pvector_chunk", true) + .await?; + pg.update_storage_status(target, "qvector_chunk", true) + .await?; + println!( + "\n✓ Vectorize stage completed for {}! ({} vectors stored)", + target, stored_count + ); + } else { + println!( + "\n✗ Vectorize stage failed for {}! (0 vectors stored)", + target + ); + } + } else { + println!("\n✓ Vectorize stage completed for all videos!"); + } + Ok(()) + } + Commands::Play { target } => { + println!("Playing: {}", target); + // TODO: Implement play + Ok(()) + } + Commands::Watch { directories } => { + println!("Starting watcher: {:?}", directories); + // TODO: Implement watch + Ok(()) + } + Commands::System { gpu } => { + let resources = SystemResources::check(); + println!("╔══════════════════════════════════════════════════════════════╗"); + println!("║ System Resources Report ║"); + println!("╠══════════════════════════════════════════════════════════════╣"); + println!( + "║ CPU: {:.1}% idle ║", + resources.cpu_idle_percent + ); + println!( + "║ Memory: {:.1}GB / {:.1}GB available ({:.0}% used) ║", + resources.memory_available_mb as f64 / 1024.0, + resources.memory_total_mb as f64 / 1024.0, + resources.memory_used_percent + ); + + if resources.gpu_available { + match resources.gpu_type { + GpuType::Nvidia => { + let util = resources.gpu_utilization.unwrap_or(0.0); + println!( + "║ GPU: NVIDIA - {:.0}% utilized ║", + util + ); + } + GpuType::AppleMps => { + println!( + "║ GPU: Apple MPS (Metal) - available ║" + ); + } + } + } else { + println!("║ GPU: None detected ║"); + } + println!("╠══════════════════════════════════════════════════════════════╣"); + + if resources.can_parallel(4096) { + println!("║ Mode: PARALLEL - Can run multiple modules together ║"); + println!( + "║ Recommended modules: {} ║", + resources.recommend_parallel_modules().join(", ") + ); + } else { + println!("║ Mode: SEQUENTIAL - Low resources, run one at a time ║"); + } + println!("╚══════════════════════════════════════════════════════════════╝"); + + if gpu { + println!("\n=== GPU Details ==="); + let output = std::process::Command::new("system_profiler") + .args(["SPDisplaysDataType", "-detailLevel", "mini"]) + .output(); + if let Ok(o) = output { + println!("{}", String::from_utf8_lossy(&o.stdout)); + } + } + + Ok(()) + } + Commands::Server { host, port } => { + let port = port.unwrap_or_else(|| *momentry_core::core::config::SERVER_PORT); + momentry_core::api::start_server(&host, port).await?; + Ok(()) + } + Commands::Worker { + max_concurrent, + poll_interval, + batch_size, + } => { + 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 db = PostgresDb::init().await?; + let redis = RedisClient::new()?; + + let worker = JobWorker::new( + std::sync::Arc::new(db), + std::sync::Arc::new(redis), + config.clone(), + ); + + println!( + "Starting worker with max_concurrent={}, poll_interval={}s", + config.max_concurrent, config.poll_interval_secs + ); + + worker.run().await?; + Ok(()) + } + Commands::Query { query } => { + println!("Query: {}", query); + // TODO: Implement query + Ok(()) + } + Commands::Lookup { path } => { + let uuid = momentry_core::uuid::compute_uuid_from_path(&path); + println!("Path: {}", path); + println!("UUID: {}", uuid); + Ok(()) + } + Commands::Resolve { uuid } => { + println!("Resolving UUID: {}", uuid); + // TODO: Look up path from UUID in database + println!("(Database lookup not implemented yet)"); + Ok(()) + } + Commands::Thumbnails { uuid, count } => { + let db = PostgresDb::init().await?; + + let videos = if let Some(ref uuid) = uuid { + vec![db + .get_video_by_uuid(uuid) + .await? + .ok_or_else(|| anyhow::anyhow!("Video not found: {}", uuid))?] + } else { + db.list_videos().await? + }; + + let output_dir = std::path::PathBuf::from("thumbnails"); + let extractor = momentry_core::ThumbnailExtractor::new(output_dir, count); + + for video in videos { + println!( + "\nGenerating thumbnails for: {} ({})", + video.file_name, video.uuid + ); + + match extractor.get_or_create(&video.file_path, &video.uuid) { + Ok(result) => { + println!(" Generated {} thumbnails", result.count); + } + Err(e) => { + println!(" Error: {}", e); + } + } + } + + println!("\nThumbnails generated successfully!"); + Ok(()) + } + Commands::Status { uuid } => { + let db = PostgresDb::init().await?; + + let videos = if let Some(ref u) = uuid { + vec![db + .get_video_by_uuid(u) + .await? + .ok_or_else(|| anyhow::anyhow!("Video not found: {}", u))?] + } else { + db.list_videos().await? + }; + + println!("\n╔══════════════════════════════════════════════════════════════════════════════════╗"); + println!( + "║ 📊 Storage Status Report ║" + ); + println!("╠══════════════════════════════════════════════════════════════════════════════════╣"); + println!( + "║ {:32} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} ║", + "Video", "FS", "FS", "PSQL", "PObj", "MObj", "PVec", "QVec" + ); + println!( + "║ {:32} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} │ {:8} ║", + "", "Video", "JSON", "Chunk", "Chunk", "Chunk", "Chunk", "Chunk" + ); + println!( + "╠{:33}╪{:9}╪{:9}╪{:9}╪{:9}╪{:9}╪{:9}╪{:9}╣", + str::repeat("─", 32), + str::repeat("─", 8), + str::repeat("─", 8), + str::repeat("─", 8), + str::repeat("─", 8), + str::repeat("─", 8), + str::repeat("─", 8), + str::repeat("─", 8) + ); + + for video in videos { + let (sentence_count, time_count) = + db.get_chunk_count(&video.uuid).await.unwrap_or((0, 0)); + let vector_count = db.get_vector_count(&video.uuid).await.unwrap_or(0); + let total_chunks = sentence_count + time_count; + + let psql_status = if total_chunks > 0 { "✓" } else { "-" }; + let pvec_status = if vector_count > 0 && total_chunks > 0 { + if vector_count >= total_chunks { + "✓" + } else { + "◐" + } + } else { + "-" + }; + let qvec_status = if video.storage.qvector_chunk { + "✓" + } else { + "-" + }; + + let file_name = if video.file_name.len() > 30 { + format!("...{}", &video.file_name[video.file_name.len() - 27..]) + } else { + video.file_name + }; + + println!( + "║ {:32} │ {} │ {} │ {} │ - │ - │ {} │ {} ║", + file_name, + if video.storage.fs_video { "✓" } else { "✗" }, + if video.storage.fs_json { "✓" } else { "-" }, + psql_status, + pvec_status, + qvec_status + ); + } + + println!("╠══════════════════════════════════════════════════════════════════════════════════╣"); + println!( + "║ Storage Types: ║" + ); + println!( + "║ FS_Video - Video file on filesystem ║" + ); + println!( + "║ FS_JSON - JSON files (probe, ASR, YOLO, etc.) ║" + ); + println!( + "║ PSQL_Chunk - Chunks stored in PostgreSQL ║" + ); + println!( + "║ PObject - Chunks as JSON objects in PostgreSQL (future) ║" + ); + println!( + "║ MObject - Chunks as JSON objects in MongoDB (future) ║" + ); + println!( + "║ PVector - Vectors in PostgreSQL ║" + ); + println!( + "║ QVector - Vectors in Qdrant ║" + ); + println!("╚══════════════════════════════════════════════════════════════════════════════════╝"); + Ok(()) + } + Commands::Backup { action, days } => { + let output_dir = OutputDir::new(); + output_dir.ensure_dir()?; + + println!("\n📁 Backup directory: {:?}", output_dir.get_backup_dir()); + + match action.as_str() { + "list" => { + let backups = output_dir.list_backups()?; + println!("\n📦 Available backups:"); + if backups.is_empty() { + println!(" (no backups found)"); + } else { + for backup in &backups { + println!(" - {}", backup.filename); + } + } + println!("\nTotal: {} backup(s)", backups.len()); + } + "cleanup" => { + let days = days.unwrap_or(30); + let deleted = output_dir.cleanup_old_backups(days)?; + println!( + "\n🗑️ Cleaned up {} old backup(s) (older than {} days)", + deleted, days + ); + } + "verify" => { + println!("\n🔍 Verifying backups..."); + let backups = output_dir.list_backups()?; + let mut verified = 0; + let mut failed = 0; + for backup in &backups { + match output_dir.verify_backup(&backup.path) { + Ok(true) => { + println!(" ✓ {}", backup.filename); + verified += 1; + } + Ok(false) => { + println!(" ✗ {} (missing checksum)", backup.filename); + failed += 1; + } + Err(e) => { + println!(" ✗ {} ({})", backup.filename, e); + failed += 1; + } + } + } + println!("\nVerified: {} OK, {} failed", verified, failed); + } + _ => { + println!("\n⚠️ Unknown action: {}", action); + println!("Available actions: list, cleanup, verify"); + } + } + Ok(()) + } + Commands::ApiKey { + action, + name, + key_type, + ttl, + key, + } => { + let db = PostgresDb::init().await?; + let db_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://accusys@localhost:5432/momentry".to_string()); + + let service = ApiKeyService::new(db_url); + + match action { + ApiKeyAction::Create => { + let name = name.unwrap_or_else(|| "unnamed-key".to_string()); + let kt = parse_key_type(key_type.as_deref()); + let request = momentry_core::core::api_key::CreateApiKeyRequest { + name: name.clone(), + key_type: kt, + user_id: None, + service_name: None, + permissions: vec!["read".to_string(), "write".to_string()], + ttl_days: ttl, + }; + + match service.create_key(request) { + Ok(response) => { + let key_hash = service.hash_key(&response.key); + let key_type_str = + serde_json::to_string(&kt).unwrap_or_else(|_| "user".to_string()); + let permissions = serde_json::json!(["read", "write"]); + + let config = momentry_core::core::db::CreateApiKeyConfig::new( + &response.key_id, + &key_hash, + kt.prefix(), + &name, + &key_type_str, + ) + .with_permissions(&permissions) + .with_expires_at(response.expires_at); + + if let Err(e) = db.create_api_key(config).await { + eprintln!( + "\n⚠️ Key generated but failed to store in database: {}", + e + ); + } + + println!("\n✅ API Key created successfully!"); + println!("\n┌─────────────────────────────────────────────────────────────────────────────┐"); + println!("│ ⚠️ IMPORTANT: Save this key now - it will not be shown again! │"); + println!("└─────────────────────────────────────────────────────────────────────────────┘"); + println!("\nKey ID: {}", response.key_id); + println!("API Key: {}", response.key); + println!("Expires: {}", response.expires_at); + if !response.warning.is_empty() { + println!("\n⚠️ {}", response.warning); + } + } + Err(e) => { + eprintln!("\n❌ Failed to create API key: {}", e); + } + } + } + ApiKeyAction::List => match db.list_api_keys().await { + Ok(keys) => { + println!("\n📋 API Key List"); + if keys.is_empty() { + println!(" (no API keys found)"); + } else { + println!("\n┌────────────────────────────────────────────────────────────────────────────┐"); + println!( + "│ {:8} │ {:20} │ {:12} │ {:8} │ {:15} │", + "Status", "Name", "Type", "Usage", "Last Used" + ); + println!("├────────────────────────────────────────────────────────────────────────────┤"); + for k in &keys { + let status = if k.status == "active" { + "✓ active" + } else { + &k.status + }; + let last_used = k + .last_used_at + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "never".to_string()); + println!( + "│ {:8} │ {:20} │ {:12} │ {:8} │ {:15} │", + status, + if k.name.len() > 20 { + &k.name[..17] + } else { + &k.name + }, + k.key_type, + k.usage_count, + last_used + ); + } + println!("└────────────────────────────────────────────────────────────────────────────┘"); + println!("\nTotal: {} key(s)", keys.len()); + } + } + Err(e) => { + eprintln!("\n❌ Failed to list API keys: {}", e); + } + }, + ApiKeyAction::Validate => { + let api_key = + key.ok_or_else(|| anyhow::anyhow!("--key required for validate"))?; + let key_hash = service.hash_key(&api_key); + + match db.get_api_key_by_hash(&key_hash).await { + Ok(Some(record)) => { + if record.status == "active" { + db.update_api_key_usage(&record.key_id, None).await.ok(); + println!("\n✅ API Key is valid"); + println!("Key ID: {}", record.key_id); + println!("Name: {}", record.name); + println!("Type: {}", record.key_type); + println!("Usage: {} times", record.usage_count + 1); + if record.rotation_required { + println!( + "⚠️ Rotation required: {}", + record.rotation_reason.as_deref().unwrap_or("unknown") + ); + } + } else { + println!("\n❌ API Key is {}", record.status); + } + } + Ok(None) => { + println!("\n❌ API Key is invalid or not found"); + } + Err(e) => { + eprintln!("\n❌ Validation error: {}", e); + } + } + } + ApiKeyAction::Revoke => { + let key = key.ok_or_else(|| anyhow::anyhow!("--key required for revoke"))?; + let key_id = service.extract_key_id(&key); + match db.revoke_api_key(&key_id).await { + Ok(_) => { + println!("\n🔴 API Key {} revoked successfully", key_id); + } + Err(e) => { + eprintln!("\n❌ Failed to revoke API key: {}", e); + } + } + } + ApiKeyAction::Rotate => { + let key = key.ok_or_else(|| anyhow::anyhow!("--key required for rotate"))?; + let key_id = service.extract_key_id(&key); + let grace_period_end = + service.calculate_grace_period_end(parse_key_type(key_type.as_deref())); + match db + .require_api_key_rotation( + &key_id, + "manual rotation requested", + grace_period_end, + ) + .await + { + Ok(_) => { + println!("\n🔄 Rotation requested for key: {}", key_id); + println!("Grace period ends: {}", grace_period_end); + } + Err(e) => { + eprintln!("\n❌ Rotation request failed: {}", e); + } + } + } + ApiKeyAction::Stats => { + match db.get_api_key_stats().await { + Ok(stats) => { + println!("\n📊 API Key Statistics"); + println!("\n┌─────────────────────────────────────────┐"); + println!("│ Total Keys: {:5} │", stats.total_keys); + println!( + "│ Active Keys: {:5} │", + stats.active_keys + ); + println!( + "│ Expired Keys: {:5} │", + stats.expired_keys + ); + println!( + "│ Rotation Required: {:4} │", + stats.rotation_required + ); + println!( + "│ Anomalies (24h): {:5} │", + stats.anomalies_last_24h + ); + println!("└─────────────────────────────────────────┘"); + } + Err(e) => { + eprintln!("\n⚠️ Failed to get stats: {}", e); + } + } + + let config = service.get_config(); + println!("\n┌─────────────────────────────────────────┐"); + println!("│ Anomaly Detection Thresholds │"); + println!("├─────────────────────────────────────────┤"); + println!( + "│ Requests/minute: {:5} │", + config.requests_per_minute_threshold + ); + println!( + "│ Requests/hour: {:5} │", + config.requests_per_hour_threshold + ); + println!( + "│ Error rate: {:5.1}% │", + config.error_rate_threshold * 100.0 + ); + println!( + "│ Unique IPs/hour: {:5} │", + config.unique_ips_per_hour_threshold + ); + println!( + "│ Lockout threshold: {:5} │", + config.lockout_threshold + ); + println!("└─────────────────────────────────────────┘"); + } + } + Ok(()) + } + Commands::Gitea { + action, + username, + password, + token_name, + scopes, + } => { + use momentry_core::core::api_key::gitea::{ + CreateGiteaTokenRequest, GiteaClient, GiteaScope, + }; + + let db = PostgresDb::init().await?; + let gitea = GiteaClient::new()?; + + match action { + GiteaAction::Create => { + let username = username + .ok_or_else(|| anyhow::anyhow!("--username required for create"))?; + let password = password + .ok_or_else(|| anyhow::anyhow!("--password required for create"))?; + let token_name = token_name + .ok_or_else(|| anyhow::anyhow!("--token-name required for create"))?; + + let scopes_vec: Vec = scopes + .map(|s| { + s.split(',') + .filter_map(|scope| scope.trim().parse::().ok()) + .collect() + }) + .unwrap_or_else(|| { + vec![GiteaScope::ReadRepository, GiteaScope::WriteRepository] + }); + + let request = CreateGiteaTokenRequest { + username: username.clone(), + password, + token_name: token_name.clone(), + scopes: scopes_vec.clone(), + }; + + match gitea.create_token(&request).await { + Ok(response) => { + if let Err(e) = db + .create_gitea_token( + response.id, + &username, + &token_name, + &response.token_last_eight, + &serde_json::json!(scopes_vec + .iter() + .map(|s| s.as_str()) + .collect::>()), + None, + ) + .await + { + eprintln!("\n⚠️ Token created but failed to store: {}", e); + } + + println!("\n✅ Gitea Token created successfully!"); + println!("\n┌─────────────────────────────────────────────────────────────────────────────┐"); + println!("│ ⚠️ IMPORTANT: Save this token now - it will not be shown again! │"); + println!("└─────────────────────────────────────────────────────────────────────────────┘"); + println!("\nToken ID: {}", response.id); + println!("Token Name: {}", response.name); + println!("SHA1: {}", response.sha1); + println!("Last 8: {}", response.token_last_eight); + println!("\nAuthorization Header:"); + println!(" Authorization: token {}", response.sha1); + } + Err(e) => { + eprintln!("\n❌ Failed to create Gitea token: {}", e); + } + } + } + GiteaAction::List => { + let username = + username.ok_or_else(|| anyhow::anyhow!("--username required for list"))?; + let password = + password.ok_or_else(|| anyhow::anyhow!("--password required for list"))?; + + match gitea.list_tokens(&username, &password).await { + Ok(tokens) => { + println!("\n📋 Gitea Tokens for user: {}", username); + if tokens.is_empty() { + println!(" (no tokens found)"); + } else { + println!("\n┌────────────────────────────────────────────────────────────────────────────┐"); + println!("│ ID │ Name │ Last 8 │ Registered │"); + println!("├────────────────────────────────────────────────────────────────────────────┤"); + for token in &tokens { + let registered = db + .get_gitea_token_by_name(&username, &token.name) + .await + .ok() + .flatten() + .map(|_| "✓") + .unwrap_or("-"); + println!( + "│ {:8} │ {:20} │ {:9} │ {:27} │", + token.id, + if token.name.len() > 20 { + &token.name[..17] + } else { + &token.name + }, + token.token_last_eight, + registered + ); + } + println!("└────────────────────────────────────────────────────────────────────────────┘"); + println!("\nTotal: {} token(s)", tokens.len()); + } + } + Err(e) => { + eprintln!("\n❌ Failed to list Gitea tokens: {}", e); + } + } + } + GiteaAction::Delete => { + let username = username + .ok_or_else(|| anyhow::anyhow!("--username required for delete"))?; + let password = password + .ok_or_else(|| anyhow::anyhow!("--password required for delete"))?; + let token_name = token_name + .ok_or_else(|| anyhow::anyhow!("--token-name required for delete"))?; + + match gitea.delete_token(&username, &password, &token_name).await { + Ok(_) => { + let _ = db.delete_gitea_token(&username, &token_name).await; + println!("\n🗑️ Token '{}' deleted successfully", token_name); + } + Err(e) => { + eprintln!("\n❌ Failed to delete Gitea token: {}", e); + } + } + } + GiteaAction::Verify => { + let token_name = token_name + .ok_or_else(|| anyhow::anyhow!("--token-name required for verify"))?; + + let record = db + .get_gitea_token_by_name( + &username.unwrap_or_else(|| "unknown".to_string()), + &token_name, + ) + .await?; + + match record { + Some(r) => { + println!("\n📋 Gitea Token: {}", r.token_name); + println!(" User: {}", r.gitea_user); + println!(" Token ID: {}", r.gitea_token_id); + println!(" Last 8: {}", r.token_last_eight); + println!(" Scopes: {}", r.scopes); + println!(" Created: {}", r.created_at); + if let Some(verified) = r.last_verified { + println!(" Last Verified: {}", verified); + } else { + println!(" Last Verified: never"); + } + } + None => { + println!("\n❌ Token not found in local database"); + } + } + } + } + Ok(()) + } + Commands::N8n { + action, + api_key, + label, + expires_in_days, + } => { + use momentry_core::core::api_key::n8n::{ + extract_last_eight, CreateN8nApiKeyRequest, N8nClient, + }; + + let db = PostgresDb::init().await?; + + match action { + N8nAction::Create => { + let api_key_value = api_key.ok_or_else(|| { + anyhow::anyhow!("--api-key required for create (existing n8n API key)") + })?; + let label = + label.ok_or_else(|| anyhow::anyhow!("--label required for create"))?; + + let n8n = N8nClient::new(api_key_value)?; + + let expires_at = expires_in_days + .map(|days| chrono::Utc::now() + chrono::Duration::days(days)); + + let request = CreateN8nApiKeyRequest { + label: label.clone(), + expires_at, + }; + + match n8n.create_api_key(&request).await { + Ok(response) => { + if let Err(e) = db + .create_n8n_api_key( + &response.id, + &label, + &extract_last_eight(&response.api_key), + None, + response.expires_at, + ) + .await + { + eprintln!("\n⚠️ API key created but failed to store: {}", e); + } + + println!("\n✅ n8n API Key created successfully!"); + println!("\n┌─────────────────────────────────────────────────────────────────────────────┐"); + println!("│ ⚠️ IMPORTANT: Save this API key now - it will not be shown again! │"); + println!("└─────────────────────────────────────────────────────────────────────────────┘"); + println!("\nKey ID: {}", response.id); + println!("Label: {}", response.label); + println!("API Key: {}", response.api_key); + println!("\nUsage:"); + println!(" curl -H 'X-N8N-API-KEY: {}' https://n8n.momentry.ddns.net/api/v1/workflows", response.api_key); + } + Err(e) => { + eprintln!("\n❌ Failed to create n8n API key: {}", e); + } + } + } + N8nAction::List => { + let api_key_value = + api_key.ok_or_else(|| anyhow::anyhow!("--api-key required for list"))?; + + let n8n = N8nClient::new(api_key_value)?; + + match n8n.list_api_keys().await { + Ok(keys) => { + println!("\n📋 n8n API Keys"); + if keys.is_empty() { + println!(" (no API keys found)"); + } else { + println!("\n┌────────────────────────────────────────────────────────────────────────────┐"); + println!("│ Label │ ID │"); + println!("├────────────────────────────────────────────────────────────────────────────┤"); + for key in &keys { + println!( + "│ {:27} │ {:39} │", + if key.label.len() > 27 { + &key.label[..24] + } else { + &key.label + }, + key.id + ); + } + println!("└────────────────────────────────────────────────────────────────────────────┘"); + println!("\nTotal: {} key(s)", keys.len()); + } + } + Err(e) => { + eprintln!("\n❌ Failed to list n8n API keys: {}", e); + } + } + } + N8nAction::Delete => { + let api_key_value = + api_key.ok_or_else(|| anyhow::anyhow!("--api-key required for delete"))?; + let label = + label.ok_or_else(|| anyhow::anyhow!("--label required for delete"))?; + + let record = db.get_n8n_api_key_by_label(&label).await?; + if let Some(r) = record { + let n8n = N8nClient::new(api_key_value)?; + match n8n.delete_api_key(&r.n8n_key_id).await { + Ok(_) => { + let _ = db.delete_n8n_api_key(&label).await; + println!("\n🗑️ API key '{}' deleted successfully", label); + } + Err(e) => { + eprintln!("\n❌ Failed to delete n8n API key: {}", e); + } + } + } else { + println!("\n❌ API key '{}' not found in local database", label); + } + } + N8nAction::Verify => { + let label = + label.ok_or_else(|| anyhow::anyhow!("--label required for verify"))?; + + let record = db.get_n8n_api_key_by_label(&label).await?; + + match record { + Some(r) => { + println!("\n📋 n8n API Key: {}", r.label); + println!(" Key ID: {}", r.n8n_key_id); + println!(" Last 8: {}", r.api_key_last_eight); + println!(" Created: {}", r.created_at); + if let Some(expires) = r.expires_at { + println!(" Expires: {}", expires); + } + if let Some(verified) = r.last_verified { + println!(" Last Verified: {}", verified); + } else { + println!(" Last Verified: never"); + } + } + None => { + println!("\n❌ API key not found in local database"); + } + } + } + } + Ok(()) + } + } +}