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
View File
+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())
+99
View File
@@ -0,0 +1,99 @@
"""EPG manager: fetch XMLTV sources, merge by tvg-id, serve filtered by user catalog."""
import asyncio
import gzip
import logging
import time
from datetime import datetime, timezone
from io import BytesIO
from lxml import etree
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..models.epg import EpgSource
from ..config import settings
logger = logging.getLogger(__name__)
_merged_epg: bytes = b""
_last_update: float = 0.0
_update_lock = asyncio.Lock()
async def refresh_epg(db: AsyncSession) -> None:
global _merged_epg, _last_update
async with _update_lock:
result = await db.execute(select(EpgSource).where(EpgSource.is_active == True)) # noqa: E712
sources = result.scalars().all()
if not sources:
logger.info("No active EPG sources configured")
return
root = etree.Element("tv")
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
for source in sources:
try:
data = await _fetch_xmltv(session, source.url)
tree = etree.fromstring(data)
for elem in tree:
root.append(elem)
logger.info(f"EPG loaded from {source.name}: {len(tree)} entries")
except Exception as e:
logger.error(f"EPG fetch failed for {source.name}: {e}")
_merged_epg = etree.tostring(root, xml_declaration=True, encoding="UTF-8")
_last_update = time.time()
from sqlalchemy import update
await db.execute(
update(EpgSource).where(EpgSource.is_active == True).values(last_fetched_at=datetime.now(timezone.utc)) # noqa: E712
)
await db.commit()
logger.info(f"EPG merge complete, {len(_merged_epg)} bytes")
async def _fetch_xmltv(session: aiohttp.ClientSession, url: str) -> bytes:
async with session.get(url, ssl=False) as resp:
resp.raise_for_status()
data = await resp.read()
if data[:2] == b"\x1f\x8b":
data = gzip.decompress(data)
return data
def get_merged_epg() -> bytes:
return _merged_epg
def filter_epg_for_channels(tvg_ids: set[str]) -> bytes:
"""Return EPG XML filtered to only contain channels/programs for given tvg-ids."""
if not _merged_epg:
return b'<?xml version="1.0" encoding="UTF-8"?><tv></tv>'
try:
tree = etree.fromstring(_merged_epg)
root = etree.Element("tv")
for elem in tree:
tag = elem.tag
if tag == "channel":
if elem.get("id") in tvg_ids:
root.append(elem)
elif tag == "programme":
if elem.get("channel") in tvg_ids:
root.append(elem)
return etree.tostring(root, xml_declaration=True, encoding="UTF-8")
except Exception as e:
logger.error(f"EPG filter error: {e}")
return _merged_epg
async def epg_background_task(get_db_func) -> None:
"""Periodic EPG refresh task — runs every N hours."""
while True:
try:
async for db in get_db_func():
await refresh_epg(db)
except Exception as e:
logger.error(f"EPG background refresh error: {e}")
await asyncio.sleep(settings.epg_refresh_hours * 3600)
+115
View File
@@ -0,0 +1,115 @@
"""Background health checker for provider URLs."""
import asyncio
import logging
import time
from datetime import datetime, timezone
import aiohttp
from sqlalchemy import select
from ..database import AsyncSessionLocal
from ..models.provider import ProviderAccount, ProviderUrl
logger = logging.getLogger(__name__)
CHECK_INTERVAL = 300 # 5 minutes
async def _check_url(base_url: str, username: str, password: str) -> tuple[str, int | None]:
url = f"{base_url.rstrip('/')}/player_api.php"
params = {"username": username, "password": password, "action": "user_info"}
start = time.time()
try:
timeout = aiohttp.ClientTimeout(total=8)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, params=params, ssl=False) as resp:
elapsed_ms = int((time.time() - start) * 1000)
if resp.status == 200:
try:
data = await resp.json(content_type=None)
if "user_info" in data:
return "ok", elapsed_ms
except Exception:
pass
return "error", elapsed_ms
except asyncio.TimeoutError:
return "timeout", None
except Exception:
return "error", None
async def check_provider_urls(provider_account_id: int) -> list[dict]:
"""Check all URLs for a specific provider. Returns list of result dicts."""
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password)
.join(ProviderAccount)
.where(
ProviderUrl.provider_account_id == provider_account_id,
ProviderUrl.is_active == True, # noqa: E712
)
)
rows = result.all()
if not rows:
return []
tasks = [_check_url(url, username, password) for _, url, username, password in rows]
results = await asyncio.gather(*tasks, return_exceptions=True)
now = datetime.now(timezone.utc)
output = []
async with AsyncSessionLocal() as db:
for (url_id, url, _, _), check_result in zip(rows, results):
if isinstance(check_result, Exception):
status, response_ms = "error", None
else:
status, response_ms = check_result
pu = await db.get(ProviderUrl, url_id)
if pu:
pu.status = status
pu.response_ms = response_ms
pu.last_checked_at = now
output.append({"id": url_id, "url": url, "status": status, "response_ms": response_ms})
await db.commit()
return output
async def health_check_loop() -> None:
"""Runs forever, checking all active provider URLs every CHECK_INTERVAL seconds."""
logger.info("Provider URL health checker started")
while True:
await asyncio.sleep(CHECK_INTERVAL)
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ProviderUrl.id, ProviderUrl.url, ProviderAccount.username, ProviderAccount.password)
.join(ProviderAccount)
.where(ProviderUrl.is_active == True) # noqa: E712
)
rows = result.all()
if not rows:
continue
tasks = [_check_url(url, username, password) for _, url, username, password in rows]
check_results = await asyncio.gather(*tasks, return_exceptions=True)
now = datetime.now(timezone.utc)
async with AsyncSessionLocal() as db:
for (url_id, _, _, _), check_result in zip(rows, check_results):
if isinstance(check_result, Exception):
status, response_ms = "error", None
else:
status, response_ms = check_result
pu = await db.get(ProviderUrl, url_id)
if pu:
pu.status = status
pu.response_ms = response_ms
pu.last_checked_at = now
await db.commit()
logger.info(f"Health check done: {len(rows)} URLs checked")
except Exception as e:
logger.error(f"Health check loop error: {e}")
+120
View File
@@ -0,0 +1,120 @@
"""Async Jellyfin API client — discovery, libraries, items, streaming."""
import logging
import aiohttp
logger = logging.getLogger(__name__)
_SHORT = aiohttp.ClientTimeout(total=10)
_MEDIUM = aiohttp.ClientTimeout(total=30)
_LONG = aiohttp.ClientTimeout(total=60)
class JellyfinClient:
def __init__(self, url: str, api_key: str):
self.url = url.rstrip("/")
self.api_key = api_key
@property
def _headers(self) -> dict:
return {"X-Emby-Token": self.api_key, "Accept": "application/json"}
# ------------------------------------------------------------------
# Discovery
# ------------------------------------------------------------------
async def test_connection(self) -> dict:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/System/Info/Public", timeout=_SHORT
) as r:
r.raise_for_status()
return await r.json()
async def get_libraries(self) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Library/VirtualFolders",
headers=self._headers,
timeout=_SHORT,
) as r:
r.raise_for_status()
data = await r.json()
return data if isinstance(data, list) else []
# ------------------------------------------------------------------
# Items (movies / series)
# ------------------------------------------------------------------
async def get_items(self, library_id: str, item_type: str) -> list[dict]:
"""Fetch all items of a given type from one library (paginated)."""
all_items: list[dict] = []
start = 0
limit = 200
while True:
params = {
"ParentId": library_id,
"IncludeItemTypes": item_type,
"Recursive": "true",
"Fields": "Overview,Genres,OfficialRating,ProductionYear",
"Limit": str(limit),
"StartIndex": str(start),
}
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Items",
params=params,
headers=self._headers,
timeout=_LONG,
) as r:
r.raise_for_status()
data = await r.json()
batch = data.get("Items", [])
all_items.extend(batch)
total = data.get("TotalRecordCount", 0)
if len(all_items) >= total or not batch:
break
start += len(batch)
return all_items
# ------------------------------------------------------------------
# Series structure (seasons + episodes)
# ------------------------------------------------------------------
async def get_seasons(self, series_id: str) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Shows/{series_id}/Seasons",
params={"Fields": "Overview"},
headers=self._headers,
timeout=_MEDIUM,
) as r:
r.raise_for_status()
return (await r.json()).get("Items", [])
async def get_episodes(self, series_id: str, season_id: str) -> list[dict]:
async with aiohttp.ClientSession() as s:
async with s.get(
f"{self.url}/Shows/{series_id}/Episodes",
params={"SeasonId": season_id, "Fields": "Overview"},
headers=self._headers,
timeout=_MEDIUM,
) as r:
r.raise_for_status()
return (await r.json()).get("Items", [])
# ------------------------------------------------------------------
# URL builders
# ------------------------------------------------------------------
def build_stream_url(self, item_id: str) -> str:
return (
f"{self.url}/Videos/{item_id}/stream"
f"?static=true&api_key={self.api_key}&Container=mkv"
)
def build_image_url(self, item_id: str, max_width: int = 400) -> str:
return (
f"{self.url}/Items/{item_id}/Images/Primary"
f"?api_key={self.api_key}&MaxWidth={max_width}"
)
+314
View File
@@ -0,0 +1,314 @@
"""ProviderPool: manages provider account slots and live stream allocation."""
import asyncio
import json
import logging
import time
from typing import AsyncIterator
import redis.asyncio as aioredis
from ..config import settings
from .restream import BroadcastGroup, ClientHandle
logger = logging.getLogger(__name__)
ACTIVE_STREAM_KEY = "ks:stream:{channel_id}"
PROVIDER_SLOT_KEY = "ks:slot:{account_id}"
USER_CONN_KEY = "ks:user_conns:{user_id}"
MONITORING_KEY = "ks:monitoring"
class NoSlotsAvailableError(Exception):
pass
class MaxConnectionsExceededError(Exception):
pass
class ProviderPool:
"""
Singleton managing all active BroadcastGroups and provider slot allocation.
Uses Redis for cross-process state and in-memory dict for the asyncio objects.
"""
def __init__(self):
self._groups: dict[int, BroadcastGroup] = {}
self._lock = asyncio.Lock()
self._redis: aioredis.Redis | None = None
async def init(self) -> None:
self._redis = aioredis.from_url(settings.redis_url, decode_responses=True)
await self._redis.ping()
# Clear any stale keys left from a previous process crash or restart
stale = await self._redis.keys("ks:*")
if stale:
await self._redis.delete(*stale)
logger.info(f"ProviderPool cleared {len(stale)} stale Redis keys on startup")
logger.info("ProviderPool initialized, Redis connected")
async def close(self) -> None:
if self._redis:
await self._redis.aclose()
# -------------------------------------------------------------------------
# Public interface
# -------------------------------------------------------------------------
async def acquire(
self,
channel_id: int,
stream_url_resolver, # async callable(provider_account_id) -> str
provider_maps: list[dict], # [{"provider_account_id": X, "stream_url": Y, "provider_name": Z}, ...]
user_id: str,
user_max_conns: int,
is_priority: bool = False,
preferred_provider_ids: list[int] | None = None,
username: str = "",
channel_name: str = "",
) -> ClientHandle:
await self._check_user_connections(user_id, user_max_conns)
async with self._lock:
# Case 1: stream already active → attach to existing group
if channel_id in self._groups:
group = self._groups[channel_id]
handle = await group.add_client(user_id, username=username, is_priority=is_priority)
await self._redis.hincrby(ACTIVE_STREAM_KEY.format(channel_id=channel_id), "clients", 1)
await self._redis.sadd(USER_CONN_KEY.format(user_id=user_id), handle.client_id)
await self._publish_monitoring()
logger.info(f"User {user_id} attached to existing stream ch={channel_id}")
return handle
# Case 2: find a free slot (preferred providers sorted first)
sorted_maps = self._sort_by_preference(provider_maps, preferred_provider_ids)
account_id, stream_url = await self._find_free_slot(sorted_maps)
if account_id is None:
if not is_priority:
raise NoSlotsAvailableError(f"No free provider slots for channel {channel_id}")
# Case 3: priority eviction — bump a non-priority stream
account_id, stream_url, victim_group = await self._find_eviction_candidate(sorted_maps)
if account_id is None:
raise NoSlotsAvailableError(f"No evictable non-priority streams for channel {channel_id}")
# Clean Redis for the victim stream inside the lock
victim_channel_id = victim_group.channel_id
victim_account_id = victim_group.provider_account_id
self._groups.pop(victim_channel_id, None)
await self._redis.delete(ACTIVE_STREAM_KEY.format(channel_id=victim_channel_id))
await self._redis.delete(PROVIDER_SLOT_KEY.format(account_id=victim_account_id))
# Stop victim group outside lock (disconnect all its clients via SENTINEL)
asyncio.create_task(victim_group.stop())
logger.info(
f"Priority eviction: stopped ch={victim_channel_id} "
f"(provider_account={victim_account_id}) for priority user {user_id}"
)
# Reserve slot
await self._redis.set(PROVIDER_SLOT_KEY.format(account_id=account_id), channel_id)
provider_name = next(
(pm.get("provider_name", "") for pm in provider_maps if pm["provider_account_id"] == account_id), ""
)
stream_urls = await self._build_stream_urls(account_id, stream_url)
group = BroadcastGroup(
channel_id=channel_id,
stream_urls=stream_urls,
provider_account_id=account_id,
channel_name=channel_name,
provider_name=provider_name,
)
self._groups[channel_id] = group
handle = await group.add_client(user_id, username=username, is_priority=is_priority)
await self._redis.hset(
ACTIVE_STREAM_KEY.format(channel_id=channel_id),
mapping={
"provider_account_id": account_id,
"stream_url": stream_url,
"clients": 1,
"started_at": time.time(),
},
)
await self._redis.sadd(USER_CONN_KEY.format(user_id=user_id), handle.client_id)
await group.start()
await self._publish_monitoring()
logger.info(f"New stream started ch={channel_id} provider_account={account_id}")
return handle
async def release(self, channel_id: int, client_id: str, user_id: str) -> None:
group_to_stop: BroadcastGroup | None = None
stopped_account_id: int | None = None
async with self._lock:
group = self._groups.get(channel_id)
if not group:
return
remaining = await group.remove_client(client_id)
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), client_id)
if remaining == 0:
# Remove from registry and clean Redis *while holding the lock*,
# but defer group.stop() (which awaits task cancellation) until
# after the lock is released to avoid blocking the pool.
self._groups.pop(channel_id, None)
group_to_stop = group
stopped_account_id = group.provider_account_id
await self._redis.delete(ACTIVE_STREAM_KEY.format(channel_id=channel_id))
await self._redis.delete(PROVIDER_SLOT_KEY.format(account_id=stopped_account_id))
else:
await self._redis.hset(
ACTIVE_STREAM_KEY.format(channel_id=channel_id), "clients", remaining
)
await self._publish_monitoring()
if group_to_stop is not None:
await group_to_stop.stop()
logger.info(f"Stream stopped ch={channel_id}, slot freed: provider_account={stopped_account_id}")
async def kill_client(self, client_id: str) -> bool:
"""Admin: force-disconnect a specific client by sending SENTINEL to their queue."""
async with self._lock:
for group in self._groups.values():
if client_id in group._clients:
return await group.force_disconnect_client(client_id)
return False
def get_active_streams(self) -> list[dict]:
return [g.stats() for g in self._groups.values()]
async def get_user_connection_count(self, user_id: str) -> int:
return await self._redis.scard(USER_CONN_KEY.format(user_id=user_id))
# -------------------------------------------------------------------------
# Internal helpers
# -------------------------------------------------------------------------
async def _check_user_connections(self, user_id: str, max_conns: int) -> None:
# Cross-check Redis set against in-memory groups to auto-heal stale entries
redis_conns = await self._redis.smembers(USER_CONN_KEY.format(user_id=user_id))
if redis_conns:
active = {cid for g in self._groups.values() for cid in g.client_ids}
stale = redis_conns - active
if stale:
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), *stale)
logger.warning(f"Auto-cleaned {len(stale)} stale conn(s) for user {user_id}")
redis_conns -= stale
count = len(redis_conns)
if count >= max_conns:
# Determine if this is a zapping scenario (same device switching channels quickly)
# or a genuine multi-device connection attempt.
# Zapping: the existing connection is very recent (< 10 s) — the TV app opens the
# new stream before the HTTP response of the old one closes. In that case we evict
# silently so the channel switch is seamless.
# Multi-device: the existing connection is older — keep it alive and raise so the
# caller can show a "blocked" stream on the new device instead.
now = time.time()
groups_snapshot = list(self._groups.values())
oldest_age = 0.0
for group in groups_snapshot:
for cid, handle in group._clients.items():
if cid in redis_conns:
oldest_age = max(oldest_age, now - handle.connected_at)
if oldest_age < 10.0:
# Zapping — evict silently
logger.info(f"User {user_id} zapping ({oldest_age:.1f}s old conn) — evicting old stream")
for client_id in list(redis_conns):
for group in groups_snapshot:
if client_id in group._clients:
await group.force_disconnect_client(client_id)
break
await self._redis.srem(USER_CONN_KEY.format(user_id=user_id), client_id)
else:
# Multi-device — reject so caller can show blocked image
raise MaxConnectionsExceededError(
f"User {user_id} already has {count}/{max_conns} connections"
)
def _sort_by_preference(
self, provider_maps: list[dict], preferred_ids: list[int] | None
) -> list[dict]:
if not preferred_ids:
return provider_maps
pref_set = set(preferred_ids)
preferred = [pm for pm in provider_maps if pm["provider_account_id"] in pref_set]
rest = [pm for pm in provider_maps if pm["provider_account_id"] not in pref_set]
return preferred + rest
async def _build_stream_urls(self, account_id: int, primary_stream_url: str) -> list[str]:
"""Build list of stream URLs for failover: one per active ProviderUrl, substituting base URL."""
from urllib.parse import urlparse, urlunparse
from ..models.provider import ProviderUrl
from ..database import AsyncSessionLocal
from sqlalchemy import select
parsed = urlparse(primary_stream_url)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ProviderUrl)
.where(
ProviderUrl.provider_account_id == account_id,
ProviderUrl.is_active == True, # noqa: E712
)
.order_by(ProviderUrl.priority)
)
provider_urls = result.scalars().all()
if not provider_urls:
return [primary_stream_url]
urls = []
for pu in provider_urls:
alt = urlparse(pu.url.rstrip('/'))
reconstructed = urlunparse((
alt.scheme or parsed.scheme,
alt.netloc,
parsed.path,
parsed.params,
parsed.query,
parsed.fragment,
))
urls.append(reconstructed)
return urls
async def _find_free_slot(self, provider_maps: list[dict]) -> tuple[int | None, str | None]:
for pm in provider_maps:
account_id = pm["provider_account_id"]
slot_key = PROVIDER_SLOT_KEY.format(account_id=account_id)
busy = await self._redis.exists(slot_key)
if not busy:
return account_id, pm["stream_url"]
return None, None
async def _find_eviction_candidate(
self, provider_maps: list[dict]
) -> tuple[int | None, str | None, BroadcastGroup | None]:
"""Find a stream with only non-priority clients that uses a slot we need."""
available = {pm["provider_account_id"]: pm["stream_url"] for pm in provider_maps}
candidate: BroadcastGroup | None = None
for group in self._groups.values():
if group.provider_account_id in available and group.all_non_priority:
if candidate is None or group.client_count < candidate.client_count:
candidate = group
if candidate is None:
return None, None, None
return candidate.provider_account_id, available[candidate.provider_account_id], candidate
async def _publish_monitoring(self) -> None:
try:
data = json.dumps([g.stats() for g in self._groups.values()])
await self._redis.publish(MONITORING_KEY, data)
except Exception:
pass
pool = ProviderPool()
+112
View File
@@ -0,0 +1,112 @@
"""FFprobe-based stream metadata — resolution, FPS, codecs, bitrate."""
import asyncio
import json
import logging
import time
logger = logging.getLogger(__name__)
# Simple in-process cache: url → (info_dict, fetched_at)
_cache: dict[str, tuple[dict, float]] = {}
_CACHE_TTL = 3600 # 1 hour — re-probe if URL expires or stream restarts
# Locks per URL to avoid parallel probes for the same stream
_locks: dict[str, asyncio.Lock] = {}
async def probe_stream(url: str) -> dict:
"""
Run ffprobe against *url* and return a dict with video/audio metadata.
Returns {} on failure (non-fatal — the stream still works).
Results are cached for CACHE_TTL seconds.
"""
now = time.time()
cached = _cache.get(url)
if cached and now - cached[1] < _CACHE_TTL:
return cached[0]
if url not in _locks:
_locks[url] = asyncio.Lock()
async with _locks[url]:
# Re-check after acquiring lock (another task may have already probed)
cached = _cache.get(url)
if cached and now - cached[1] < _CACHE_TTL:
return cached[0]
info = await _run_ffprobe(url)
_cache[url] = (info, time.time())
return info
async def _run_ffprobe(url: str) -> dict:
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-show_format",
"-analyzeduration", "3000000", # 3 s — fast enough for live streams
"-probesize", "1000000", # 1 MB
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
logger.debug(f"ffprobe timed out for {url}")
return {}
data = json.loads(stdout)
except Exception as e:
logger.debug(f"ffprobe failed for {url}: {e}")
return {}
info: dict = {}
for stream in data.get("streams", []):
ctype = stream.get("codec_type", "")
if ctype == "video" and "video_codec" not in info:
info["video_codec"] = stream.get("codec_name", "")
info["video_profile"] = stream.get("profile", "")
info["width"] = stream.get("width", 0)
info["height"] = stream.get("height", 0)
# FPS: prefer avg_frame_rate, fallback r_frame_rate
for fps_field in ("avg_frame_rate", "r_frame_rate"):
fps_str = stream.get(fps_field, "")
if fps_str and "/" in fps_str:
try:
num, den = fps_str.split("/")
fps = round(int(num) / int(den), 3) if int(den) else 0
if 1 < fps < 200: # sanity check
info["fps"] = round(fps, 2)
break
except (ValueError, ZeroDivisionError):
pass
elif ctype == "audio" and "audio_codec" not in info:
info["audio_codec"] = stream.get("codec_name", "")
info["audio_channels"] = stream.get("channels", 0)
info["audio_sample_rate"] = int(stream.get("sample_rate", 0) or 0)
fmt = data.get("format", {})
try:
br = int(fmt.get("bit_rate", 0) or 0)
if br > 0:
info["bitrate_bps"] = br
except (ValueError, TypeError):
pass
logger.debug(f"Probed {url}: {info}")
return info
def invalidate(url: str) -> None:
"""Evict a URL from the cache (call when stream URL changes)."""
_cache.pop(url, None)
+610
View File
@@ -0,0 +1,610 @@
"""BroadcastGroup: fan-out a single provider stream to N simultaneous clients.
Recovery model
--------------
The pump loop runs indefinitely while self._running is True. Each iteration
spawns an ffmpeg process in copy (remux) mode. ffmpeg owns the upstream
HTTP connection. When the provider closes the session (typically every
~2 minutes) ffmpeg exits and the outer loop spawns a fresh process — the
provider delivers from the current live edge so timestamps are always
forward-moving and the player never sees a backward seek (rewind).
If ffmpeg itself exits (unrecoverable error or stall beyond its own timeout)
the outer loop drains all client queues, backs off briefly, and spawns a
fresh ffmpeg process — again without disconnecting clients.
SENTINEL is only pushed when stop() is called or the pump truly gives up.
Stall detection
---------------
ffmpeg's -timeout option (socket-level read timeout) handles provider stalls.
A safety-net asyncio.wait_for wraps each stdout read in case ffmpeg hangs
without producing output or exiting.
A/V sync
--------
ffmpeg re-muxes the incoming MPEG-TS into a fresh output stream. On every
reconnect the output PTS/DTS sequence is continuous regardless of provider-
side timestamp resets, eliminating audio desync entirely. No video or audio
is re-encoded — quality is bit-for-bit identical to the source.
"""
import asyncio
import logging
import os
import shutil
import time
import uuid
from dataclasses import dataclass, field
from typing import AsyncIterator
from ..config import settings
logger = logging.getLogger(__name__)
CHUNK_SIZE = settings.stream_chunk_size
QUEUE_MAX = settings.stream_queue_maxsize
STALL_TIMEOUT = 10.0 # seconds — passed to ffmpeg -timeout (in µs below)
MAX_RETRIES = 999 # effectively infinite; pool stops the group when 0 clients
_BACKOFF_CAP = 5.0 # max seconds between outer retries
_SENTINEL = object() # signals end-of-stream to client queues
_FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
# ffmpeg stderr patterns that indicate input-side A/V issues (demuxer warnings).
# These don't affect output quality with -use_wallclock_as_timestamps but are
# counted as a health metric to surface in the dashboard.
_AV_WARN_PATTERNS = (
"timestamp discontinuity",
"Packet corrupt",
"PES packet size mismatch",
"DTS, out of order",
)
# Health check: data-age thresholds
_HEALTH_WARN_SECS = 12.0 # > 12 s without data → warning (normal reconnects are 1-4 s)
_HEALTH_ERROR_SECS = 25.0 # > 25 s → error, trigger correction
def _url_netloc(url: str) -> str:
from urllib.parse import urlparse
try:
return urlparse(url).netloc
except Exception:
return url
@dataclass
class ClientHandle:
client_id: str
queue: asyncio.Queue = field(default_factory=lambda: asyncio.Queue(maxsize=QUEUE_MAX))
connected_at: float = field(default_factory=time.time)
is_priority: bool = False
username: str = ""
_overflow_count: int = 0
_overflow_since: float | None = None
async def read(self) -> AsyncIterator[bytes]:
while True:
chunk = await self.queue.get()
if chunk is _SENTINEL:
break
yield chunk
class BroadcastGroup:
"""Streams one provider URL and distributes chunks to all registered clients."""
def __init__(
self,
channel_id: int,
stream_urls: list[str],
provider_account_id: int,
channel_name: str = "",
provider_name: str = "",
# kept for backward compat in callers that still pass stream_url as kwarg
stream_url: str | None = None,
):
self.channel_id = channel_id
self.channel_name = channel_name
# Support old callers that pass a single stream_url
if stream_urls:
self._stream_urls = stream_urls
elif stream_url:
self._stream_urls = [stream_url]
else:
self._stream_urls = []
self.stream_url = self._stream_urls[0] if self._stream_urls else ""
self._active_url: str = self.stream_url
self.provider_account_id = provider_account_id
self.provider_name = provider_name
self.started_at = time.time()
self._clients: dict[str, ClientHandle] = {}
self._lock = asyncio.Lock()
self._task: asyncio.Task | None = None
self._running = False
# Throughput tracking
self._bytes_pumped: int = 0
self._bytes_prev: int = 0
self._bps_time: float = time.time()
self._bps_down: float = 0.0
# Health / metadata
self.stream_info: dict = {}
self._reconnect_count: int = 0
self._status: str = "connecting"
self._last_data_at: float = time.time()
# ffmpeg process tracking
self._ffmpeg_pid: int | None = None
self._ffmpeg_start_time: float | None = None # wall time when ffmpeg started
self._ffmpeg_cpu: float = 0.0
self._cpu_task: asyncio.Task | None = None
# A/V health tracking
self._av_health: str = "ok" # ok / warning / error
self._av_desync_count: int = 0 # cumulative stderr A/V-warning events
self._corrections: int = 0 # how many times health monitor killed stuck ffmpeg
self._health_task: asyncio.Task | None = None
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def client_count(self) -> int:
return len(self._clients)
@property
def client_ids(self) -> list[str]:
return list(self._clients.keys())
@property
def all_non_priority(self) -> bool:
return all(not h.is_priority for h in self._clients.values())
async def start(self) -> None:
self._running = True
self._task = asyncio.create_task(
self._pump_with_retry(), name=f"pump-ch{self.channel_id}"
)
self._cpu_task = asyncio.create_task(
self._monitor_cpu(), name=f"cpu-ch{self.channel_id}"
)
self._health_task = asyncio.create_task(
self._health_check_loop(), name=f"health-ch{self.channel_id}"
)
asyncio.create_task(self._probe_info(), name=f"probe-ch{self.channel_id}")
async def add_client(self, user_id: str, username: str = "", is_priority: bool = False) -> ClientHandle:
client_id = f"{user_id}-{uuid.uuid4().hex[:8]}"
handle = ClientHandle(client_id=client_id, is_priority=is_priority, username=username)
async with self._lock:
self._clients[client_id] = handle
logger.info(
f"Client {client_id} ({username}) joined channel {self.channel_id} "
f"(total={len(self._clients)})"
)
return handle
async def force_disconnect_client(self, client_id: str) -> bool:
async with self._lock:
handle = self._clients.get(client_id)
if handle is None:
return False
_drain_queue(handle.queue)
try:
handle.queue.put_nowait(_SENTINEL)
except asyncio.QueueFull:
pass
return True
async def remove_client(self, client_id: str) -> int:
async with self._lock:
self._clients.pop(client_id, None)
remaining = len(self._clients)
logger.info(f"Client {client_id} left channel {self.channel_id} (remaining={remaining})")
return remaining
async def stop(self) -> None:
self._running = False
self._status = "stopped"
for task in (self._cpu_task, self._health_task):
if task and not task.done():
task.cancel()
self._cpu_task = self._health_task = None
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async with self._lock:
for handle in self._clients.values():
try:
handle.queue.put_nowait(_SENTINEL)
except asyncio.QueueFull:
pass
def stats(self) -> dict:
n = self.client_count or 1
return {
"stream_type": "live",
"channel_id": self.channel_id,
"channel_name": self.channel_name or f"Canal #{self.channel_id}",
"provider_account_id": self.provider_account_id,
"provider_name": self.provider_name,
"client_count": self.client_count,
"clients": [
{
"client_id": h.client_id,
"username": h.username or h.client_id.rsplit("-", 1)[0],
}
for h in self._clients.values()
],
"started_at": self.started_at,
"bytes_pumped": self._bytes_pumped,
"bps_down": self._bps_down,
"bps_up": self._bps_down * n,
"running": self._running,
"status": self._status,
"reconnect_count": self._reconnect_count,
"last_data_ago": round(time.time() - self._last_data_at, 1),
"stream_info": self.stream_info,
# ffmpeg process info
"ffmpeg_pid": self._ffmpeg_pid,
"ffmpeg_cpu_pct": round(self._ffmpeg_cpu, 2),
# multi-domain failover info
"active_url_domain": _url_netloc(self._active_url),
"url_count": len(self._stream_urls),
# A/V health
"av_health": self._av_health,
"av_desync_count": self._av_desync_count,
"corrections": self._corrections,
"buffer_pct": self._buffer_pct(),
"buffer_server_bytes": self._buffer_server_bytes(),
"buffer_server_secs": self._buffer_server_secs(),
"buffer_capacity": QUEUE_MAX * CHUNK_SIZE,
}
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _monitor_cpu(self) -> None:
"""Update self._ffmpeg_cpu every 3 s using cumulative average since start.
Delta-based sampling fails for low-CPU processes (copy-mode ffmpeg uses
<1% CPU) because the tick delta in 2 s is 0-1 integer ticks, making the
result always 0.0%. Cumulative average ((total_cpu_ticks / clk_tck) /
elapsed_wall) gives a stable, accurate reading — the same method ps uses.
"""
clk_tck = float(os.sysconf(os.sysconf_names.get("SC_CLK_TCK", 2)) or 100)
while self._running:
await asyncio.sleep(3)
pid = self._ffmpeg_pid
start_time = self._ffmpeg_start_time
if pid is None or start_time is None:
self._ffmpeg_cpu = 0.0
continue
try:
with open(f"/proc/{pid}/stat") as f:
parts = f.read().split()
cpu_ticks = int(parts[13]) + int(parts[14]) # utime + stime
elapsed = time.time() - start_time
if elapsed > 0:
self._ffmpeg_cpu = cpu_ticks / clk_tck / elapsed * 100
except (FileNotFoundError, IndexError, ValueError, OSError):
self._ffmpeg_cpu = 0.0
def _buffer_pct(self) -> float:
"""Return queue fill % of the slowest (most backlogged) client (0–100)."""
if not self._clients:
return 0.0
max_fill = max(h.queue.qsize() for h in self._clients.values())
return round(max_fill / QUEUE_MAX * 100, 1)
def _buffer_server_bytes(self) -> int:
"""Bytes buffered for the least-buffered client (first to run dry on provider drop)."""
if not self._clients:
return 0
return min(h.queue.qsize() for h in self._clients.values()) * CHUNK_SIZE
def _buffer_server_secs(self) -> float:
"""Estimated seconds of coverage at current bitrate."""
if not self._clients or self._bps_down <= 0:
return 0.0
return round(self._buffer_server_bytes() / self._bps_down, 1)
async def _health_check_loop(self) -> None:
"""Update _av_health every 5 s based on data-flow age.
If ffmpeg is running but producing no output for _HEALTH_ERROR_SECS,
kill it so _pump_with_retry can restart it immediately — this is the
auto-correction the caller sees as a 'corrections' counter tick.
"""
consecutive_error = 0
while self._running:
await asyncio.sleep(5)
if not self._running:
break
age = time.time() - self._last_data_at
if age < _HEALTH_WARN_SECS:
self._av_health = "ok"
consecutive_error = 0
elif age < _HEALTH_ERROR_SECS:
self._av_health = "warning"
consecutive_error = 0
else:
self._av_health = "error"
consecutive_error += 1
# After 2 consecutive error checks (≈10 s) kill a stuck ffmpeg
# so _pump_with_retry can restart it without waiting for the
# 25-second safety-net timeout in _pump_once.
if consecutive_error >= 2 and self._ffmpeg_pid is not None:
import signal as _sig
try:
os.kill(self._ffmpeg_pid, _sig.SIGTERM)
self._corrections += 1
logger.warning(
f"[ch{self.channel_id}] Health correction #{self._corrections}: "
f"killed stuck ffmpeg pid={self._ffmpeg_pid} "
f"(no data for {age:.1f}s)"
)
except (ProcessLookupError, PermissionError):
pass
consecutive_error = 0 # reset so we don't spam kills
async def _log_stream_event(
self,
event_type: str,
domain: str,
error_message: str,
attempt: int,
) -> None:
"""Persist a stream failure/recovery event to the database (fire-and-forget)."""
try:
from ..database import AsyncSessionLocal
from ..models.stream_event import StreamEvent
async with AsyncSessionLocal() as db:
ev = StreamEvent(
channel_id=self.channel_id,
channel_name=self.channel_name or f"Canal #{self.channel_id}",
provider_name=self.provider_name,
event_type=event_type,
domain=domain,
error_message=error_message[:500] if error_message else None,
attempt_number=attempt,
)
db.add(ev)
await db.commit()
except Exception as exc:
logger.debug(f"[ch{self.channel_id}] Failed to persist stream event: {exc}")
async def _probe_info(self) -> None:
from .probe import probe_stream
try:
info = await probe_stream(self.stream_url)
self.stream_info.update(info)
except Exception as e:
logger.debug(f"probe_info failed for ch{self.channel_id}: {e}")
async def _pump_with_retry(self) -> None:
consecutive = 0
base_wait = 0.5
url_idx = 0
n = len(self._stream_urls) or 1
try:
while self._running:
current_url = self._stream_urls[url_idx % n]
self._active_url = current_url
pump_start = time.time()
try:
await self._pump_once(current_url)
url_idx = 0 # reset to primary after successful pump
break
except asyncio.CancelledError:
raise
except Exception as e:
if not self._running:
break
pump_duration = time.time() - pump_start
# Recovery detection: had failures before but this attempt ran stably
# for >30s — the stream was healthy and dropped again (e.g. provider
# session timeout). Log recovery, then treat next failure as fresh drop.
if consecutive > 1 and pump_duration > 30:
asyncio.create_task(self._log_stream_event(
"recuperado",
_url_netloc(current_url),
f"Estable {int(pump_duration)}s antes de caer de nuevo",
consecutive,
))
consecutive = 0 # reset so next failure is logged as a fresh "caida"
consecutive += 1
self._reconnect_count += 1
next_idx = (url_idx + 1) % n
cycle_done = (consecutive % n == 0)
# Log the first drop of each failure sequence
if consecutive == 1:
asyncio.create_task(self._log_stream_event(
"caida",
_url_netloc(current_url),
str(e),
1,
))
if n > 1:
logger.warning(
f"[ch{self.channel_id}] Domain {_url_netloc(current_url)} failed "
f"(attempt {consecutive}), trying {_url_netloc(self._stream_urls[next_idx])}: {e}"
)
else:
logger.warning(
f"[ch{self.channel_id}] Upstream error (attempt {consecutive}): {e}"
)
url_idx = next_idx
self._status = "reconnecting"
if cycle_done:
# All domains tried — back off before next cycle.
# Do NOT drain client queues: the buffered data (up to 32 MB) covers
# the reconnect window so players never see a black screen.
wait = min(base_wait * (2 ** (consecutive // n - 1)), _BACKOFF_CAP)
logger.warning(f"[ch{self.channel_id}] All {n} domain(s) failed, retrying in {wait:.1f}s")
await asyncio.sleep(wait)
# else: try next domain immediately (no sleep)
finally:
self._status = "stopped"
self._running = False
async with self._lock:
for handle in self._clients.values():
_drain_queue(handle.queue)
try:
handle.queue.put_nowait(_SENTINEL)
except asyncio.QueueFull:
pass
async def _pump_once(self, url: str | None = None) -> None:
"""
Spawn ffmpeg in copy (remux) mode and pump its stdout to all client
queues until ffmpeg exits or self._running goes False.
Reconnection is intentionally NOT delegated to ffmpeg (-reconnect_streamed
is omitted). When the provider closes the TCP session (typically every
~2 minutes), ffmpeg exits cleanly and _pump_with_retry immediately spawns
a fresh process that opens a brand-new HTTP connection. The provider then
delivers the stream from the current live edge — timestamps are always
forward-moving. If ffmpeg were to reconnect internally it would often
receive a few seconds of already-seen data (the provider rewinds to the
last keyframe boundary), causing a visible backward seek in the player.
"""
stream_url = url if url is not None else self.stream_url
cmd = [
_FFMPEG,
"-hide_banner", "-loglevel", "warning",
"-timeout", str(int(STALL_TIMEOUT * 1_000_000)),
"-i", stream_url,
"-c", "copy",
"-f", "mpegts",
"pipe:1",
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._status = "ok"
self._last_data_at = time.time()
self._ffmpeg_pid = proc.pid
self._ffmpeg_start_time = time.time()
logger.info(
f"[ch{self.channel_id}] ffmpeg started pid={proc.pid} "
f"(reconnects so far: {self._reconnect_count})"
)
# Drain stderr in background so the pipe never fills and blocks ffmpeg.
stderr_task = asyncio.create_task(self._log_ffmpeg_stderr(proc))
try:
while self._running:
try:
chunk = await asyncio.wait_for(
proc.stdout.read(CHUNK_SIZE),
timeout=STALL_TIMEOUT + 15, # safety net beyond ffmpeg's own timeout
)
except asyncio.TimeoutError:
raise RuntimeError(
f"ffmpeg produced no output for {STALL_TIMEOUT + 15:.0f}s"
)
if not chunk:
# ffmpeg exited cleanly or with error
rc = proc.returncode
raise RuntimeError(f"ffmpeg exited (rc={rc})")
# Throughput accounting
self._bytes_pumped += len(chunk)
self._last_data_at = time.time()
now = self._last_data_at
if now - self._bps_time >= 2.0:
self._bps_down = (self._bytes_pumped - self._bytes_prev) / (now - self._bps_time)
self._bytes_prev = self._bytes_pumped
self._bps_time = now
await self._dispatch(chunk, now)
finally:
self._ffmpeg_pid = None
self._ffmpeg_start_time = None
self._ffmpeg_cpu = 0.0
stderr_task.cancel()
try:
proc.kill()
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except asyncio.TimeoutError:
pass
async def _log_ffmpeg_stderr(self, proc: asyncio.subprocess.Process) -> None:
"""Read ffmpeg stderr lines, log them and count A/V-quality events."""
try:
async for line in proc.stderr:
text = line.decode(errors="replace").rstrip()
if text:
logger.warning(f"[ch{self.channel_id}] ffmpeg: {text}")
if any(p in text for p in _AV_WARN_PATTERNS):
self._av_desync_count += 1
except asyncio.CancelledError:
pass
except Exception:
pass
async def _dispatch(self, chunk: bytes, now: float) -> None:
"""Push chunk to every client queue; evict clients that have been full for >30 s."""
async with self._lock:
dead: list[str] = []
for cid, handle in self._clients.items():
try:
handle.queue.put_nowait(chunk)
handle._overflow_count = 0
handle._overflow_since = None
except asyncio.QueueFull:
handle._overflow_count += 1
if handle._overflow_since is None:
handle._overflow_since = now
elif now - handle._overflow_since > 30:
dead.append(cid)
for cid in dead:
logger.warning(
f"[ch{self.channel_id}] Evicting unresponsive client {cid} "
f"after 30 s of sustained queue overflow"
)
dropped = self._clients.pop(cid, None)
if dropped is not None:
_drain_queue(dropped.queue)
try:
dropped.queue.put_nowait(_SENTINEL)
except asyncio.QueueFull:
pass
def _drain_queue(q: asyncio.Queue) -> None:
"""Empty a queue non-blockingly."""
while not q.empty():
try:
q.get_nowait()
except asyncio.QueueEmpty:
break
+164
View File
@@ -0,0 +1,164 @@
"""VodTracker: lightweight session tracking for VOD and Series streams."""
import asyncio
import logging
import time
import uuid
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
STALE_TIMEOUT = 10 # seconds of no new bytes before a session is considered dead
@dataclass
class VodSession:
session_id: str
user_id: str
username: str
channel_id: int
channel_name: str
provider_account_id: int
provider_name: str
stream_type: str # "movie" or "series"
stream_url: str
started_at: float = field(default_factory=time.time)
last_active: float = field(default_factory=time.time)
bytes_pumped: int = 0
bps_down: float = 0.0 # download speed from provider
bps_up: float = 0.0 # upload speed to client (same as down for VOD, 1 client)
running: bool = True
_stop: asyncio.Event = field(default_factory=asyncio.Event)
# Set by _proxy_vod so kill() can immediately abort the upstream connection
_http_session: object = field(default=None, repr=False)
@property
def should_stop(self) -> bool:
return self._stop.is_set()
def kill(self) -> None:
self._stop.set()
# Close the upstream aiohttp session immediately so iter_chunked() raises
# and the generator's finally block runs without waiting for the next chunk.
http = self._http_session
if http is not None:
try:
http.connector.close()
except Exception:
pass
def to_dict(self) -> dict:
return {
"stream_type": self.stream_type,
"channel_id": self.channel_id,
"channel_name": self.channel_name,
"provider_account_id": self.provider_account_id,
"provider_name": self.provider_name,
"client_count": 1,
"clients": [{"client_id": self.session_id, "username": self.username}],
"started_at": self.started_at,
"bytes_pumped": self.bytes_pumped,
"bps_down": self.bps_down,
"bps_up": self.bps_up,
"running": self.running,
}
class VodTracker:
def __init__(self) -> None:
self._sessions: dict[str, VodSession] = {}
self._user_channel_map: dict[tuple[str, int], str] = {}
self._cleanup_task: asyncio.Task | None = None
def _ensure_cleanup_running(self) -> None:
"""Start the background staleness sweep if not already running."""
if self._cleanup_task is None or self._cleanup_task.done():
try:
loop = asyncio.get_running_loop()
self._cleanup_task = loop.create_task(self._cleanup_loop(), name="vod-cleanup")
except RuntimeError:
pass # no event loop yet — task will start on first session
async def _cleanup_loop(self) -> None:
"""Every 5 s, remove sessions that have received no new bytes for STALE_TIMEOUT s."""
while True:
await asyncio.sleep(5)
now = time.time()
stale = [
s for s in list(self._sessions.values())
if now - s.last_active > STALE_TIMEOUT
]
for s in stale:
logger.info(
f"Removing stale VOD session {s.session_id} "
f"(user={s.user_id}, ch={s.channel_id}, "
f"idle={now - s.last_active:.0f}s)"
)
s.kill()
self._sessions.pop(s.session_id, None)
key = (s.user_id, s.channel_id)
if self._user_channel_map.get(key) == s.session_id:
self._user_channel_map.pop(key, None)
def start(
self,
user_id: str,
username: str,
channel_id: int,
channel_name: str,
provider_account_id: int,
provider_name: str,
stream_type: str,
stream_url: str,
user_max_connections: int = 1,
) -> VodSession:
self._ensure_cleanup_running()
# Kill ALL existing VOD sessions for this user — one active VOD per user.
# Handles both probe-zombies (same channel) and movie switching (different channel).
for s in list(self._sessions.values()):
if s.user_id == user_id:
self._sessions.pop(s.session_id, None)
old_key = (s.user_id, s.channel_id)
if self._user_channel_map.get(old_key) == s.session_id:
self._user_channel_map.pop(old_key, None)
s.kill()
logger.info(
f"Killed VOD session {s.session_id} (ch={s.channel_id}) "
f"for user={user_id} — new session starting"
)
session_id = f"vod-{uuid.uuid4().hex[:8]}"
session = VodSession(
session_id=session_id,
user_id=user_id,
username=username,
channel_id=channel_id,
channel_name=channel_name,
provider_account_id=provider_account_id,
provider_name=provider_name,
stream_type=stream_type,
stream_url=stream_url,
)
self._sessions[session_id] = session
self._user_channel_map[(user_id, channel_id)] = session_id
return session
def end(self, session_id: str) -> None:
session = self._sessions.pop(session_id, None)
if session:
key = (session.user_id, session.channel_id)
if self._user_channel_map.get(key) == session_id:
self._user_channel_map.pop(key, None)
def kill(self, session_id: str) -> bool:
s = self._sessions.get(session_id)
if s:
s.kill()
return True
return False
def get_all(self) -> list[dict]:
return [s.to_dict() for s in self._sessions.values()]
vod_tracker = VodTracker()
+87
View File
@@ -0,0 +1,87 @@
"""Client for consuming Xtream Codes API from provider."""
import aiohttp
import asyncio
from typing import Any
import logging
logger = logging.getLogger(__name__)
class XtreamClient:
def __init__(self, base_url: str, username: str, password: str):
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
def _api_url(self) -> str:
return f"{self.base_url}/player_api.php"
async def _get(self, params: dict) -> Any:
params = {"username": self.username, "password": self.password, **params}
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(self._api_url(), params=params, ssl=False) as resp:
resp.raise_for_status()
return await resp.json(content_type=None)
async def get_user_info(self) -> dict:
return await self._get({})
async def get_live_categories(self) -> list[dict]:
return await self._get({"action": "get_live_categories"})
async def get_live_streams(self, category_id: str | None = None) -> list[dict]:
params = {"action": "get_live_streams"}
if category_id:
params["category_id"] = category_id
return await self._get(params)
async def get_vod_categories(self) -> list[dict]:
return await self._get({"action": "get_vod_categories"})
async def get_vod_streams(self, category_id: str | None = None) -> list[dict]:
params = {"action": "get_vod_streams"}
if category_id:
params["category_id"] = category_id
return await self._get(params)
async def get_series_categories(self) -> list[dict]:
return await self._get({"action": "get_series_categories"})
async def get_series(self, category_id: str | None = None) -> list[dict]:
params = {"action": "get_series"}
if category_id:
params["category_id"] = category_id
return await self._get(params)
async def get_series_info(self, series_id: str) -> dict:
return await self._get({"action": "get_series_info", "series_id": series_id})
async def get_vod_info(self, vod_id: str) -> dict:
return await self._get({"action": "get_vod_info", "vod_id": vod_id})
async def get_epg(self, stream_id: str | None = None) -> dict:
params = {"action": "get_short_epg"}
if stream_id:
params["stream_id"] = stream_id
return await self._get(params)
def build_live_stream_url(self, stream_id: str, ext: str = "ts") -> str:
return f"{self.base_url}/{self.username}/{self.password}/{stream_id}.{ext}"
def build_vod_stream_url(self, stream_id: str, ext: str = "mkv") -> str:
return f"{self.base_url}/movie/{self.username}/{self.password}/{stream_id}.{ext}"
def build_series_stream_url(self, stream_id: str, ext: str = "mkv") -> str:
return f"{self.base_url}/series/{self.username}/{self.password}/{stream_id}.{ext}"
def get_xmltv_url(self) -> str:
return f"{self.base_url}/xmltv.php?username={self.username}&password={self.password}"
async def test_connection(self) -> bool:
try:
info = await self.get_user_info()
return info.get("user_info", {}).get("status") == "Active"
except Exception as e:
logger.warning(f"Provider connection test failed: {e}")
return False