完善直播节点监控与边缘代理

This commit is contained in:
admin committed 2026-08-12 21:14:42 +08:00
1 parent 058bb4b99f
commit 20cd99efa4
19 files changed
+401 -47

No files matched your search

+46
View File
@@ -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()
+1
View File
@@ -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
View File
@@ -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}
+17 -7
View File
@@ -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"],
}
+6
View File
@@ -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
+27
View File
@@ -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)
+12 -1
View File
@@ -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,
}