Initial commit: KiraTV IPTV proxy server
Full-stack IPTV management system with Xtream Codes API compatibility, multi-account provider fan-out, Jellyfin local library integration, real-time dashboard, EPG support, and A/V-sync-safe stream recovery.
This commit is contained in:
@@ -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,98 @@
|
||||
"""EPG manager: fetch XMLTV sources, merge by tvg-id, serve filtered by user catalog."""
|
||||
import asyncio
|
||||
import gzip
|
||||
import logging
|
||||
import time
|
||||
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=asyncio.get_event_loop().time()) # 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,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,276 @@
|
||||
"""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), ""
|
||||
)
|
||||
group = BroadcastGroup(
|
||||
channel_id=channel_id,
|
||||
stream_url=stream_url,
|
||||
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 _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,350 @@
|
||||
"""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
|
||||
opens a fresh upstream connection. If the connection fails or stalls the loop
|
||||
sends SENTINEL to every client (forcing the player to reconnect from scratch),
|
||||
sleeps briefly, then retries the upstream connection.
|
||||
|
||||
Sending SENTINEL on reconnect is intentional: keeping the TCP connection alive
|
||||
while splicing in chunks from a new provider connection causes a PTS/DTS jump
|
||||
that most players cannot reconcile silently — the result is permanent audio
|
||||
desync. A clean player reconnect gives the player a fresh timestamp timeline
|
||||
and guarantees A/V sync from the first frame of the new connection.
|
||||
|
||||
Stall detection
|
||||
---------------
|
||||
Each chunk read is wrapped in asyncio.wait_for(STALL_TIMEOUT). If the
|
||||
upstream stops sending bytes for STALL_TIMEOUT seconds we treat it as a dead
|
||||
stream and reconnect immediately rather than waiting for a TCP timeout that
|
||||
could take minutes.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import AsyncIterator
|
||||
|
||||
import aiohttp
|
||||
|
||||
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 of silence → reconnect (fast stall detection)
|
||||
MAX_RETRIES = 999 # effectively infinite — pool stops the group when 0 clients remain
|
||||
# Back-off: 0.5 s, 1 s, 2 s, 4 s … capped at 5 s
|
||||
_BACKOFF_CAP = 5.0
|
||||
|
||||
_SENTINEL = object() # signals end-of-stream to client queues
|
||||
|
||||
|
||||
@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_url: str,
|
||||
provider_account_id: int,
|
||||
channel_name: str = "",
|
||||
provider_name: str = "",
|
||||
):
|
||||
self.channel_id = channel_id
|
||||
self.channel_name = channel_name
|
||||
self.stream_url = 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 = {} # populated by background ffprobe
|
||||
self._reconnect_count: int = 0
|
||||
self._status: str = "connecting" # connecting | ok | reconnecting | stopped
|
||||
self._last_data_at: float = time.time()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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}"
|
||||
)
|
||||
# Probe stream info in background — does NOT delay the first chunk
|
||||
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"
|
||||
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,
|
||||
# Health
|
||||
"status": self._status,
|
||||
"reconnect_count": self._reconnect_count,
|
||||
"last_data_ago": round(time.time() - self._last_data_at, 1),
|
||||
# Stream metadata from ffprobe
|
||||
"stream_info": self.stream_info,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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:
|
||||
"""
|
||||
Outer retry loop. Each iteration calls _pump_once() which runs until
|
||||
the upstream connection dies or stalls. On failure we back off and
|
||||
retry without touching client connections.
|
||||
"""
|
||||
consecutive = 0
|
||||
base_wait = 1.0
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
await self._pump_once()
|
||||
# _pump_once returned cleanly (self._running went False)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if not self._running:
|
||||
break
|
||||
consecutive += 1
|
||||
self._reconnect_count += 1
|
||||
wait = min(base_wait * (2 ** (consecutive - 1)), _BACKOFF_CAP)
|
||||
self._status = "reconnecting"
|
||||
logger.warning(
|
||||
f"[ch{self.channel_id}] Upstream error (attempt {consecutive}), "
|
||||
f"reconnecting in {wait:.1f}s: {e}"
|
||||
)
|
||||
# Send SENTINEL to every client so the player drops its
|
||||
# own buffers and reconnects from scratch. This is the
|
||||
# only reliable way to prevent A/V desync: if we keep the
|
||||
# TCP connection alive but splice in chunks from a new
|
||||
# provider connection, the player sees a PTS/DTS jump it
|
||||
# cannot reconcile silently. A clean player reconnect
|
||||
# gives it a fresh timeline and guarantees sync.
|
||||
async with self._lock:
|
||||
for handle in self._clients.values():
|
||||
_drain_queue(handle.queue)
|
||||
try:
|
||||
handle.queue.put_nowait(_SENTINEL)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
await asyncio.sleep(wait)
|
||||
finally:
|
||||
self._status = "stopped"
|
||||
self._running = False
|
||||
# Signal all waiting clients that the stream is over
|
||||
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) -> None:
|
||||
"""
|
||||
Open one upstream connection and pump until EOF, stall, or error.
|
||||
Resets consecutive-failure counter on first successful chunk.
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
connect=settings.stream_connect_timeout,
|
||||
sock_read=None, # we control read timeouts via wait_for ourselves
|
||||
)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(self.stream_url, ssl=False) as resp:
|
||||
if resp.status not in (200, 206):
|
||||
raise RuntimeError(f"Provider returned HTTP {resp.status}")
|
||||
|
||||
self._status = "ok"
|
||||
self._last_data_at = time.time()
|
||||
logger.info(
|
||||
f"[ch{self.channel_id}] Stream connected "
|
||||
f"(reconnects so far: {self._reconnect_count})"
|
||||
)
|
||||
|
||||
while self._running:
|
||||
# --- read one chunk with stall timeout ---
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
resp.content.readany(), timeout=STALL_TIMEOUT
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise RuntimeError(
|
||||
f"Stream stalled: no data for {STALL_TIMEOUT:.0f}s"
|
||||
)
|
||||
|
||||
if not chunk:
|
||||
raise RuntimeError("Provider closed the stream (EOF)")
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- distribute to clients ---
|
||||
await self._dispatch(chunk, now)
|
||||
|
||||
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:
|
||||
# Drop-newest: discard this chunk for the slow client.
|
||||
# Preserves the already-buffered sequence → no A/V desync from gaps.
|
||||
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