fix wheplayer
This commit is contained in:
+56
-8
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user