完善直播节点监控与边缘代理
This commit is contained in:
1 parent
058bb4b99f
commit
20cd99efa4
19 files changed
+401
-47
No files matched your search
@@ -6,17 +6,63 @@ from fastapi.responses import FileResponse
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.background import BackgroundTask
|
||||
import httpx
|
||||
import asyncio
|
||||
import time
|
||||
import psutil
|
||||
from database import init_db
|
||||
from services.transcoder import transcode_mgr
|
||||
from routes import streams_router, nodes_router, auth_router
|
||||
from routes.settings_route import router as settings_router
|
||||
from config import settings
|
||||
from services.load_balancer import lb
|
||||
from services.node_store import load_nodes
|
||||
|
||||
|
||||
async def monitor_origin():
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
load_nodes()
|
||||
await init_db()
|
||||
origin_monitor = asyncio.create_task(monitor_origin())
|
||||
yield
|
||||
origin_monitor.cancel()
|
||||
transcode_mgr.stop_all()
|
||||
|
||||
|
||||
|
||||
@@ -6,3 +6,4 @@ pydantic>=2.7.0
|
||||
pydantic-settings>=2.2.0
|
||||
python-multipart>=0.0.9
|
||||
httpx>=0.27.0
|
||||
psutil>=5.9.0
|
||||
+25
-3
@@ -5,14 +5,16 @@ from config import settings
|
||||
from routes.auth import require_auth
|
||||
from services.load_balancer import lb
|
||||
from services.multicast_detector import multicast_detector
|
||||
from services.node_store import save_nodes
|
||||
|
||||
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
|
||||
|
||||
|
||||
class NodeUpdate(BaseModel):
|
||||
weight: int
|
||||
weight: int | None = None
|
||||
enabled: bool | None = None
|
||||
subnet: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class StrategyUpdate(BaseModel):
|
||||
@@ -27,6 +29,12 @@ class NodeHeartbeat(BaseModel):
|
||||
bandwidth_mbps: float | None = None
|
||||
active_connections: int | None = None
|
||||
hls_readers: int | None = None
|
||||
network_in_mbps: float | None = None
|
||||
network_out_mbps: float | None = None
|
||||
viewer_count: int | None = None
|
||||
frame_drop_indicators: int | None = None
|
||||
packet_loss_indicators: int | None = None
|
||||
media_metrics_available: bool = False
|
||||
|
||||
|
||||
class NodeRegister(BaseModel):
|
||||
@@ -83,15 +91,22 @@ async def update_node(node_id: str, body: NodeUpdate, _: bool = Depends(require_
|
||||
found = False
|
||||
for n in settings.edge_nodes:
|
||||
if n["id"] == node_id:
|
||||
n["weight"] = body.weight
|
||||
if body.weight is not None:
|
||||
n["weight"] = body.weight
|
||||
if body.enabled is not None:
|
||||
n["enabled"] = body.enabled
|
||||
if body.subnet is not None:
|
||||
n["subnet"] = body.subnet
|
||||
if body.name is not None:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="Node name cannot be empty")
|
||||
n["name"] = name
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
save_nodes()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -100,13 +115,13 @@ async def register_node(body: NodeRegister):
|
||||
for n in settings.edge_nodes:
|
||||
if n["id"] == body.node_id:
|
||||
n.update({
|
||||
"name": body.name,
|
||||
"host": body.host,
|
||||
"hls_port": body.hls_port,
|
||||
"api_port": body.api_port,
|
||||
"weight": body.weight,
|
||||
"subnet": body.subnet,
|
||||
})
|
||||
save_nodes()
|
||||
return {"ok": True, "action": "updated"}
|
||||
settings.edge_nodes.append({
|
||||
"id": body.node_id,
|
||||
@@ -119,6 +134,7 @@ async def register_node(body: NodeRegister):
|
||||
"subnet": body.subnet,
|
||||
"is_origin": False,
|
||||
})
|
||||
save_nodes()
|
||||
return {"ok": True, "action": "registered"}
|
||||
|
||||
|
||||
@@ -130,6 +146,12 @@ async def node_heartbeat(body: NodeHeartbeat):
|
||||
"bandwidth_mbps": body.bandwidth_mbps,
|
||||
"active_connections": body.active_connections,
|
||||
"hls_readers": body.hls_readers,
|
||||
"network_in_mbps": body.network_in_mbps,
|
||||
"network_out_mbps": body.network_out_mbps,
|
||||
"viewer_count": body.viewer_count,
|
||||
"frame_drop_indicators": body.frame_drop_indicators,
|
||||
"packet_loss_indicators": body.packet_loss_indicators,
|
||||
"media_metrics_available": body.media_metrics_available,
|
||||
})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -62,14 +62,23 @@ async def list_streams(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("")
|
||||
async def create_stream(body: StreamCreate, db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(require_auth)):
|
||||
_: bool = Depends(require_auth)):
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=422, detail="直播标题不能为空")
|
||||
svc = StreamService(db)
|
||||
stream = await svc.create_stream(
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
cover_url=body.cover_url,
|
||||
scheduled_at=body.scheduled_at,
|
||||
)
|
||||
try:
|
||||
stream = await svc.create_stream(
|
||||
title=title,
|
||||
description=body.description,
|
||||
cover_url=body.cover_url,
|
||||
scheduled_at=body.scheduled_at,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail="创建直播失败,请检查日期格式和数据库状态") from exc
|
||||
rtmp_url = media_service.get_rtmp_url(stream.stream_key)
|
||||
return {
|
||||
"id": stream.id,
|
||||
@@ -179,6 +188,7 @@ async def get_stream_by_key(stream_key: str, request: Request, db: AsyncSession
|
||||
"title": s.title,
|
||||
"status": s.status.value,
|
||||
"multicast_available": urls["multicast_available"],
|
||||
"selected_node": urls.get("selected_node"),
|
||||
"lines": urls["lines"],
|
||||
"qualities": urls["qualities"],
|
||||
}
|
||||
@@ -132,6 +132,12 @@ class LoadBalancer:
|
||||
"bandwidth_mbps": h.get("bandwidth_mbps"),
|
||||
"active_connections": h.get("active_connections"),
|
||||
"hls_readers": h.get("hls_readers"),
|
||||
"network_in_mbps": h.get("network_in_mbps"),
|
||||
"network_out_mbps": h.get("network_out_mbps"),
|
||||
"viewer_count": h.get("viewer_count"),
|
||||
"frame_drop_indicators": h.get("frame_drop_indicators"),
|
||||
"packet_loss_indicators": h.get("packet_loss_indicators"),
|
||||
"media_metrics_available": h.get("media_metrics_available", False),
|
||||
} if h else None,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
STORE_PATH = Path(__file__).resolve().parent.parent / "nodes.json"
|
||||
|
||||
|
||||
def load_nodes() -> None:
|
||||
if not STORE_PATH.exists():
|
||||
return
|
||||
try:
|
||||
nodes = json.loads(STORE_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return
|
||||
if isinstance(nodes, list):
|
||||
settings.edge_nodes = nodes
|
||||
|
||||
|
||||
def save_nodes() -> None:
|
||||
temporary = STORE_PATH.with_suffix(".json.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(settings.edge_nodes, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(STORE_PATH)
|
||||
@@ -7,6 +7,7 @@ from services.multicast_detector import multicast_detector
|
||||
from services.transcoder import transcode_mgr
|
||||
from config import settings
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StreamService:
|
||||
@@ -17,13 +18,19 @@ class StreamService:
|
||||
cover_url: str | None = None,
|
||||
scheduled_at: str | None = None) -> Stream:
|
||||
import uuid
|
||||
scheduled_datetime = None
|
||||
if scheduled_at:
|
||||
try:
|
||||
scheduled_datetime = datetime.fromisoformat(scheduled_at.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("预定开播时间格式无效") from exc
|
||||
stream = Stream(
|
||||
title=title,
|
||||
description=description,
|
||||
cover_url=cover_url,
|
||||
stream_key=uuid.uuid4().hex,
|
||||
status=StreamStatus.pending,
|
||||
scheduled_at=scheduled_at,
|
||||
scheduled_at=scheduled_datetime,
|
||||
)
|
||||
self.db.add(stream)
|
||||
await self.db.commit()
|
||||
@@ -113,4 +120,8 @@ class StreamService:
|
||||
"lines": lines,
|
||||
"qualities": [{"label": q["label"], "width": q["width"], "height": q["height"]} for q in settings.qualities],
|
||||
"multicast_available": multicast_detector.is_available(),
|
||||
"selected_node": {
|
||||
"id": best_node["id"],
|
||||
"name": best_node["name"],
|
||||
} if best_node else None,
|
||||
}
|
||||
Reference in new issue
Block a user