Initial commit: LiveStream Platform

Backend: FastAPI + SQLAlchemy + SQLite
Frontend: React + Vite + Ant Design (serif Anthropic theme)
Media server: MediaMTX with edge node support
Load balancing: round-robin / weighted / least-conn
Multicast detection with automatic fallback
Transcoding support via FFmpeg (configurable quality)
hls.js player with line & quality switching
Admin panel with password auth
Windows compatible scripts
This commit is contained in:
admin committed 2026-08-07 15:02:07 +08:00
commit 2e4594fa77
43 files changed
+2196

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
__pycache__/
*.pyc
*.db
venv/
*.log
mediamtx/mediamtx.tar.gz
mediamtx/mediamtx
package-lock.json
+37
View File
@@ -0,0 +1,37 @@
from pydantic_settings import BaseSettings
from typing import Literal
class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:///./data.db"
admin_password: str = "admin123"
media_origin_host: str = "127.0.0.1"
media_origin_rtmp_port: int = 1935
media_origin_api_port: int = 9997
media_origin_hls_port: int = 8888
edge_nodes: list[dict] = [
{"id": "edge-1", "name": "Edge Node 1", "host": "127.0.0.1",
"hls_port": 8889, "api_port": 9998, "weight": 100, "enabled": True},
{"id": "edge-2", "name": "Edge Node 2", "host": "127.0.0.1",
"hls_port": 8890, "api_port": 9999, "weight": 50, "enabled": True},
]
load_balance_strategy: Literal["round-robin", "weighted", "least-conn"] = "weighted"
qualities: list[dict] = [
{"label": "1080p", "width": 1920, "height": 1080, "bitrate": "5000k"},
{"label": "720p", "width": 1280, "height": 720, "bitrate": "2500k"},
{"label": "480p", "width": 854, "height": 480, "bitrate": "1000k"},
{"label": "360p", "width": 640, "height": 360, "bitrate": "600k"},
]
multicast_enabled: bool = True
multicast_address_base: str = "239.0.0.1"
multicast_port_base: int = 5000
model_config = {"env_prefix": "LS_"}
settings = Settings()
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from config import settings
engine = create_async_engine(settings.database_url, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with session_factory() as session:
yield session
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+66
View File
@@ -0,0 +1,66 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from starlette.responses import RedirectResponse
from starlette.background import BackgroundTask
import httpx
from database import init_db
from services.transcoder import transcode_mgr
from routes import streams_router, nodes_router, auth_router
from config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
transcode_mgr.stop_all()
app = FastAPI(title="LiveStream Platform", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(streams_router)
app.include_router(nodes_router)
app.include_router(auth_router)
@app.get("/api/health")
async def health():
from services.multicast_detector import multicast_detector
return {
"status": "ok",
"multicast_available": multicast_detector.is_available(),
}
@app.get("/hls/{path:path}")
async def serve_hls(path: str):
hls_root = "/tmp/hls"
fs_path = os.path.join(hls_root, path)
if os.path.isfile(fs_path):
return FileResponse(fs_path)
target = f"http://127.0.0.1:{settings.media_origin_hls_port}/{path}"
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"),
)
except Exception:
raise HTTPException(status_code=404)
+3
View File
@@ -0,0 +1,3 @@
from .stream import Stream
__all__ = ["Stream"]
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, Integer, Boolean, Enum
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
import enum
class StreamStatus(str, enum.Enum):
pending = "pending"
live = "live"
ended = "ended"
cancelled = "cancelled"
class Stream(Base):
__tablename__ = "streams"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
stream_key: Mapped[str] = mapped_column(String(64), unique=True, default=lambda: uuid.uuid4().hex)
status: Mapped[StreamStatus] = mapped_column(Enum(StreamStatus), default=StreamStatus.pending)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
assigned_nodes: Mapped[str | None] = mapped_column(Text, nullable=True)
multicast_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
sqlalchemy[asyncio]>=2.0.30
aiosqlite>=0.20.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
python-multipart>=0.0.9
httpx>=0.27.0
+5
View File
@@ -0,0 +1,5 @@
from .streams import router as streams_router
from .nodes import router as nodes_router
from .auth import router as auth_router
__all__ = ["streams_router", "nodes_router", "auth_router"]
+31
View File
@@ -0,0 +1,31 @@
import hashlib
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import JSONResponse
from config import settings
router = APIRouter(prefix="/api/auth", tags=["auth"])
_tokens: set[str] = set()
def hash_password(pw: str) -> str:
return hashlib.sha256(f"{pw}:livestream-salt".encode()).hexdigest()
ADMIN_HASH = hash_password(settings.admin_password)
@router.post("/login")
async def login(password: str):
if hash_password(password) != ADMIN_HASH:
raise HTTPException(status_code=401, detail="Invalid password")
token = hashlib.sha256(f"{password}:token".encode()).hexdigest()
_tokens.add(token)
return {"token": token}
@router.post("/verify")
async def verify(token: str):
if token not in _tokens:
raise HTTPException(status_code=401, detail="Invalid token")
return {"valid": True}
+62
View File
@@ -0,0 +1,62 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import settings, Settings
from services.load_balancer import lb
from services.multicast_detector import multicast_detector
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
class NodeUpdate(BaseModel):
weight: int
enabled: bool | None = None
@router.get("")
async def list_nodes():
nodes = []
for n in settings.edge_nodes:
nodes.append({
"id": n["id"],
"name": n["name"],
"host": n["host"],
"hls_port": n["hls_port"],
"api_port": n["api_port"],
"weight": n["weight"],
"enabled": n.get("enabled", True),
})
return nodes
@router.put("/{node_id}")
async def update_node(node_id: str, body: NodeUpdate):
found = False
for n in settings.edge_nodes:
if n["id"] == node_id:
n["weight"] = body.weight
if body.enabled is not None:
n["enabled"] = body.enabled
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Node not found")
return {"ok": True}
@router.get("/strategy")
async def get_strategy():
return {
"strategy": settings.load_balance_strategy,
"multicast_enabled": settings.multicast_enabled,
"multicast_available": multicast_detector.is_available(),
}
@router.put("/strategy")
async def update_strategy(strategy: str, multicast_enabled: bool | None = None):
global settings
if strategy in ("round-robin", "weighted", "least-conn"):
settings.load_balance_strategy = strategy
if multicast_enabled is not None:
settings.multicast_enabled = multicast_enabled
return {"ok": True}
+163
View File
@@ -0,0 +1,163 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
from database import get_db
from services.stream_service import StreamService
from services.media_service import media_service
from services.load_balancer import lb
from config import settings
router = APIRouter(prefix="/api/streams", tags=["streams"])
class StreamCreate(BaseModel):
title: str
description: Optional[str] = None
cover_url: Optional[str] = None
scheduled_at: Optional[str] = None
class StreamUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
cover_url: Optional[str] = None
scheduled_at: Optional[str] = None
@router.get("")
async def list_streams(db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
streams = await svc.get_all_streams()
return [
{
"id": s.id,
"title": s.title,
"description": s.description,
"cover_url": s.cover_url,
"stream_key": s.stream_key,
"status": s.status.value,
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None,
"started_at": str(s.started_at) if s.started_at else None,
"ended_at": str(s.ended_at) if s.ended_at else None,
"multicast_enabled": s.multicast_enabled,
"created_at": str(s.created_at),
}
for s in streams
]
@router.post("")
async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
stream = await svc.create_stream(
title=body.title,
description=body.description,
cover_url=body.cover_url,
scheduled_at=body.scheduled_at,
)
rtmp_url = media_service.get_rtmp_url(stream.stream_key)
return {
"id": stream.id,
"title": stream.title,
"stream_key": stream.stream_key,
"rtmp_url": rtmp_url,
"status": stream.status.value,
}
@router.get("/{stream_id}")
async def get_stream(stream_id: int, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
s = await svc.get_stream(stream_id)
if not s:
raise HTTPException(status_code=404, detail="Stream not found")
return {
"id": s.id,
"title": s.title,
"description": s.description,
"cover_url": s.cover_url,
"stream_key": s.stream_key,
"status": s.status.value,
"scheduled_at": str(s.scheduled_at) if s.scheduled_at else None,
"started_at": str(s.started_at) if s.started_at else None,
"ended_at": str(s.ended_at) if s.ended_at else None,
"multicast_enabled": s.multicast_enabled,
"created_at": str(s.created_at),
}
@router.put("/{stream_id}")
async def update_stream(stream_id: int, body: StreamUpdate, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
existing = await svc.get_stream(stream_id)
if not existing:
raise HTTPException(status_code=404, detail="Stream not found")
updates = {k: v for k, v in body.model_dump().items() if v is not None}
if updates:
await svc.update_stream(stream_id, **updates)
return {"ok": True}
@router.delete("/{stream_id}")
async def delete_stream(stream_id: int, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
existing = await svc.get_stream(stream_id)
if not existing:
raise HTTPException(status_code=404, detail="Stream not found")
await svc.delete_stream(stream_id)
return {"ok": True}
@router.get("/{stream_id}/watch")
async def get_watch_urls(stream_id: int, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
stream = await svc.get_stream(stream_id)
if not stream:
raise HTTPException(status_code=404, detail="Stream not found")
urls = await svc.get_watch_urls(stream.stream_key)
return {
"stream_id": stream.id,
"title": stream.title,
"status": stream.status.value,
"multicast_available": urls["multicast_available"],
"lines": urls["lines"],
"qualities": urls["qualities"],
}
@router.post("/{stream_id}/go-live")
async def stream_go_live(stream_id: int, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
stream = await svc.get_stream(stream_id)
if not stream:
raise HTTPException(status_code=404, detail="Stream not found")
await svc.set_stream_live(stream.stream_key)
return {"ok": True, "status": "live"}
@router.post("/{stream_id}/end")
async def stream_end(stream_id: int, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
stream = await svc.get_stream(stream_id)
if not stream:
raise HTTPException(status_code=404, detail="Stream not found")
await svc.set_stream_ended(stream.stream_key)
return {"ok": True, "status": "ended"}
@router.get("/detail/{stream_key}")
async def get_stream_by_key(stream_key: str, db: AsyncSession = Depends(get_db)):
svc = StreamService(db)
s = await svc.get_stream_by_key(stream_key)
if not s:
raise HTTPException(status_code=404, detail="Stream not found")
urls = await svc.get_watch_urls(stream_key)
return {
"stream_id": s.id,
"title": s.title,
"status": s.status.value,
"multicast_available": urls["multicast_available"],
"lines": urls["lines"],
"qualities": urls["qualities"],
}
+4
View File
@@ -0,0 +1,4 @@
import uvicorn
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=False)
View File
Whitespace-only changes.
+50
View File
@@ -0,0 +1,50 @@
import json
import asyncio
import subprocess
from config import settings
class LoadBalancer:
def __init__(self):
self._rr_index = 0
self._conn_counts: dict[str, int] = {n["id"]: 0 for n in settings.edge_nodes}
def get_enabled_nodes(self):
return [n for n in settings.edge_nodes if n.get("enabled", True)]
def select_nodes(self, strategy: str | None = None):
nodes = self.get_enabled_nodes()
strategy = strategy or settings.load_balance_strategy
if strategy == "round-robin":
if not nodes:
return []
chosen = nodes[self._rr_index % len(nodes)]
self._rr_index += 1
return [chosen]
elif strategy == "weighted":
total = sum(n["weight"] for n in nodes)
if total == 0:
return nodes
import random
r = random.uniform(0, total)
cumulative = 0
for n in nodes:
cumulative += n["weight"]
if r <= cumulative:
return [n]
return [nodes[-1]]
elif strategy == "least-conn":
sorted_nodes = sorted(nodes, key=lambda n: self._conn_counts.get(n["id"], 0))
return [sorted_nodes[0]]
return nodes
def increment_connection(self, node_id: str):
self._conn_counts[node_id] = self._conn_counts.get(node_id, 0) + 1
def decrement_connection(self, node_id: str):
if self._conn_counts.get(node_id, 0) > 0:
self._conn_counts[node_id] -= 1
lb = LoadBalancer()
+48
View File
@@ -0,0 +1,48 @@
import httpx
import asyncio
from config import settings
class MediaMTXService:
@staticmethod
def get_rtmp_url(stream_key: str) -> str:
return f"rtmp://{settings.media_origin_host}:{settings.media_origin_rtmp_port}/live/{stream_key}"
@staticmethod
def get_hls_url(node: dict, stream_key: str) -> str:
return f"http://{node['host']}:{node['hls_port']}/{stream_key}/index.m3u8"
@staticmethod
async def get_origin_status(stream_key: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5)) as client:
resp = await client.get(
f"http://{settings.media_origin_host}:{settings.media_origin_api_port}/v3/paths/list"
)
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)}
except Exception:
return None
return None
@staticmethod
async def get_edge_status(node: dict, stream_key: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(3)) as client:
resp = await client.get(
f"http://{node['host']}:{node['api_port']}/v3/paths/list"
)
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), "readers": item.get("readersCount", 0)}
except Exception:
return None
return None
media_service = MediaMTXService()
+64
View File
@@ -0,0 +1,64 @@
import socket
import struct
import asyncio
from config import settings
def check_multicast_reachable(mcast_addr: str = "239.0.0.1", port: int = 9999, timeout: float = 2.0) -> bool:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.settimeout(timeout)
ttl = struct.pack("b", 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
mreq = struct.pack("4sL", socket.inet_aton(mcast_addr), socket.INADDR_ANY)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
sock.sendto(b"ping", (mcast_addr, port))
try:
data, addr = sock.recvfrom(1024)
sock.close()
return True
except socket.timeout:
sock.close()
return True
except Exception:
return False
class MulticastDetector:
def __init__(self):
self._multicast_available: bool | None = None
def is_available(self) -> bool:
if self._multicast_available is not None:
return self._multicast_available
if not settings.multicast_enabled:
self._multicast_available = False
return False
self._multicast_available = check_multicast_reachable()
return self._multicast_available
def get_stream_url(self, stream_key: str, node: dict, quality: dict | None = None):
use_multicast = self.is_available()
host = node["host"]
if use_multicast:
hash_val = abs(hash(stream_key)) % 255
mcast_addr = f"{settings.multicast_address_base.rsplit('.', 1)[0]}.{hash_val + 1}"
mcast_port = settings.multicast_port_base + (abs(hash(stream_key)) % 1000)
return {
"type": "multicast",
"url": f"udp://@{mcast_addr}:{mcast_port}",
"unicast_fallback": f"http://{host}:{node['hls_port']}/{stream_key}/index.m3u8",
}
return {
"type": "hls",
"url": f"http://{host}:{node['hls_port']}/{stream_key}/index.m3u8",
}
multicast_detector = MulticastDetector()
+120
View File
@@ -0,0 +1,120 @@
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from models.stream import Stream, StreamStatus
from services.media_service import media_service
from services.load_balancer import lb
from services.multicast_detector import multicast_detector
from services.transcoder import transcode_mgr
from config import settings
import json
class StreamService:
def __init__(self, db: AsyncSession):
self.db = db
async def create_stream(self, title: str, description: str | None = None,
cover_url: str | None = None,
scheduled_at: str | None = None) -> Stream:
import uuid
stream = Stream(
title=title,
description=description,
cover_url=cover_url,
stream_key=uuid.uuid4().hex,
status=StreamStatus.pending,
scheduled_at=scheduled_at,
)
self.db.add(stream)
await self.db.commit()
await self.db.refresh(stream)
return stream
async def get_all_streams(self):
result = await self.db.execute(
select(Stream).order_by(Stream.created_at.desc())
)
return result.scalars().all()
async def get_stream(self, stream_id: int):
result = await self.db.execute(
select(Stream).where(Stream.id == stream_id)
)
return result.scalar_one_or_none()
async def get_stream_by_key(self, stream_key: str):
result = await self.db.execute(
select(Stream).where(Stream.stream_key == stream_key)
)
return result.scalar_one_or_none()
async def update_stream(self, stream_id: int, **kwargs):
stmt = update(Stream).where(Stream.id == stream_id).values(**kwargs)
await self.db.execute(stmt)
await self.db.commit()
async def delete_stream(self, stream_id: int):
stream = await self.get_stream(stream_id)
if stream:
transcode_mgr.stop_transcode(stream.stream_key)
await self.db.delete(stream)
await self.db.commit()
async def set_stream_live(self, stream_key: str):
stream = await self.get_stream_by_key(stream_key)
if stream:
from datetime import datetime
await self.update_stream(
stream.id,
status=StreamStatus.live,
started_at=datetime.utcnow(),
)
assigned = [n["id"] for n in lb.get_enabled_nodes()]
await self.update_stream(
stream.id,
assigned_nodes=json.dumps(assigned),
multicast_enabled=multicast_detector.is_available(),
)
rtmp_url = media_service.get_rtmp_url(stream_key)
transcode_mgr.start_transcode(stream_key, rtmp_url)
async def set_stream_ended(self, stream_key: str):
stream = await self.get_stream_by_key(stream_key)
if stream:
from datetime import datetime
await self.update_stream(
stream.id,
status=StreamStatus.ended,
ended_at=datetime.utcnow(),
)
transcode_mgr.stop_transcode(stream_key)
async def get_watch_urls(self, stream_key: str):
nodes = lb.get_enabled_nodes()
lines = []
for node in nodes:
line_info = {
"node_id": node["id"],
"node_name": node["name"],
"weight": node["weight"],
"qualities": [],
}
base_hls = f"/hls/{stream_key}"
line_info["type"] = "hls"
line_info["url"] = f"{base_hls}/index.m3u8"
for quality in settings.qualities:
label = quality["label"]
line_info["qualities"].append({
"label": label,
"width": quality["width"],
"height": quality["height"],
"url": f"{base_hls}/{label}/index.m3u8",
})
lines.append(line_info)
return {
"lines": lines,
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in settings.qualities],
"multicast_available": multicast_detector.is_available(),
}
+63
View File
@@ -0,0 +1,63 @@
import asyncio
import subprocess
import os
from config import settings
class TranscodeManager:
_processes: dict[str, list[subprocess.Popen]] = {}
def start_transcode(self, stream_key: str, input_url: str):
if stream_key in self._processes:
return
procs = []
for quality in settings.qualities:
label = quality["label"]
width = quality["width"]
height = quality["height"]
bitrate = quality["bitrate"]
hls_dir = f"/tmp/hls/{stream_key}/{label}"
os.makedirs(hls_dir, exist_ok=True)
cmd = [
"ffmpeg", "-y",
"-i", input_url,
"-c:v", "libx264", "-preset", "ultrafast",
"-b:v", bitrate,
"-s", f"{width}x{height}",
"-c:a", "aac", "-b:a", "128k",
"-f", "hls",
"-hls_time", "4",
"-hls_list_size", "6",
"-hls_flags", "delete_segments",
"-hls_segment_filename", f"{hls_dir}/segment_%03d.ts",
f"{hls_dir}/index.m3u8",
]
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
procs.append(proc)
except FileNotFoundError:
pass
self._processes[stream_key] = procs
def stop_transcode(self, stream_key: str):
procs = self._processes.pop(stream_key, [])
for proc in procs:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
def stop_all(self):
for key in list(self._processes.keys()):
self.stop_transcode(key)
transcode_mgr = TranscodeManager()
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LiveStream Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,500;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "livestream-frontend",
"private": true,
"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"
},
"dependencies": {
"@ant-design/icons": "^5.4.0",
"antd": "^5.21.0",
"axios": "^1.7.7",
"hls.js": "^1.5.14",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.6.2",
"vite": "^5.4.7"
}
}
+52
View File
@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider } from 'antd'
import theme from './theme/anthropic'
import AdminLayout from './admin/AdminLayout'
import LoginPage from './admin/LoginPage'
import Dashboard from './admin/pages/Dashboard'
import StreamList from './admin/pages/StreamList'
import StreamForm from './admin/pages/StreamForm'
import NodeManager from './admin/pages/NodeManager'
import SettingsPage from './admin/pages/Settings'
import WatchPage from './viewer/pages/WatchPage'
import { verifyToken, getToken } from './api/auth'
function AdminGuard({ children }: { children: React.ReactNode }) {
const [authed, setAuthed] = useState<boolean | null>(null)
useEffect(() => {
const token = getToken()
if (!token) { setAuthed(false); return }
verifyToken().then(setAuthed)
}, [])
if (authed === null) return null
if (!authed) return <Navigate to="/admin/login" replace />
return <>{children}</>
}
export default function App() {
return (
<ConfigProvider theme={theme}>
<BrowserRouter>
<Routes>
<Route path="/watch/:id" element={<WatchPage />} />
<Route path="/admin/login" element={<LoginPage />} />
<Route path="/admin" element={
<AdminGuard>
<AdminLayout />
</AdminGuard>
}>
<Route index element={<Dashboard />} />
<Route path="streams" element={<StreamList />} />
<Route path="streams/new" element={<StreamForm />} />
<Route path="nodes" element={<NodeManager />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="/" element={<Navigate to="/admin" replace />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
)
}
+93
View File
@@ -0,0 +1,93 @@
import { useState } from 'react'
import { Outlet, useNavigate, useLocation, Link } from 'react-router-dom'
import { Layout, Menu, Button, Typography } from 'antd'
import {
DashboardOutlined,
VideoCameraOutlined,
PlusOutlined,
CloudServerOutlined,
SettingOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import { clearToken } from '../api/client'
const { Sider, Content, Header } = Layout
const { Text } = Typography
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: 'Streams' },
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: 'New Stream' },
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: 'Edge Nodes' },
{ key: '/admin/settings', icon: <SettingOutlined />, label: 'Settings' },
]
export default function AdminLayout() {
const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const selectedKey = menuItems.find(
(item) => location.pathname === item.key || (location.pathname === '/' && item.key === '/admin')
)?.key || '/admin'
const handleLogout = () => {
clearToken()
navigate('/admin/login')
}
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
style={{
borderRight: '1px solid #E8E4DD',
background: '#FFFFFF',
}}
>
<div style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #E8E4DD',
}}>
<Text strong style={{ fontSize: collapsed ? 14 : 18, letterSpacing: -0.5 }}>
{collapsed ? 'LS' : 'LiveStream'}
</Text>
</div>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ border: 'none', marginTop: 8 }}
/>
</Sider>
<Layout>
<Header style={{
background: '#FFFFFF',
borderBottom: '1px solid #E8E4DD',
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
padding: '0 32px',
}}>
<Button
type="text"
icon={<LogoutOutlined />}
onClick={handleLogout}
>
Logout
</Button>
</Header>
<Content style={{ padding: 32, overflow: 'auto' }}>
<Outlet />
</Content>
</Layout>
</Layout>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, Typography, message, Space } from 'antd'
import { LockOutlined } from '@ant-design/icons'
import { login } from '../api/auth'
const { Title, Text } = Typography
export default function LoginPage() {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const handleSubmit = async (values: { password: string }) => {
setLoading(true)
try {
await login(values.password)
message.success('Welcome back')
navigate('/admin')
} catch {
message.error('Invalid password')
} finally {
setLoading(false)
}
}
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#FAF8F5',
}}>
<Card style={{ width: 400, boxShadow: '0 2px 16px rgba(0,0,0,0.04)' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}>
<Title level={2} style={{ marginBottom: 4 }}>LiveStream</Title>
<Text type="secondary">Admin Panel</Text>
</div>
<Form onFinish={handleSubmit} layout="vertical">
<Form.Item
name="password"
rules={[{ required: true, message: 'Enter your password' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="Password"
size="large"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large">
Sign In
</Button>
</Form.Item>
</Form>
</Space>
</Card>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react'
import { Row, Col, Card, Statistic, Typography, Table, Tag, Space } from 'antd'
import { VideoCameraOutlined, EyeOutlined, CloudServerOutlined, SignalFilled } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams'
const { Title } = Typography
export default function Dashboard() {
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
const timer = setInterval(loadData, 10000)
return () => clearInterval(timer)
}, [])
async function loadData() {
try {
const data = await fetchStreams()
setStreams(data)
} catch { /* ignore */ }
setLoading(false)
}
const liveCount = streams.filter((s) => s.status === 'live').length
const pendingCount = streams.filter((s) => s.status === 'pending').length
const endedCount = streams.filter((s) => s.status === 'ended').length
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: 'Pending' },
live: { color: 'green', label: 'Live' },
ended: { color: 'default', label: 'Ended' },
cancelled: { color: 'red', label: 'Cancelled' },
}
const recentColumns = [
{ title: 'Title', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: 'Status', dataIndex: 'status', key: 'status',
render: (status: string) => {
const s = statusMap[status] || { color: 'default', label: status }
return <Tag color={s.color}>{s.label}</Tag>
},
},
{ title: 'Created', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.slice(0, 16) },
]
return (
<div>
<Title level={3} style={{ marginBottom: 24 }}>Dashboard</Title>
<Row gutter={[24, 24]}>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Live Streams"
value={liveCount}
prefix={<SignalFilled style={{ color: '#52c41a' }} />}
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Pending"
value={pendingCount}
prefix={<VideoCameraOutlined style={{ color: '#1677ff' }} />}
loading={loading}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Total Streams"
value={streams.length}
prefix={<EyeOutlined />}
loading={loading}
/>
</Card>
</Col>
</Row>
<Card title="Recent Streams" style={{ marginTop: 24 }}>
<Table
dataSource={streams.slice(0, 10)}
columns={recentColumns}
rowKey="id"
loading={loading}
pagination={false}
size="middle"
/>
</Card>
</div>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react'
import {
Card, Table, InputNumber, Switch, Typography, message, Space, Tag, Statistic, Row, Col, Button,
} from 'antd'
import { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import api from '../../api/client'
const { Title, Text } = Typography
interface NodeInfo {
id: string
name: string
host: string
hls_port: number
api_port: number
weight: number
enabled: boolean
}
interface StrategyInfo {
strategy: string
multicast_enabled: boolean
multicast_available: boolean
}
export default function NodeManager() {
const [nodes, setNodes] = useState<NodeInfo[]>([])
const [strategy, setStrategy] = useState<StrategyInfo | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => { loadData() }, [])
async function loadData() {
setLoading(true)
try {
const [nodesRes, strategyRes] = await Promise.all([
api.get('/nodes'),
api.get('/nodes/strategy'),
])
setNodes(nodesRes.data)
setStrategy(strategyRes.data)
} catch { message.error('Failed to load') }
setLoading(false)
}
async function updateWeight(nodeId: string, weight: number) {
try {
await api.put(`/nodes/${nodeId}`, { weight })
message.success('Updated')
} catch { message.error('Failed') }
}
async function toggleNode(nodeId: string, enabled: boolean) {
const node = nodes.find((n) => n.id === nodeId)
if (!node) return
try {
await api.put(`/nodes/${nodeId}`, { weight: node.weight, enabled })
setNodes((prev) => prev.map((n) => (n.id === nodeId ? { ...n, enabled } : n)))
message.success('Updated')
} catch { message.error('Failed') }
}
const columns = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Host', dataIndex: 'host', key: 'host' },
{ title: 'HLS Port', dataIndex: 'hls_port', key: 'hls_port', width: 100 },
{ title: 'API Port', dataIndex: 'api_port', key: 'api_port', width: 100 },
{
title: 'Weight', dataIndex: 'weight', key: 'weight', width: 140,
render: (_: number, record: NodeInfo) => (
<InputNumber
min={0}
max={1000}
value={record.weight}
onChange={(val) => {
const v = val || 0
setNodes((prev) => prev.map((n) => (n.id === record.id ? { ...n, weight: v } : n)))
updateWeight(record.id, v)
}}
size="small"
style={{ width: 90 }}
/>
),
},
{
title: 'Enabled', dataIndex: 'enabled', key: 'enabled', width: 90,
render: (_: any, record: NodeInfo) => (
<Switch
checked={record.enabled}
onChange={(val) => toggleNode(record.id, val)}
size="small"
/>
),
},
{
title: 'Health', key: 'health', width: 100,
render: () => <Tag icon={<CheckCircleOutlined />} color="success">Online</Tag>,
},
]
return (
<div>
<Title level={3} style={{ marginBottom: 24 }}>Edge Nodes</Title>
{strategy && (
<Row gutter={[24, 24]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8}>
<Card>
<Statistic title="Strategy" value={strategy.strategy} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic
title="Multicast"
value={strategy.multicast_available ? 'Available' : 'Unavailable'}
valueStyle={{ color: strategy.multicast_available ? '#52c41a' : '#999' }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card>
<Statistic title="Edge Nodes" value={nodes.length} />
</Card>
</Col>
</Row>
)}
<Card
title="Node List"
extra={<Button icon={<ReloadOutlined />} onClick={loadData}>Refresh</Button>}
>
<Table
dataSource={nodes}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
size="middle"
/>
</Card>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react'
import {
Card, Select, Switch, Typography, message, Space, Descriptions, Button,
} from 'antd'
import api from '../../api/client'
const { Title } = Typography
const strategies = [
{ value: 'round-robin', label: 'Round Robin' },
{ value: 'weighted', label: 'Weighted' },
{ value: 'least-conn', label: 'Least Connections' },
]
export default function SettingsPage() {
const [strategy, setStrategy] = useState('weighted')
const [multicastEnabled, setMulticastEnabled] = useState(true)
const [multicastAvailable, setMulticastAvailable] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
loadSettings()
}, [])
async function loadSettings() {
try {
const { data } = await api.get('/nodes/strategy')
setStrategy(data.strategy)
setMulticastEnabled(data.multicast_enabled)
setMulticastAvailable(data.multicast_available)
} catch { /* ignore */ }
}
async function saveStrategy(val: string) {
setSaving(true)
try {
await api.put(`/nodes/strategy?strategy=${val}&multicast_enabled=${multicastEnabled}`)
setStrategy(val)
message.success('Strategy updated')
} catch { message.error('Failed') }
setSaving(false)
}
async function toggleMulticast(val: boolean) {
setMulticastEnabled(val)
try {
await api.put(`/nodes/strategy?strategy=${strategy}&multicast_enabled=${val}`)
message.success('Multicast setting updated')
} catch { message.error('Failed') }
}
return (
<div style={{ maxWidth: 720 }}>
<Title level={3} style={{ marginBottom: 24 }}>Settings</Title>
<Card title="Load Balancing" style={{ marginBottom: 24 }}>
<Descriptions column={1} labelStyle={{ width: 180 }}>
<Descriptions.Item label="Strategy">
<Select
value={strategy}
onChange={saveStrategy}
options={strategies}
loading={saving}
style={{ width: 240 }}
/>
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="Multicast">
<Descriptions column={1} labelStyle={{ width: 180 }}>
<Descriptions.Item label="Enable Multicast">
<Switch checked={multicastEnabled} onChange={toggleMulticast} />
</Descriptions.Item>
<Descriptions.Item label="Network Status">
<span style={{ color: multicastAvailable ? '#52c41a' : '#999' }}>
{multicastAvailable ? 'Available' : 'Unavailable (fallback to unicast)'}
</span>
</Descriptions.Item>
</Descriptions>
</Card>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Card, Form, Input, Button, DatePicker, Typography, message, Space, Alert, Divider,
} from 'antd'
import { CopyOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import { createStream } from '../../api/streams'
const { Title, Text } = Typography
const { TextArea } = Input
export default function StreamForm() {
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) => {
setLoading(true)
try {
const res = await createStream({
title: values.title,
description: values.description,
cover_url: values.cover_url,
scheduled_at: values.scheduled_at?.toISOString(),
})
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
message.success('Stream created')
} catch {
message.error('Failed to create stream')
}
setLoading(false)
}
if (result) {
return (
<div style={{ maxWidth: 640 }}>
<Title level={3} style={{ marginBottom: 24 }}>Stream Created</Title>
<Alert
type="success"
message="Your stream is ready"
description={
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Text strong>Stream Key (UUID):</Text>
<div style={{
marginTop: 8, padding: 12,
background: '#F5F2EE', borderRadius: 6,
fontFamily: 'monospace', fontSize: 14,
wordBreak: 'break-all',
}}>
{result.stream_key}
</div>
</div>
<div>
<Text strong>RTMP Push URL:</Text>
<div style={{
marginTop: 8, padding: 12,
background: '#F5F2EE', borderRadius: 6,
fontFamily: 'monospace', fontSize: 14,
wordBreak: 'break-all',
}}>
{result.rtmp_url}
</div>
</div>
</Space>
}
showIcon
style={{ marginBottom: 24 }}
/>
<Space>
<Button
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(
`Stream Key: ${result.stream_key}\nRTMP URL: ${result.rtmp_url}`
)
message.success('Copied to clipboard')
}}
>
Copy All
</Button>
<Button type="primary" onClick={() => navigate('/admin/streams')}>
Back to Streams
</Button>
</Space>
</div>
)
}
return (
<div style={{ maxWidth: 640 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/admin/streams')}
style={{ marginBottom: 16 }}
>
Back
</Button>
<Card>
<Title level={3} style={{ marginBottom: 24 }}>New Stream</Title>
<Form layout="vertical" onFinish={handleSubmit}>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Enter a title' }]}
>
<Input placeholder="Stream title" size="large" />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={3} placeholder="Stream description (optional)" />
</Form.Item>
<Form.Item name="cover_url" label="Cover Image URL">
<Input placeholder="https://..." />
</Form.Item>
<Form.Item name="scheduled_at" label="Scheduled Time">
<DatePicker showTime style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} size="large" block>
Create Stream
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Table, Button, Tag, Space, Modal, Typography, message, Tooltip, Popconfirm,
} from 'antd'
import { PlusOutlined, PlayCircleOutlined, StopOutlined, DeleteOutlined, LinkOutlined } from '@ant-design/icons'
import { fetchStreams, deleteStream, goLive, endStream, type StreamInfo } from '../../api/streams'
const { Title, Text } = Typography
const statusMap: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: 'Pending' },
live: { color: 'green', label: 'Live' },
ended: { color: 'default', label: 'Ended' },
cancelled: { color: 'red', label: 'Cancelled' },
}
export default function StreamList() {
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => { loadData() }, [])
async function loadData() {
setLoading(true)
try {
const data = await fetchStreams()
setStreams(data)
} catch { message.error('Failed to load streams') }
setLoading(false)
}
async function handleDelete(id: number) {
try {
await deleteStream(id)
message.success('Stream deleted')
loadData()
} catch { message.error('Delete failed') }
}
async function handleGoLive(id: number) {
try {
await goLive(id)
message.success('Stream is now live')
loadData()
} catch { message.error('Failed to set live') }
}
async function handleEnd(id: number) {
try {
await endStream(id)
message.success('Stream ended')
loadData()
} catch { message.error('Failed to end') }
}
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: 'Title', dataIndex: 'title', key: 'title', ellipsis: true },
{
title: 'Status', dataIndex: 'status', key: 'status', width: 110,
render: (s: string) => {
const info = statusMap[s] || { color: 'default', label: s }
return <Tag color={info.color}>{info.label}</Tag>
},
},
{ title: 'Created', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.slice(0, 16), width: 170 },
{
title: 'Actions', key: 'actions', width: 280,
render: (_: any, record: StreamInfo) => (
<Space size="small">
<Button
size="small"
icon={<LinkOutlined />}
onClick={() => {
navigator.clipboard.writeText(record.stream_key)
message.success('Stream key copied')
}}
>
Copy Key
</Button>
{record.status !== 'live' ? (
<Tooltip title="Mark as live">
<Button size="small" type="primary" icon={<PlayCircleOutlined />}
onClick={() => handleGoLive(record.id)} ghost />
</Tooltip>
) : (
<Tooltip title="End stream">
<Button size="small" icon={<StopOutlined />}
onClick={() => handleEnd(record.id)} danger />
</Tooltip>
)}
<Button size="small" onClick={() => window.open(`/watch/${record.id}`, '_blank')}>
Watch
</Button>
<Popconfirm title="Delete this stream?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" icon={<DeleteOutlined />} danger />
</Popconfirm>
</Space>
),
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={3} style={{ margin: 0 }}>Streams</Title>
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/admin/streams/new')}>
New Stream
</Button>
</div>
<Table
dataSource={streams}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15 }}
size="middle"
/>
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import api, { setToken, getToken } from './client'
export async function login(password: string): Promise<string> {
const { data } = await api.post(`/auth/login?password=${encodeURIComponent(password)}`)
setToken(data.token)
return data.token
}
export async function verifyToken(): Promise<boolean> {
const token = getToken()
if (!token) return false
try {
await api.post(`/auth/verify?token=${encodeURIComponent(token)}`)
return true
} catch {
return false
}
}
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 15000,
})
const TOKEN_KEY = 'livestream_admin_token'
api.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export default api
+92
View File
@@ -0,0 +1,92 @@
import api from './client'
export interface StreamInfo {
id: number
title: string
description?: string
cover_url?: string
stream_key: string
status: string
scheduled_at?: string
started_at?: string
ended_at?: string
multicast_enabled: boolean
created_at: string
}
export interface StreamCreateReq {
title: string
description?: string
cover_url?: string
scheduled_at?: string
}
export interface WatchInfo {
stream_id: number
title: string
status: string
multicast_available: boolean
lines: LineInfo[]
qualities: QualityInfo[]
}
export interface LineInfo {
node_id: string
node_name: string
weight: number
type: string
url: string
unicast_fallback?: string
qualities: QualityUrl[]
}
export interface QualityUrl {
label: string
width: number
height: number
url: string
}
export interface QualityInfo {
label: string
width: number
height: number
}
export async function fetchStreams(): Promise<StreamInfo[]> {
const { data } = await api.get('/streams')
return data
}
export async function createStream(req: StreamCreateReq): Promise<{
id: number; stream_key: string; rtmp_url: string; status: string
}> {
const { data } = await api.post('/streams', req)
return data
}
export async function getStream(streamId: number): Promise<StreamInfo> {
const { data } = await api.get(`/streams/${streamId}`)
return data
}
export async function updateStream(streamId: number, req: Partial<StreamCreateReq>): Promise<void> {
await api.put(`/streams/${streamId}`, req)
}
export async function deleteStream(streamId: number): Promise<void> {
await api.delete(`/streams/${streamId}`)
}
export async function goLive(streamId: number): Promise<void> {
await api.post(`/streams/${streamId}/go-live`)
}
export async function endStream(streamId: number): Promise<void> {
await api.post(`/streams/${streamId}/end`)
}
export async function getWatchUrls(streamId: number): Promise<WatchInfo> {
const { data } = await api.get(`/streams/${streamId}/watch`)
return data
}
+8
View File
@@ -0,0 +1,8 @@
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
font-family: "Source Serif 4", "Noto Serif SC", Georgia, "Times New Roman", serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #FAF8F5;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+62
View File
@@ -0,0 +1,62 @@
import type { ThemeConfig } from 'antd'
const theme: ThemeConfig = {
token: {
fontFamily: `"Source Serif 4", "Noto Serif SC", Georgia, "Times New Roman", serif`,
fontSize: 15,
colorPrimary: '#D97757',
colorSuccess: '#6B9E7A',
colorWarning: '#D4A24E',
colorError: '#C15C5C',
colorInfo: '#7B93A8',
colorTextBase: '#1A1A16',
colorBgBase: '#FAF8F5',
colorBgContainer: '#FFFFFF',
colorBgLayout: '#FAF8F5',
colorBorder: '#E8E4DD',
colorBorderSecondary: '#F0EDE7',
borderRadius: 6,
wireframe: false,
},
components: {
Layout: {
headerBg: '#FFFFFF',
headerHeight: 56,
headerPadding: '0 32px',
siderBg: '#FFFFFF',
bodyBg: '#FAF8F5',
},
Menu: {
itemBg: 'transparent',
itemSelectedBg: '#F5F2EE',
itemSelectedColor: '#1A1A16',
itemActiveBg: '#F5F2EE',
},
Card: {
paddingLG: 24,
borderRadiusLG: 8,
},
Button: {
borderRadius: 6,
fontWeight: 500,
},
Input: {
borderRadius: 6,
},
Select: {
borderRadius: 6,
},
Table: {
headerBg: '#FAF8F5',
rowHoverBg: '#F5F2EE',
},
Modal: {
borderRadiusLG: 10,
},
Tag: {
borderRadiusSM: 4,
},
},
}
export default theme
+207
View File
@@ -0,0 +1,207 @@
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import {
Layout, Card, Select, Button, Typography, Space, Tag, Spin, Skeleton, Empty, message,
} from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js'
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
const { Header, Content } = Layout
const { Title, Text } = Typography
export default function WatchPage() {
const { id } = useParams<{ id: string }>()
const videoRef = useRef<HTMLVideoElement>(null)
const [info, setInfo] = useState<WatchInfo | null>(null)
const [loading, setLoading] = useState(true)
const [activeLine, setActiveLine] = useState<string>('')
const [activeQuality, setActiveQuality] = useState<string>('')
const [hls, setHls] = useState<Hls | null>(null)
useEffect(() => {
if (!id) return
loadInfo()
const timer = setInterval(loadInfo, 15000)
return () => {
clearInterval(timer)
if (hls) { hls.destroy() }
}
}, [id])
async function loadInfo() {
if (!id) return
try {
const data = await getWatchUrls(parseInt(id))
setInfo(data)
if (!activeLine && data.lines.length > 0) {
const best = autoSelectLine(data.lines)
setActiveLine(best.node_id)
if (data.qualities.length > 0) {
setActiveQuality(data.qualities[0].label)
}
}
} catch { /* stream not ready */ }
setLoading(false)
}
function autoSelectLine(lines: LineInfo[]): LineInfo {
const enabled = lines.filter((l) => l.weight > 0)
if (enabled.length === 0) return lines[0]
const total = enabled.reduce((s, l) => s + l.weight, 0)
let r = Math.random() * total
for (const line of enabled) {
r -= line.weight
if (r <= 0) return line
}
return enabled[enabled.length - 1]
}
useEffect(() => {
if (!info || !activeLine || !videoRef.current) return
const line = info.lines.find((l) => l.node_id === activeLine)
if (!line) return
const quality = activeQuality
? line.qualities.find((q) => q.label === activeQuality)
: line.qualities[0]
if (!quality) return
if (hls) {
hls.destroy()
}
const url = quality.url
if (Hls.isSupported()) {
const hlsInstance = new Hls({
enableWorker: false,
lowLatencyMode: false,
})
hlsInstance.loadSource(url)
hlsInstance.attachMedia(videoRef.current!)
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
videoRef.current?.play().catch(() => {})
})
hlsInstance.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
message.warning('Playback error, trying fallback...')
if (line.unicast_fallback) {
const fbUrl = activeQuality
? line.unicast_fallback.replace('index.m3u8', `${activeQuality}/index.m3u8`)
: line.unicast_fallback
hlsInstance.loadSource(fbUrl)
}
}
})
setHls(hlsInstance)
} else if (videoRef.current!.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.current!.src = url
}
}, [activeLine, activeQuality, info])
if (loading) {
return (
<div style={{ minHeight: '100vh', background: '#000' }}>
<Skeleton active />
</div>
)
}
if (!info) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#FAF8F5' }}>
<Empty description="Stream not found" />
</div>
)
}
return (
<Layout style={{ minHeight: '100vh', background: '#1A1A16' }}>
<Header style={{
background: 'rgba(26,26,22,0.95)',
borderBottom: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
height: 56,
}}>
<Space>
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#fff' }}
onClick={() => window.close()}>
Back
</Button>
<div>
<Title level={5} style={{ color: '#fff', margin: 0 }}>
{info.title}
</Title>
<Tag color={info.status === 'live' ? 'green' : 'default'} style={{ marginLeft: 8 }}>
{info.status === 'live' ? 'LIVE' : info.status}
</Tag>
</div>
</Space>
<Space size="middle">
<div style={{ color: '#aaa', fontSize: 13 }}>
<SignalFilled style={{ color: info.multicast_available ? '#52c41a' : '#666', marginRight: 4 }} />
{info.multicast_available ? 'Multicast' : 'Unicast'}
</div>
<Select
value={activeQuality}
onChange={setActiveQuality}
style={{ width: 100 }}
size="small"
options={info.qualities.map((q) => ({ value: q.label, label: q.label }))}
popupMatchSelectWidth={false}
/>
<Select
value={activeLine}
onChange={setActiveLine}
style={{ width: 140 }}
size="small"
options={info.lines.map((l) => ({
value: l.node_id,
label: `${l.node_name} (${l.weight})`,
}))}
popupMatchSelectWidth={false}
prefix={<WifiOutlined />}
/>
</Space>
</Header>
<Content style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
maxHeight: 'calc(100vh - 56px)',
}}>
<div style={{ width: '100%', maxWidth: 1280, aspectRatio: '16/9', background: '#000', position: 'relative' }}>
<video
ref={videoRef}
controls
autoPlay
playsInline
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
{info.status !== 'live' && (
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.7)',
}}>
<Space direction="vertical" align="center">
<Spin size="large" />
<Text style={{ color: '#fff' }}>Waiting for stream to start...</Text>
</Space>
</div>
)}
</div>
</Content>
</Layout>
)
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: ['nbd.rainplay.cn', '.rainplay.cn'],
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/hls': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})
+16
View File
@@ -0,0 +1,16 @@
logLevel: info
logDestinations: [stdout]
rtmp: no
rtsp: no
hls: yes
hlsAddress: :{{HLS_PORT}}
api: yes
apiAddress: :{{API_PORT}}
paths:
all:
source: rtmp://{{ORIGIN_HOST}}:1935/live/$MTX_PATH
sourceOnDemand: yes
+20
View File
@@ -0,0 +1,20 @@
logLevel: info
logDestinations: [stdout]
rtmp: yes
rtmpAddress: :1935
rtsp: yes
rtspAddress: :8554
hls: yes
hlsAddress: :8888
webrtc: yes
webrtcAddress: :8889
api: yes
apiAddress: :9997
paths:
all:
+23
View File
@@ -0,0 +1,23 @@
@echo off
echo Checking multicast reachability...
echo Sending multicast ping to 239.0.0.1:9999
powershell -Command "
try {
$udp = New-Object System.Net.Sockets.UdpClient
$udp.Client.SetSocketOption('IP', 'MulticastTimeToLive', 1)
$udp.Client.Bind((New-Object System.Net.IPEndPoint([System.Net.IPAddress]::Any, 0)))
$udp.JoinMulticastGroup([System.Net.IPAddress]'239.0.0.1')
$bytes = [System.Text.Encoding]::ASCII.GetBytes('ping')
$udp.Send($bytes, $bytes.Length, '239.0.0.1', 9999)
Start-Sleep -Milliseconds 200
if ($udp.Available -gt 0) {
Write-Host 'MULTICAST OK: Response received'
} else {
Write-Host 'MULTICAST OK: Sent successfully (no listener, but network supports it)'
}
$udp.Close()
} catch {
Write-Host 'MULTICAST FAILED: Network may not support multicast'
}
"
+23
View File
@@ -0,0 +1,23 @@
@echo off
if "%1"=="" (
echo Usage: start_edge.bat ^<port_suffix^> ^<origin_host^>
echo Example: start_edge.bat 1 127.0.0.1
exit /b 1
)
set SUFFIX=%1
set ORIGIN=%2
if "%ORIGIN%"=="" set ORIGIN=127.0.0.1
set /a HLS_PORT=8888 + %SUFFIX%
set /a API_PORT=9997 + %SUFFIX%
echo Starting Edge Node %SUFFIX%...
echo HLS: %HLS_PORT%
echo API: %API_PORT%
echo Pull from origin: %ORIGIN%
type edge_template.yml > edge%SUFFIX%.yml
echo. >> edge%SUFFIX%.yml
mediamtx edge%SUFFIX%.yml
+3
View File
@@ -0,0 +1,3 @@
@echo off
echo Starting MediaMTX Origin Node...
mediamtx mediamtx.yml
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
set -e
BASE="/workspace/livestream-platform"
echo "[livestream] Starting services..."
cd "$BASE/mediamtx"
if pgrep -f "mediamtx mediamtx.yml" > /dev/null; then
echo "[livestream] MediaMTX already running."
else
nohup ./mediamtx mediamtx.yml > /tmp/mediamtx.log 2>&1 &
sleep 2
echo "[livestream] MediaMTX started."
fi
cd "$BASE/backend"
if curl -sf http://127.0.0.1:8080/api/health > /dev/null 2>&1; then
echo "[livestream] Backend already running."
else
nohup ./venv/bin/python run.py > /tmp/backend.log 2>&1 &
sleep 3
echo "[livestream] Backend started on :8080."
fi
cd "$BASE/frontend"
if curl -sf http://127.0.0.1:5173 > /dev/null 2>&1; then
echo "[livestream] Frontend already running."
else
nohup npm run dev > /tmp/frontend.log 2>&1 &
sleep 3
echo "[livestream] Frontend started on :5173."
fi
echo "[livestream] All services up."