fix one machine only
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
# Frontend dev variables only.
|
# Frontend dev variables only.
|
||||||
VITE_DEV_BACKEND_URL=http://localhost:8008
|
VITE_DEV_BACKEND_URL=http://localhost:8008
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
VITE_MEDIAMTX_WEBRTC_URL=
|
|
||||||
|
|||||||
+2
-63
@@ -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
|
## 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.
|
||||||
|
|
||||||
- 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
|
## 1) Yêu cầu
|
||||||
|
|
||||||
@@ -42,13 +31,6 @@ recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
|
|||||||
recordPartDuration: 5m
|
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
|
### 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.
|
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
|
- `9997/tcp` MediaMTX Control API
|
||||||
- `8189/udp` ICE
|
- `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) Backend FastAPI
|
||||||
|
|
||||||
### 3.1 Cài dependencies
|
### 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`).
|
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).
|
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`.
|
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:
|
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`
|
- 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`
|
- 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
|
### 3.3 Chạy backend
|
||||||
|
|
||||||
```bash
|
```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) Systemd service (khuyến nghị)
|
||||||
|
|
||||||
### 5.1 Backend service
|
### 5.1 Backend service
|
||||||
|
|||||||
@@ -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/health`
|
||||||
- `GET /api/config`
|
- `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)
|
- `GET /api/paths` (proxy trạng thái từ MediaMTX)
|
||||||
- `POST /api/recording` (bật/tắt ghi hình ngay)
|
- `POST /api/recording` (bật/tắt ghi hình ngay)
|
||||||
- `POST /api/scheduler/enabled` / `POST /api/scheduler/schedule`
|
- `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
|
## Cấu hình
|
||||||
|
|
||||||
Backend chỉ dùng file `api/data/config.json` (không đọc `.env`).
|
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_api_url`: ví dụ `http://127.0.0.1:9997`
|
||||||
- `mediamtx_webrtc_url`: ví dụ `http://127.0.0.1:8889`
|
- `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)
|
- `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
|
- `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`)
|
- `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
|
- `schedule`: lịch ghi hình
|
||||||
|
|
||||||
Ví dụ `config.json`:
|
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
|
## Triển khai
|
||||||
|
|
||||||
Xem hướng dẫn chi tiết trong `INSTALL.md`.
|
Xem hướng dẫn chi tiết trong `INSTALL.md`.
|
||||||
|
|||||||
+37
-70
@@ -5,7 +5,6 @@ import logging
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import yaml
|
import yaml
|
||||||
@@ -17,6 +16,7 @@ from .config_store import default_store
|
|||||||
from .mediamtx_client import MediaMTXClient
|
from .mediamtx_client import MediaMTXClient
|
||||||
from .models import (
|
from .models import (
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
AppConfigUpdate,
|
||||||
Camera,
|
Camera,
|
||||||
MediaMTXAddCameraRequest,
|
MediaMTXAddCameraRequest,
|
||||||
MediaMTXCamera,
|
MediaMTXCamera,
|
||||||
@@ -50,51 +50,11 @@ app.add_middleware(
|
|||||||
store = default_store()
|
store = default_store()
|
||||||
|
|
||||||
|
|
||||||
def _extract_port(address: str, fallback: int) -> int:
|
def _clean_text(value: Optional[str]) -> Optional[str]:
|
||||||
if not address:
|
if value is None:
|
||||||
return fallback
|
return None
|
||||||
text = str(address).strip()
|
cleaned = str(value).strip().strip("`'\"")
|
||||||
if text.startswith(":"):
|
return cleaned or None
|
||||||
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 _load_mediamtx_yml() -> dict:
|
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:
|
def _build_mediamtx_view(data: dict, cfg: AppConfig) -> 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)
|
|
||||||
|
|
||||||
path_defaults = data.get("pathDefaults") or {}
|
path_defaults = data.get("pathDefaults") or {}
|
||||||
record_enabled = bool(path_defaults.get("record", False))
|
record_enabled = bool(path_defaults.get("record", False))
|
||||||
|
|
||||||
cameras: list[MediaMTXCamera] = []
|
cameras: list[MediaMTXCamera] = []
|
||||||
paths = data.get("paths") or {}
|
paths = data.get("paths") or {}
|
||||||
if isinstance(paths, dict):
|
if isinstance(paths, dict):
|
||||||
for name, cfg in paths.items():
|
for name, path_cfg in paths.items():
|
||||||
if not isinstance(cfg, dict):
|
if not isinstance(path_cfg, dict):
|
||||||
continue
|
continue
|
||||||
source = cfg.get("source")
|
source = path_cfg.get("source")
|
||||||
if isinstance(source, str) and source.strip():
|
if isinstance(source, str) and source.strip():
|
||||||
cameras.append(MediaMTXCamera(name=str(name), rtsp_url=source))
|
cameras.append(MediaMTXCamera(name=str(name), rtsp_url=source))
|
||||||
cameras.sort(key=lambda x: x.name)
|
cameras.sort(key=lambda x: x.name)
|
||||||
|
|
||||||
return MediaMTXConfigView(
|
return MediaMTXConfigView(
|
||||||
api_url=f"http://{api_host}:{api_port}",
|
api_url=cfg.mediamtx_api_url,
|
||||||
webrtc_url=f"http://{host}:{webrtc_port}",
|
webrtc_url=cfg.mediamtx_webrtc_url,
|
||||||
record_enabled=record_enabled,
|
record_enabled=record_enabled,
|
||||||
cameras=cameras,
|
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:
|
async def _sync_app_config_from_mediamtx() -> AppConfig:
|
||||||
cfg = await store.load()
|
cfg = await store.load()
|
||||||
data = _load_mediamtx_yml()
|
data = _load_mediamtx_yml()
|
||||||
view = _build_mediamtx_view(data, cfg)
|
cfg.cameras = [
|
||||||
|
Camera(name=c.name, rtsp_url=c.rtsp_url)
|
||||||
cfg.mediamtx_api_url = view.api_url
|
for c in _build_mediamtx_view(data, cfg).cameras
|
||||||
cfg.mediamtx_webrtc_url = view.webrtc_url
|
]
|
||||||
cfg.cameras = [Camera(name=c.name, rtsp_url=c.rtsp_url) for c in view.cameras]
|
|
||||||
await store.save(cfg)
|
await store.save(cfg)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
@@ -274,14 +224,29 @@ async def get_config() -> AppConfig:
|
|||||||
|
|
||||||
@app.get("/api/mediamtx/config")
|
@app.get("/api/mediamtx/config")
|
||||||
async def get_mediamtx_config() -> MediaMTXConfigView:
|
async def get_mediamtx_config() -> MediaMTXConfigView:
|
||||||
|
cfg = await store.load()
|
||||||
data = _load_mediamtx_yml()
|
data = _load_mediamtx_yml()
|
||||||
view = _build_mediamtx_view(data, await store.load())
|
view = _build_mediamtx_view(data, cfg)
|
||||||
await _sync_app_config_from_mediamtx()
|
await _sync_app_config_from_mediamtx()
|
||||||
return view
|
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")
|
@app.post("/api/mediamtx/cameras")
|
||||||
async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView:
|
async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView:
|
||||||
|
cfg = await store.load()
|
||||||
data = _load_mediamtx_yml()
|
data = _load_mediamtx_yml()
|
||||||
paths = data.setdefault("paths", {})
|
paths = data.setdefault("paths", {})
|
||||||
if not isinstance(paths, dict):
|
if not isinstance(paths, dict):
|
||||||
@@ -295,11 +260,12 @@ async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConf
|
|||||||
paths[name] = {"source": payload.rtsp_url.strip()}
|
paths[name] = {"source": payload.rtsp_url.strip()}
|
||||||
_save_mediamtx_yml(data)
|
_save_mediamtx_yml(data)
|
||||||
await _sync_app_config_from_mediamtx()
|
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}")
|
@app.delete("/api/mediamtx/cameras/{name}")
|
||||||
async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView:
|
async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView:
|
||||||
|
cfg = await store.load()
|
||||||
data = _load_mediamtx_yml()
|
data = _load_mediamtx_yml()
|
||||||
paths = data.get("paths") or {}
|
paths = data.get("paths") or {}
|
||||||
if not isinstance(paths, dict) or name not in paths:
|
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
|
data["paths"] = paths
|
||||||
_save_mediamtx_yml(data)
|
_save_mediamtx_yml(data)
|
||||||
await _sync_app_config_from_mediamtx()
|
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")
|
@app.post("/api/mediamtx/recording")
|
||||||
async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
|
async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
|
||||||
|
cfg = await store.load()
|
||||||
payload = _load_mediamtx_yml()
|
payload = _load_mediamtx_yml()
|
||||||
path_defaults = payload.setdefault("pathDefaults", {})
|
path_defaults = payload.setdefault("pathDefaults", {})
|
||||||
if not isinstance(path_defaults, dict):
|
if not isinstance(path_defaults, dict):
|
||||||
@@ -320,7 +287,7 @@ async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
|
|||||||
path_defaults["record"] = bool(data.enabled)
|
path_defaults["record"] = bool(data.enabled)
|
||||||
payload["pathDefaults"] = path_defaults
|
payload["pathDefaults"] = path_defaults
|
||||||
_save_mediamtx_yml(payload)
|
_save_mediamtx_yml(payload)
|
||||||
return _build_mediamtx_view(payload, await store.load())
|
return _build_mediamtx_view(payload, cfg)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/mediamtx/restart")
|
@app.post("/api/mediamtx/restart")
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ class AppConfig(BaseModel):
|
|||||||
schedule: Schedule = Field(default_factory=Schedule)
|
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):
|
class RecordingToggle(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|||||||
+124
-4
@@ -2,7 +2,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useConfigStore } from "@/stores/configStore";
|
import { useConfigStore } from "@/stores/configStore";
|
||||||
import type { MediaMtxConfigView } from "@/types/api";
|
import type { AppConfigUpdate, MediaMtxConfigView } from "@/types/api";
|
||||||
import { apiJson } from "@/utils/api";
|
import { apiJson } from "@/utils/api";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
@@ -24,6 +24,12 @@ export default function Settings() {
|
|||||||
const [weekdaysFrom, setWeekdaysFrom] = useState("18:00");
|
const [weekdaysFrom, setWeekdaysFrom] = useState("18:00");
|
||||||
const [weekdaysTo, setWeekdaysTo] = useState("08:00");
|
const [weekdaysTo, setWeekdaysTo] = useState("08:00");
|
||||||
const [weekendAllDay, setWeekendAllDay] = useState(true);
|
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 () => {
|
const loadMtx = async () => {
|
||||||
setMtxBusy(true);
|
setMtxBusy(true);
|
||||||
@@ -55,6 +61,16 @@ export default function Settings() {
|
|||||||
setWeekendAllDay(schedule.weekend_all_day);
|
setWeekendAllDay(schedule.weekend_all_day);
|
||||||
}, [schedule]);
|
}, [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 canAdd = useMemo(() => rtspUrl.trim().length > 0, [rtspUrl]);
|
||||||
|
|
||||||
const onAdd = async (e: FormEvent) => {
|
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) => {
|
const onDelete = async (name: string) => {
|
||||||
setMtxBusy(true);
|
setMtxBusy(true);
|
||||||
setMtxError(null);
|
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="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="text-xs font-semibold text-zinc-200">Recording & Scheduler</div>
|
||||||
<div className="mt-1 text-xs text-zinc-400">Có thể đổi record trong `mediamtx.yml` và 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="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">
|
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -304,9 +424,9 @@ export default function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
|
<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 />
|
<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 />
|
<br />
|
||||||
Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span>
|
Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export type AppConfig = {
|
|||||||
schedule: Schedule;
|
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 = {
|
export type RecordingItem = {
|
||||||
camera: string;
|
camera: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|||||||
+1
-2
@@ -44,7 +44,6 @@ function normalizeLoopbackHost(rawUrl: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getMediamtxWebrtcBaseUrl(configUrl?: string) {
|
export function getMediamtxWebrtcBaseUrl(configUrl?: string) {
|
||||||
const env = import.meta.env.VITE_MEDIAMTX_WEBRTC_URL as string | undefined;
|
return normalizeLoopbackHost(configUrl ?? "http://localhost:8889");
|
||||||
return normalizeLoopbackHost(env ?? configUrl ?? "http://localhost:8889");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user