Initial commit: KiraTV IPTV proxy server

Full-stack IPTV management system with Xtream Codes API compatibility,
multi-account provider fan-out, Jellyfin local library integration,
real-time dashboard, EPG support, and A/V-sync-safe stream recovery.
This commit is contained in:
joaquin
2026-05-17 23:22:05 +02:00
commit f5d6745ed0
62 changed files with 8567 additions and 0 deletions
+132
View File
@@ -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()