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

+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()