fix wheplayer

This commit is contained in:
2026-04-28 17:24:26 +07:00
parent ecd9845e14
commit 8ad28090eb
5 changed files with 121 additions and 21 deletions
+56 -8
View File
@@ -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")
+4 -1
View File
@@ -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 (
<div className="min-h-dvh bg-zinc-950 text-zinc-50">
<div className={["min-h-dvh", isDark ? "bg-zinc-950 text-zinc-50" : "bg-zinc-100 text-zinc-900"].join(" ")}>
<TopNav />
<main className="mx-auto w-full max-w-7xl px-4 py-4 md:px-6 md:py-6">
{children}
+40 -9
View File
@@ -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 (
<NavLink
@@ -18,8 +21,8 @@ function NavItem({
[
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition",
isActive
? "bg-zinc-800 text-zinc-50"
: "text-zinc-300 hover:bg-zinc-900 hover:text-zinc-50",
? (isDark ? "bg-zinc-800 text-zinc-50" : "bg-zinc-200 text-zinc-900")
: (isDark ? "text-zinc-300 hover:bg-zinc-900 hover:text-zinc-50" : "text-zinc-700 hover:bg-zinc-100"),
].join(" ")
}
>
@@ -30,23 +33,51 @@ function NavItem({
}
export default function TopNav() {
const { isDark, toggleTheme } = useTheme();
return (
<header className="border-b border-zinc-800 bg-zinc-950/70 backdrop-blur">
<header
className={[
"border-b backdrop-blur",
isDark ? "border-zinc-800 bg-zinc-950/70" : "border-zinc-300 bg-white/90",
].join(" ")}
>
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-4 py-3 md:px-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-900 ring-1 ring-zinc-800">
<div
className={[
"flex h-9 w-9 items-center justify-center rounded-lg ring-1",
isDark ? "bg-zinc-900 ring-zinc-800" : "bg-zinc-100 ring-zinc-300",
].join(" ")}
>
<Video className="h-5 w-5" />
</div>
<div className="leading-tight">
<div className="text-sm font-semibold">IPCam Dashboard</div>
<div className="text-xs text-zinc-400">MediaMTX + Orange Pi</div>
<div className={["text-xs", isDark ? "text-zinc-400" : "text-zinc-500"].join(" ")}>
MediaMTX + Orange Pi
</div>
</div>
</div>
<nav className="flex items-center gap-1">
<NavItem to="/live" label="Live" icon={<Video className="h-4 w-4" />} />
<NavItem to="/playback" label="Playback" icon={<Film className="h-4 w-4" />} />
<NavItem to="/settings" label="Settings" icon={<Settings2 className="h-4 w-4" />} />
<NavItem to="/live" label="Live" icon={<Video className="h-4 w-4" />} isDark={isDark} />
<NavItem to="/playback" label="Playback" icon={<Film className="h-4 w-4" />} isDark={isDark} />
<NavItem to="/settings" label="Settings" icon={<Settings2 className="h-4 w-4" />} isDark={isDark} />
<button
type="button"
onClick={toggleTheme}
className={[
"ml-1 inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm font-medium transition",
isDark
? "border-zinc-700 bg-zinc-900/70 text-zinc-100 hover:bg-zinc-800"
: "border-zinc-300 bg-white text-zinc-900 hover:bg-zinc-100",
].join(" ")}
title={isDark ? "Chuyen sang light mode" : "Chuyen sang dark mode"}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDark ? "Light" : "Dark"}
</button>
</nav>
</div>
</header>
+5
View File
@@ -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}` : ""}`);
}
+16 -3
View File
@@ -29,8 +29,21 @@ export async function apiJson<T>(path: string, init?: RequestInit): Promise<T> {
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");
}