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:
KiraStream
2026-05-19 14:43:31 +00:00
commit b969b7e5af
65 changed files with 13279 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
"""Resolve channel catalog for a given user based on their assigned categories."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models.user import User, UserCatalogEntry, UserProviderPreference
from ..models.channel import Channel, Category, ChannelProviderMap, ContentType
from ..models.provider import ProviderAccount
async def get_user_categories(db: AsyncSession, user: User, content_type: ContentType | None = None) -> list[Category]:
q = (
select(Category)
.join(UserCatalogEntry, UserCatalogEntry.category_id == Category.id)
.where(UserCatalogEntry.user_id == user.id)
)
if content_type:
q = q.where(Category.type == content_type)
result = await db.execute(q)
return list(result.scalars().all())
async def get_user_channels(
db: AsyncSession,
user: User,
content_type: ContentType | None = None,
category_id: int | None = None,
) -> list[Channel]:
category_ids_q = (
select(UserCatalogEntry.category_id)
.where(UserCatalogEntry.user_id == user.id)
)
q = (
select(Channel)
.options(selectinload(Channel.provider_maps), selectinload(Channel.category))
.where(Channel.category_id.in_(category_ids_q))
.where(Channel.is_active == True) # noqa: E712
)
if content_type:
q = q.where(Channel.type == content_type)
if category_id:
q = q.where(Channel.category_id == category_id)
result = await db.execute(q)
return list(result.scalars().all())
async def get_channel_provider_maps(db: AsyncSession, channel_id: int) -> list[dict]:
q = (
select(ChannelProviderMap, ProviderAccount.name.label("provider_name"))
.join(ProviderAccount, ProviderAccount.id == ChannelProviderMap.provider_account_id)
.where(ChannelProviderMap.channel_id == channel_id)
)
result = await db.execute(q)
rows = result.all()
return [
{
"provider_account_id": row.ChannelProviderMap.provider_account_id,
"stream_url": row.ChannelProviderMap.stream_url,
"provider_name": row.provider_name or f"#{row.ChannelProviderMap.provider_account_id}",
}
for row in rows
]
async def can_user_access_channel(db: AsyncSession, user: User, channel_id: int) -> bool:
channels = await get_user_channels(db, user)
return any(ch.id == channel_id for ch in channels)
async def get_user_preferred_provider_ids(db: AsyncSession, user: User) -> list[int]:
result = await db.execute(
select(UserProviderPreference.provider_account_id)
.where(UserProviderPreference.user_id == user.id)
.order_by(UserProviderPreference.priority)
)
return list(result.scalars().all())