diff --git a/api/app/main.py b/api/app/main.py index f5a27e5..9604338 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -5,6 +5,7 @@ import logging import subprocess from pathlib import Path from typing import Optional +from urllib.parse import urlparse import httpx import yaml @@ -64,6 +65,38 @@ def _extract_port(address: str, fallback: int) -> int: 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: if not MEDIAMTX_YML_PATH.exists(): raise HTTPException(status_code=500, detail="mediamtx_yml_not_found") @@ -78,11 +111,15 @@ def _save_mediamtx_yml(data: dict) -> None: ) -def _build_mediamtx_view(data: dict) -> MediaMTXConfigView: +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 "127.0.0.1" + 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 {} record_enabled = bool(path_defaults.get("record", False)) @@ -99,7 +136,7 @@ def _build_mediamtx_view(data: dict) -> MediaMTXConfigView: cameras.sort(key=lambda x: x.name) return MediaMTXConfigView( - api_url=f"http://127.0.0.1:{api_port}", + api_url=f"http://{api_host}:{api_port}", webrtc_url=f"http://{host}:{webrtc_port}", record_enabled=record_enabled, cameras=cameras, @@ -109,7 +146,7 @@ def _build_mediamtx_view(data: dict) -> MediaMTXConfigView: async def _sync_app_config_from_mediamtx() -> AppConfig: cfg = await store.load() data = _load_mediamtx_yml() - view = _build_mediamtx_view(data) + view = _build_mediamtx_view(data, cfg) cfg.mediamtx_api_url = view.api_url cfg.mediamtx_webrtc_url = view.webrtc_url @@ -174,6 +211,17 @@ async def _scheduler_loop() -> None: try: cfg = await store.load() await scheduler.tick(cfg.schedule) + except httpx.HTTPStatusError as e: + code = e.response.status_code + if code == 401: + logger.warning( + "scheduler_tick_unauthorized: MediaMTX API rejected credentials. " + "Set mediamtx_api_user/mediamtx_api_pass in api/data/config.json" + ) + else: + logger.exception("scheduler_tick_http_status_%s", code) + except httpx.HTTPError: + logger.exception("scheduler_tick_http_error") except Exception: logger.exception("scheduler_tick_failed") finally: @@ -227,7 +275,7 @@ async def get_config() -> AppConfig: @app.get("/api/mediamtx/config") async def get_mediamtx_config() -> MediaMTXConfigView: data = _load_mediamtx_yml() - view = _build_mediamtx_view(data) + view = _build_mediamtx_view(data, await store.load()) await _sync_app_config_from_mediamtx() return view @@ -247,7 +295,7 @@ 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) + return _build_mediamtx_view(data, await store.load()) @app.delete("/api/mediamtx/cameras/{name}") @@ -260,7 +308,7 @@ 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) + return _build_mediamtx_view(data, await store.load()) @app.post("/api/mediamtx/recording") @@ -272,7 +320,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) + return _build_mediamtx_view(payload, await store.load()) @app.post("/api/mediamtx/restart") diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 10ac8e7..36a6e3f 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,9 +1,12 @@ import { ReactNode } from "react"; import TopNav from "@/components/TopNav"; +import { useTheme } from "@/hooks/useTheme"; export default function AppShell({ children }: { children: ReactNode }) { + const { isDark } = useTheme(); + return ( -
+
{children} diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index 7f6e6ea..4509614 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -1,15 +1,18 @@ import { NavLink } from "react-router-dom"; -import { Film, Settings2, Video } from "lucide-react"; +import { Film, Moon, Settings2, Sun, Video } from "lucide-react"; import type { ReactNode } from "react"; +import { useTheme } from "@/hooks/useTheme"; function NavItem({ to, label, icon, + isDark, }: { to: string; label: string; icon: ReactNode; + isDark: boolean; }) { return ( @@ -30,23 +33,51 @@ function NavItem({ } export default function TopNav() { + const { isDark, toggleTheme } = useTheme(); + return ( -
+
-
+
IPCam Dashboard
-
MediaMTX + Orange Pi
+
+ MediaMTX + Orange Pi +
diff --git a/src/hooks/useWhepPlayer.ts b/src/hooks/useWhepPlayer.ts index 6bf48e4..dfd4841 100644 --- a/src/hooks/useWhepPlayer.ts +++ b/src/hooks/useWhepPlayer.ts @@ -145,6 +145,11 @@ export function useWhepPlayer({ if (!res.ok) { const t = await res.text().catch(() => ""); + if (res.status === 405) { + throw new Error( + "whep_http_405: endpoint WebRTC khong cho phep POST. Kiem tra mediamtx_webrtc_url (host/port) va nginx proxy." + ); + } throw new Error(`whep_http_${res.status}${t ? `: ${t}` : ""}`); } diff --git a/src/utils/api.ts b/src/utils/api.ts index 37ff23c..1c57d5f 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -29,8 +29,21 @@ export async function apiJson(path: string, init?: RequestInit): Promise { return (await res.json()) as T; } -export function getMediamtxWebrtcBaseUrl(configUrl?: string) { - const env = import.meta.env.VITE_MEDIAMTX_WEBRTC_URL as string | undefined; - return env ?? configUrl ?? "http://localhost:8889"; +function normalizeLoopbackHost(rawUrl: string): string { + try { + const url = new URL(rawUrl, window.location.origin); + const loopbacks = new Set(["127.0.0.1", "localhost", "::1"]); + if (loopbacks.has(url.hostname) && !loopbacks.has(window.location.hostname)) { + url.hostname = window.location.hostname; + } + return url.toString().replace(/\/+$/, ""); + } catch { + return rawUrl.replace(/\/+$/, ""); + } +} + +export function getMediamtxWebrtcBaseUrl(configUrl?: string) { + const env = import.meta.env.VITE_MEDIAMTX_WEBRTC_URL as string | undefined; + return normalizeLoopbackHost(env ?? configUrl ?? "http://localhost:8889"); }