fix one machine only

This commit is contained in:
2026-04-30 21:29:11 +07:00
parent 5e1f529ed7
commit 176e6bdb8f
8 changed files with 185 additions and 149 deletions
-1
View File
@@ -1,4 +1,3 @@
# Frontend dev variables only.
VITE_DEV_BACKEND_URL=http://localhost:8008
VITE_API_BASE_URL=/api
VITE_MEDIAMTX_WEBRTC_URL=
+2 -63
View File
@@ -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
+3 -9
View File
@@ -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``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`.
+37 -70
View File
@@ -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")
+9
View File
@@ -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
+124 -4
View File
@@ -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() {
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4">
<div className="text-xs font-semibold text-zinc-200">Recording & Scheduler</div>
<div className="mt-1 text-xs text-zinc-400"> thể đi record trong `mediamtx.yml` scheduler backend</div>
<div className="mt-1 text-xs text-zinc-400">Camera lấy từ `mediamtx.yml`, các URL/credentials lấy từ `config.json`</div>
<div className="mt-4 space-y-3">
<div className="grid gap-2">
<div>
<label className="text-xs text-zinc-400">MediaMTX API URL</label>
<input
value={mediamtxApiUrl}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-zinc-400">MediaMTX WebRTC URL</label>
<input
value={mediamtxWebrtcUrl}
onChange={(e) => 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"
/>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div>
<label className="text-xs text-zinc-400">MediaMTX API User</label>
<input
value={mediamtxApiUser}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-zinc-400">MediaMTX API Pass</label>
<input
type="password"
value={mediamtxApiPass}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div>
<label className="text-xs text-zinc-400">Recordings Dir</label>
<input
value={recordingsDir}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-zinc-400">API Port</label>
<input
type="number"
min={1}
max={65535}
value={apiPort}
onChange={(e) => 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"
/>
</div>
</div>
<button
type="button"
onClick={() => void onSaveBasicConfig()}
disabled={mtxBusy}
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-200 transition hover:bg-zinc-900 disabled:cursor-not-allowed disabled:opacity-60"
>
Save Config
</button>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
@@ -304,9 +424,9 @@ export default function Settings() {
</button>
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
MediaMTX API: <span className="text-zinc-200">{mtx?.api_url ?? config?.mediamtx_api_url ?? "-"}</span>
MediaMTX API: <span className="text-zinc-200">{config?.mediamtx_api_url ?? "-"}</span>
<br />
WebRTC: <span className="text-zinc-200">{mtx?.webrtc_url ?? config?.mediamtx_webrtc_url ?? "-"}</span>
WebRTC: <span className="text-zinc-200">{config?.mediamtx_webrtc_url ?? "-"}</span>
<br />
Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span>
</div>
+9
View File
@@ -21,6 +21,15 @@ export type AppConfig = {
schedule: Schedule;
};
export type AppConfigUpdate = {
mediamtx_api_url: string;
mediamtx_webrtc_url: string;
mediamtx_api_user?: string | null;
mediamtx_api_pass?: string | null;
recordings_dir: string;
api_port: number;
};
export type RecordingItem = {
camera: string;
filename: string;
+1 -2
View File
@@ -44,7 +44,6 @@ function normalizeLoopbackHost(rawUrl: string): string {
}
export function getMediamtxWebrtcBaseUrl(configUrl?: string) {
const env = import.meta.env.VITE_MEDIAMTX_WEBRTC_URL as string | undefined;
return normalizeLoopbackHost(env ?? configUrl ?? "http://localhost:8889");
return normalizeLoopbackHost(configUrl ?? "http://localhost:8889");
}