From 176e6bdb8f40e8872df96fc468156fb6f526c9ae Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Thu, 30 Apr 2026 21:29:11 +0700 Subject: [PATCH] fix one machine only --- .env.example | 1 - INSTALL.md | 65 +-------------------- README.md | 12 +--- api/app/main.py | 107 ++++++++++++---------------------- api/app/models.py | 9 +++ src/pages/Settings.tsx | 128 +++++++++++++++++++++++++++++++++++++++-- src/types/api.ts | 9 +++ src/utils/api.ts | 3 +- 8 files changed, 185 insertions(+), 149 deletions(-) diff --git a/.env.example b/.env.example index 7f6a2d5..df0b939 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ # Frontend dev variables only. VITE_DEV_BACKEND_URL=http://localhost:8008 VITE_API_BASE_URL=/api -VITE_MEDIAMTX_WEBRTC_URL= diff --git a/INSTALL.md b/INSTALL.md index 57d46e2..b4efd19 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -4,18 +4,7 @@ Tài liệu này hướng dẫn triển khai trên Orange Pi (Linux). Mục tiê ## 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) +- MediaMTX + Dashboard backend + Dashboard frontend chạy cùng máy. ## 1) Yêu cầu @@ -42,13 +31,6 @@ 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 -``` - ### 2.1) Nếu MediaMTX API bị 401 Unauthorized 401 nghĩa là request thiếu thông tin xác thực hợp lệ. Khi bật auth trong MediaMTX, bạn cần tạo user có quyền `api` và cấu hình backend dashboard gửi Basic Auth. @@ -88,33 +70,6 @@ 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 @@ -131,6 +86,7 @@ pip install -r api/requirements.txt Backend chỉ đọc cấu hình từ `api/data/config.json` (không đọc `.env`). Chạy lần đầu sẽ tự tạo file này (lưu camera + schedule + các tham số backend). Danh sách camera trong `config.json` sẽ được backend đồng bộ từ `mediamtx.yml`. +Các thông số kết nối MediaMTX (`mediamtx_api_url`, `mediamtx_webrtc_url`, credentials, `recordings_dir`) do user điền trong dashboard Settings hoặc chỉnh trực tiếp `config.json`. Bạn có thể chỉnh: @@ -147,21 +103,6 @@ Các thao tác trong Settings: - MediaMTX record: bật/tắt `pathDefaults.record` trong `mediamtx.yml` - Restart MediaMTX Docker: gọi `docker compose -f mediamtx/docker-compose.yml restart mediamtx` -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", - "mediamtx_api_user": null, - "mediamtx_api_pass": null, - "recordings_dir": "./mediamtx/recordings", - "api_port": 8008, - "cameras": [], - "schedule": { "enabled": true, "weekdays_from": "18:00", "weekdays_to": "08:00", "weekend_all_day": true } -} -``` - ### 3.3 Chạy backend ```bash @@ -212,8 +153,6 @@ 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 fcbbe65..c91308d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Dashboard giám sát camera IP gọn nhẹ chạy trên Orange Pi, dựa trên M - `GET /api/health` - `GET /api/config` +- `POST /api/config/basic` (cập nhật thông số `config.json`) - `GET /api/paths` (proxy trạng thái từ MediaMTX) - `POST /api/recording` (bật/tắt ghi hình ngay) - `POST /api/scheduler/enabled` / `POST /api/scheduler/schedule` @@ -50,6 +51,7 @@ Frontend dev server đã được cấu hình proxy `/api` và `/videos` sang `h ## Cấu hình Backend chỉ dùng file `api/data/config.json` (không đọc `.env`). +Frontend dùng `.env` chỉ với `VITE_DEV_BACKEND_URL` và `VITE_API_BASE_URL`. - `mediamtx_api_url`: ví dụ `http://127.0.0.1:9997` - `mediamtx_webrtc_url`: ví dụ `http://127.0.0.1:8889` @@ -57,7 +59,7 @@ Backend chỉ dùng file `api/data/config.json` (không đọc `.env`). - `mediamtx_api_pass`: password API (nếu bật auth trong MediaMTX) - `recordings_dir`: ví dụ `./mediamtx/recordings` (cùng máy) hoặc đường dẫn mount NFS/SMB - `api_port`: cổng chạy backend (mặc định `8008`) -- `cameras`: danh sách camera đồng bộ từ `mediamtx.yml` (`name` + `rtsp_url`) +- `cameras`: danh sách camera được đồng bộ từ `mediamtx.yml` (`name` + `rtsp_url`) - `schedule`: lịch ghi hình Ví dụ `config.json`: @@ -80,14 +82,6 @@ Ví dụ `config.json`: } ``` -## 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à set `recordings_dir` trong `api/data/config.json`) - -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 9604338..ba53f9b 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -5,7 +5,6 @@ import logging import subprocess from pathlib import Path from typing import Optional -from urllib.parse import urlparse import httpx import yaml @@ -17,6 +16,7 @@ from .config_store import default_store from .mediamtx_client import MediaMTXClient from .models import ( AppConfig, + AppConfigUpdate, Camera, MediaMTXAddCameraRequest, MediaMTXCamera, @@ -50,51 +50,11 @@ app.add_middleware( store = default_store() -def _extract_port(address: str, fallback: int) -> int: - if not address: - return fallback - text = str(address).strip() - if text.startswith(":"): - text = text[1:] - if ":" in text: - text = text.rsplit(":", 1)[-1] - try: - value = int(text) - return value if 1 <= value <= 65535 else fallback - except ValueError: - return fallback - - -def _host_from_url(url: str, fallback: str) -> str: - try: - parsed = urlparse(url) - host = (parsed.hostname or "").strip() - if host and host not in {"0.0.0.0", "::"}: - return host - except ValueError: - pass - return fallback - - -def _extract_host(address: str, fallback: str) -> str: - text = str(address or "").strip() - if not text: - return fallback - if text.startswith(":"): - return fallback - if "://" in text: - return _host_from_url(text, fallback) - if text.startswith("[") and "]" in text: - host = text[1 : text.index("]")] - return host or fallback - if ":" in text: - host = text.rsplit(":", 1)[0].strip() - if host and host not in {"0.0.0.0", "::"}: - return host - return fallback - if text in {"0.0.0.0", "::"}: - return fallback - return text +def _clean_text(value: Optional[str]) -> Optional[str]: + if value is None: + return None + cleaned = str(value).strip().strip("`'\"") + return cleaned or None def _load_mediamtx_yml() -> dict: @@ -111,33 +71,24 @@ def _save_mediamtx_yml(data: dict) -> None: ) -def _build_mediamtx_view(data: dict, current_cfg: Optional[AppConfig] = None) -> MediaMTXConfigView: - api_port = _extract_port(str(data.get("apiAddress", ":9997")), 9997) - webrtc_port = _extract_port(str(data.get("webrtcAddress", ":8889")), 8889) - current_api_host = _host_from_url((current_cfg.mediamtx_api_url if current_cfg else ""), "127.0.0.1") - current_webrtc_host = _host_from_url((current_cfg.mediamtx_webrtc_url if current_cfg else ""), "127.0.0.1") - api_host = _extract_host(str(data.get("apiAddress", ":9997")), current_api_host) - - hosts = data.get("webrtcAdditionalHosts") or [] - host = hosts[0] if isinstance(hosts, list) and hosts else _extract_host(str(data.get("webrtcAddress", ":8889")), current_webrtc_host) - +def _build_mediamtx_view(data: dict, cfg: AppConfig) -> MediaMTXConfigView: path_defaults = data.get("pathDefaults") or {} record_enabled = bool(path_defaults.get("record", False)) cameras: list[MediaMTXCamera] = [] paths = data.get("paths") or {} if isinstance(paths, dict): - for name, cfg in paths.items(): - if not isinstance(cfg, dict): + for name, path_cfg in paths.items(): + if not isinstance(path_cfg, dict): continue - source = cfg.get("source") + source = path_cfg.get("source") if isinstance(source, str) and source.strip(): cameras.append(MediaMTXCamera(name=str(name), rtsp_url=source)) cameras.sort(key=lambda x: x.name) return MediaMTXConfigView( - api_url=f"http://{api_host}:{api_port}", - webrtc_url=f"http://{host}:{webrtc_port}", + api_url=cfg.mediamtx_api_url, + webrtc_url=cfg.mediamtx_webrtc_url, record_enabled=record_enabled, cameras=cameras, ) @@ -146,11 +97,10 @@ def _build_mediamtx_view(data: dict, current_cfg: Optional[AppConfig] = None) -> async def _sync_app_config_from_mediamtx() -> AppConfig: cfg = await store.load() data = _load_mediamtx_yml() - view = _build_mediamtx_view(data, cfg) - - cfg.mediamtx_api_url = view.api_url - cfg.mediamtx_webrtc_url = view.webrtc_url - cfg.cameras = [Camera(name=c.name, rtsp_url=c.rtsp_url) for c in view.cameras] + cfg.cameras = [ + Camera(name=c.name, rtsp_url=c.rtsp_url) + for c in _build_mediamtx_view(data, cfg).cameras + ] await store.save(cfg) return cfg @@ -274,14 +224,29 @@ async def get_config() -> AppConfig: @app.get("/api/mediamtx/config") async def get_mediamtx_config() -> MediaMTXConfigView: + cfg = await store.load() data = _load_mediamtx_yml() - view = _build_mediamtx_view(data, await store.load()) + view = _build_mediamtx_view(data, cfg) await _sync_app_config_from_mediamtx() return view +@app.post("/api/config/basic") +async def update_basic_config(payload: AppConfigUpdate) -> AppConfig: + cfg = await store.load() + cfg.mediamtx_api_url = _clean_text(payload.mediamtx_api_url) or cfg.mediamtx_api_url + cfg.mediamtx_webrtc_url = _clean_text(payload.mediamtx_webrtc_url) or cfg.mediamtx_webrtc_url + cfg.mediamtx_api_user = _clean_text(payload.mediamtx_api_user) + cfg.mediamtx_api_pass = _clean_text(payload.mediamtx_api_pass) + cfg.recordings_dir = _clean_text(payload.recordings_dir) or cfg.recordings_dir + cfg.api_port = payload.api_port + await store.save(cfg) + return await _sync_app_config_from_mediamtx() + + @app.post("/api/mediamtx/cameras") async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView: + cfg = await store.load() data = _load_mediamtx_yml() paths = data.setdefault("paths", {}) if not isinstance(paths, dict): @@ -295,11 +260,12 @@ async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConf paths[name] = {"source": payload.rtsp_url.strip()} _save_mediamtx_yml(data) await _sync_app_config_from_mediamtx() - return _build_mediamtx_view(data, await store.load()) + return _build_mediamtx_view(data, cfg) @app.delete("/api/mediamtx/cameras/{name}") async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView: + cfg = await store.load() data = _load_mediamtx_yml() paths = data.get("paths") or {} if not isinstance(paths, dict) or name not in paths: @@ -308,11 +274,12 @@ async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView: data["paths"] = paths _save_mediamtx_yml(data) await _sync_app_config_from_mediamtx() - return _build_mediamtx_view(data, await store.load()) + return _build_mediamtx_view(data, cfg) @app.post("/api/mediamtx/recording") async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView: + cfg = await store.load() payload = _load_mediamtx_yml() path_defaults = payload.setdefault("pathDefaults", {}) if not isinstance(path_defaults, dict): @@ -320,7 +287,7 @@ async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView: path_defaults["record"] = bool(data.enabled) payload["pathDefaults"] = path_defaults _save_mediamtx_yml(payload) - return _build_mediamtx_view(payload, await store.load()) + return _build_mediamtx_view(payload, cfg) @app.post("/api/mediamtx/restart") diff --git a/api/app/models.py b/api/app/models.py index bac9989..51cbdbc 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -29,6 +29,15 @@ class AppConfig(BaseModel): schedule: Schedule = Field(default_factory=Schedule) +class AppConfigUpdate(BaseModel): + mediamtx_api_url: str = Field(min_length=1, max_length=2048) + mediamtx_webrtc_url: str = Field(min_length=1, max_length=2048) + mediamtx_api_user: Optional[str] = None + mediamtx_api_pass: Optional[str] = None + recordings_dir: str = Field(min_length=1, max_length=4096) + api_port: int = Field(ge=1, le=65535, default=8008) + + class RecordingToggle(BaseModel): enabled: bool diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index bec13db..ad05b04 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2,7 +2,7 @@ import { Plus, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { FormEvent } from "react"; import { useConfigStore } from "@/stores/configStore"; -import type { MediaMtxConfigView } from "@/types/api"; +import type { AppConfigUpdate, MediaMtxConfigView } from "@/types/api"; import { apiJson } from "@/utils/api"; export default function Settings() { @@ -24,6 +24,12 @@ export default function Settings() { const [weekdaysFrom, setWeekdaysFrom] = useState("18:00"); const [weekdaysTo, setWeekdaysTo] = useState("08:00"); const [weekendAllDay, setWeekendAllDay] = useState(true); + const [mediamtxApiUrl, setMediamtxApiUrl] = useState("http://127.0.0.1:9997"); + const [mediamtxWebrtcUrl, setMediamtxWebrtcUrl] = useState("http://127.0.0.1:8889"); + const [mediamtxApiUser, setMediamtxApiUser] = useState(""); + const [mediamtxApiPass, setMediamtxApiPass] = useState(""); + const [recordingsDir, setRecordingsDir] = useState(""); + const [apiPort, setApiPort] = useState("8008"); const loadMtx = async () => { setMtxBusy(true); @@ -55,6 +61,16 @@ export default function Settings() { setWeekendAllDay(schedule.weekend_all_day); }, [schedule]); + useEffect(() => { + if (!config) return; + setMediamtxApiUrl(config.mediamtx_api_url); + setMediamtxWebrtcUrl(config.mediamtx_webrtc_url); + setMediamtxApiUser(config.mediamtx_api_user ?? ""); + setMediamtxApiPass(config.mediamtx_api_pass ?? ""); + setRecordingsDir(config.recordings_dir); + setApiPort(String(config.api_port ?? 8008)); + }, [config]); + const canAdd = useMemo(() => rtspUrl.trim().length > 0, [rtspUrl]); const onAdd = async (e: FormEvent) => { @@ -90,6 +106,38 @@ export default function Settings() { }); }; + const onSaveBasicConfig = async () => { + setMtxBusy(true); + setMtxError(null); + setRestartMsg(null); + const payload: AppConfigUpdate = { + mediamtx_api_url: mediamtxApiUrl.trim(), + mediamtx_webrtc_url: mediamtxWebrtcUrl.trim(), + mediamtx_api_user: mediamtxApiUser.trim() || null, + mediamtx_api_pass: mediamtxApiPass.trim() || null, + recordings_dir: recordingsDir.trim(), + api_port: Number(apiPort) || 8008, + }; + try { + await apiJson("/config/basic", { + method: "POST", + body: JSON.stringify(payload), + }); + await load(); + await loadMtx(); + setRestartMsg("Lưu config.json thành công"); + } catch (e) { + if (typeof e === "object" && e && "status" in e) { + const ex = e as { status: number; bodyText?: string }; + setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`); + } else { + setMtxError("failed_to_save_config"); + } + } finally { + setMtxBusy(false); + } + }; + const onDelete = async (name: string) => { setMtxBusy(true); setMtxError(null); @@ -232,9 +280,81 @@ export default function Settings() {
Recording & Scheduler
-
Có thể đổi record trong `mediamtx.yml` và scheduler backend
+
Camera lấy từ `mediamtx.yml`, các URL/credentials lấy từ `config.json`
+
+
+ + setMediamtxApiUrl(e.target.value)} + placeholder="http://127.0.0.1:9997" + className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" + /> +
+
+ + setMediamtxWebrtcUrl(e.target.value)} + placeholder="http://127.0.0.1:8889" + className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" + /> +
+
+
+ + setMediamtxApiUser(e.target.value)} + placeholder="dashboard" + className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" + /> +
+
+ + setMediamtxApiPass(e.target.value)} + placeholder="password" + className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" + /> +
+
+
+
+ + setRecordingsDir(e.target.value)} + placeholder="/mnt/ssd/IPCam_OrangePi_Dashboard/mediamtx/recordings" + className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" + /> +
+
+ + setApiPort(e.target.value)} + className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" + /> +
+
+ +
+