From 996e3fea395be2e88248407c36c87721bd11a98e Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 22 Sep 2026 18:07:56 +0800 Subject: [PATCH] feat: consolidate on the shadcn frontend and harden playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend - Replace the Ant Design app with the shadcn/ui app as the single frontend, keeping the portfolio side-by-side revertible in this commit. - Drop the abandoned mdui experiment and the AntD tree so there is one app, one router and one design system to maintain. Live playback - Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a single 4s segment, so playback ran at up to 1.10x and the forward buffer bottomed out at 0.1s, re-buffering every segment. Measured over 40s: buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls. - Remove the 落后 labels and the page-level edge-snapping timer that fought hls.js over two different definitions of "latency". - Live can no longer be paused: the control is not rendered while it plays, so the icon can never misreport state. - Fix autoplay: muted autoplay started silently and never unmuted, and the gesture that restored sound also toggled playback off. One owner now. Source ("原画") rendition - Publish the publisher's stream remuxed losslessly via -c:v copy alongside the transcoded ones, so a source option exists even when every ladder entry downsizes. - Give renditions an ASCII path slug: the HLS path is part of the URL and MediaMTX rejects non-alphanumeric paths, so 原画 could never be served. Backend - Supervise MediaMTX from the backend: start it at boot and restart it on exit, never killing an origin it did not start. - Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist left by a dead encoder hangs hls.js forever. - Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8 and the live board's active-viewer count was permanently zero. - Rebind a viewer session when it arrives on another stream instead of failing, and survive the StrictMode double-heartbeat race on first insert. - Replace the Linux-only /proc and os.getloadavg metrics with psutil. - Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the httpx client open for the streamed body. Housekeeping - Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and .venv; remove generated screenshots, load test scripts and stray files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 23 +- DEPLOYMENT.md | 26 +- backend/config.py | 20 + backend/main.py | 235 ++++-- backend/routes/settings_route.py | 54 +- backend/routes/streams.py | 62 +- backend/services/media_service.py | 12 +- backend/services/mediamtx_manager.py | 158 ++++ backend/services/stream_service.py | 78 +- backend/services/transcoder.py | 112 ++- frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/README.md | 32 + frontend/components.json | 21 + frontend/index.html | 6 +- frontend/package.json | 32 +- frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 133 ++-- frontend/src/admin/AdminLayout.tsx | 169 +++-- frontend/src/admin/LoginPage.tsx | 106 +-- frontend/src/admin/pages/Dashboard.tsx | 167 ++--- frontend/src/admin/pages/LiveBoard.tsx | 314 +++++--- frontend/src/admin/pages/NodeManager.tsx | 331 ++++++--- frontend/src/admin/pages/ReplayEditor.tsx | 297 +++++--- frontend/src/admin/pages/Settings.css | 11 - frontend/src/admin/pages/Settings.tsx | 202 ++++-- frontend/src/admin/pages/StreamForm.tsx | 337 ++++++--- frontend/src/admin/pages/StreamList.tsx | 230 ++++-- frontend/src/admin/pages/SwaggerPage.tsx | 60 ++ frontend/src/components/page-header.tsx | 27 + frontend/src/components/stat-card.tsx | 36 + frontend/src/components/status-badge.tsx | 24 + frontend/src/components/ui/alert-dialog.tsx | 139 ++++ frontend/src/components/ui/alert.tsx | 59 ++ frontend/src/components/ui/badge.tsx | 36 + frontend/src/components/ui/button.tsx | 57 ++ frontend/src/components/ui/card.tsx | 76 ++ frontend/src/components/ui/dialog.tsx | 120 ++++ frontend/src/components/ui/dropdown-menu.tsx | 199 ++++++ frontend/src/components/ui/input.tsx | 22 + frontend/src/components/ui/label.tsx | 24 + frontend/src/components/ui/popover.tsx | 33 + frontend/src/components/ui/progress.tsx | 26 + frontend/src/components/ui/select.tsx | 157 ++++ frontend/src/components/ui/separator.tsx | 31 + frontend/src/components/ui/skeleton.tsx | 15 + frontend/src/components/ui/switch.tsx | 29 + frontend/src/components/ui/table.tsx | 120 ++++ frontend/src/components/ui/tabs.tsx | 53 ++ frontend/src/components/ui/textarea.tsx | 22 + frontend/src/components/ui/tooltip.tsx | 32 + frontend/src/global.css | 675 ------------------ frontend/src/index.css | 126 ++++ frontend/src/lib/status.ts | 41 ++ frontend/src/lib/utils.ts | 6 + frontend/src/main.tsx | 2 +- frontend/src/pageTransitions.css | 116 --- frontend/src/theme/anthropic.ts | 78 -- .../src/viewer/components/VideoPlayer.tsx | 663 +++++++++++++---- frontend/src/viewer/components/player.css | 169 ----- frontend/src/viewer/pages/HomePage.tsx | 242 +++---- frontend/src/viewer/pages/WatchPage.tsx | 280 +++++--- frontend/src/viewer/pages/viewer.css | 648 ----------------- frontend/tailwind.config.js | 74 ++ frontend/tsconfig.json | 5 +- frontend/vite.config.ts | 36 +- start.ps1 | 74 +- start.sh | 51 +- 70 files changed, 4844 insertions(+), 3070 deletions(-) create mode 100644 backend/services/mediamtx_manager.py create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/components.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg delete mode 100644 frontend/src/admin/pages/Settings.css create mode 100644 frontend/src/admin/pages/SwaggerPage.tsx create mode 100644 frontend/src/components/page-header.tsx create mode 100644 frontend/src/components/stat-card.tsx create mode 100644 frontend/src/components/status-badge.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx delete mode 100644 frontend/src/global.css create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/status.ts create mode 100644 frontend/src/lib/utils.ts delete mode 100644 frontend/src/pageTransitions.css delete mode 100644 frontend/src/theme/anthropic.ts delete mode 100644 frontend/src/viewer/components/player.css delete mode 100644 frontend/src/viewer/pages/viewer.css create mode 100644 frontend/tailwind.config.js mode change 100755 => 100644 start.sh diff --git a/.gitignore b/.gitignore index e80cb53..c28a99a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,34 @@ +# dependencies and build output node_modules/ dist/ __pycache__/ *.pyc +*.tsbuildinfo + +# local state and secrets *.db venv/ +.venv/ *.log -mediamtx/mediamtx.tar.gz +# OSS credentials (accessKeySecret lives here) — never commit +RainOSS +RainOSS.bak + +# generated at runtime +runtime/ +media/ +.playwright-mcp/ +*.crt +*.key + +# MediaMTX binaries and archives are fetched, not committed mediamtx/mediamtx +mediamtx/mediamtx.exe +mediamtx/mediamtx.tar.gz + +# lockfiles are intentionally not tracked package-lock.json # edge-agent edge-agent/dist/ edge-agent/mediamtx/ - diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 6159f4b..3c07efe 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -14,43 +14,45 @@ Run these commands from PowerShell on the Windows node. ### 1. Install prerequisites -Required on PATH: Python 3.11+, Node.js LTS with npm, FFmpeg, and MediaMTX unless starting with -NoMediaMTX. Optional for GPU verification: NVIDIA driver and nvidia-smi. +Required on PATH: Python 3.11+, FFmpeg, and MediaMTX. MediaMTX may instead be left in the repository's `mediamtx\` directory, which is where the backend looks second. Node.js is only needed to build the frontend for deployment. ### 2. Install application dependencies ```powershell -powershell -ExecutionPolicy Bypass -File .\start.ps1 -InstallDeps -NoMediaMTX +powershell -ExecutionPolicy Bypass -File .\start.ps1 -InstallDeps ``` -### 3. Start the full Windows stack +### 3. Start the stack ```powershell powershell -ExecutionPolicy Bypass -File .\start.ps1 ``` -This starts MediaMTX origin, FastAPI backend on 0.0.0.0:8080, Vite frontend on 0.0.0.0:5173, HLS output under runtime\hls, and logs under runtime\logs. +This starts the FastAPI backend on 0.0.0.0:8080. The backend owns everything else: -Open backend docs at http://127.0.0.1:8080/docs and frontend UI at http://127.0.0.1:5173/. Publish RTMP to rtmp://127.0.0.1:1935/live/. MediaMTX HLS is http://127.0.0.1:8888//index.m3u8. +- **MediaMTX** is started at boot if nothing is already answering its API on :9997, and restarted by the supervisor if it dies. Set `LS_MEDIA_ORIGIN_AUTOSTART=false` to manage it externally; the backend then only supervises transcoders. +- **Transcoders** are started for every live stream whose MediaMTX path is publishing, and restarted within ~10s of an ffmpeg process exiting. +- **HLS** is written under `runtime\hls` and served through `GET /hls/...`, which falls back to proxying MediaMTX's own HLS when the local files are absent. -### 4. Useful Windows variants +Publish RTMP to rtmp://127.0.0.1:1935/live/. Backend docs are at http://127.0.0.1:8080/docs. + +### 4. Useful variants ```powershell -powershell -ExecutionPolicy Bypass -File .\start.ps1 -NoMediaMTX -powershell -ExecutionPolicy Bypass -File .\start.ps1 -NoFrontend -powershell -ExecutionPolicy Bypass -File .\start.ps1 -HostAddress 0.0.0.0 -BackendPort 8080 -FrontendPort 5173 +powershell -ExecutionPolicy Bypass -File .\start.ps1 -HostAddress 0.0.0.0 -BackendPort 8080 ``` ### 5. Smoke test commands ```powershell -curl.exe -I http://127.0.0.1:5173/ curl.exe http://127.0.0.1:8080/api/health +curl.exe http://127.0.0.1:9997/v3/paths/list ffmpeg -version mediamtx --version nvidia-smi ``` -If browser or network tests run from another machine, open the Windows firewall for ports 5173, 8080, 1935, and 8888. +If browser or network tests run from another machine, open the Windows firewall for ports 8080, 1935, and 8888. ## Windows edge node launcher @@ -82,4 +84,4 @@ Replay records store paths relative to LS_REPLAY_MEDIA_DIR. Absolute paths and p ## Linux and macOS -Linux and macOS are not the primary deployment path. For local development, run the backend with uvicorn from backend, run the frontend with npm run dev from frontend, and set LS_HLS_RUNTIME_DIR to a writable directory. \ No newline at end of file +Linux and macOS are not the primary deployment path. For local development, run the backend with uvicorn from `backend`, run the frontend with `npm run dev` from `frontend`, and set `LS_HLS_RUNTIME_DIR` to a writable directory. \ No newline at end of file diff --git a/backend/config.py b/backend/config.py index eac1292..fd37cd2 100644 --- a/backend/config.py +++ b/backend/config.py @@ -16,6 +16,15 @@ class Settings(BaseSettings): media_origin_rtmp_port: int = 1935 media_origin_api_port: int = 9997 media_origin_hls_port: int = 8888 + # MediaMTX is supervised by the backend: started at boot if the origin is not + # already answering, and restarted if it dies. Set LS_MEDIA_ORIGIN_AUTOSTART=false + # to manage it externally (e.g. systemd, or an edge node's own launcher). + media_origin_autostart: bool = True + # "mediamtx" resolves via PATH; the repo-local copies are the fallback so a + # checkout works without a system-wide install. On Windows the binary sits in + # mediamtx\, on Linux/macOS it is usually a bare `mediamtx` on PATH. + media_origin_binary: str = "mediamtx" + media_origin_config: str = str(APP_DIR.parent / "mediamtx" / "mediamtx.yml") replay_media_dir: str = str(APP_DIR.parent / "media") hls_runtime_dir: str = str(APP_DIR.parent / "runtime" / "hls") hls_segment_seconds: int = 4 @@ -43,6 +52,17 @@ class Settings(BaseSettings): {"label": "360p", "width": 640, "height": 360, "bitrate": "600k"}, ] + # Extra rendition that is published alongside the configured ones: the + # publisher's own video+audio stream remuxed into HLS without re-encoding. + # It costs almost no CPU (stream copy) and gives viewers a lossless + # "原画" (source) option even when every configured rendition downsizes. + source_rendition_label: str = "原画" + source_rendition_enabled: bool = True + # On-disk/URL name for that rendition. It IS the HLS path segment, and MediaMTX + # rejects any path containing non-alphanumeric characters, so the Chinese + # display label above cannot be used here — a CI would 500 trying to serve it. + source_rendition_slug: str = "source" + transcode_profiles: dict[str, list[dict]] = { "standard": qualities, "bandwidth-saver": [ diff --git a/backend/main.py b/backend/main.py index c88386e..2254719 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,11 +8,13 @@ from starlette.background import BackgroundTask import httpx import asyncio import time +import traceback import psutil from database import init_db, session_factory from services.transcoder import transcode_mgr from services.stream_service import StreamService from services.media_service import media_service +from services.mediamtx_manager import mediamtx_mgr from models.stream import StreamStatus from routes import streams_router, nodes_router, auth_router from routes.settings_route import router as settings_router @@ -21,42 +23,124 @@ from services.load_balancer import lb from services.node_store import load_nodes +async def _origin_paths() -> dict[str, dict]: + """Fetch MediaMTX path list keyed by path name. Empty dict when origin is unreachable.""" + try: + async with httpx.AsyncClient(timeout=3) as client: + response = await client.get( + f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list" + ) + response.raise_for_status() + return {item.get("name"): item for item in response.json().get("items", [])} + except Exception: + return {} + + +async def supervise_transcoders() -> list[str]: + """Restart transcoders that died or drifted from the stream's config. + + A live stream whose upstream is publishing (MediaMTX path ready) but whose + local ffmpeg processes have exited would otherwise stay black forever: the + HLS output stops updating and nothing re-triggers a start. + + Returns the stream keys that were (re)started this tick. + """ + restarted: list[str] = [] + paths = await _origin_paths() + if not paths: + return restarted + async with session_factory() as db: + service = StreamService(db) + for stream in await service.get_all_streams(): + if stream.status != StreamStatus.live: + continue + path = paths.get(stream.stream_key) or paths.get(f"live/{stream.stream_key}") + # Only act when the publisher is actually connected: restarting against + # a missing source just burns ffmpeg processes that exit immediately. + if not path or not path.get("ready"): + continue + # Must match what start_transcode is given below: needs_restart + # compares against the signature recorded at the last start, so + # passing a different list here would restart the encoders every tick. + qualities = service.transcode_renditions(stream) + if transcode_mgr.needs_restart(stream.stream_key, qualities): + transcode_mgr.start_transcode( + stream.stream_key, + media_service.get_rtmp_url(stream.stream_key), + qualities, + ) + restarted.append(stream.stream_key) + return restarted + + +async def _metrics_tick(previous, previous_time): + current = psutil.net_io_counters() + current_time = time.monotonic() + elapsed = current_time - previous_time + network_in = ((current.bytes_recv - previous.bytes_recv) * 8 / elapsed / 1e6) if elapsed > 0 else None + network_out = ((current.bytes_sent - previous.bytes_sent) * 8 / elapsed / 1e6) if elapsed > 0 else None + viewer_count = None + try: + async with httpx.AsyncClient(timeout=3) as client: + response = await client.get(f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list") + response.raise_for_status() + payload = response.json() + # MediaMTX reports readers as an array of reader objects; there is no + # readersCount field in the version this project ships. Summing the + # array directly raised TypeError as soon as anyone was watching, + # which the bare except below turned into a permanent "no viewers". + viewer_count = sum(len(item.get("readers") or []) for item in payload.get("items", [])) + except Exception: + pass + lb.report_health("default", { + "cpu_percent": psutil.cpu_percent(), + "memory_percent": psutil.virtual_memory().percent, + "bandwidth_mbps": network_in + network_out if network_in is not None and network_out is not None else None, + "network_in_mbps": network_in, + "network_out_mbps": network_out, + "active_connections": len(psutil.net_connections(kind="tcp")), + "hls_readers": viewer_count, + "viewer_count": viewer_count, + "frame_drop_indicators": None, + "packet_loss_indicators": None, + "media_metrics_available": False, + }) + return current, current_time + + async def monitor_origin(): + """Background supervisor loop. + + Every step is individually guarded: this task is created with create_task, so + an exception escaping it would silently kill supervision forever with no + traceback reaching the logs. Each phase catches its own errors instead. + """ previous = psutil.net_io_counters() previous_time = time.monotonic() while True: await asyncio.sleep(10) - current = psutil.net_io_counters() - current_time = time.monotonic() - elapsed = current_time - previous_time - network_in = ((current.bytes_recv - previous.bytes_recv) * 8 / elapsed / 1e6) if elapsed > 0 else None - network_out = ((current.bytes_sent - previous.bytes_sent) * 8 / elapsed / 1e6) if elapsed > 0 else None - media_available = False - viewer_count = None try: - async with httpx.AsyncClient(timeout=3) as client: - response = await client.get(f"http://127.0.0.1:{settings.media_origin_api_port}/v3/paths/list") - response.raise_for_status() - payload = response.json() - viewer_count = sum(item.get("readers", 0) for item in payload.get("items", [])) - media_available = True + previous, previous_time = await _metrics_tick(previous, previous_time) except Exception: - pass - lb.report_health("default", { - "cpu_percent": psutil.cpu_percent(), - "memory_percent": psutil.virtual_memory().percent, - "bandwidth_mbps": network_in + network_out if network_in is not None and network_out is not None else None, - "network_in_mbps": network_in, - "network_out_mbps": network_out, - "active_connections": len(psutil.net_connections(kind="tcp")), - "hls_readers": viewer_count, - "viewer_count": viewer_count, - "frame_drop_indicators": None, - "packet_loss_indicators": None, - "media_metrics_available": False, - }) - previous = current - previous_time = current_time + traceback.print_exc() + try: + # MediaMTX first: the transcoder pass below only acts on a stream whose + # MediaMTX path is ready, so a dead origin would otherwise be reported + # as "nothing to do" forever. + if await mediamtx_mgr.ensure_running(): + await mediamtx_mgr.wait_until_ready() + except asyncio.CancelledError: + raise + except Exception: + traceback.print_exc() + try: + restarted = await supervise_transcoders() + for stream_key in restarted: + print(f"[supervisor] restarted transcoder for {stream_key}", flush=True) + except asyncio.CancelledError: + raise + except Exception: + traceback.print_exc() @asynccontextmanager @@ -64,6 +148,12 @@ async def lifespan(app: FastAPI): load_nodes() await init_db() os.makedirs(settings.hls_runtime_dir, exist_ok=True) + # Bring the media origin up before the transcoders: they immediately open RTMP + # connections to it, and against a dead origin every ffmpeg exits at once. + if settings.media_origin_autostart and not await mediamtx_mgr.is_healthy(): + mediamtx_mgr.start() + if not await mediamtx_mgr.wait_until_ready(): + print("[supervisor] MediaMTX not ready yet; it will be retried", flush=True) async with session_factory() as db: service = StreamService(db) for stream in await service.get_all_streams(): @@ -71,12 +161,13 @@ async def lifespan(app: FastAPI): transcode_mgr.start_transcode( stream.stream_key, media_service.get_rtmp_url(stream.stream_key), - service.effective_renditions(stream), + service.transcode_renditions(stream), ) origin_monitor = asyncio.create_task(monitor_origin()) yield origin_monitor.cancel() transcode_mgr.stop_all() + mediamtx_mgr.stop() app = FastAPI(title="ProgramCore", lifespan=lifespan) @@ -112,16 +203,78 @@ async def serve_hls(path: str): return FileResponse(fs_path) target = f"http://127.0.0.1:{settings.media_origin_hls_port}/{path}" + # MediaMTX gates HLS behind a "cookieCheck" round trip: it answers with a 302 to + # "?cookieCheck=1" and sets a *Secure* cookie. Over plain HTTP (any + # non-TLS deployment, and every dev machine) browsers refuse to store a Secure + # cookie, so the redirect never terminates and the player loops until it gives + # up. The browser should never see that handshake at all, so complete it here + # and hand back only the final playlist. + # + # The client also has to outlive this function: the body is streamed after we + # return, so an `async with` would close the pool before the first chunk. + client = httpx.AsyncClient(timeout=httpx.Timeout(10), follow_redirects=True) try: - async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client: - req = client.build_request("GET", target) - resp = await client.send(req, stream=True) - from starlette.responses import StreamingResponse - return StreamingResponse( - resp.aiter_bytes(), - status_code=resp.status_code, - headers=dict(resp.headers), - media_type=resp.headers.get("content-type"), - ) + request = client.build_request("GET", target) + response = await client.send(request, stream=True) except Exception: - raise HTTPException(status_code=404) \ No newline at end of file + await client.aclose() + raise HTTPException(status_code=404) + + from starlette.responses import StreamingResponse + + headers = { + key: value + for key, value in response.headers.items() + # Length/transfer headers describe the origin's framing, not ours, and + # hop-by-hop headers must not be relayed. + if key.lower() not in {"content-length", "transfer-encoding", "connection"} + } + + async def relay(): + try: + async for chunk in response.aiter_bytes(): + yield chunk + finally: + await response.aclose() + await client.aclose() + + return StreamingResponse( + relay(), + status_code=response.status_code, + headers=headers, + media_type=response.headers.get("content-type"), + ) + + from starlette.responses import StreamingResponse + + headers = { + key: value + for key, value in response.headers.items() + # Length/transfer headers describe the origin's framing, not ours, and + # hop-by-hop headers must not be relayed. + if key.lower() not in {"content-length", "transfer-encoding", "connection"} + } + # MediaMTX answers HLS requests with a 302 to "?cookieCheck=1" so the + # player picks up its session cookie. That Location is relative to the origin + # root, so relaying it unchanged sends the browser to / on the backend + # host — not an /hls route, hence a 404 instead of a playlist. Re-anchor it. + location = response.headers.get("location") + if response.is_redirect and location: + parts = urlsplit(location) + if parts.path: + headers["location"] = f"/hls{parts.path}" + (f"?{parts.query}" if parts.query else "") + + async def relay(): + try: + async for chunk in response.aiter_bytes(): + yield chunk + finally: + await response.aclose() + await client.aclose() + + return StreamingResponse( + relay(), + status_code=response.status_code, + headers=headers, + media_type=response.headers.get("content-type"), + ) \ No newline at end of file diff --git a/backend/routes/settings_route.py b/backend/routes/settings_route.py index bc8c120..69258cb 100644 --- a/backend/routes/settings_route.py +++ b/backend/routes/settings_route.py @@ -2,6 +2,7 @@ import os import shutil from pathlib import Path +import psutil from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from typing import Optional @@ -88,33 +89,27 @@ async def update_transcode_profiles( @router.get("/system-metrics") async def get_system_metrics(_: bool = Depends(require_auth)): - load_1m, load_5m, load_15m = os.getloadavg() - memory_total = 0 - memory_available = 0 - meminfo = Path("/proc/meminfo") - if meminfo.exists(): - values = {} - for line in meminfo.read_text(encoding="ascii").splitlines(): - key, _, value = line.partition(":") - if value: - values[key] = int(value.strip().split()[0]) * 1024 - memory_total = values.get("MemTotal", 0) - memory_available = values.get("MemAvailable", 0) + cpu_count = os.cpu_count() or 1 + + # There is no load average on Windows: os.getloadavg does not exist there, and + # psutil's emulation reads as 0.0 until its background sampler warms up (and + # never means quite the same thing). Report real CPU utilisation instead, so + # the dashboard's "CPU 使用率" card is not permanently stuck at zero. + if hasattr(os, "getloadavg"): + load_1m, load_5m, load_15m = os.getloadavg() + load_percent = round(min(100, load_1m / cpu_count * 100), 1) + else: + load_1m = load_5m = load_15m = None + # interval=None never blocks; it measures since the previous call anywhere + # in the process, which the supervisor's 10s metrics tick keeps fresh. + load_percent = round(psutil.cpu_percent(), 1) + + memory = psutil.virtual_memory() + network = psutil.net_io_counters() metrics_path = Path(settings.replay_media_dir).resolve() disk_path = metrics_path if metrics_path.exists() else Path.cwd() disk = shutil.disk_usage(disk_path) - cpu_count = os.cpu_count() or 1 - network_in_bytes = 0 - network_out_bytes = 0 - netdev = Path("/proc/net/dev") - if netdev.exists(): - for line in netdev.read_text(encoding="ascii").splitlines()[2:]: - _, _, counters = line.partition(":") - fields = counters.split() - if len(fields) >= 9: - network_in_bytes += int(fields[0]) - network_out_bytes += int(fields[8]) return { "cpu": { @@ -122,12 +117,13 @@ async def get_system_metrics(_: bool = Depends(require_auth)): "load_1m": load_1m, "load_5m": load_5m, "load_15m": load_15m, - "load_percent": round(min(100, load_1m / cpu_count * 100), 1), + "load_percent": load_percent, + "percent": load_percent, }, "memory": { - "total": memory_total, - "available": memory_available, - "used_percent": round((1 - memory_available / memory_total) * 100, 1) if memory_total else 0, + "total": memory.total, + "available": memory.available, + "used_percent": round((1 - memory.available / memory.total) * 100, 1) if memory.total else 0, }, "disk": { "total": disk.total, @@ -135,7 +131,7 @@ async def get_system_metrics(_: bool = Depends(require_auth)): "used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0, }, "network": { - "bytes_recv": network_in_bytes, - "bytes_sent": network_out_bytes, + "bytes_recv": network.bytes_recv, + "bytes_sent": network.bytes_sent, }, } diff --git a/backend/routes/streams.py b/backend/routes/streams.py index f52513b..75fc57b 100644 --- a/backend/routes/streams.py +++ b/backend/routes/streams.py @@ -9,10 +9,11 @@ from pathlib import Path from fastapi import APIRouter, HTTPException, Depends, Request from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, Field from typing import Optional -from database import get_db +from database import get_db, session_factory from routes.auth import require_auth from services.stream_service import StreamService from services.media_service import media_service @@ -121,6 +122,21 @@ def _valid_replay_url(url: str | None) -> str | None: return url +def _utc_iso(moment: datetime) -> str: + """Render a stored timestamp as an explicit UTC instant for JSON. + + Rows are written with datetime.utcnow(), so the value is UTC but carries no + tzinfo. `isoformat()` therefore emits "2026-09-22T07:47:11" with no offset, + and JavaScript's Date() reads that as *local* time — in UTC+8 every heartbeat + looked eight hours old and the live board's "active viewer" filter discarded + every session, so the count was permanently zero. The trailing Z is what makes + the client parse it as UTC. + """ + if moment is None: + return None + return moment.replace(microsecond=0).isoformat() + "Z" + + def _status_payload(stream) -> dict: return { "stream_id": stream.id, @@ -250,10 +266,33 @@ async def viewer_heartbeat(stream_id: int, body: ViewerHeartbeat, raise HTTPException(status_code=422, detail="观众会话 ID 无效") viewer = await db.get(ViewerSession, session_id) if viewer and viewer.stream_id != stream_id: - raise HTTPException(status_code=409, detail="观众会话已用于其他节目") + # A session id lives in the viewer's browser, not per programme, so anyone + # who switches streams arrives still bound to the one they started on. + # Re-bind them rather than 409: "this session is watching something else" + # is not a conflict a viewer can act on, and leaving it stuck meant every + # later heartbeat and command poll for this viewer failed for good. + viewer.stream_id = stream_id + viewer.remote_adjusted = False if not viewer: viewer = ViewerSession(session_id=session_id, stream_id=stream_id) db.add(viewer) + try: + await db.flush() + except IntegrityError: + # A first heartbeat can be sent twice in quick succession (React + # StrictMode runs mount effects twice in development), and both + # requests can miss the lookup above before either commits. The loser + # adopts the winner's row instead of returning a 500. + # + # Recovery runs on its own session: this one is now in a failed + # transaction, and a session that is still dirty at teardown makes + # get_db commit on exit and re-raise the very IntegrityError we just + # handled. + async with session_factory() as recovery: + viewer = await recovery.get(ViewerSession, session_id) + if not viewer: + raise HTTPException(status_code=409, detail="观众会话创建失败,请刷新页面") + return {"ok": True, "remote_adjusted": viewer.remote_adjusted} viewer.node_id = body.node_id viewer.node_name = body.node_name viewer.quality = body.quality @@ -282,7 +321,7 @@ async def list_viewer_sessions(stream_id: int, db: AsyncSession = Depends(get_db "buffered_seconds": viewer.buffered_seconds, "error": viewer.error, "remote_adjusted": viewer.remote_adjusted, - "last_heartbeat": viewer.last_heartbeat.isoformat(), + "last_heartbeat": _utc_iso(viewer.last_heartbeat), } for viewer in viewers] @@ -300,8 +339,8 @@ async def control_audit(stream_id: int, db: AsyncSession = Depends(get_db), "value": command.value, "status": command.status, "result": command.result, - "created_at": command.created_at.isoformat(), - "claimed_at": command.claimed_at.isoformat() if command.claimed_at else None, + "created_at": _utc_iso(command.created_at), + "claimed_at": _utc_iso(command.claimed_at), } for command in commands] @@ -552,8 +591,17 @@ async def send_remote_command(stream_id: int, body: RemoteCommand, async def poll_remote_command(stream_id: int, session_id: str, db: AsyncSession = Depends(get_db)): viewer = await db.get(ViewerSession, session_id) - if not viewer or viewer.stream_id != stream_id: + if not viewer: raise HTTPException(status_code=404, detail="观众会话不存在") + if viewer.stream_id != stream_id: + # A session id is stored per browser, not per programme, so a viewer who + # switches streams arrives with the id they registered on the old one. + # Remote control is meaningless if that 404s forever, so re-bind them + # instead — they are watching this stream now, which is the only fact the + # admin panel needs. Commands aimed at the old stream are not carried over. + viewer.stream_id = stream_id + viewer.remote_adjusted = False + await db.commit() command = (await db.execute(select(ControlCommand).where( ControlCommand.stream_id == stream_id, ControlCommand.target_session_id == session_id, @@ -566,7 +614,7 @@ async def poll_remote_command(stream_id: int, session_id: str, viewer.remote_adjusted = command.action != "restore_auto" await db.commit() return {"id": command.id, "action": command.action, "value": command.value, - "created_at": command.created_at.isoformat()} + "created_at": _utc_iso(command.created_at)} @router.post("/{stream_id}/go-live") diff --git a/backend/services/media_service.py b/backend/services/media_service.py index 469ab8e..5a7aa4d 100644 --- a/backend/services/media_service.py +++ b/backend/services/media_service.py @@ -22,8 +22,16 @@ class MediaMTXService: if resp.status_code == 200: data = resp.json() for item in data.get("items", []): - if item.get("name") == stream_key: - return {"ready": item.get("ready", False), "source_ready": item.get("sourceReady", False)} + # Paths are named after the RTMP URL key, so a publisher on + # rtmp://host/live/ shows up as "live/", never as + # the bare key. This MediaMTX exposes the reader list itself + # rather than a readersCount/sourceReady pair. + if item.get("name") in (stream_key, f"live/{stream_key}"): + return { + "ready": item.get("ready", False), + "source_ready": bool(item.get("source")), + "readers": len(item.get("readers") or []), + } except Exception: return None return None diff --git a/backend/services/mediamtx_manager.py b/backend/services/mediamtx_manager.py new file mode 100644 index 0000000..5a0d062 --- /dev/null +++ b/backend/services/mediamtx_manager.py @@ -0,0 +1,158 @@ +import asyncio +import os +import shutil +import subprocess + +import httpx + +from config import settings + + +def _log(message: str) -> None: + print(f"[mediamtx] {message}", flush=True) + + +def resolve_binary(configured: str) -> str | None: + """Find the MediaMTX executable: the configured name on PATH, else the repo copy. + + The repository ships both an ELF `mediamtx` and a Windows `mediamtx.exe`, so a + bare-name fallback has to prefer the platform's own extension first — picking + the other one fails at exec time with "not a valid Win32 application". + """ + found = shutil.which(configured) + if found: + return found + if os.path.dirname(configured): + return configured if os.path.isfile(configured) else None + + repo_dir = os.path.dirname(os.path.abspath(settings.media_origin_config)) + candidates = [f"{configured}.exe", configured] if os.name == "nt" else [configured, f"{configured}.exe"] + for candidate in candidates: + path = os.path.join(repo_dir, candidate) + if os.path.isfile(path): + return path + return None + + +class MediaMTXManager: + """Keeps the MediaMTX origin alive. + + The backend owns the media origin: it is the only thing that can tell whether + the origin is actually serving (its own /v3 API), and RTMP ingest is dead + without it while transcoders keep burning CPU against a missing source. When + an external supervisor already runs MediaMTX, the health probe below sees it + and this manager stays out of the way. + + Only the process this manager started is ever stopped, so an externally + managed origin is never killed on shutdown. + """ + + def __init__(self) -> None: + self._process: subprocess.Popen | None = None + self._failures = 0 + self._binary_missing_logged = False + + async def is_healthy(self) -> bool: + """True when the origin API answers, regardless of who started it.""" + try: + async with httpx.AsyncClient(timeout=2) as client: + response = await client.get( + f"http://{settings.media_origin_host}:{settings.media_origin_api_port}/v3/paths/list" + ) + return response.status_code == 200 + except Exception: + return False + + def _started_process_alive(self) -> bool: + return self._process is not None and self._process.poll() is None + + def start(self) -> None: + """Launch MediaMTX unless one is already running. Never raises.""" + if self._process is not None: + # A process we started exited; clear it so the next tick can retry. + if self._process.poll() is not None: + _log(f"process exited with code {self._process.returncode}") + self._process = None + else: + return + + binary = resolve_binary(settings.media_origin_binary) + if binary is None: + # No MediaMTX anywhere is a deployment gap, not a crash: the API still + # serves, the admin UI can explain it, and RTMP ingest is simply down. + if not self._binary_missing_logged: + _log(f"'{settings.media_origin_binary}' not found on PATH or in mediamtx/; RTMP ingest unavailable") + self._binary_missing_logged = True + return + + config_path = settings.media_origin_config + command = [binary] + if config_path and os.path.isfile(config_path): + command.append(config_path) + else: + _log(f"config not found at {config_path}; running with defaults") + config_path = "" + + try: + self._process = subprocess.Popen( + command, + cwd=os.path.dirname(os.path.abspath(config_path)) if config_path else None, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except FileNotFoundError: + if not self._binary_missing_logged: + _log(f"failed to launch {binary}; RTMP ingest unavailable") + self._binary_missing_logged = True + self._process = None + except Exception as error: # pragma: no cover - defensive + _log(f"failed to start: {error!r}") + self._process = None + + async def ensure_running(self) -> bool: + """Start the origin if it is neither healthy nor already running. + + Returns True when this call started it. + """ + if not settings.media_origin_autostart: + return False + if await self.is_healthy() or self._started_process_alive(): + self._failures = 0 + return False + # Back off after repeated failures so a broken binary cannot be relaunched + # every supervisor tick, which would spam processes and logs. + if self._failures: + self._failures -= 1 + return False + self.start() + if self._process is None: + self._failures = 12 + return False + self._failures = 0 + _log(f"started (pid {self._process.pid})") + return True + + async def wait_until_ready(self, timeout: float = 15.0) -> bool: + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + if await self.is_healthy(): + return True + await asyncio.sleep(0.5) + return False + + def stop(self) -> None: + """Stop only the process this manager started.""" + process, self._process = self._process, None + if process is None or process.poll() is not None: + return + try: + process.terminate() + process.wait(timeout=5) + except Exception: + try: + process.kill() + except Exception: + pass + + +mediamtx_mgr = MediaMTXManager() diff --git a/backend/services/stream_service.py b/backend/services/stream_service.py index 7b5bb9d..d6b1f26 100644 --- a/backend/services/stream_service.py +++ b/backend/services/stream_service.py @@ -7,9 +7,24 @@ from services.multicast_detector import multicast_detector from services.transcoder import transcode_mgr from config import settings import json +import re from datetime import datetime +# MediaMTX rejects any HLS path that is not alphanumeric ("invalid path name: can +# contain only alphanumeric characters"), and the label is also used as a directory +# name, so every rendition needs a path-safe slug of its own. +def hls_path_slug(label: str) -> str: + """Filesystem- and MediaMTX-safe rendition path for a display label.""" + slug = re.sub(r"[^A-Za-z0-9]", "", label or "") + return slug or "rendition" + + +def with_hls_path(rendition: dict) -> dict: + """Attach the `path` an admin-configured rendition is stored and served under.""" + return {**rendition, "path": hls_path_slug(rendition.get("label", ""))} + + class StreamService: def __init__(self, db: AsyncSession): self.db = db @@ -25,6 +40,56 @@ class StreamService: pass return settings.transcode_profiles.get(stream.transcode_profile, settings.qualities) + @staticmethod + def source_label() -> str: + """Display name of the stream-copy rendition, or '' when it is disabled. + + A blank label would leak into the HLS path and produce a URL like + /hls///index.m3u8, so it degrades to "off" instead. + """ + return settings.source_rendition_label.strip() if settings.source_rendition_enabled else "" + + @classmethod + def source_slug(cls) -> str: + """Path segment for that rendition, safe for MediaMTX and the filesystem.""" + if not cls.source_label(): + return "" + return hls_path_slug(settings.source_rendition_slug) + + def watch_renditions(self, stream: Stream) -> list: + """Renditions a viewer may pick: the stream-copy rendition first, then the configured ones. + + Each entry carries both the label shown to viewers and the `path` used in + HLS URLs — MediaMTX only accepts alphanumeric HLS paths, so a Chinese label + such as "原画" cannot double as a directory name. + + The source entry carries no size: the input resolution is only known to + ffmpeg, so the player measures it from the media it ends up decoding. + """ + renditions = [with_hls_path(r) for r in self.effective_renditions(stream)] + label, slug = self.source_label(), self.source_slug() + if not label or not slug or not renditions or renditions[0]["path"] == slug: + return renditions + return [{"label": label, "path": slug, "width": None, "height": None, "copy": True}] + renditions + + def transcode_renditions(self, stream: Stream) -> list: + """Renditions to hand to the transcoder, including the stream-copy source rendition. + + Carries the same `path` values as watch_renditions so the files ffmpeg + writes are the files the player asks for. + + The copy entry is the top rendition with its label replaced, so it carries + every key the transcoder reads. ffmpeg ignores those numbers for a stream + copy, but the supervisor in main.py compares TranscodeManager's recorded + signature against this list every tick — an entry missing a field would + both crash the start and make the signature drift. + """ + renditions = [with_hls_path(r) for r in self.effective_renditions(stream)] + label, slug = self.source_label(), self.source_slug() + if not label or not slug or not renditions or renditions[0]["path"] == slug: + return renditions + return [{**renditions[0], "label": label, "path": slug, "copy": True}] + renditions + async def create_stream(self, title: str, description: str | None = None, cover_url: str | None = None, scheduled_at: str | None = None, @@ -32,6 +97,7 @@ class StreamService: replay_url: str | None = None, chapters: list[dict] | None = None, transcode_profile: str = "standard", + renditions_json: str | None = None, homepage_visible: bool = True, remote_control_enabled: bool = False) -> Stream: import uuid @@ -49,6 +115,7 @@ class StreamService: replay_url=replay_url, chapters_json=json.dumps(chapters or [], ensure_ascii=False), transcode_profile=transcode_profile, + renditions_json=renditions_json, homepage_visible=homepage_visible, remote_control_enabled=remote_control_enabled, stream_key=uuid.uuid4().hex, @@ -108,7 +175,7 @@ class StreamService: started_at=datetime.utcnow(), ) rtmp_url = media_service.get_rtmp_url(stream_key) - transcode_mgr.start_transcode(stream_key, rtmp_url, self.effective_renditions(stream)) + transcode_mgr.start_transcode(stream_key, rtmp_url, self.transcode_renditions(stream)) async def set_stream_ended(self, stream_key: str): stream = await self.get_stream_by_key(stream_key) @@ -125,7 +192,7 @@ class StreamService: stream = await self.get_stream_by_key(stream_key) if not stream: return {"lines": [], "qualities": [], "multicast_available": False, "selected_node": None} - qualities = self.effective_renditions(stream) + qualities = self.watch_renditions(stream) best_node = lb.select_best_node(client_ip) if not best_node: best_node = lb.get_enabled_nodes()[0] if lb.get_enabled_nodes() else None @@ -142,12 +209,13 @@ class StreamService: "qualities": [], } for quality in qualities: - label = quality["label"] + # The URL segment is the rendition's path slug, not its label: + # labels are display text and may be Chinese. line_info["qualities"].append({ - "label": label, + "label": quality["label"], "width": quality["width"], "height": quality["height"], - "url": f"{base_hls}/{label}/index.m3u8", + "url": f"{base_hls}/{quality['path']}/index.m3u8", }) lines.append(line_info) diff --git a/backend/services/transcoder.py b/backend/services/transcoder.py index e16269c..629cdf9 100644 --- a/backend/services/transcoder.py +++ b/backend/services/transcoder.py @@ -5,15 +5,67 @@ from config import settings class TranscodeManager: _processes: dict[str, list[subprocess.Popen]] = {} + # Signature of the last successful start, so a supervisor can tell whether a + # running set of processes still matches the stream's current configuration. + _signatures: dict[str, str] = {} + _failed_starts: dict[str, int] = {} + + @staticmethod + def _signature(qualities: list[dict]) -> str: + return "|".join( + f"{q.get('label')}:{q.get('width')}x{q.get('height')}:{q.get('bitrate')}:" + f"{'copy' if q.get('copy') else q.get('codec', 'libx264')}" + for q in qualities + ) + + def is_running(self, stream_key: str) -> bool: + """True when at least one transcode process exists and none have exited.""" + procs = self._processes.get(stream_key) + if not procs: + return False + return any(proc.poll() is None for proc in procs) + + def needs_restart(self, stream_key: str, qualities: list[dict]) -> bool: + if not qualities: + return False + return self._signatures.get(stream_key) != self._signature(qualities) or not self.is_running(stream_key) + + def _clear_stale_output(self, hls_dir: str) -> None: + """Drop the previous run's playlist and segments. + + ffmpeg writes a live playlist with no #EXT-X-ENDLIST, so a playlist left + behind by an encoder that died looks to hls.js exactly like a stream that + simply stopped: it keeps reloading a file that will never change and + buffers forever. Removing it makes the player see a missing manifest + instead, which it retries until the supervisor has the encoder back up. + """ + if not os.path.isdir(hls_dir): + return + for name in os.listdir(hls_dir): + if name.endswith(".ts") or name.endswith(".m3u8") or name.endswith(".m3u8.tmp"): + try: + os.remove(os.path.join(hls_dir, name)) + except OSError: + # A player holding the file open (Windows) is fine: it will be + # overwritten by the restarting encoder. + pass def start_transcode(self, stream_key: str, input_url: str, qualities: list[dict]): - if stream_key in self._processes: + if stream_key in self._processes and self.is_running(stream_key): return + self.stop_transcode(stream_key) procs = [] segment_seconds = max(1, settings.hls_segment_seconds) for quality in qualities: label = quality["label"] + # The stream-copy ("原画") rendition carries the top rendition's numbers + # only so the signature stays complete; ffmpeg copies the input rather + # than encoding to them. See StreamService.transcode_renditions. + is_copy = bool(quality.get("copy")) + # Files land under the rendition's path slug, never its label: MediaMTX + # refuses non-alphanumeric HLS paths and the label may be Chinese. + path_slug = quality.get("path") or label width = quality["width"] height = quality["height"] fps = quality.get("fps", 30) @@ -24,20 +76,10 @@ class TranscodeManager: audio_bitrate = quality.get("audio_bitrate", "128k") keyframe_seconds = quality.get("keyframe_seconds", segment_seconds) keyframe_frames = keyframe_seconds * fps - hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, label) + hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, path_slug) os.makedirs(hls_dir, exist_ok=True) - cmd = [ - "ffmpeg", "-y", - "-i", input_url, - "-c:v", codec, "-preset", preset, - "-r", str(fps), "-b:v", bitrate, "-crf", str(crf), - "-s", f"{width}x{height}", - "-sc_threshold", "0", - "-g", str(keyframe_frames), - "-keyint_min", str(keyframe_frames), - "-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})", - "-copyts", "-start_at_zero", - "-c:a", "aac", "-b:a", audio_bitrate, + self._clear_stale_output(hls_dir) + hls_output = [ "-f", "hls", "-hls_time", str(segment_seconds), "-hls_list_size", "6", @@ -45,6 +87,44 @@ class TranscodeManager: "-hls_segment_filename", os.path.join(hls_dir, "segment_%03d.ts"), os.path.join(hls_dir, "index.m3u8"), ] + # NOTE: ffmpeg's -reconnect* flags are HTTP/HTTPS-only protocol + # options and make RTMP inputs fail with "Option not found". + # RTMP drop recovery is handled by the supervisor in main.py, + # which restarts exited encoders on its next tick. + hls_input = ["-rw_timeout", "10000000", "-i", input_url] + if is_copy: + # Source ("原画") rendition: keep the publisher's video as-is and only + # re-wrap it, so the output keeps the exact incoming resolution, + # frame rate and bitrate. Video copy costs no CPU; audio is re-encoded + # because MPEG-TS cannot carry every codec a publisher might send, and + # one AAC track is a rounding error next to the video encoders. + cmd = ( + ["ffmpeg", "-y"] + hls_input + + [ + "-c:v", "copy", + "-c:a", "aac", "-b:a", "192k", + "-copyts", "-start_at_zero", + "-map", "0:v:0", "-map", "0:a:0?", + ] + + hls_output + ) + else: + cmd = ( + ["ffmpeg", "-y"] + hls_input + + [ + "-c:v", codec, "-preset", preset, + "-r", str(fps), "-b:v", bitrate, "-crf", str(crf), + "-s", f"{width}x{height}", + "-sc_threshold", "0", + "-g", str(keyframe_frames), + "-keyint_min", str(keyframe_frames), + "-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})", + "-copyts", "-start_at_zero", + "-c:a", "aac", "-b:a", audio_bitrate, + ] + + hls_output + ) + try: proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL @@ -54,6 +134,8 @@ class TranscodeManager: pass self._processes[stream_key] = procs + self._signatures[stream_key] = self._signature(qualities) + self._failed_starts[stream_key] = 0 if procs else self._failed_starts.get(stream_key, 0) + 1 def stop_transcode(self, stream_key: str): procs = self._processes.pop(stream_key, []) @@ -72,4 +154,4 @@ class TranscodeManager: self.stop_transcode(key) -transcode_mgr = TranscodeManager() \ No newline at end of file +transcode_mgr = TranscodeManager() diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..275d416 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/index.html b/frontend/index.html index 9f1447a..aa1e627 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,12 +1,10 @@ - + + StreamCore - - -
diff --git a/frontend/package.json b/frontend/package.json index 9f12799..74461e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,25 +4,45 @@ "version": "1.0.0", "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0 --port 5173", - "build": "tsc -b && vite build", - "preview": "vite preview --host 0.0.0.0" + "dev": "vite --host 0.0.0.0 --port 5175", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0 --port 5175" }, "dependencies": { - "@ant-design/icons": "^5.4.0", - "antd": "^5.21.0", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-progress": "^1.1.16", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", "axios": "^1.7.7", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "hls.js": "^1.5.14", + "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-router-dom": "^6.26.2", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "sonner": "^1.7.1", + "tailwind-merge": "^2.5.4" }, "devDependencies": { + "@types/node": "^22.7.4", "@types/react": "^18.3.8", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.5.6", + "postcss": "^8.5.28", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", "typescript": "^5.6.2", "vite": "^5.4.7" } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae927a0..88511f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,24 +1,23 @@ -import { Component, lazy, Suspense, useState, useEffect, type ErrorInfo, type ReactNode } from 'react' -import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom' -import { Button, ConfigProvider, Result } from 'antd' -import zhCN from 'antd/locale/zh_CN' -import theme from './theme/anthropic' -import './pageTransitions.css' -import { BrandProvider, useBrand } from './brand/BrandContext' -import { verifyToken } from './api/auth' -import { getToken } from './api/client' +import { Component, lazy, Suspense, useEffect, useState, type ErrorInfo, type ReactNode } from 'react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { Toaster } from 'sonner' +import { Button } from '@/components/ui/button' +import { BrandProvider, useBrand } from '@/brand/BrandContext' +import { verifyToken } from '@/api/auth' +import { getToken } from '@/api/client' -const HomePage = lazy(() => import('./viewer/pages/HomePage')) -const WatchPage = lazy(() => import('./viewer/pages/WatchPage')) -const AdminLayout = lazy(() => import('./admin/AdminLayout')) -const LoginPage = lazy(() => import('./admin/LoginPage')) -const Dashboard = lazy(() => import('./admin/pages/Dashboard')) -const LiveBoard = lazy(() => import('./admin/pages/LiveBoard')) -const StreamList = lazy(() => import('./admin/pages/StreamList')) -const StreamForm = lazy(() => import('./admin/pages/StreamForm')) -const ReplayEditor = lazy(() => import('./admin/pages/ReplayEditor')) -const NodeManager = lazy(() => import('./admin/pages/NodeManager')) -const SettingsPage = lazy(() => import('./admin/pages/Settings')) +const HomePage = lazy(() => import('@/viewer/pages/HomePage')) +const WatchPage = lazy(() => import('@/viewer/pages/WatchPage')) +const AdminLayout = lazy(() => import('@/admin/AdminLayout')) +const LoginPage = lazy(() => import('@/admin/LoginPage')) +const Dashboard = lazy(() => import('@/admin/pages/Dashboard')) +const LiveBoard = lazy(() => import('@/admin/pages/LiveBoard')) +const StreamList = lazy(() => import('@/admin/pages/StreamList')) +const StreamForm = lazy(() => import('@/admin/pages/StreamForm')) +const ReplayEditor = lazy(() => import('@/admin/pages/ReplayEditor')) +const NodeManager = lazy(() => import('@/admin/pages/NodeManager')) +const SettingsPage = lazy(() => import('@/admin/pages/Settings')) +const SwaggerPage = lazy(() => import('@/admin/pages/SwaggerPage')) class PageErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { state = { failed: false } @@ -34,24 +33,26 @@ class PageErrorBoundary extends Component<{ children: ReactNode }, { failed: boo render() { if (this.state.failed) { return ( - window.location.reload()}>重新加载} - /> +
+

页面加载失败

+

页面遇到异常,请重新加载后再试。

+ +
) } return this.props.children } } -function AdminGuard({ children }: { children: React.ReactNode }) { +function AdminGuard({ children }: { children: ReactNode }) { const [authed, setAuthed] = useState(null) useEffect(() => { const token = getToken() - if (!token) { setAuthed(false); return } + if (!token) { + setAuthed(false) + return + } verifyToken().then(setAuthed) }, []) @@ -60,45 +61,55 @@ function AdminGuard({ children }: { children: React.ReactNode }) { return <>{children} } -function AppRoutes() { - const location = useLocation() - const routeTransitionKey = location.pathname.startsWith('/admin') ? '/admin' : location.pathname +function RouteShell() { const { brand } = useBrand() - useEffect(() => { document.title = brand.brand_name }, [brand.brand_name]) + useEffect(() => { + document.title = brand.brand_name + }, [brand.brand_name]) + return ( - - }> - - } /> - } /> - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - - + + +
+
+ } + > + + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + +
+
) } export default function App() { return ( - - - - + + + + - - + ) -} \ No newline at end of file +} diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 7ef7dc6..caba8f2 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -1,33 +1,28 @@ -import { Suspense, useEffect, useState } from 'react' -import { Outlet, useNavigate, useLocation } from 'react-router-dom' -import { Layout, Menu, Button, Typography } from 'antd' +import { useState } from 'react' +import { Outlet, useNavigate, useLocation, NavLink } from 'react-router-dom' import { - DashboardOutlined, VideoCameraOutlined, PlusOutlined, MonitorOutlined, - CloudServerOutlined, SettingOutlined, LogoutOutlined, -} from '@ant-design/icons' -import { clearToken } from '../api/client' -import { useBrand } from '../brand/BrandContext' + LayoutDashboard, + MonitorPlay, + Video, + Plus, + Server, + Settings, + LogOut, + FileCode2, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { clearToken } from '@/api/client' +import { useBrand } from '@/brand/BrandContext' +import { cn } from '@/lib/utils' -const { Sider, Content, Header } = Layout -const { Text } = Typography - -const menuItems = [ - { key: '/admin', icon: , label: '概览' }, - { key: '/admin/board', icon: , label: '直播看板' }, - { key: '/admin/streams', icon: , label: '节目列表' }, - { key: '/admin/streams/new', icon: , label: '创建节目' }, - { key: '/admin/nodes', icon: , label: '边缘节点' }, - { key: '/admin/settings', icon: , label: '系统设置' }, -] - -const adminPageImports = [ - () => import('./pages/Dashboard'), - () => import('./pages/LiveBoard'), - () => import('./pages/StreamList'), - () => import('./pages/StreamForm'), - () => import('./pages/ReplayEditor'), - () => import('./pages/NodeManager'), - () => import('./pages/Settings'), +const navItems = [ + { to: '/admin', label: '概览', icon: LayoutDashboard, end: true }, + { to: '/admin/board', label: '直播看板', icon: MonitorPlay }, + { to: '/admin/streams', label: '节目列表', icon: Video }, + { to: '/admin/streams/new', label: '创建节目', icon: Plus }, + { to: '/admin/nodes', label: '边缘节点', icon: Server }, + { to: '/admin/settings', label: '系统设置', icon: Settings }, + { to: '/admin/swagger', label: 'API 文档', icon: FileCode2 }, ] export default function AdminLayout() { @@ -35,49 +30,91 @@ export default function AdminLayout() { const [collapsed, setCollapsed] = useState(false) const navigate = useNavigate() const location = useLocation() - function navigateWithTransition(path: string) { - if (path !== location.pathname) navigate(path) + + const handleLogout = () => { + clearToken() + navigate('/admin/login') } - - const selectedKey = menuItems.find( - item => location.pathname === item.key || (location.pathname === '/' && item.key === '/admin') - )?.key || '/admin' - - const handleLogout = () => { clearToken(); navigate('/admin/login') } + const currentLabel = + navItems + .filter((item) => location.pathname === item.to || (!item.end && location.pathname.startsWith(item.to + '/'))) + .sort((a, b) => b.to.length - a.to.length)[0]?.label || '管理控制台' return ( - - + {/* Sidebar */} + + + {/* Main */} +
+
+ + +
+ +
+ +
+
+ ) } diff --git a/frontend/src/admin/LoginPage.tsx b/frontend/src/admin/LoginPage.tsx index 24f6550..bb7d3c3 100644 --- a/frontend/src/admin/LoginPage.tsx +++ b/frontend/src/admin/LoginPage.tsx @@ -1,70 +1,76 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Form, Input, Button, Typography, message } from 'antd' -import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons' -import { login } from '../api/auth' -import { useBrand } from '../brand/BrandContext' - -const { Title, Text } = Typography +import { Lock } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { login } from '@/api/auth' +import { useBrand } from '@/brand/BrandContext' export default function LoginPage() { const { brand } = useBrand() + const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) const navigate = useNavigate() - const handleSubmit = async (values: { password: string }) => { + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!password) { + toast.error('请输入管理员密码') + return + } setLoading(true) - try { await login(values.password); message.success('登录成功'); navigate('/admin') } - catch { message.error('密码错误') } - finally { setLoading(false) } + try { + await login(password) + toast.success('登录成功') + navigate('/admin') + } catch { + toast.error('密码错误') + } finally { + setLoading(false) + } } return ( -
-
-
-
+
+
+
+
{brand.brand_logo_url && ( - {brand.brand_name} + {brand.brand_name} )} - - {brand.brand_name} - - - {brand.brand_tagline || '管理控制台'} - +

{brand.brand_name}

+

{brand.brand_tagline || '管理控制台'}

-
- - } - placeholder="管理员密码" - style={{ height: 48, borderRadius: 8 }} - /> - - - - -
+
+
+ +
+ + setPassword(e.target.value)} + placeholder="请输入管理员密码" + className="h-11 pl-9" + autoFocus + /> +
+
+ +
-
-
diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 2f58c0d..1c0beee 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -1,96 +1,99 @@ import { useState, useEffect } from 'react' -import { Row, Col, Statistic, Table, Tag, Typography } from 'antd' -import { SignalFilled, ClockCircleOutlined, PlayCircleOutlined } from '@ant-design/icons' -import { fetchStreams, type StreamInfo } from '../../api/streams' -import { useBrand } from '../../brand/BrandContext' - -const { Title, Text } = Typography +import { Radio, Clock, PlayCircle } from 'lucide-react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { PageHeader } from '@/components/page-header' +import { StatCard } from '@/components/stat-card' +import { StatusBadge } from '@/components/status-badge' +import { fetchStreams, type StreamInfo } from '@/api/streams' +import { formatDateTime } from '@/lib/status' export default function Dashboard() { - const { brand } = useBrand() const [streams, setStreams] = useState([]) const [loading, setLoading] = useState(true) - useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, []) + useEffect(() => { + let active = true + const loadData = async () => { + try { + const data = await fetchStreams(true) + if (active) setStreams(data) + } catch { + /* ignore */ + } + if (active) setLoading(false) + } + void loadData() + const t = setInterval(loadData, 10000) + return () => { + active = false + clearInterval(t) + } + }, []) - async function loadData() { - try { setStreams(await fetchStreams(true)) } catch { /* */ } - setLoading(false) - } - - const liveCount = streams.filter(s => s.status === 'live').length - const pendingCount = streams.filter(s => s.status === 'pending').length - - const statusMap: Record = { - pending: { color: 'blue', label: '待开播' }, - live: { color: 'green', label: '直播中' }, - ended: { color: 'default', label: '已结束' }, - cancelled: { color: 'red', label: '已取消' }, - } - - const columns = [ - { title: '标题', dataIndex: 'title', key: 'title', ellipsis: true }, - { title: '状态', dataIndex: 'status', key: 'status', width: 90, - render: (s: string) => {(statusMap[s] || { label: s }).label} }, - { title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.slice(0, 16), width: 170 }, - ] + const liveCount = streams.filter((s) => s.status === 'live').length + const pendingCount = streams.filter((s) => s.status === 'pending').length return ( -
-
- - {brand.brand_name} - - 管理控制台 · 实时刷新 +
+ + +
+ + +
- - -
- } /> -
- - -
- } /> -
- - -
- } /> -
- -
- -
- 最近节目 - - + + + 最近节目 + 最新创建的 10 个节目 + + +
+ + + 标题 + 状态 + 创建时间 + + + + {loading ? ( + + + 加载中… + + + ) : streams.length === 0 ? ( + + + 暂无节目数据 + + + ) : ( + streams.slice(0, 10).map((s) => ( + + {s.title} + + + + {formatDateTime(s.created_at)} + + )) + )} + +
+ +
) } diff --git a/frontend/src/admin/pages/LiveBoard.tsx b/frontend/src/admin/pages/LiveBoard.tsx index a5c07ee..9ecc593 100644 --- a/frontend/src/admin/pages/LiveBoard.tsx +++ b/frontend/src/admin/pages/LiveBoard.tsx @@ -1,8 +1,26 @@ import { useEffect, useState } from 'react' -import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd' -import { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons' -import api from '../../api/client' -import { getSystemMetrics, type SystemMetrics } from '../../api/system' +import { RefreshCw, Maximize2, Minimize2, Users, Signal, TriangleAlert, Cpu, MemoryStick, HardDrive, Download, Upload } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import api from '@/api/client' +import { getSystemMetrics, type SystemMetrics } from '@/api/system' import { createRemoteSession, fetchStreams, @@ -10,9 +28,10 @@ import { sendRemoteCommand, type StreamInfo, type ViewerSessionInfo, -} from '../../api/streams' - -const { Title, Text } = Typography +} from '@/api/streams' +import { formatBytes } from '@/lib/status' +import { PageHeader } from '@/components/page-header' +import { StatCard } from '@/components/stat-card' interface NodeMetric { id: string @@ -27,19 +46,13 @@ interface NodeMetric { } | null } -const formatBytes = (value = 0) => { - const units = ['B', 'KB', 'MB', 'GB', 'TB'] - let amount = Math.max(0, value) - let index = 0 - while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1 } - return `${amount.toFixed(index ? 1 : 0)} ${units[index]}` -} +const QUALITIES = ['1080p', '720p', '480p', '360p'] export default function LiveBoard() { const [streams, setStreams] = useState([]) const [streamId, setStreamId] = useState() const [sessions, setSessions] = useState([]) - const [selected, setSelected] = useState([]) + const [selected, setSelected] = useState>(new Set()) const [loading, setLoading] = useState(true) const [systemMetrics, setSystemMetrics] = useState(null) const [nodes, setNodes] = useState([]) @@ -51,7 +64,7 @@ export default function LiveBoard() { try { const programs = await fetchStreams(true) setStreams(programs) - const nextId = streamId || programs.find(program => program.status === 'live')?.id + const nextId = streamId || programs.find((p) => p.status === 'live')?.id if (nextId) { setStreamId(nextId) setSessions(await fetchViewerSessions(nextId)) @@ -59,7 +72,7 @@ export default function LiveBoard() { setSessions([]) } } catch { - message.error('看板数据加载失败') + toast.error('看板数据加载失败') } finally { try { setSystemMetrics(await getSystemMetrics()) @@ -83,102 +96,203 @@ export default function LiveBoard() { }, [streamId]) async function controlViewers() { - if (!streamId || selected.length === 0) return + if (!streamId || selected.size === 0) return try { const session = await createRemoteSession(streamId) - const targets = selected.map(String) + const targets = Array.from(selected) if (nodeId !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_node', targets, nodeId) if (quality !== 'auto') await sendRemoteCommand(streamId, session.token, 'switch_quality', targets, quality) if (nodeId === 'auto' && quality === 'auto') await sendRemoteCommand(streamId, session.token, 'restore_auto', targets) - message.success(`已向 ${selected.length} 个观众下发节点与清晰度策略`) + toast.success(`已向 ${selected.size} 个观众下发节点与清晰度策略`) } catch { - message.error('控制命令下发失败,请确认节目已启用审计式播放控制') + toast.error('控制命令下发失败,请确认节目已启用审计式播放控制') } } - const activeSessions = sessions.filter(item => Date.now() - new Date(item.last_heartbeat).getTime() < 30000) - const buffering = activeSessions.filter(item => item.playback_state === 'buffering' || Number(item.buffered_seconds || 0) < 1) - const totalCardsLoading = !systemMetrics + const activeSessions = sessions.filter((s) => Date.now() - new Date(s.last_heartbeat).getTime() < 30000) + const buffering = activeSessions.filter( + (s) => s.playback_state === 'buffering' || Number(s.buffered_seconds || 0) < 1, + ) + + const toggleSelect = (id: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } return ( -
- -
-
- 直播看板 - 实时检查节目、观众会话、线路与播放健康度 +
+ + + + + + +
+ + + +
+ +
+ + + + + +
+ + + + 节点实时指标 + + +
+ {nodes.map((node) => ( +
+
+ {node.name} + {node.online ? '在线' : '离线'} +
+
+
CPU:{node.metrics?.cpu_percent?.toFixed(1) ?? '-'}%
+
内存:{node.metrics?.memory_percent?.toFixed(1) ?? '-'}%
+
NET IN:{node.metrics?.network_in_mbps?.toFixed(2) ?? '-'} Mbps
+
NET OUT:{node.metrics?.network_out_mbps?.toFixed(2) ?? '-'} Mbps
+
观众:{node.metrics?.viewer_count ?? 0}
+
+
+ ))}
- - + + + + + 节点:保持自动 + {nodes.filter((n) => n.online).map((n) => ( + + 节点:{n.name} + + ))} + + + + +
+ + +
+ + + + + 0 && selected.size === sessions.length} + onChange={(e) => setSelected(e.target.checked ? new Set(sessions.map((s) => s.session_id)) : new Set())} + className="h-4 w-4 accent-primary" + /> + + 会话 + 节点 + 清晰度 + 状态 + 缓冲 + 远程调整 + 最后心跳 + 错误 + + + + {loading && sessions.length === 0 ? ( + + + 加载中… + + + ) : sessions.length === 0 ? ( + + + 暂无观众会话 + + + ) : ( + sessions.map((s) => ( + + + toggleSelect(s.session_id)} + className="h-4 w-4 accent-primary" + /> + + + {s.session_id.slice(0, 12)}… + + {s.node_name || '未知'} + {s.quality || '自动'} + + + {s.playback_state} + + + {(Number(s.buffered_seconds || 0)).toFixed(1)}s + {s.remote_adjusted ? 已调整 : '否'} + {new Date(s.last_heartbeat).toLocaleTimeString()} + {s.error || '-'} + + )) + )} + +
+
+
- - {!streamId ? : ( - - - - - } - > - value || '未知' }, - { title: '清晰度', dataIndex: 'quality', render: value => value || '自动' }, - { title: '状态', dataIndex: 'playback_state', render: value => {value} }, - { title: '缓冲', dataIndex: 'buffered_seconds', render: value => `${Number(value || 0).toFixed(1)}s` }, - { title: '远程调整', dataIndex: 'remote_adjusted', render: value => value ? 已调整 : '否' }, - { title: '最后心跳', dataIndex: 'last_heartbeat', render: value => new Date(value).toLocaleTimeString() }, - { title: '错误', dataIndex: 'error', ellipsis: true, render: value => value || '-' }, - ]} - /> - - )} - + )} ) -} \ No newline at end of file +} diff --git a/frontend/src/admin/pages/NodeManager.tsx b/frontend/src/admin/pages/NodeManager.tsx index 55a751d..dedd5f6 100644 --- a/frontend/src/admin/pages/NodeManager.tsx +++ b/frontend/src/admin/pages/NodeManager.tsx @@ -1,129 +1,264 @@ import { useState, useEffect } from 'react' -import { Table, Input, InputNumber, Switch, Typography, message, Tag, Statistic, Row, Col, Button, Progress } from 'antd' -import { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons' -import api from '../../api/client' +import { RefreshCw } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { Progress } from '@/components/ui/progress' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import api from '@/api/client' +import { PageHeader } from '@/components/page-header' +import { StatCard } from '@/components/stat-card' -const { Title, Text } = Typography +interface NodeMetrics { + cpu_percent: number | null + memory_percent: number | null + bandwidth_mbps: number | null + active_connections: number | null + hls_readers: number | null + network_in_mbps: number | null + network_out_mbps: number | null + viewer_count: number | null + frame_drop_indicators: number | null + packet_loss_indicators: number | null + media_metrics_available: boolean +} +interface NodeInfo { + id: string + name: string + host: string + hls_port: number + api_port: number + weight: number + enabled: boolean + subnet: string + is_origin: boolean + online: boolean + metrics: NodeMetrics | null +} +interface StrategyInfo { + strategy: string + multicast_enabled: boolean + multicast_available: boolean +} -interface NodeMetrics { cpu_percent: number | null; memory_percent: number | null; bandwidth_mbps: number | null; active_connections: number | null; hls_readers: number | null; network_in_mbps: number | null; network_out_mbps: number | null; viewer_count: number | null; frame_drop_indicators: number | null; packet_loss_indicators: number | null; media_metrics_available: boolean } -interface NodeInfo { id: string; name: string; host: string; hls_port: number; api_port: number; weight: number; enabled: boolean; subnet: string; is_origin: boolean; online: boolean; metrics: NodeMetrics | null } -interface StrategyInfo { strategy: string; multicast_enabled: boolean; multicast_available: boolean } - -const sL: Record = { 'subnet': '子网优先', 'round-robin': '轮询', 'weighted': '权重', 'least-conn': '最少连接' } +const STRATEGY_LABEL: Record = { + subnet: '子网优先', + 'round-robin': '轮询', + weighted: '权重', + 'least-conn': '最少连接', +} export default function NodeManager() { const [nodes, setNodes] = useState([]) const [strategy, setStrategy] = useState(null) const [loading, setLoading] = useState(true) - useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, []) + useEffect(() => { + void loadData() + const t = setInterval(loadData, 10000) + return () => clearInterval(t) + }, []) async function loadData() { setLoading(true) - try { const [nr, sr] = await Promise.all([api.get('/nodes'), api.get('/nodes/strategy')]); setNodes(nr.data); setStrategy(sr.data) } - catch { message.error('加载失败') } + try { + const [nr, sr] = await Promise.all([api.get('/nodes'), api.get('/nodes/strategy')]) + setNodes(nr.data) + setStrategy(sr.data) + } catch { + toast.error('加载失败') + } setLoading(false) } + async function updateWeight(nid: string, w: number) { - try { await api.put(`/nodes/${nid}`, { weight: w }); message.success('已更新') } catch { message.error('更新失败') } + try { + await api.put(`/nodes/${nid}`, { weight: w }) + toast.success('已更新') + } catch { + toast.error('更新失败') + } } + async function toggleNode(nid: string, en: boolean) { - const node = nodes.find(n => n.id === nid); if (!node) return - try { await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en }); setNodes(p => p.map(n => n.id === nid ? { ...n, enabled: en } : n)) } catch { message.error('更新失败') } + const node = nodes.find((n) => n.id === nid) + if (!node) return + try { + await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en }) + setNodes((p) => p.map((n) => (n.id === nid ? { ...n, enabled: en } : n))) + } catch { + toast.error('更新失败') + } } + async function updateName(nid: string, name: string) { const trimmed = name.trim() - if (!trimmed) { message.error('节点名称不能为空'); loadData(); return } - try { await api.put(`/nodes/${nid}`, { name: trimmed }); setNodes(p => p.map(n => n.id === nid ? { ...n, name: trimmed } : n)); message.success('名称已更新') } - catch { message.error('名称更新失败'); loadData() } + if (!trimmed) { + toast.error('节点名称不能为空') + void loadData() + return + } + try { + await api.put(`/nodes/${nid}`, { name: trimmed }) + setNodes((p) => p.map((n) => (n.id === nid ? { ...n, name: trimmed } : n))) + toast.success('名称已更新') + } catch { + toast.error('名称更新失败') + void loadData() + } } - const onlineCount = nodes.filter(n => n.online).length - - const columns = [ - { - title: '', dataIndex: 'online', key: 'online', width: 40, - render: (v: boolean) => ( -
- ), - }, - { title: '名称', dataIndex: 'name', key: 'name', width: 190, - render: (v: string, r: NodeInfo) => updateName(r.id, e.currentTarget.value)} onBlur={e => { if (e.currentTarget.value !== v) updateName(r.id, e.currentTarget.value) }} />{r.is_origin && 主节点} }, - { title: '地址', dataIndex: 'host', key: 'host', width: 130 }, - { title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => {v} }, - { title: 'CPU', key: 'cpu', width: 80, - render: (_: any, r: NodeInfo) => r.metrics?.cpu_percent != null - ? 80 ? '#B54A4A' : r.metrics.cpu_percent > 60 ? '#C28B3A' : '#C2693A'} /> - : -- }, - { title: '内存', key: 'mem', width: 80, - render: (_: any, r: NodeInfo) => r.metrics?.memory_percent != null - ? 80 ? '#B54A4A' : r.metrics.memory_percent > 60 ? '#C28B3A' : '#C2693A'} /> - : -- }, - { title: '带宽', key: 'bw', width: 85, - render: (_: any, r: NodeInfo) => r.metrics?.bandwidth_mbps != null ? {r.metrics.bandwidth_mbps.toFixed(1)} Mbps : -- }, - { title: '网络入/出', key: 'network', width: 125, - render: (_: any, r: NodeInfo) => r.metrics?.network_in_mbps != null && r.metrics?.network_out_mbps != null ? {r.metrics.network_in_mbps.toFixed(1)} / {r.metrics.network_out_mbps.toFixed(1)} Mbps : 不可用 }, - { title: '观众', key: 'viewers', width: 65, - render: (_: any, r: NodeInfo) => r.metrics?.viewer_count != null ? r.metrics.viewer_count : 不可用 }, - { title: '媒体异常', key: 'media', width: 120, - render: (_: any, r: NodeInfo) => r.metrics?.media_metrics_available ? 丢帧 {r.metrics.frame_drop_indicators ?? 0} / 丢包 {r.metrics.packet_loss_indicators ?? 0} : 不可用 }, - { title: '连接', key: 'conn', width: 60, - render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null ? r.metrics.active_connections : -- }, - { title: '权重', dataIndex: 'weight', key: 'weight', width: 120, - render: (_: number, r: NodeInfo) => { const val = v || 0; setNodes(p => p.map(n => n.id === r.id ? { ...n, weight: val } : n)); updateWeight(r.id, val) }} /> }, - { title: '启用', dataIndex: 'enabled', key: 'enabled', width: 60, - render: (_: any, r: NodeInfo) => toggleNode(r.id, v)} /> }, - ] + const onlineCount = nodes.filter((n) => n.online).length return ( -
-
- 边缘节点 - 管理 CDN 边缘节点集群,监控实时状态 -
+
+ {strategy && ( - -
-
- -
- - -
- -
- - -
- / {nodes.length}} - valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} /> -
- - -
- 0 ? '#B54A4A' : '#949088', fontSize: 20 }} - prefix={(nodes.length - onlineCount) > 0 ? : undefined} /> -
- - +
+ + + 0 ? 'success' : 'default'} + /> + 0 ? 'destructive' : 'default'} + /> +
)} -
-
- 节点列表 - -
-
- + + + 节点列表 + + + +
+
+ + + + 名称 + 地址 + 子网 + CPU + 内存 + 网络入/出 + 观众 + 权重 + 启用 + + + + {loading && nodes.length === 0 ? ( + + + 加载中… + + + ) : nodes.length === 0 ? ( + + + 暂无边缘节点 + + + ) : ( + nodes.map((n) => ( + + + + + +
+ { + if (e.currentTarget.value !== n.name) updateName(n.id, e.currentTarget.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur() + }} + /> + {n.is_origin && 主节点} +
+
+ {n.host} + + {n.subnet} + + + {n.metrics?.cpu_percent != null ? ( + + ) : ( + -- + )} + + + {n.metrics?.memory_percent != null ? ( + {n.metrics.memory_percent.toFixed(0)}% + ) : ( + -- + )} + + + {n.metrics?.network_in_mbps != null && n.metrics?.network_out_mbps != null + ? `${n.metrics.network_in_mbps.toFixed(1)} / ${n.metrics.network_out_mbps.toFixed(1)} Mbps` + : '不可用'} + + + {n.metrics?.viewer_count != null ? n.metrics.viewer_count : -} + + + { + const val = Number(e.target.value || 0) + setNodes((p) => p.map((x) => (x.id === n.id ? { ...x, weight: val } : x))) + updateWeight(n.id, val) + }} + /> + + + toggleNode(n.id, v)} /> + +
+ )) + )} +
+
+
+ +
) } diff --git a/frontend/src/admin/pages/ReplayEditor.tsx b/frontend/src/admin/pages/ReplayEditor.tsx index 9a86517..9231d82 100644 --- a/frontend/src/admin/pages/ReplayEditor.tsx +++ b/frontend/src/admin/pages/ReplayEditor.tsx @@ -1,24 +1,41 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { Alert, Button, Input, InputNumber, Segmented, Select, Space, Spin, Typography, message } from 'antd' -import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons' -import { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams' - -const { Title, Text } = Typography +import { ArrowLeft, ArrowDown, ArrowUp, Trash2, Plus } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + fetchMediaFiles, + getStream, + updateStream, + type Chapter, + type StreamInfo, +} from '@/api/streams' +import VideoPlayer, { VideoPlayerHandle } from '@/viewer/components/VideoPlayer' +import { PageHeader } from '@/components/page-header' export default function ReplayEditor() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() - const videoRef = useRef(null) + const playerRef = useRef(null) const [stream, setStream] = useState(null) const [mediaFiles, setMediaFiles] = useState([]) const [replaySource, setReplaySource] = useState<'local' | 'remote'>('local') const [replayFile, setReplayFile] = useState() const [replayUrl, setReplayUrl] = useState('') const [previewUrl, setPreviewUrl] = useState() - const [previewLoading, setPreviewLoading] = useState(false) const [previewError, setPreviewError] = useState('') - const previewObjectUrlRef = useRef() const [chapters, setChapters] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -35,66 +52,27 @@ export default function ReplayEditor() { setChapters(program.chapters || []) setMediaFiles(files) }) - .catch(() => message.error('无法载入节目回放配置')) + .catch(() => toast.error('无法载入节目回放配置')) .finally(() => setLoading(false)) }, [id]) useEffect(() => { - if (previewObjectUrlRef.current) { - URL.revokeObjectURL(previewObjectUrlRef.current) - previewObjectUrlRef.current = undefined - } - setPreviewUrl(undefined) setPreviewError('') - - if (replaySource === 'local') { - setPreviewLoading(false) - if (stream && replayFile) { - setPreviewUrl(`/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}`) - } + if (!stream) { + setPreviewUrl(undefined) return } - - const remoteUrl = replayUrl.trim() - if (!remoteUrl) { - setPreviewLoading(false) - return - } - - const controller = new AbortController() - let disposed = false - setPreviewLoading(true) - void fetch(remoteUrl, { signal: controller.signal }) - .then(response => { - if (!response.ok) throw new Error(`HTTP ${response.status}`) - return response.blob() - }) - .then(blob => { - if (disposed) return - const objectUrl = URL.createObjectURL(blob) - previewObjectUrlRef.current = objectUrl - setPreviewUrl(objectUrl) - }) - .catch(error => { - if (error instanceof DOMException && error.name === 'AbortError') return - if (!disposed) setPreviewError('远端回放拉取失败,请检查地址及其 CORS 配置') - }) - .finally(() => { - if (!disposed) setPreviewLoading(false) - }) - - return () => { - disposed = true - controller.abort() - if (previewObjectUrlRef.current) { - URL.revokeObjectURL(previewObjectUrlRef.current) - previewObjectUrlRef.current = undefined - } + if (replaySource === 'local' && replayFile) { + setPreviewUrl(`/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}`) + } else if (replaySource === 'remote' && replayUrl.trim()) { + setPreviewUrl(replayUrl.trim()) + } else { + setPreviewUrl(undefined) } }, [replaySource, replayFile, replayUrl, stream]) const move = (index: number, offset: number) => { - setChapters(current => { + setChapters((current) => { const target = index + offset if (target < 0 || target >= current.length) return current const next = [...current] @@ -104,74 +82,175 @@ export default function ReplayEditor() { } const addChapter = () => { - const time = Math.max(0, Math.round((videoRef.current?.currentTime || 0) * 10) / 10) - setChapters(current => [...current, { title: `章节 ${current.length + 1}`, time }]) + const video = playerRef.current?.getVideo() + const time = Math.max(0, Math.round((video?.currentTime || 0) * 10) / 10) + setChapters((current) => [...current, { title: `章节 ${current.length + 1}`, time }]) } const save = async () => { - if (!stream || !replayFile) return message.warning('请先选择回放文件') + if (!stream) return + const remoteUrl = replayUrl.trim() + if (replaySource === 'local' && !replayFile) return toast.warning('请先选择回放文件') + if (replaySource === 'remote' && !remoteUrl) return toast.warning('请输入外部回放 URL') + if (replaySource === 'remote' && !/^https?:\/\//i.test(remoteUrl)) { + return toast.warning('外部回放 URL 必须以 http:// 或 https:// 开头') + } setSaving(true) try { - await updateStream(stream.id, { replay_file: replayFile, chapters }) - message.success('回放和章节已保存') + await updateStream(stream.id, { + replay_file: replaySource === 'local' ? replayFile : null, + replay_url: replaySource === 'remote' ? remoteUrl : null, + chapters, + }) + toast.success('回放和章节已保存') } catch { - message.error('保存失败,请检查章节标题、时间和媒体文件') + toast.error('保存失败,请检查回放来源、URL 和章节内容') } finally { setSaving(false) } } - if (loading) return
- if (!stream) return + if (loading) { + return ( +
+
+
+ ) + } + if (!stream) return 节目不存在 + + const isEditable = stream.status === 'ended' return ( -
- - 回放与章节 - {stream.title} - {stream.status !== 'ended' ? ( - +
+ + + + {!isEditable ? ( + + 不可编辑 + 节目结束后才能配置回放和章节 + ) : ( - - + + + + + {mediaFiles.length === 0 && ( +
媒体目录中暂无可用视频文件
+ )} + {mediaFiles.map((file) => ( + + {file} + + ))} +
+ + ) : ( setChapters(current => current.map((item, i) => i === index ? { ...item, title: event.target.value } : item))} - style={{ width: 320, maxWidth: '100%', flex: '1 1 220px' }} + value={replayUrl} + placeholder="https://example.com/path/replay.mp4" + onChange={(e) => setReplayUrl(e.target.value)} /> - setChapters(current => current.map((item, i) => i === index ? { ...item, time: Number(value || 0) } : item))} - /> - -
+ )} + + {replaySource === 'remote' && ( + + 跨域提示 + 外部地址需允许浏览器跨域访问(CORS),播放器将直接流式加载。 + + )} + + {previewError && ( + + 播放出错 + {previewError} + + )} + + {}} + onQualityChange={() => {}} + onError={setPreviewError} + /> + + + + + + {chapters.length > 0 && ( + + + 章节列表 + + + {chapters.map((chapter, index) => ( +
+ + setChapters((cur) => cur.map((item, i) => (i === index ? { ...item, title: e.target.value } : item))) + } + className="flex-1" + /> + + setChapters((cur) => cur.map((item, i) => (i === index ? { ...item, time: Number(e.target.value || 0) } : item))) + } + className="w-28" + /> + + + +
+ ))} +
+
+ )} + + +
)}
) diff --git a/frontend/src/admin/pages/Settings.css b/frontend/src/admin/pages/Settings.css deleted file mode 100644 index 3cd1e1b..0000000 --- a/frontend/src/admin/pages/Settings.css +++ /dev/null @@ -1,11 +0,0 @@ -.settings-page { width: 100%; max-width: 960px; min-width: 0; } -.settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: linear-gradient(145deg, rgba(26, 33, 47, .78), rgba(12, 17, 27, .72)); border: 1px solid rgba(255, 255, 255, .095); border-radius: 18px; box-shadow: 0 18px 48px rgba(0, 0, 0, .26); backdrop-filter: blur(24px) saturate(135%); } -.profile-block { display: grid; gap: 12px; margin: 20px 0; min-width: 0; } -.quality-row { display: grid; grid-template-columns: minmax(130px, 1.4fr) repeat(2, minmax(115px, .8fr)) minmax(120px, 1fr) auto; gap: 10px; align-items: center; min-width: 0; } -.quality-row > * { min-width: 0; max-width: 100%; } -@media (max-width: 720px) { - .settings-card { padding: 16px; } - .quality-row { grid-template-columns: 1fr 1fr; } - .quality-row > :first-child, .quality-row > :nth-child(4), .quality-row > :last-child { grid-column: 1 / -1; width: 100%; } - .settings-page .ant-select, .settings-page .ant-input-number { width: 100% !important; } -} \ No newline at end of file diff --git a/frontend/src/admin/pages/Settings.tsx b/frontend/src/admin/pages/Settings.tsx index 0842c4e..555642b 100644 --- a/frontend/src/admin/pages/Settings.tsx +++ b/frontend/src/admin/pages/Settings.tsx @@ -1,15 +1,27 @@ import { useEffect, useState } from 'react' -import { Button, Form, Input, InputNumber, Select, Switch, Typography, message } from 'antd' -import api from '../../api/client' -import './Settings.css' -import { useBrand } from '../../brand/BrandContext' - -const { Title, Text } = Typography +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { Trash2, Plus } from 'lucide-react' +import api from '@/api/client' +import { useBrand } from '@/brand/BrandContext' +import { PageHeader } from '@/components/page-header' type Quality = { label: string; width: number; height: number; bitrate: string } type Profiles = Record -const strategies = [ +const STRATEGIES = [ { value: 'subnet', label: '子网优先(推荐)' }, { value: 'weighted', label: '权重均衡' }, { value: 'round-robin', label: '轮询' }, @@ -35,7 +47,7 @@ export default function SettingsPage() { setMulticastAvailable(nodes.data.multicast_available) setProfiles(transcode.data.profiles || {}) }) - .catch(() => message.error('系统设置加载失败')) + .catch(() => toast.error('系统设置加载失败')) }, []) useEffect(() => { @@ -44,20 +56,25 @@ export default function SettingsPage() { setBrandLogoUrl(brand.brand_logo_url) }, [brand]) - function replaceProfile(profile: string, qualities: Quality[]) { - setProfiles(current => Object.assign({}, current, Object.fromEntries([[profile, qualities]]))) - } - function updateQuality(profile: string, index: number, patch: Partial) { - replaceProfile(profile, profiles[profile].map((quality, i) => i === index ? { ...quality, ...patch } : quality)) + setProfiles((current) => ({ + ...current, + [profile]: current[profile].map((quality, i) => (i === index ? { ...quality, ...patch } : quality)), + })) } function addQuality(profile: string) { - replaceProfile(profile, [...profiles[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }]) + setProfiles((current) => ({ + ...current, + [profile]: [...current[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }], + })) } function removeQuality(profile: string, index: number) { - replaceProfile(profile, profiles[profile].filter((_, i) => i !== index)) + setProfiles((current) => ({ + ...current, + [profile]: current[profile].filter((_, i) => i !== index), + })) } async function saveAll() { @@ -69,59 +86,130 @@ export default function SettingsPage() { api.put('/settings/transcode-profiles', { profiles }), ]) await refresh() - message.success('设置已保存') + toast.success('设置已保存') } catch { - message.error('保存失败,请检查转码参数') + toast.error('保存失败,请检查转码参数') } finally { setSaving(false) } } return ( -
- 系统设置 - 配置分发策略、转码清晰度与平台品牌 +
+ -
- 负载均衡 -
- updateQuality(profile, index, { label: e.target.value })} /> - updateQuality(profile, index, { width: v || 160 })} /> - updateQuality(profile, index, { height: v || 90 })} /> - updateQuality(profile, index, { bitrate: e.target.value })} /> - -
- ))} - + + + 负载均衡 + + +
+ +
- ))} - +
+
+ +

{multicastAvailable ? '可用' : '不可用,将回退至单播'}

+
+ +
+
+
-
- 品牌设置 - - setBrandName(e.target.value)} /> - setBrandTagline(e.target.value)} /> - setBrandLogoUrl(e.target.value)} /> - -
+ + + 自定义转码配置 + + + {Object.entries(profiles).map(([profile, qualities]) => ( +
+
+ {profile} + +
+ {qualities.map((quality, index) => ( +
+ updateQuality(profile, index, { label: e.target.value })} + className="w-32" + /> + updateQuality(profile, index, { width: Number(e.target.value || 160) })} + className="w-24" + /> + updateQuality(profile, index, { height: Number(e.target.value || 90) })} + className="w-24" + /> + updateQuality(profile, index, { bitrate: e.target.value })} + className="w-28" + /> + +
+ ))} + +
+ ))} +
+
- + + + 品牌设置 + + +
+ + setBrandName(e.target.value)} /> +
+
+ + setBrandTagline(e.target.value)} /> +
+
+ + setBrandLogoUrl(e.target.value)} /> +
+
+
+ +
) } diff --git a/frontend/src/admin/pages/StreamForm.tsx b/frontend/src/admin/pages/StreamForm.tsx index b8d2f29..f61fafc 100644 --- a/frontend/src/admin/pages/StreamForm.tsx +++ b/frontend/src/admin/pages/StreamForm.tsx @@ -1,151 +1,266 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Form, Input, InputNumber, Button, DatePicker, Typography, message, Space, Alert, Select, Switch, Card, Row, Col } from 'antd' -import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons' -import { createStream } from '../../api/streams' +import { Copy, ArrowLeft, CheckCircle2, Plus, Trash2 } from 'lucide-react' +import { toast } from 'sonner' import axios from 'axios' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Switch } from '@/components/ui/switch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { createStream, type RenditionConfig } from '@/api/streams' -const { Title, Text } = Typography -const { TextArea } = Input +const DEFAULT_RENDITIONS: RenditionConfig[] = [ + { label: '1080p', enabled: true, width: 1920, height: 1080, fps: 30, codec: 'libx264', bitrate: '5000k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 }, + { label: '720p', enabled: true, width: 1280, height: 720, fps: 30, codec: 'libx264', bitrate: '2500k', crf: 23, preset: 'veryfast', audio_bitrate: '128k', keyframe_seconds: 2 }, +] + +const PRESETS = ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow'] export default function StreamForm() { + const navigate = useNavigate() + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [coverUrl, setCoverUrl] = useState('') + const [scheduledAt, setScheduledAt] = useState('') + const [transcodeProfile, setTranscodeProfile] = useState('standard') + const [homepageVisible, setHomepageVisible] = useState(true) + const [remoteControl, setRemoteControl] = useState(false) + const [renditions, setRenditions] = useState(DEFAULT_RENDITIONS) const [loading, setLoading] = useState(false) const [result, setResult] = useState<{ rtmp_url: string; stream_key: string } | null>(null) - const navigate = useNavigate() - const handleSubmit = async (values: any) => { + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!title.trim()) { + toast.error('请输入节目标题') + return + } setLoading(true) try { const res = await createStream({ - title: values.title, - description: values.description, - cover_url: values.cover_url, - scheduled_at: values.scheduled_at?.toISOString(), - transcode_profile: values.transcode_profile, - homepage_visible: values.homepage_visible, - remote_control_enabled: values.remote_control_enabled, - renditions: values.renditions, + title: title.trim(), + description: description || undefined, + cover_url: coverUrl || undefined, + scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : undefined, + transcode_profile: transcodeProfile, + homepage_visible: homepageVisible, + remote_control_enabled: remoteControl, + renditions, }) setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key }) - message.success('直播已创建') + toast.success('直播已创建') } catch (error) { const detail = axios.isAxiosError(error) ? error.response?.data?.detail : undefined - message.error(typeof detail === 'string' ? detail : '创建失败,请稍后重试') + toast.error(typeof detail === 'string' ? detail : '创建失败,请稍后重试') } finally { setLoading(false) } } + const updateRendition = (index: number, patch: Partial) => { + setRenditions((cur) => cur.map((r, i) => (i === index ? { ...r, ...patch } : r))) + } + if (result) { return ( -
-
- - 直播已创建 - 将以下信息提供给推流端即可开始直播 +
+
+ +

直播已创建

+

将以下信息提供给推流端即可开始直播

-
- + +
- 推流码 -
- {result.stream_key} -
+ +
{result.stream_key}
- RTMP 推流地址 -
- {result.rtmp_url} -
+ +
{result.rtmp_url}
-
-
+ + - - - - +
+ + +
) } return ( -
- +
+ -
- 创建节目 - - 填写节目信息并预约播出,系统将生成推流码与 RTMP 地址 - + + + 创建节目 +

填写节目信息并预约播出,系统将生成推流码与 RTMP 地址

+
+ +
+
+ + setTitle(e.target.value)} placeholder="输入节目标题" /> +
+
+ +