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,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())
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user