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 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
@@ -64,6 +65,38 @@ def _extract_port(address: str, fallback: int) -> int:
return fallback 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:
if not MEDIAMTX_YML_PATH.exists(): if not MEDIAMTX_YML_PATH.exists():
raise HTTPException(status_code=500, detail="mediamtx_yml_not_found") 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) api_port = _extract_port(str(data.get("apiAddress", ":9997")), 9997)
webrtc_port = _extract_port(str(data.get("webrtcAddress", ":8889")), 8889) 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 [] 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 {} path_defaults = data.get("pathDefaults") or {}
record_enabled = bool(path_defaults.get("record", False)) 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) cameras.sort(key=lambda x: x.name)
return MediaMTXConfigView( 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}", webrtc_url=f"http://{host}:{webrtc_port}",
record_enabled=record_enabled, record_enabled=record_enabled,
cameras=cameras, cameras=cameras,
@@ -109,7 +146,7 @@ def _build_mediamtx_view(data: dict) -> MediaMTXConfigView:
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) view = _build_mediamtx_view(data, cfg)
cfg.mediamtx_api_url = view.api_url cfg.mediamtx_api_url = view.api_url
cfg.mediamtx_webrtc_url = view.webrtc_url cfg.mediamtx_webrtc_url = view.webrtc_url
@@ -174,6 +211,17 @@ async def _scheduler_loop() -> None:
try: try:
cfg = await store.load() cfg = await store.load()
await scheduler.tick(cfg.schedule) 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: except Exception:
logger.exception("scheduler_tick_failed") logger.exception("scheduler_tick_failed")
finally: finally:
@@ -227,7 +275,7 @@ 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:
data = _load_mediamtx_yml() data = _load_mediamtx_yml()
view = _build_mediamtx_view(data) view = _build_mediamtx_view(data, await store.load())
await _sync_app_config_from_mediamtx() await _sync_app_config_from_mediamtx()
return view return view
@@ -247,7 +295,7 @@ 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) return _build_mediamtx_view(data, await store.load())
@app.delete("/api/mediamtx/cameras/{name}") @app.delete("/api/mediamtx/cameras/{name}")
@@ -260,7 +308,7 @@ 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) return _build_mediamtx_view(data, await store.load())
@app.post("/api/mediamtx/recording") @app.post("/api/mediamtx/recording")
@@ -272,7 +320,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) return _build_mediamtx_view(payload, await store.load())
@app.post("/api/mediamtx/restart") @app.post("/api/mediamtx/restart")
+4 -1
View File
@@ -1,9 +1,12 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import TopNav from "@/components/TopNav"; import TopNav from "@/components/TopNav";
import { useTheme } from "@/hooks/useTheme";
export default function AppShell({ children }: { children: ReactNode }) { export default function AppShell({ children }: { children: ReactNode }) {
const { isDark } = useTheme();
return ( 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 /> <TopNav />
<main className="mx-auto w-full max-w-7xl px-4 py-4 md:px-6 md:py-6"> <main className="mx-auto w-full max-w-7xl px-4 py-4 md:px-6 md:py-6">
{children} {children}
+40 -9
View File
@@ -1,15 +1,18 @@
import { NavLink } from "react-router-dom"; 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 type { ReactNode } from "react";
import { useTheme } from "@/hooks/useTheme";
function NavItem({ function NavItem({
to, to,
label, label,
icon, icon,
isDark,
}: { }: {
to: string; to: string;
label: string; label: string;
icon: ReactNode; icon: ReactNode;
isDark: boolean;
}) { }) {
return ( return (
<NavLink <NavLink
@@ -18,8 +21,8 @@ function NavItem({
[ [
"inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition", "inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition",
isActive isActive
? "bg-zinc-800 text-zinc-50" ? (isDark ? "bg-zinc-800 text-zinc-50" : "bg-zinc-200 text-zinc-900")
: "text-zinc-300 hover:bg-zinc-900 hover:text-zinc-50", : (isDark ? "text-zinc-300 hover:bg-zinc-900 hover:text-zinc-50" : "text-zinc-700 hover:bg-zinc-100"),
].join(" ") ].join(" ")
} }
> >
@@ -30,23 +33,51 @@ function NavItem({
} }
export default function TopNav() { export default function TopNav() {
const { isDark, toggleTheme } = useTheme();
return ( 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="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 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" /> <Video className="h-5 w-5" />
</div> </div>
<div className="leading-tight"> <div className="leading-tight">
<div className="text-sm font-semibold">IPCam Dashboard</div> <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>
</div> </div>
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-1">
<NavItem to="/live" label="Live" icon={<Video 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" />} /> <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" />} /> <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> </nav>
</div> </div>
</header> </header>
+5
View File
@@ -145,6 +145,11 @@ export function useWhepPlayer({
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(() => ""); 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}` : ""}`); 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; return (await res.json()) as T;
} }
export function getMediamtxWebrtcBaseUrl(configUrl?: string) { function normalizeLoopbackHost(rawUrl: string): string {
const env = import.meta.env.VITE_MEDIAMTX_WEBRTC_URL as string | undefined; try {
return env ?? configUrl ?? "http://localhost:8889"; 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");
} }