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,195 @@
|
||||
"""Connection log API — query and stats."""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func, desc, distinct
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...database import get_db
|
||||
from ...models.log import ConnectionLog
|
||||
from ...models.stream_event import StreamEvent
|
||||
from ..auth import get_current_admin
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_logs(
|
||||
username: str | None = Query(None),
|
||||
channel: str | None = Query(None),
|
||||
content_type: str | None = Query(None),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_admin),
|
||||
):
|
||||
q = select(ConnectionLog).order_by(desc(ConnectionLog.started_at))
|
||||
|
||||
if username:
|
||||
q = q.where(ConnectionLog.username.ilike(f"%{username}%"))
|
||||
if channel:
|
||||
q = q.where(ConnectionLog.channel_name.ilike(f"%{channel}%"))
|
||||
if content_type:
|
||||
q = q.where(ConnectionLog.content_type == content_type)
|
||||
if date_from:
|
||||
try:
|
||||
q = q.where(ConnectionLog.started_at >= datetime.fromisoformat(date_from))
|
||||
except ValueError:
|
||||
pass
|
||||
if date_to:
|
||||
try:
|
||||
q = q.where(ConnectionLog.started_at <= datetime.fromisoformat(date_to))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total_result = await db.execute(select(func.count()).select_from(q.subquery()))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
q = q.offset((page - 1) * limit).limit(limit)
|
||||
result = await db.execute(q)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"items": [_serialize(log) for log in logs],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
top_users_q = await db.execute(
|
||||
select(
|
||||
ConnectionLog.username,
|
||||
func.count().label("sessions"),
|
||||
func.sum(ConnectionLog.bytes_transferred).label("bytes"),
|
||||
)
|
||||
.group_by(ConnectionLog.username)
|
||||
.order_by(desc("sessions"))
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
top_channels_q = await db.execute(
|
||||
select(ConnectionLog.channel_name, func.count().label("sessions"))
|
||||
.where(ConnectionLog.channel_name.isnot(None))
|
||||
.group_by(ConnectionLog.channel_name)
|
||||
.order_by(desc("sessions"))
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
totals_q = await db.execute(
|
||||
select(
|
||||
func.count().label("total_sessions"),
|
||||
func.sum(ConnectionLog.bytes_transferred).label("total_bytes"),
|
||||
func.avg(ConnectionLog.duration_seconds).label("avg_duration"),
|
||||
func.count(distinct(ConnectionLog.username)).label("unique_users"),
|
||||
)
|
||||
)
|
||||
t = totals_q.one()
|
||||
|
||||
return {
|
||||
"total_sessions": t.total_sessions or 0,
|
||||
"total_bytes": t.total_bytes or 0,
|
||||
"avg_duration_seconds": round(t.avg_duration or 0),
|
||||
"unique_users": t.unique_users or 0,
|
||||
"top_users": [
|
||||
{"username": r.username, "sessions": r.sessions, "bytes": r.bytes or 0}
|
||||
for r in top_users_q
|
||||
],
|
||||
"top_channels": [
|
||||
{"channel_name": r.channel_name, "sessions": r.sessions}
|
||||
for r in top_channels_q
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("", status_code=204)
|
||||
async def clear_logs(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
"""Delete all logs (admin use)."""
|
||||
from sqlalchemy import delete
|
||||
await db.execute(delete(ConnectionLog))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def get_events(
|
||||
channel: str | None = Query(None),
|
||||
event_type: str | None = Query(None),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_admin),
|
||||
):
|
||||
q = select(StreamEvent).order_by(desc(StreamEvent.created_at))
|
||||
|
||||
if channel:
|
||||
q = q.where(StreamEvent.channel_name.ilike(f"%{channel}%"))
|
||||
if event_type:
|
||||
q = q.where(StreamEvent.event_type == event_type)
|
||||
if date_from:
|
||||
try:
|
||||
q = q.where(StreamEvent.created_at >= datetime.fromisoformat(date_from))
|
||||
except ValueError:
|
||||
pass
|
||||
if date_to:
|
||||
try:
|
||||
q = q.where(StreamEvent.created_at <= datetime.fromisoformat(date_to))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total_result = await db.execute(select(func.count()).select_from(q.subquery()))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
q = q.offset((page - 1) * limit).limit(limit)
|
||||
result = await db.execute(q)
|
||||
events = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"items": [_serialize_event(ev) for ev in events],
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/events", status_code=204)
|
||||
async def clear_events(db: AsyncSession = Depends(get_db), _=Depends(get_current_admin)):
|
||||
from sqlalchemy import delete
|
||||
await db.execute(delete(StreamEvent))
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _serialize_event(ev: StreamEvent) -> dict:
|
||||
return {
|
||||
"id": ev.id,
|
||||
"channel_id": ev.channel_id,
|
||||
"channel_name": ev.channel_name,
|
||||
"provider_name": ev.provider_name,
|
||||
"event_type": ev.event_type,
|
||||
"domain": ev.domain,
|
||||
"error_message": ev.error_message,
|
||||
"attempt_number": ev.attempt_number,
|
||||
"created_at": ev.created_at.isoformat() if ev.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize(log: ConnectionLog) -> dict:
|
||||
return {
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"username": log.username,
|
||||
"channel_id": log.channel_id,
|
||||
"channel_name": log.channel_name,
|
||||
"content_type": log.content_type,
|
||||
"stream_id": log.stream_id,
|
||||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||
"ended_at": log.ended_at.isoformat() if log.ended_at else None,
|
||||
"duration_seconds": log.duration_seconds,
|
||||
"bytes_transferred": log.bytes_transferred,
|
||||
"client_ip": log.client_ip,
|
||||
}
|
||||
Reference in New Issue
Block a user