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 ( -