From 0de7d67511ebc2fbe9e3e46a5000807482d2b78f Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Mon, 27 Apr 2026 22:05:18 +0700 Subject: [PATCH] thay doi tinh nang chay trong mang noi bo --- .gitignore | 5 ++- INSTALL.md | 72 ++++++++++++++++++++++++++++++++++++++ README.md | 14 ++++++++ api/app/main.py | 4 +-- api/app/models.py | 12 +++++-- mediamtx/mediamtx.yml | 1 + src/hooks/useWhepPlayer.ts | 8 ++++- vite.config.ts | 71 +++++++++++++++++++------------------ 8 files changed, 146 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 55783d4..dcee78e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,7 @@ dist-ssr api/.venv/ api/app/__pycache__/ -api/data \ No newline at end of file +api/data + +# recordings +mediamtx/recordings \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md index f91486f..4b2e9e9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,6 +2,21 @@ Tài liệu này hướng dẫn triển khai trên Orange Pi (Linux). Mục tiêu: MediaMTX chạy như media server, FastAPI chạy như dashboard backend, React build ra static. +## 0) Mô hình triển khai + +### A) 1 thiết bị (đơn giản nhất) + +- MediaMTX + Dashboard backend + Dashboard frontend chạy cùng máy + +### B) 2 thiết bị (cùng LAN / cùng lớp mạng) + +- Máy A: chạy MediaMTX + lưu recordings +- Máy B: chạy Dashboard backend + frontend +- Máy B sẽ gọi: + - MediaMTX API của máy A (port 9997) để add/remove camera + bật/tắt recording + - WebRTC/WHEP của máy A (port 8889 + UDP 8189) để xem live + - Playback: máy B cần đọc được recordings của máy A (khuyến nghị mount NFS/SMB) + ## 1) Yêu cầu - Orange Pi chạy Debian/Ubuntu @@ -19,6 +34,7 @@ apiAddress: :9997 webrtc: yes webrtcAddress: :8889 +webrtcAllowOrigin: '*' record: yes recordFormat: fmp4 @@ -26,6 +42,13 @@ recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f recordPartDuration: 5m ``` +Nếu chạy mô hình 2 thiết bị, đảm bảo MediaMTX trả về đúng IP LAN để client kết nối ICE: + +```yaml +webrtcAdditionalHosts: + - 192.168.88.10 +``` + Trong `paths`, bạn có thể để dashboard tự thêm path bằng API (Settings → Add Camera). Tạo thư mục recordings: @@ -42,6 +65,33 @@ Mở firewall/cổng (tuỳ hệ thống): - `9997/tcp` MediaMTX Control API - `8189/udp` ICE +## 2.1) Playback khi tách 2 thiết bị (mount recordings) + +Vì MediaMTX ghi file recordings trên máy A, nên Dashboard (máy B) cần truy cập được folder này để: + +- list file `/api/recordings` +- serve file `/videos/...` + +Khuyến nghị dùng NFS (Linux-Linux): + +Máy A: + +```bash +sudo apt-get update +sudo apt-get install -y nfs-kernel-server +echo "/recordings 192.168.88.0/24(rw,sync,no_subtree_check)" | sudo tee -a /etc/exports +sudo exportfs -ra +``` + +Máy B: + +```bash +sudo apt-get update +sudo apt-get install -y nfs-common +sudo mkdir -p /recordings +sudo mount -t nfs 192.168.88.10:/recordings /recordings +``` + ## 3) Backend FastAPI ### 3.1 Cài dependencies @@ -63,6 +113,26 @@ Bạn có thể chỉnh: - `mediamtx_webrtc_url` (mặc định `http://127.0.0.1:8889`) - `recordings_dir` (mặc định `/recordings`) +Nếu chạy mô hình 2 thiết bị, set theo IP máy A (MediaMTX), ví dụ: + +```json +{ + "mediamtx_api_url": "http://192.168.88.10:9997", + "mediamtx_webrtc_url": "http://192.168.88.10:8889", + "recordings_dir": "/recordings", + "cameras": [], + "schedule": { "enabled": true, "weekdays_from": "18:00", "weekdays_to": "08:00", "weekend_all_day": true } +} +``` + +Hoặc set ENV trước lần chạy đầu tiên để tạo config mặc định: + +```bash +export MEDIAMTX_API_URL="http://192.168.88.10:9997" +export MEDIAMTX_WEBRTC_URL="http://192.168.88.10:8889" +export RECORDINGS_DIR="/recordings" +``` + Nếu MediaMTX API có auth, export biến môi trường: ```bash @@ -120,6 +190,8 @@ server { } ``` +Nếu chạy mô hình 2 thiết bị, phần Nginx ở máy B không cần proxy tới MediaMTX. Frontend sẽ gọi trực tiếp `mediamtx_webrtc_url` (máy A). + ## 5) Systemd service (khuyến nghị) ### 5.1 Backend service diff --git a/README.md b/README.md index 5410934..ed8ca64 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,20 @@ Backend tự tạo file cấu hình tại `api/data/config.json` khi chạy lầ Có thể override WebRTC base URL ở frontend bằng biến môi trường `VITE_MEDIAMTX_WEBRTC_URL`. +Có thể set giá trị mặc định khi chạy lần đầu bằng ENV (backend): + +- `MEDIAMTX_API_URL` (ví dụ `http://192.168.88.10:9997`) +- `MEDIAMTX_WEBRTC_URL` (ví dụ `http://192.168.88.10:8889`) +- `RECORDINGS_DIR` (ví dụ `/recordings` hoặc đường dẫn mount NFS/SMB) + +## Chạy tách 2 thiết bị (cùng LAN) + +- Máy A (MediaMTX + ổ lưu recordings): chạy MediaMTX, mở cổng `9997/tcp`, `8889/tcp`, `8189/udp`, `8554/tcp` +- Máy B (Dashboard backend + frontend): chạy FastAPI + serve web UI +- Playback: máy B cần đọc được thư mục recordings của máy A (khuyến nghị mount NFS/SMB về `RECORDINGS_DIR`) + +Chi tiết xem `INSTALL.md`. + ## Triển khai Xem hướng dẫn chi tiết trong `INSTALL.md`. diff --git a/api/app/main.py b/api/app/main.py index 392084a..41a1f19 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -69,8 +69,8 @@ async def _scheduler_loop() -> None: async def _startup() -> None: cfg = await store.load() recordings_dir = cfg.recordings_dir - if Path(recordings_dir).exists(): - app.mount("/videos", StaticFiles(directory=recordings_dir), name="videos") + Path(recordings_dir).mkdir(parents=True, exist_ok=True) + app.mount("/videos", StaticFiles(directory=recordings_dir), name="videos") asyncio.create_task(_scheduler_loop()) diff --git a/api/app/models.py b/api/app/models.py index a43693c..c10d264 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -1,3 +1,5 @@ +import os + from pydantic import BaseModel, Field @@ -14,9 +16,13 @@ class Schedule(BaseModel): class AppConfig(BaseModel): - mediamtx_api_url: str = "http://127.0.0.1:9997" - mediamtx_webrtc_url: str = "http://127.0.0.1:8889" - recordings_dir: str = "/recordings" + mediamtx_api_url: str = Field( + default_factory=lambda: os.getenv("MEDIAMTX_API_URL", "http://127.0.0.1:9997") + ) + mediamtx_webrtc_url: str = Field( + default_factory=lambda: os.getenv("MEDIAMTX_WEBRTC_URL", "http://127.0.0.1:8889") + ) + recordings_dir: str = Field(default_factory=lambda: os.getenv("RECORDINGS_DIR", "/recordings")) cameras: list[Camera] = Field(default_factory=list) schedule: Schedule = Field(default_factory=Schedule) diff --git a/mediamtx/mediamtx.yml b/mediamtx/mediamtx.yml index 853972e..d596d71 100644 --- a/mediamtx/mediamtx.yml +++ b/mediamtx/mediamtx.yml @@ -30,6 +30,7 @@ hlsPartDuration: 200ms ############################################ webrtc: true webrtcAddress: :8889 +webrtcAllowOrigin: '*' webrtcLocalUDPAddress: :8189 webrtcAdditionalHosts: - 192.168.88.3 diff --git a/src/hooks/useWhepPlayer.ts b/src/hooks/useWhepPlayer.ts index a5b58cb..8eb978e 100644 --- a/src/hooks/useWhepPlayer.ts +++ b/src/hooks/useWhepPlayer.ts @@ -147,7 +147,13 @@ export function useWhepPlayer({ const answerSdp = await res.text(); const location = res.headers.get("location") || res.headers.get("Location"); - if (location) sessionUrlRef.current = location; + if (location) { + try { + sessionUrlRef.current = new URL(location, whepUrl).toString(); + } catch { + sessionUrlRef.current = location; + } + } await pc.setRemoteDescription({ type: "answer", sdp: answerSdp }); if (!disposed) setStatus("playing"); diff --git a/vite.config.ts b/vite.config.ts index 24f9b0e..5a5ed75 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,40 +1,43 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tsconfigPaths from "vite-tsconfig-paths"; import { traeBadgePlugin } from 'vite-plugin-trae-solo-badge'; // https://vite.dev/config/ -export default defineConfig({ - server: { - proxy: { - "/api": "http://localhost:8000", - "/videos": "http://localhost:8000", - }, - }, - build: { - sourcemap: 'hidden', - }, - plugins: [ - react({ - babel: { - plugins: [ - 'react-dev-locator', - ], +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const backend = env.VITE_DEV_BACKEND_URL || "http://localhost:8000"; + + return { + server: { + proxy: { + "/api": backend, + "/videos": backend, }, - }), - traeBadgePlugin({ - variant: 'dark', - position: 'bottom-right', - prodOnly: true, - clickable: true, - clickUrl: 'https://www.trae.ai/solo?showJoin=1', - autoTheme: true, - autoThemeTarget: '#root' - }), - tsconfigPaths() - ], - test: { - environment: "jsdom", - setupFiles: ["./src/test/setup.ts"], - }, -}) + }, + build: { + sourcemap: "hidden", + }, + plugins: [ + react({ + babel: { + plugins: ["react-dev-locator"], + }, + }), + traeBadgePlugin({ + variant: "dark", + position: "bottom-right", + prodOnly: true, + clickable: true, + clickUrl: "https://www.trae.ai/solo?showJoin=1", + autoTheme: true, + autoThemeTarget: "#root", + }), + tsconfigPaths(), + ], + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + }, + }; +});