diff --git a/INSTALL.md b/INSTALL.md index 269b7ed..57d46e2 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -72,7 +72,7 @@ Sau đó set trực tiếp trong `api/data/config.json` của dashboard backend: "mediamtx_api_pass": "dashboard_password" ``` -Trong `paths`, bạn có thể để dashboard tự thêm path bằng API (Settings → Add Camera). +Trong `paths`, bạn có thể để dashboard tự thêm path bằng Settings (nhập RTSP URL). Backend sẽ cập nhật trực tiếp file `mediamtx.yml`. Tạo thư mục recordings: @@ -130,6 +130,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`. Bạn có thể chỉnh: @@ -139,6 +140,13 @@ Bạn có thể chỉnh: - `recordings_dir` (mặc định `./mediamtx/recordings` trong project) - `api_port` (mặc định `8008`) +Các thao tác trong Settings: + +- Add Camera: chỉ nhập RTSP URL, backend tự tạo tên `camN` trong `mediamtx.yml` +- Delete Camera: xóa path tương ứng 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` + Nếu chạy mô hình 2 thiết bị, set theo IP máy A (MediaMTX), ví dụ: ```json diff --git a/README.md b/README.md index f34258d..fcbbe65 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Dashboard giám sát camera IP gọn nhẹ chạy trên Orange Pi, dựa trên M - Live View (WebRTC WHEP) dạng grid, auto reconnect, lazy load - Playback fMP4 theo camera + ngày (file list + HTML5 video) -- Settings: quản lý camera + lịch ghi hình (scheduler ở backend) +- Settings: quản lý camera trực tiếp trong `mediamtx.yml` + lịch ghi hình ## Cấu trúc thư mục @@ -16,10 +16,14 @@ 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/cameras` / `DELETE /api/cameras/{name}` - `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` +- `GET /api/mediamtx/config` +- `POST /api/mediamtx/cameras` (body: `{ "rtsp_url": "..." }`) +- `DELETE /api/mediamtx/cameras/{name}` +- `POST /api/mediamtx/recording` (ghi `pathDefaults.record` vào `mediamtx.yml`) +- `POST /api/mediamtx/restart` (restart container `mediamtx`) - `GET /api/recordings?camera=cam1&date=YYYY-MM-DD` - `GET /videos//.fmp4` @@ -53,7 +57,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 (name + rtsp_url) +- `cameras`: danh sách camera đồng bộ từ `mediamtx.yml` (`name` + `rtsp_url`) - `schedule`: lịch ghi hình Ví dụ `config.json`: diff --git a/api/app/main.py b/api/app/main.py index 885a7a3..f5a27e5 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -2,10 +2,12 @@ from __future__ import annotations import asyncio import logging +import subprocess from pathlib import Path from typing import Optional import httpx +import yaml from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -15,6 +17,9 @@ from .mediamtx_client import MediaMTXClient from .models import ( AppConfig, Camera, + MediaMTXAddCameraRequest, + MediaMTXCamera, + MediaMTXConfigView, RecordingToggle, ScheduleUpdate, SchedulerEnabled, @@ -28,6 +33,8 @@ def _cors_origins() -> list[str]: logger = logging.getLogger("ipcam_dashboard") PROJECT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_LOCAL_RECORDINGS_DIR = PROJECT_ROOT / "mediamtx" / "recordings" +MEDIAMTX_YML_PATH = PROJECT_ROOT / "mediamtx" / "mediamtx.yml" +MEDIAMTX_COMPOSE_PATH = PROJECT_ROOT / "mediamtx" / "docker-compose.yml" app = FastAPI(title="IPCam Dashboard API") @@ -42,9 +49,112 @@ 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 _load_mediamtx_yml() -> dict: + if not MEDIAMTX_YML_PATH.exists(): + raise HTTPException(status_code=500, detail="mediamtx_yml_not_found") + raw = MEDIAMTX_YML_PATH.read_text(encoding="utf-8") + return yaml.safe_load(raw) or {} + + +def _save_mediamtx_yml(data: dict) -> None: + MEDIAMTX_YML_PATH.write_text( + yaml.safe_dump(data, sort_keys=False, allow_unicode=False), + encoding="utf-8", + ) + + +def _build_mediamtx_view(data: dict) -> MediaMTXConfigView: + api_port = _extract_port(str(data.get("apiAddress", ":9997")), 9997) + webrtc_port = _extract_port(str(data.get("webrtcAddress", ":8889")), 8889) + hosts = data.get("webrtcAdditionalHosts") or [] + host = hosts[0] if isinstance(hosts, list) and hosts else "127.0.0.1" + + 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): + continue + source = 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://127.0.0.1:{api_port}", + webrtc_url=f"http://{host}:{webrtc_port}", + record_enabled=record_enabled, + cameras=cameras, + ) + + +async def _sync_app_config_from_mediamtx() -> AppConfig: + cfg = await store.load() + data = _load_mediamtx_yml() + view = _build_mediamtx_view(data) + + 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] + await store.save(cfg) + return cfg + + +def _restart_mediamtx() -> dict: + cmds = [ + ["docker", "compose", "-f", str(MEDIAMTX_COMPOSE_PATH), "restart", "mediamtx"], + ["docker-compose", "-f", str(MEDIAMTX_COMPOSE_PATH), "restart", "mediamtx"], + ] + last_err = "" + for cmd in cmds: + try: + proc = subprocess.run( + cmd, + cwd=str(MEDIAMTX_COMPOSE_PATH.parent), + capture_output=True, + text=True, + timeout=40, + check=False, + ) + if proc.returncode == 0: + return {"ok": True, "output": proc.stdout.strip()} + last_err = (proc.stderr or proc.stdout or "").strip() + except FileNotFoundError: + last_err = "docker_command_not_found" + except subprocess.TimeoutExpired: + last_err = "docker_restart_timeout" + raise HTTPException(status_code=500, detail=f"mediamtx_restart_failed: {last_err}") + + async def _apply_recording(enabled: bool) -> None: cfg = await store.load() - names = [c.name for c in cfg.cameras] + try: + paths = await MediaMTXClient( + api_url=cfg.mediamtx_api_url, + username=cfg.mediamtx_api_user, + password=cfg.mediamtx_api_pass, + ).list_paths_status() + names = [it.get("name") for it in (paths.get("items") or []) if isinstance(it, dict) and it.get("name")] + except Exception: + names = [c.name for c in cfg.cameras] if not names: return @@ -82,7 +192,7 @@ def _raise_mediamtx_http_error(err: httpx.HTTPError) -> None: @app.on_event("startup") async def _startup() -> None: - cfg = await store.load() + cfg = await _sync_app_config_from_mediamtx() recordings_dir = Path(cfg.recordings_dir) # Backward-compatible migration: old defaults used "/recordings". @@ -111,49 +221,63 @@ async def health() -> dict: @app.get("/api/config") async def get_config() -> AppConfig: - return await store.load() + return await _sync_app_config_from_mediamtx() -@app.post("/api/cameras") -async def add_camera(camera: Camera) -> AppConfig: - cfg = await store.load() - if any(c.name == camera.name for c in cfg.cameras): - raise HTTPException(status_code=409, detail="camera_already_exists") - - cfg.cameras.append(camera) - await store.save(cfg) - - client = MediaMTXClient( - api_url=cfg.mediamtx_api_url, - username=cfg.mediamtx_api_user, - password=cfg.mediamtx_api_pass, - ) - try: - await client.upsert_paths_sources_bulk({camera.name: camera.rtsp_url}) - except httpx.HTTPError as e: - _raise_mediamtx_http_error(e) - return cfg +@app.get("/api/mediamtx/config") +async def get_mediamtx_config() -> MediaMTXConfigView: + data = _load_mediamtx_yml() + view = _build_mediamtx_view(data) + await _sync_app_config_from_mediamtx() + return view -@app.delete("/api/cameras/{name}") -async def delete_camera(name: str) -> AppConfig: - cfg = await store.load() - before = len(cfg.cameras) - cfg.cameras = [c for c in cfg.cameras if c.name != name] - if len(cfg.cameras) == before: +@app.post("/api/mediamtx/cameras") +async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView: + data = _load_mediamtx_yml() + paths = data.setdefault("paths", {}) + if not isinstance(paths, dict): + raise HTTPException(status_code=400, detail="invalid_mediamtx_paths") + + used = {str(k) for k in paths.keys()} + idx = 1 + while f"cam{idx}" in used: + idx += 1 + name = f"cam{idx}" + paths[name] = {"source": payload.rtsp_url.strip()} + _save_mediamtx_yml(data) + await _sync_app_config_from_mediamtx() + return _build_mediamtx_view(data) + + +@app.delete("/api/mediamtx/cameras/{name}") +async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView: + data = _load_mediamtx_yml() + paths = data.get("paths") or {} + if not isinstance(paths, dict) or name not in paths: raise HTTPException(status_code=404, detail="camera_not_found") - await store.save(cfg) + del paths[name] + data["paths"] = paths + _save_mediamtx_yml(data) + await _sync_app_config_from_mediamtx() + return _build_mediamtx_view(data) - client = MediaMTXClient( - api_url=cfg.mediamtx_api_url, - username=cfg.mediamtx_api_user, - password=cfg.mediamtx_api_pass, - ) - try: - await client.delete_path(name) - except httpx.HTTPError as e: - _raise_mediamtx_http_error(e) - return cfg + +@app.post("/api/mediamtx/recording") +async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView: + payload = _load_mediamtx_yml() + path_defaults = payload.setdefault("pathDefaults", {}) + if not isinstance(path_defaults, dict): + raise HTTPException(status_code=400, detail="invalid_mediamtx_path_defaults") + path_defaults["record"] = bool(data.enabled) + payload["pathDefaults"] = path_defaults + _save_mediamtx_yml(payload) + return _build_mediamtx_view(payload) + + +@app.post("/api/mediamtx/restart") +async def restart_mediamtx() -> dict: + return _restart_mediamtx() @app.get("/api/paths") @@ -206,7 +330,7 @@ async def recordings( limit: int = 200, offset: int = 0, ) -> list[dict]: - cfg = await store.load() + cfg = await _sync_app_config_from_mediamtx() if not any(c.name == camera for c in cfg.cameras): raise HTTPException(status_code=404, detail="camera_not_found") if limit < 1 or limit > 2000: diff --git a/api/app/models.py b/api/app/models.py index 7ffa323..bac9989 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -42,3 +42,18 @@ class ScheduleUpdate(BaseModel): weekdays_to: str = Field(pattern=r"^\d{2}:\d{2}$") weekend_all_day: bool + +class MediaMTXCamera(BaseModel): + name: str + rtsp_url: str + + +class MediaMTXConfigView(BaseModel): + api_url: str + webrtc_url: str + record_enabled: bool + cameras: list[MediaMTXCamera] + + +class MediaMTXAddCameraRequest(BaseModel): + rtsp_url: str = Field(min_length=1, max_length=2048) diff --git a/api/requirements.txt b/api/requirements.txt index 446d91c..ab57319 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -3,4 +3,5 @@ uvicorn[standard]>=0.27 httpx>=0.27 pydantic>=2.6 python-multipart>=0.0.9 +pyyaml>=6.0.2 diff --git a/src/hooks/useWhepPlayer.ts b/src/hooks/useWhepPlayer.ts index 8eb978e..6bf48e4 100644 --- a/src/hooks/useWhepPlayer.ts +++ b/src/hooks/useWhepPlayer.ts @@ -57,6 +57,7 @@ export function useWhepPlayer({ pc.oniceconnectionstatechange = null; pc.close(); } catch { + // Ignore teardown errors when peer connection is already closed. } } @@ -66,6 +67,7 @@ export function useWhepPlayer({ v.srcObject = null; v.removeAttribute("src"); } catch { + // Ignore cleanup errors on detached video element. } } @@ -73,6 +75,7 @@ export function useWhepPlayer({ try { await fetch(sessionUrl, { method: "DELETE" }); } catch { + // Ignore best-effort WHEP session cleanup failures. } } }, []); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8369417..bec13db 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2,6 +2,8 @@ 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 { apiJson } from "@/utils/api"; export default function Settings() { const { @@ -9,21 +11,41 @@ export default function Settings() { isLoading, error, load, - addCamera, - deleteCamera, setSchedulerEnabled, updateSchedule, } = useConfigStore(); - const [name, setName] = useState(""); const [rtspUrl, setRtspUrl] = useState(""); + const [mtx, setMtx] = useState(null); + const [mtxBusy, setMtxBusy] = useState(false); + const [mtxError, setMtxError] = useState(null); + const [restartMsg, setRestartMsg] = useState(null); const schedule = config?.schedule; const [weekdaysFrom, setWeekdaysFrom] = useState("18:00"); const [weekdaysTo, setWeekdaysTo] = useState("08:00"); const [weekendAllDay, setWeekendAllDay] = useState(true); + const loadMtx = async () => { + setMtxBusy(true); + setMtxError(null); + try { + const data = await apiJson("/mediamtx/config", { method: "GET" }); + setMtx(data); + } 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_load_mediamtx_config"); + } + } finally { + setMtxBusy(false); + } + }; + useEffect(() => { if (!config) void load(); + void loadMtx(); }, [config, load]); useEffect(() => { @@ -33,17 +55,31 @@ export default function Settings() { setWeekendAllDay(schedule.weekend_all_day); }, [schedule]); - const canAdd = useMemo( - () => name.trim().length > 0 && rtspUrl.trim().length > 0, - [name, rtspUrl] - ); + const canAdd = useMemo(() => rtspUrl.trim().length > 0, [rtspUrl]); const onAdd = async (e: FormEvent) => { e.preventDefault(); if (!canAdd) return; - await addCamera({ name: name.trim(), rtsp_url: rtspUrl.trim() }); - setName(""); - setRtspUrl(""); + setMtxBusy(true); + setMtxError(null); + try { + const data = await apiJson("/mediamtx/cameras", { + method: "POST", + body: JSON.stringify({ rtsp_url: rtspUrl.trim() }), + }); + setMtx(data); + setRtspUrl(""); + await load(); + } 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_add_camera"); + } + } finally { + setMtxBusy(false); + } }; const onSaveSchedule = async () => { @@ -54,6 +90,69 @@ export default function Settings() { }); }; + const onDelete = async (name: string) => { + setMtxBusy(true); + setMtxError(null); + try { + const data = await apiJson(`/mediamtx/cameras/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + setMtx(data); + await load(); + } 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_delete_camera"); + } + } finally { + setMtxBusy(false); + } + }; + + const onToggleRecord = async (enabled: boolean) => { + setMtxBusy(true); + setMtxError(null); + try { + const data = await apiJson("/mediamtx/recording", { + method: "POST", + body: JSON.stringify({ enabled }), + }); + setMtx(data); + } 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_toggle_recording"); + } + } finally { + setMtxBusy(false); + } + }; + + const onRestartDocker = async () => { + setMtxBusy(true); + setRestartMsg(null); + setMtxError(null); + try { + const res = await apiJson<{ ok: boolean; output?: string }>("/mediamtx/restart", { + method: "POST", + }); + setRestartMsg(res.ok ? "Docker restart thành công" : "Docker restart thất bại"); + } 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_restart_docker"); + } + } finally { + setMtxBusy(false); + } + }; + return (
@@ -66,35 +165,44 @@ export default function Settings() { {error}
) : null} + {mtxError ? ( +
+ {mtxError} +
+ ) : null} + {restartMsg ? ( +
+ {restartMsg} +
+ ) : null}
Cameras
-
- Đồng bộ paths lên MediaMTX thông qua Control API -
+
Đọc và chỉnh trực tiếp từ `mediamtx.yml`
- {(config?.cameras ?? []).map((c) => ( + {(mtx?.cameras ?? []).map((camera) => (
-
{c.name}
-
{c.rtsp_url}
+
{camera.name}
+
{camera.rtsp_url}
))} - {(config?.cameras ?? []).length === 0 ? ( + {(mtx?.cameras ?? []).length === 0 ? (
Chưa có camera.
@@ -102,29 +210,18 @@ export default function Settings() {
void onAdd(e)} className="mt-4 space-y-2"> -
-
- - setName(e.target.value)} - placeholder="cam1" - className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" - /> -
-
- - setRtspUrl(e.target.value)} - placeholder="rtsp://user:pass@ip:554/stream" - className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" - /> -
+
+ + setRtspUrl(e.target.value)} + placeholder="rtsp://user:pass@ip:554/stream" + className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100" + />
-
Recording Schedule
-
Backend sẽ bật/tắt record mỗi 60 giây
+
Recording & Scheduler
+
Có thể đổi record trong `mediamtx.yml` và scheduler backend
+ +