Mejoras de estabilidad de stream IPTV y nuevo registro de fallos
- restream.py: eliminar -reconnect_streamed de ffmpeg para evitar rebobinados al reconectar (el proveedor re-enviaba desde keyframe anterior causando PTS backward). El loop de Python gestiona las reconexiones con conexión fresca. - restream.py: eliminar _drain_queue del loop de reconexión para que el buffer de la cola cubra el tiempo de reconexión sin pantalla negra en el player. - restream.py: añadir buffer_server_bytes/secs/capacity a stats() y método _log_stream_event() para persistir eventos de caída y recuperación en BD. - models/stream_event.py: nuevo modelo StreamEvent para registro de fallos. - database.py: registrar StreamEvent en init_db. - api/admin/logs.py: nuevos endpoints GET/DELETE /logs/events. - Dashboard.tsx: mostrar reserva de buffer del servidor (capacidad + estado). - Logs.tsx: añadir pestaña "Fallos de stream" con tabla de eventos persistidos. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
"""Admin API: real-time stream monitoring via REST and WebSocket."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...config import settings
|
||||
from ...core.pool import pool, MONITORING_KEY
|
||||
from ...core.vod_tracker import vod_tracker
|
||||
from ...database import get_db
|
||||
from ...models.jellyfin import JellyfinConfig
|
||||
from ...models.provider import ProviderAccount
|
||||
from ..auth import get_current_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/monitoring", tags=["monitoring"])
|
||||
|
||||
|
||||
def _all_streams() -> list[dict]:
|
||||
"""Combined live + VOD/series sessions."""
|
||||
return pool.get_active_streams() + vod_tracker.get_all()
|
||||
|
||||
|
||||
@router.get("/streams")
|
||||
async def get_active_streams(_=Depends(get_current_admin)):
|
||||
return _all_streams()
|
||||
|
||||
|
||||
@router.get("/slots")
|
||||
async def get_provider_slots(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
# Exclude Jellyfin dummy providers — they are local, have no slot limits
|
||||
jf_subq = (
|
||||
select(JellyfinConfig.provider_account_id)
|
||||
.where(JellyfinConfig.provider_account_id.isnot(None))
|
||||
.scalar_subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(ProviderAccount).where(
|
||||
ProviderAccount.is_active == True, # noqa: E712
|
||||
ProviderAccount.id.notin_(jf_subq),
|
||||
)
|
||||
)
|
||||
accounts = result.scalars().all()
|
||||
|
||||
active_streams = _all_streams()
|
||||
busy_accounts = {s["provider_account_id"] for s in active_streams}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": acc.id,
|
||||
"name": acc.name,
|
||||
"max_connections": acc.max_connections,
|
||||
"status": "busy" if acc.id in busy_accounts else "free",
|
||||
"channel_id": next(
|
||||
(s["channel_id"] for s in active_streams if s["provider_account_id"] == acc.id), None
|
||||
),
|
||||
}
|
||||
for acc in accounts
|
||||
]
|
||||
|
||||
|
||||
class KillRequest(BaseModel):
|
||||
client_id: str
|
||||
|
||||
|
||||
@router.post("/kill")
|
||||
async def kill_connection(body: KillRequest, _=Depends(get_current_admin)):
|
||||
"""Force-disconnect a client. Works for both live streams and VOD/series sessions."""
|
||||
cid = body.client_id
|
||||
if cid.startswith("vod-"):
|
||||
killed = vod_tracker.kill(cid)
|
||||
else:
|
||||
killed = await pool.kill_client(cid)
|
||||
if not killed:
|
||||
raise HTTPException(404, f"Client '{cid}' not found in any active stream")
|
||||
return {"ok": True, "client_id": cid}
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def monitoring_websocket(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
pubsub = redis_client.pubsub()
|
||||
|
||||
try:
|
||||
await pubsub.subscribe(MONITORING_KEY)
|
||||
|
||||
# Send initial combined state
|
||||
await websocket.send_text(json.dumps({"type": "state", "data": _all_streams()}))
|
||||
|
||||
async def redis_reader():
|
||||
"""Forward live-stream pool events from Redis pub/sub."""
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
try:
|
||||
# Pool publishes only live streams; merge with VOD here
|
||||
await websocket.send_text(json.dumps({"type": "update", "data": _all_streams()}))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
async def broadcaster():
|
||||
"""Push full state every 2 s for VOD bytes/bps updates."""
|
||||
while True:
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
await websocket.send_text(json.dumps({"type": "update", "data": _all_streams()}))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
reader_task = asyncio.create_task(redis_reader())
|
||||
broadcaster_task = asyncio.create_task(broadcaster())
|
||||
|
||||
try:
|
||||
await asyncio.gather(reader_task, broadcaster_task)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
reader_task.cancel()
|
||||
broadcaster_task.cancel()
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Monitoring WS error: {e}")
|
||||
finally:
|
||||
await pubsub.unsubscribe(MONITORING_KEY)
|
||||
await redis_client.aclose()
|
||||
Reference in New Issue
Block a user