Files
KiraStream b969b7e5af 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>
2026-05-19 14:43:31 +00:00

133 lines
4.3 KiB
Python

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