Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
View File
+13
View File
@@ -0,0 +1,13 @@
import sys
from django.apps import AppConfig
class TSProxyConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.proxy.ts_proxy'
verbose_name = "TS Stream Proxies"
def ready(self):
"""Initialize proxy servers when Django starts"""
if 'manage.py' not in sys.argv:
from .server import ProxyServer
ProxyServer.get_instance()
+538
View File
@@ -0,0 +1,538 @@
import logging
import time
import re
from .server import ProxyServer
from .redis_keys import RedisKeys
from .constants import TS_PACKET_SIZE, ChannelMetadataField
from redis.exceptions import ConnectionError, TimeoutError
from .utils import get_logger
from .client_manager import ClientManager
from django.db import DatabaseError # Add import for error handling
logger = get_logger()
class ChannelStatus:
@staticmethod
def _calculate_bitrate(total_bytes, duration):
"""Calculate bitrate in Kbps based on total bytes and duration in seconds"""
if duration <= 0:
return 0
# Convert bytes to bits (x8) and divide by duration to get bits per second
# Then divide by 1000 to get Kbps
return (total_bytes * 8) / duration / 1000
def get_detailed_channel_info(channel_id):
proxy_server = ProxyServer.get_instance()
# Get channel metadata
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if not metadata:
return None
# Basic channel info
buffer_index_key = RedisKeys.buffer_index(channel_id)
buffer_index_value = proxy_server.redis_client.get(buffer_index_key)
info = {
'channel_id': channel_id,
'state': metadata.get(ChannelMetadataField.STATE, 'unknown'),
'url': metadata.get(ChannelMetadataField.URL, ''),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ''),
'started_at': metadata.get(ChannelMetadataField.INIT_TIME, '0'),
'owner': metadata.get(ChannelMetadataField.OWNER, 'unknown'),
'buffer_index': int(buffer_index_value) if buffer_index_value else 0,
}
# Add stream ID and name information
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
try:
stream_id = int(stream_id_bytes)
info['stream_id'] = stream_id
# Look up stream name from database
try:
from apps.channels.models import Stream
stream = Stream.objects.filter(id=stream_id).first()
if stream:
info['stream_name'] = stream.name
except (ImportError, DatabaseError) as e:
logger.warning(f"Failed to get stream name for ID {stream_id}: {e}")
except ValueError:
logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}")
# Add M3U profile information
m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE)
if m3u_profile_id_bytes:
try:
m3u_profile_id = int(m3u_profile_id_bytes)
info['m3u_profile_id'] = m3u_profile_id
# Look up M3U profile name from database
try:
from apps.m3u.models import M3UAccountProfile
m3u_profile = M3UAccountProfile.objects.filter(id=m3u_profile_id).first()
if m3u_profile:
info['m3u_profile_name'] = m3u_profile.name
except (ImportError, DatabaseError) as e:
logger.warning(f"Failed to get M3U profile name for ID {m3u_profile_id}: {e}")
except ValueError:
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id_bytes}")
# Add timing information
state_changed_field = ChannelMetadataField.STATE_CHANGED_AT
if state_changed_field in metadata:
state_changed_at = float(metadata[state_changed_field])
info['state_changed_at'] = state_changed_at
info['state_duration'] = time.time() - state_changed_at
init_time_field = ChannelMetadataField.INIT_TIME
if init_time_field in metadata:
created_at = float(metadata[init_time_field])
info['started_at'] = created_at
info['uptime'] = time.time() - created_at
# Add data throughput information
total_bytes_field = ChannelMetadataField.TOTAL_BYTES
if total_bytes_field in metadata:
total_bytes = int(metadata[total_bytes_field])
info['total_bytes'] = total_bytes
# Format total bytes in human-readable form
if total_bytes < 1024:
info['total_data'] = f"{total_bytes} B"
elif total_bytes < 1024 * 1024:
info['total_data'] = f"{total_bytes / 1024:.2f} KB"
elif total_bytes < 1024 * 1024 * 1024:
info['total_data'] = f"{total_bytes / (1024 * 1024):.2f} MB"
else:
info['total_data'] = f"{total_bytes / (1024 * 1024 * 1024):.2f} GB"
# Calculate average bitrate if we have uptime
if 'uptime' in info and info['uptime'] > 0:
avg_bitrate = ChannelStatus._calculate_bitrate(total_bytes, info['uptime'])
info['avg_bitrate_kbps'] = avg_bitrate
# Format in Mbps if over 1000 Kbps
if avg_bitrate > 1000:
info['avg_bitrate'] = f"{avg_bitrate / 1000:.2f} Mbps"
else:
info['avg_bitrate'] = f"{avg_bitrate:.2f} Kbps"
# Get client information
client_set_key = RedisKeys.clients(channel_id)
client_ids = proxy_server.redis_client.smembers(client_set_key)
clients = []
stale_client_ids = []
for client_id in client_ids:
client_id_str = client_id
client_key = RedisKeys.client_metadata(channel_id, client_id_str)
client_data = proxy_server.redis_client.hgetall(client_key)
if not client_data:
# Metadata hash expired but SET entry persists (ghost client).
stale_client_ids.append(client_id)
continue
client_info = {
'client_id': client_id_str,
'user_agent': client_data.get('user_agent', 'unknown'),
'worker_id': client_data.get('worker_id', 'unknown'),
'ip_address': client_data.get('ip_address', 'unknown'),
'user_id': client_data.get('user_id', '0'),
}
if 'connected_at' in client_data:
connected_at = float(client_data['connected_at'])
client_info['connected_at'] = connected_at
client_info['connection_duration'] = time.time() - connected_at
if 'last_active' in client_data:
last_active = float(client_data['last_active'])
client_info['last_active'] = last_active
client_info['last_active_ago'] = time.time() - last_active
# Add transfer rate statistics
if 'bytes_sent' in client_data:
client_info['bytes_sent'] = int(client_data['bytes_sent'])
# Add average transfer rate
if 'avg_rate_KBps' in client_data:
client_info['avg_rate_KBps'] = float(client_data['avg_rate_KBps'])
elif 'transfer_rate_KBps' in client_data: # For backward compatibility
client_info['avg_rate_KBps'] = float(client_data['transfer_rate_KBps'])
# Add current transfer rate
if 'current_rate_KBps' in client_data:
client_info['current_rate_KBps'] = float(client_data['current_rate_KBps'])
clients.append(client_info)
# Clean up stale SET entries so SCARD stays accurate.
if stale_client_ids:
proxy_server.redis_client.srem(client_set_key, *stale_client_ids)
logger.info(
f"Removed {len(stale_client_ids)} ghost client(s) from "
f"channel {channel_id} client set"
)
info['clients'] = clients
info['client_count'] = len(clients)
# Get buffer health with improved diagnostics
buffer_stats = {
'chunks': info['buffer_index'],
'diagnostics': {}
}
# Sample a few recent chunks to check sizes with better error handling
if info['buffer_index'] > 0:
try:
sample_chunks = min(5, info['buffer_index'])
chunk_sizes = []
chunk_keys_found = []
chunk_keys_missing = []
# Check if the keys exist before getting
for i in range(info['buffer_index']-sample_chunks+1, info['buffer_index']+1):
chunk_key = RedisKeys.buffer_chunk(channel_id, i)
# Check if key exists first
if proxy_server.redis_client.exists(chunk_key):
chunk_data = proxy_server.redis_client.get(chunk_key)
if chunk_data:
chunk_size = len(chunk_data)
chunk_sizes.append(chunk_size)
chunk_keys_found.append(i)
# Check for TS alignment (packets are 188 bytes)
ts_packets = chunk_size // 188
ts_aligned = chunk_size % 188 == 0
# Add for first chunk only to avoid too much data
if len(chunk_keys_found) == 1:
buffer_stats['diagnostics']['first_chunk'] = {
'index': i,
'size': chunk_size,
'ts_packets': ts_packets,
'aligned': ts_aligned,
'first_byte': chunk_data[0] if chunk_size > 0 else None
}
else:
chunk_keys_missing.append(i)
# Add detailed diagnostics
if chunk_sizes:
buffer_stats['avg_chunk_size'] = sum(chunk_sizes) / len(chunk_sizes)
buffer_stats['recent_chunk_sizes'] = chunk_sizes
buffer_stats['keys_found'] = chunk_keys_found
buffer_stats['keys_missing'] = chunk_keys_missing
# Calculate data rate
total_data = sum(chunk_sizes)
buffer_stats['total_sample_bytes'] = total_data
# Add TS packet analysis
total_ts_packets = total_data // TS_PACKET_SIZE
buffer_stats['estimated_ts_packets'] = total_ts_packets
buffer_stats['is_ts_aligned'] = all(size % TS_PACKET_SIZE == 0 for size in chunk_sizes)
else:
# If no chunks found, scan for keys to help debug
all_buffer_keys = []
cursor = 0
buffer_key_pattern = f"ts_proxy:channel:{channel_id}:buffer:chunk:*"
while True:
cursor, keys = proxy_server.redis_client.scan(cursor, match=buffer_key_pattern, count=100)
if keys:
all_buffer_keys.extend([k for k in keys])
if cursor == 0 or len(all_buffer_keys) >= 20: # Limit to 20 keys
break
buffer_stats['diagnostics']['all_buffer_keys'] = all_buffer_keys[:20] # First 20 keys
buffer_stats['diagnostics']['total_buffer_keys'] = len(all_buffer_keys)
except Exception as e:
# Capture any errors for diagnostics
buffer_stats['error'] = str(e)
buffer_stats['diagnostics']['exception'] = str(e)
# Add TTL information to see if chunks are expiring
chunk_ttl_key = RedisKeys.buffer_chunk(channel_id, info['buffer_index'])
chunk_ttl = proxy_server.redis_client.ttl(chunk_ttl_key)
buffer_stats['latest_chunk_ttl'] = chunk_ttl
info['buffer_stats'] = buffer_stats
# Get local worker info if available
if channel_id in proxy_server.stream_managers:
manager = proxy_server.stream_managers[channel_id]
info['local_manager'] = {
'healthy': manager.healthy,
'connected': manager.connected,
'last_data_time': manager.last_data_time,
'last_data_age': time.time() - manager.last_data_time
}
# Add FFmpeg stream information
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC)
if video_codec:
info['video_codec'] = video_codec
resolution = metadata.get(ChannelMetadataField.RESOLUTION)
if resolution:
info['resolution'] = resolution
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS)
if source_fps:
info['source_fps'] = source_fps
pixel_format = metadata.get(ChannelMetadataField.PIXEL_FORMAT)
if pixel_format:
info['pixel_format'] = pixel_format
source_bitrate = metadata.get(ChannelMetadataField.SOURCE_BITRATE)
if source_bitrate:
info['source_bitrate'] = source_bitrate
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC)
if audio_codec:
info['audio_codec'] = audio_codec
sample_rate = metadata.get(ChannelMetadataField.SAMPLE_RATE)
if sample_rate:
info['sample_rate'] = sample_rate
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS)
if audio_channels:
info['audio_channels'] = audio_channels
audio_bitrate = metadata.get(ChannelMetadataField.AUDIO_BITRATE)
if audio_bitrate:
info['audio_bitrate'] = audio_bitrate
# Add FFmpeg performance stats
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED)
if ffmpeg_speed:
info['ffmpeg_speed'] = ffmpeg_speed
ffmpeg_fps = metadata.get(ChannelMetadataField.FFMPEG_FPS)
if ffmpeg_fps:
info['ffmpeg_fps'] = ffmpeg_fps
actual_fps = metadata.get(ChannelMetadataField.ACTUAL_FPS)
if actual_fps:
info['actual_fps'] = actual_fps
ffmpeg_bitrate = metadata.get(ChannelMetadataField.FFMPEG_BITRATE)
if ffmpeg_bitrate:
info['ffmpeg_bitrate'] = ffmpeg_bitrate
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE)
if stream_type:
info['stream_type'] = stream_type
return info
@staticmethod
def _execute_redis_command(command_func):
"""Execute Redis command with error handling"""
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return None
try:
return command_func()
except (ConnectionError, TimeoutError) as e:
logger.warning(f"Redis connection error in ChannelStatus: {e}")
return None
except Exception as e:
logger.error(f"Redis command error in ChannelStatus: {e}")
return None
@staticmethod
def get_basic_channel_info(channel_id):
"""Get basic channel information with Redis error handling"""
proxy_server = ProxyServer.get_instance()
try:
# Use _execute_redis_command for Redis operations
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = ChannelStatus._execute_redis_command(
lambda: proxy_server.redis_client.hgetall(metadata_key)
)
if not metadata:
return None
# Basic channel info only - omit diagnostics and details
buffer_index_key = RedisKeys.buffer_index(channel_id)
buffer_index_value = proxy_server.redis_client.get(buffer_index_key)
# Count clients (using efficient count method)
client_set_key = RedisKeys.clients(channel_id)
client_count = proxy_server.redis_client.scard(client_set_key) or 0
# Calculate uptime
init_time_bytes = metadata.get(ChannelMetadataField.INIT_TIME, '0')
created_at = float(init_time_bytes)
uptime = time.time() - created_at if created_at > 0 else 0
# Simplified info
info = {
'channel_id': channel_id,
'state': metadata.get(ChannelMetadataField.STATE),
'url': metadata.get(ChannelMetadataField.URL, ""),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ""),
'owner': metadata.get(ChannelMetadataField.OWNER),
'buffer_index': int(buffer_index_value) if buffer_index_value else 0,
'client_count': client_count,
'uptime': uptime
}
# Add stream ID and name information
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
try:
stream_id = int(stream_id_bytes)
info['stream_id'] = stream_id
# Look up stream name from database
try:
from apps.channels.models import Stream
stream = Stream.objects.filter(id=stream_id).first()
if stream:
info['stream_name'] = stream.name
except (ImportError, DatabaseError) as e:
logger.warning(f"Failed to get stream name for ID {stream_id}: {e}")
except ValueError:
logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}")
# Add data throughput information to basic info
total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES)
if total_bytes_bytes:
total_bytes = int(total_bytes_bytes)
info['total_bytes'] = total_bytes
# Calculate and add bitrate
if uptime > 0:
avg_bitrate = ChannelStatus._calculate_bitrate(total_bytes, uptime)
info['avg_bitrate_kbps'] = avg_bitrate
# Format for display
if avg_bitrate > 1000:
info['avg_bitrate'] = f"{avg_bitrate / 1000:.2f} Mbps"
else:
info['avg_bitrate'] = f"{avg_bitrate:.2f} Kbps"
# Quick health check if available locally
if channel_id in proxy_server.stream_managers:
manager = proxy_server.stream_managers[channel_id]
info['healthy'] = manager.healthy
# Get concise client information
clients = []
client_ids = proxy_server.redis_client.smembers(client_set_key)
# Remove ghost SET entries before building the client list.
# Pass the already-fetched client_ids to avoid a redundant SMEMBERS.
stale_client_ids = ClientManager.remove_ghost_clients(
proxy_server.redis_client, channel_id, client_ids=client_ids
)
if stale_client_ids:
client_count = max(0, client_count - len(stale_client_ids))
# Build concise client list (up to 10) from remaining live clients.
if client_ids:
for client_id in list(client_ids)[:10]:
if client_id in stale_client_ids:
continue
client_key = RedisKeys.client_metadata(channel_id, client_id)
client_info = {
'client_id': client_id,
}
user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent')
client_info['user_agent'] = user_agent_bytes
ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address')
if ip_address_bytes:
client_info['ip_address'] = ip_address_bytes
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
if connected_at_bytes:
connected_at = float(connected_at_bytes)
client_info['connected_since'] = time.time() - connected_at
user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id')
if user_id_bytes:
client_info['user_id'] = user_id_bytes
clients.append(client_info)
# Add clients to info
info['clients'] = clients
info['client_count'] = client_count
# Add M3U profile information
m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE)
if m3u_profile_id:
try:
m3u_profile_id = int(m3u_profile_id)
info['m3u_profile_id'] = m3u_profile_id
# Look up M3U profile name from database
try:
from apps.m3u.models import M3UAccountProfile
m3u_profile = M3UAccountProfile.objects.filter(id=m3u_profile_id).first()
if m3u_profile:
info['m3u_profile_name'] = m3u_profile.name
except (ImportError, DatabaseError) as e:
logger.warning(f"Failed to get M3U profile name for ID {m3u_profile_id}: {e}")
except ValueError:
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id}")
# Add stream info to basic info as well
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC)
if video_codec:
info['video_codec'] = video_codec
resolution = metadata.get(ChannelMetadataField.RESOLUTION)
if resolution:
info['resolution'] = resolution
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS)
if source_fps:
info['source_fps'] = float(source_fps)
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED)
if ffmpeg_speed:
info['ffmpeg_speed'] = float(ffmpeg_speed)
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC)
if audio_codec:
info['audio_codec'] = audio_codec
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS)
if audio_channels:
info['audio_channels'] = audio_channels
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE)
if stream_type:
info['stream_type'] = stream_type
return info
except Exception as e:
logger.error(f"Error getting channel info: {e}", exc_info=True) # Added exc_info for better debugging
return None
+451
View File
@@ -0,0 +1,451 @@
"""Client connection management for TS streams"""
import threading
import time
import json
from typing import Set, Optional
from apps.proxy.config import TSConfig as Config
from redis.exceptions import ConnectionError, TimeoutError
from .constants import EventType, ChannelState, ChannelMetadataField
from .config_helper import ConfigHelper
from .redis_keys import RedisKeys
from .utils import get_logger
from core.utils import send_websocket_update
logger = get_logger()
class ClientManager:
"""Manages client connections with no duplicates"""
def __init__(self, channel_id=None, redis_client=None, heartbeat_interval=1, worker_id=None):
self.channel_id = channel_id
self.redis_client = redis_client
self.clients = set()
self.lock = threading.RLock()
self.last_active_time = time.time()
self.worker_id = worker_id # Store worker ID as instance variable
self._heartbeat_running = True # Flag to control heartbeat thread
# STANDARDIZED KEYS: Move client set under channel namespace
self.client_set_key = RedisKeys.clients(channel_id)
self.client_ttl = ConfigHelper.get('CLIENT_RECORD_TTL', 60)
self.heartbeat_interval = ConfigHelper.get('CLIENT_HEARTBEAT_INTERVAL', 10)
self.last_heartbeat_time = {}
# Get ProxyServer instance for ownership checks
from .server import ProxyServer
self.proxy_server = ProxyServer.get_instance()
# Start heartbeat thread for local clients
self._start_heartbeat_thread()
self._registered_clients = set() # Track already registered client IDs
def _trigger_stats_update(self):
"""Trigger a channel stats update via WebSocket in a background thread.
Offloaded so the caller is not blocked. send_websocket_update is
gevent-safe (offloads async_to_sync to a native OS thread).
"""
threading.Thread(target=self._do_stats_update, daemon=True).start()
def _do_stats_update(self):
"""Perform the stats update in the background."""
try:
from apps.proxy.ts_proxy.channel_status import ChannelStatus
import redis
from django.conf import settings
redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0')
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
redis_client = redis.Redis.from_url(redis_url, decode_responses=True, **ssl_params)
all_channels = []
cursor = 0
while True:
cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=100)
for key in keys:
parts = key.split(':')
if len(parts) >= 4:
ch_id = parts[2]
channel_info = ChannelStatus.get_basic_channel_info(ch_id)
if channel_info:
all_channels.append(channel_info)
if cursor == 0:
break
send_websocket_update(
"updates",
"update",
{
"success": True,
"type": "channel_stats",
"stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})
}
)
except Exception as e:
logger.debug(f"Failed to trigger stats update: {e}")
def _start_heartbeat_thread(self):
"""Start thread to regularly refresh client presence in Redis for local clients"""
def heartbeat_task():
logger.debug(f"Started heartbeat thread for channel {self.channel_id} (interval: {self.heartbeat_interval}s)")
while self._heartbeat_running:
try:
# Wait for the interval, but check stop flag frequently for quick shutdown
# Sleep in 1-second increments to allow faster response to stop signal
for _ in range(int(self.heartbeat_interval)):
if not self._heartbeat_running:
break
time.sleep(1)
# Final check before doing work
if not self._heartbeat_running:
break
# Send heartbeat for all local clients
with self.lock:
# Skip this cycle if we have no local clients
if not self.clients:
continue
# IMPROVED GHOST DETECTION: Check for stale clients before sending heartbeats
current_time = time.time()
clients_to_remove = set()
# First identify clients that should be removed
for client_id in self.clients:
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
# Check if client exists in Redis at all
exists = self.redis_client.exists(client_key)
if not exists:
logger.debug(f"Client {client_id} no longer exists in Redis, removing locally")
clients_to_remove.add(client_id)
continue
# Check for stale activity using last_active field
last_active = self.redis_client.hget(client_key, "last_active")
if last_active:
last_active_time = float(last_active)
ghost_timeout = self.heartbeat_interval * getattr(Config, 'GHOST_CLIENT_MULTIPLIER', 5.0)
if current_time - last_active_time > ghost_timeout:
logger.debug(f"Client {client_id} inactive for {current_time - last_active_time:.1f}s, removing as ghost")
clients_to_remove.add(client_id)
# Remove ghost clients in a separate step
for client_id in clients_to_remove:
self.remove_client(client_id)
if clients_to_remove:
logger.info(f"Removed {len(clients_to_remove)} ghost clients from channel {self.channel_id}")
# Now send heartbeats only for remaining clients
pipe = self.redis_client.pipeline()
current_time = time.time()
for client_id in self.clients:
# Skip clients we just marked for removal
if client_id in clients_to_remove:
continue
# Skip if we just sent a heartbeat recently
if client_id in self.last_heartbeat_time:
time_since_heartbeat = current_time - self.last_heartbeat_time[client_id]
if time_since_heartbeat < self.heartbeat_interval * 0.5: # Only heartbeat at half interval minimum
continue
# Only refresh TTL - do NOT update last_active
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
pipe.expire(client_key, self.client_ttl)
# Keep client in the set with TTL
pipe.sadd(self.client_set_key, client_id)
pipe.expire(self.client_set_key, self.client_ttl)
# Track last heartbeat locally
self.last_heartbeat_time[client_id] = current_time
# Execute all commands atomically
pipe.execute()
# Only notify if we have real clients
if self.clients and not all(c in clients_to_remove for c in self.clients):
self._notify_owner_of_activity()
except Exception as e:
logger.error(f"Error in client heartbeat thread: {e}")
logger.debug(f"Heartbeat thread exiting for channel {self.channel_id}")
thread = threading.Thread(target=heartbeat_task, daemon=True)
thread.name = f"client-heartbeat-{self.channel_id}"
thread.start()
logger.debug(f"Started client heartbeat thread for channel {self.channel_id} (interval: {self.heartbeat_interval}s)")
def stop(self):
"""Stop the heartbeat thread and cleanup"""
logger.debug(f"Stopping ClientManager for channel {self.channel_id}")
self._heartbeat_running = False
# Give the thread a moment to exit gracefully
# Note: We don't join() here because it's a daemon thread and will exit on its own
def _execute_redis_command(self, command_func):
"""Execute Redis command with error handling"""
if not self.redis_client:
return None
try:
return command_func()
except (ConnectionError, TimeoutError) as e:
logger.warning(f"Redis connection error in ClientManager: {e}")
return None
except Exception as e:
logger.error(f"Redis command error in ClientManager: {e}")
return None
def _notify_owner_of_activity(self):
"""Notify channel owner that clients are active on this worker"""
if not self.redis_client or not self.clients:
return
try:
worker_id = self.worker_id or "unknown"
# STANDARDIZED KEY: Worker info under channel namespace
worker_key = f"ts_proxy:channel:{self.channel_id}:worker:{worker_id}"
self._execute_redis_command(
lambda: self.redis_client.setex(worker_key, self.client_ttl, str(len(self.clients)))
)
# STANDARDIZED KEY: Activity timestamp under channel namespace
activity_key = f"ts_proxy:channel:{self.channel_id}:activity"
self._execute_redis_command(
lambda: self.redis_client.setex(activity_key, self.client_ttl, str(time.time()))
)
except Exception as e:
logger.error(f"Error notifying owner of client activity: {e}")
def add_client(self, client_id, client_ip, user_agent=None, user=None):
"""Add a client with duplicate prevention"""
if client_id in self._registered_clients:
logger.debug(f"Client {client_id} already registered, skipping")
return False
self._registered_clients.add(client_id)
# Use a function to get the client key
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
# Prepare client data
current_time = str(time.time())
client_data = {
"user_agent": user_agent or "unknown",
"ip_address": client_ip,
"connected_at": current_time,
"last_active": current_time,
"worker_id": self.worker_id or "unknown",
"user_id": str(user.id) if user is not None else "0",
# "user_level": user.user_level if user is not None else 100, # default to a high value since no user means the non-user specific M3U/HDHR
}
try:
with self.lock:
# Store client in local set
self.clients.add(client_id)
# Store in Redis
if self.redis_client:
# FIXED: Store client data just once with proper key
self.redis_client.hset(client_key, mapping=client_data)
self.redis_client.expire(client_key, self.client_ttl)
# Add to the client set
self.redis_client.sadd(self.client_set_key, client_id)
self.redis_client.expire(self.client_set_key, self.client_ttl)
# Clear any initialization timer
init_key = f"ts_proxy:channel:{self.channel_id}:init_time"
self.redis_client.delete(init_key)
self._notify_owner_of_activity()
# Publish client connected event with user agent
event_data = {
"event": EventType.CLIENT_CONNECTED, # Use constant instead of string
"channel_id": self.channel_id,
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time(),
"username": user.username if user is not None else "unknown"
}
if user_agent:
event_data["user_agent"] = user_agent
logger.debug(f"Storing user agent '{user_agent}' for client {client_id}")
else:
logger.debug(f"No user agent provided for client {client_id}")
self.redis_client.publish(
RedisKeys.events_channel(self.channel_id), # Use RedisKeys instead of string
json.dumps(event_data)
)
# Trigger channel stats update via WebSocket
self._trigger_stats_update()
# Get total clients across all workers
total_clients = self.get_total_client_count()
logger.info(f"New client connected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
self.last_heartbeat_time[client_id] = time.time()
return len(self.clients)
except Exception as e:
logger.error(f"Error adding client {client_id}: {e}")
return False
def remove_client(self, client_id):
"""Remove a client from this channel and Redis"""
with self.lock:
if client_id in self.clients:
self.clients.remove(client_id)
if client_id in self.last_heartbeat_time:
del self.last_heartbeat_time[client_id]
self.last_active_time = time.time()
if self.redis_client:
# Get client data before removing the data
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
client_username = self.redis_client.hget(client_key, "username") or "unknown"
if isinstance(client_username, bytes):
client_username = client_username.decode("utf-8")
# Remove from channel's client set
self.redis_client.srem(self.client_set_key, client_id)
# STANDARDIZED KEY: Delete individual client keys
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
self.redis_client.delete(client_key)
# Check if this was the last client
remaining = self.redis_client.scard(self.client_set_key) or 0
if remaining == 0:
logger.warning(f"Last client removed: {client_id} - channel may shut down soon")
# Trigger disconnect time tracking even if we're not the owner
disconnect_key = RedisKeys.last_client_disconnect(self.channel_id)
self.redis_client.setex(disconnect_key, 60, str(time.time()))
self._notify_owner_of_activity()
# Check if we're the owner - if so, handle locally; if not, publish event
am_i_owner = self.proxy_server and self.proxy_server.am_i_owner(self.channel_id)
if am_i_owner:
# We're the owner - handle the disconnect directly
logger.debug(f"Owner handling CLIENT_DISCONNECTED for client {client_id} locally (not publishing)")
if remaining == 0:
# Trigger shutdown check directly via ProxyServer method
logger.debug(f"No clients left - triggering immediate shutdown check")
# Spawn greenlet to avoid blocking
import gevent
gevent.spawn(self.proxy_server.handle_client_disconnect, self.channel_id)
else:
# We're not the owner - publish event so owner can handle it
logger.debug(f"Non-owner publishing CLIENT_DISCONNECTED event for client {client_id} on channel {self.channel_id} from worker {self.worker_id}")
event_data = json.dumps({
"event": EventType.CLIENT_DISCONNECTED,
"channel_id": self.channel_id,
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time(),
"remaining_clients": remaining,
"username": client_username
})
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
# Trigger channel stats update via WebSocket
self._trigger_stats_update()
total_clients = self.get_total_client_count()
logger.info(f"Client disconnected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
return len(self.clients)
def get_client_count(self):
"""Get local client count"""
with self.lock:
return len(self.clients)
def get_total_client_count(self):
"""Get total client count across all workers"""
if not self.redis_client:
return len(self.clients)
try:
# Count members in the client set
return self.redis_client.scard(self.client_set_key) or 0
except Exception as e:
logger.error(f"Error getting total client count: {e}")
return len(self.clients) # Fall back to local count
def refresh_client_ttl(self):
"""Refresh TTL for active clients to prevent expiration"""
if not self.redis_client:
return
try:
# Refresh TTL for all clients belonging to this worker
for client_id in self.clients:
# STANDARDIZED: Use channel namespace for client keys
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
self.redis_client.expire(client_key, self.client_ttl)
# Refresh TTL on the set itself
self.redis_client.expire(self.client_set_key, self.client_ttl)
except Exception as e:
logger.error(f"Error refreshing client TTL: {e}")
@staticmethod
def remove_ghost_clients(redis_client, channel_id, client_ids=None):
"""Remove client SET entries whose metadata hash has expired.
Returns the list of removed (stale) client IDs, or an empty list
if none were found. Uses a pipelined EXISTS check for efficiency.
Args:
client_ids: Optional pre-fetched result of SMEMBERS for this
channel. Pass this to avoid a redundant SMEMBERS
call when the caller has already fetched it.
"""
client_set_key = RedisKeys.clients(channel_id)
if client_ids is None:
client_ids = redis_client.smembers(client_set_key)
if not client_ids:
return []
client_id_list = list(client_ids)
pipe = redis_client.pipeline()
for cid in client_id_list:
pipe.exists(RedisKeys.client_metadata(channel_id, cid))
results = pipe.execute()
stale_ids = [
cid for cid, exists in zip(client_id_list, results)
if not exists
]
if stale_ids:
redis_client.srem(client_set_key, *stale_ids)
logger.info(
f"Removed {len(stale_ids)} ghost client(s) from "
f"channel {channel_id} client set"
)
return stale_ids
+120
View File
@@ -0,0 +1,120 @@
"""
Helper module to access configuration values with proper defaults.
"""
from apps.proxy.config import TSConfig as Config
class ConfigHelper:
"""
Helper class for accessing configuration values with sensible defaults.
This simplifies code and ensures consistent defaults across the application.
"""
@staticmethod
def get(name, default=None):
"""Get a configuration value with a default fallback"""
return getattr(Config, name, default)
# Commonly used configuration values
@staticmethod
def connection_timeout():
"""Get connection timeout in seconds"""
return ConfigHelper.get('CONNECTION_TIMEOUT', 10)
@staticmethod
def client_wait_timeout():
"""Get client wait timeout in seconds"""
return ConfigHelper.get('CLIENT_WAIT_TIMEOUT', 30)
@staticmethod
def stream_timeout():
"""Get stream timeout in seconds"""
return ConfigHelper.get('STREAM_TIMEOUT', 60)
@staticmethod
def channel_shutdown_delay():
"""Get channel shutdown delay in seconds"""
return Config.get_channel_shutdown_delay()
@staticmethod
def initial_behind_chunks():
"""Get number of chunks to start behind"""
return ConfigHelper.get('INITIAL_BEHIND_CHUNKS', 4)
@staticmethod
def new_client_behind_seconds():
"""Get number of seconds behind live to start new clients.
0 means start at live (buffer head).
Loaded from DB proxy_settings so users can change it at runtime."""
from apps.proxy.config import TSConfig
settings = TSConfig.get_proxy_settings()
return settings.get('new_client_behind_seconds', 5)
@staticmethod
def keepalive_interval():
"""Get keepalive interval in seconds"""
return ConfigHelper.get('KEEPALIVE_INTERVAL', 0.5)
@staticmethod
def cleanup_check_interval():
"""Get cleanup check interval in seconds"""
return ConfigHelper.get('CLEANUP_CHECK_INTERVAL', 3)
@staticmethod
def redis_chunk_ttl():
"""Get Redis chunk TTL in seconds"""
return Config.get_redis_chunk_ttl()
@staticmethod
def chunk_size():
"""Get chunk size in bytes"""
return ConfigHelper.get('CHUNK_SIZE', 8192)
@staticmethod
def max_retries():
"""Get maximum retry attempts"""
return ConfigHelper.get('MAX_RETRIES', 3)
@staticmethod
def max_stream_switches():
"""Get maximum number of stream switch attempts"""
return ConfigHelper.get('MAX_STREAM_SWITCHES', 10)
@staticmethod
def retry_wait_interval():
"""Get wait interval between connection retries in seconds"""
return ConfigHelper.get('RETRY_WAIT_INTERVAL', 0.5) # Default to 0.5 second
@staticmethod
def url_switch_timeout():
"""Get URL switch timeout in seconds (max time allowed for a stream switch operation)"""
return ConfigHelper.get('URL_SWITCH_TIMEOUT', 20) # Default to 20 seconds
@staticmethod
def failover_grace_period():
"""Get extra time (in seconds) to allow for stream switching before disconnecting clients"""
return ConfigHelper.get('FAILOVER_GRACE_PERIOD', 20) # Default to 20 seconds
@staticmethod
def buffering_timeout():
"""Get buffering timeout in seconds"""
return Config.get_buffering_timeout()
@staticmethod
def buffering_speed():
"""Get buffering speed threshold"""
return Config.get_buffering_speed()
@staticmethod
def channel_init_grace_period():
"""Get channel initialization grace period in seconds"""
return Config.get_channel_init_grace_period()
@staticmethod
def chunk_timeout():
"""
Get chunk timeout in seconds (used for both socket and HTTP read timeouts).
This controls how long we wait for each chunk before timing out.
Set this higher (e.g., 30s) for slow providers that may have intermittent delays.
"""
return ConfigHelper.get('CHUNK_TIMEOUT', 5) # Default 5 seconds
+117
View File
@@ -0,0 +1,117 @@
"""
Constants used throughout the TS Proxy application.
Centralizing constants makes it easier to maintain and modify them.
"""
# Redis related constants
REDIS_KEY_PREFIX = "ts_proxy"
REDIS_TTL_DEFAULT = 3600 # 1 hour
REDIS_TTL_SHORT = 60 # 1 minute
REDIS_TTL_MEDIUM = 300 # 5 minutes
# Channel states
class ChannelState:
INITIALIZING = "initializing"
CONNECTING = "connecting"
WAITING_FOR_CLIENTS = "waiting_for_clients"
ACTIVE = "active"
ERROR = "error"
STOPPING = "stopping"
STOPPED = "stopped"
BUFFERING = "buffering"
# States before a channel is fully active. Used by the stream manager
# finally block to decide whether a failed stream can write ERROR.
PRE_ACTIVE = frozenset([INITIALIZING, CONNECTING, BUFFERING, WAITING_FOR_CLIENTS])
# Event types
class EventType:
STREAM_SWITCH = "stream_switch"
STREAM_SWITCHED = "stream_switched"
CHANNEL_STOP = "channel_stop"
CHANNEL_STOPPED = "channel_stopped"
CLIENT_CONNECTED = "client_connected"
CLIENT_DISCONNECTED = "client_disconnected"
CLIENT_STOP = "client_stop"
# Stream types
class StreamType:
HLS = "hls"
RTSP = "rtsp"
UDP = "udp"
TS = "ts"
UNKNOWN = "unknown"
# Channel metadata field names stored in Redis
class ChannelMetadataField:
# Basic fields
URL = "url"
USER_AGENT = "user_agent"
STATE = "state"
OWNER = "owner"
STREAM_ID = "stream_id"
# Profile fields
STREAM_PROFILE = "stream_profile"
M3U_PROFILE = "m3u_profile"
# Status and error fields
ERROR_MESSAGE = "error_message"
ERROR_TIME = "error_time"
STATE_CHANGED_AT = "state_changed_at"
INIT_TIME = "init_time"
CONNECTION_READY_TIME = "connection_ready_time"
# Buffer and data tracking
BUFFER_CHUNKS = "buffer_chunks"
TOTAL_BYTES = "total_bytes"
# Stream switching
STREAM_SWITCH_TIME = "stream_switch_time"
STREAM_SWITCH_REASON = "stream_switch_reason"
# FFmpeg performance metrics
FFMPEG_SPEED = "ffmpeg_speed"
FFMPEG_FPS = "ffmpeg_fps"
ACTUAL_FPS = "actual_fps"
FFMPEG_OUTPUT_BITRATE = "ffmpeg_output_bitrate"
FFMPEG_BITRATE = "ffmpeg_bitrate"
FFMPEG_STATS_UPDATED = "ffmpeg_stats_updated"
# Video stream info
VIDEO_CODEC = "video_codec"
RESOLUTION = "resolution"
WIDTH = "width"
HEIGHT = "height"
SOURCE_FPS = "source_fps"
PIXEL_FORMAT = "pixel_format"
VIDEO_BITRATE = "video_bitrate"
SOURCE_BITRATE = "source_bitrate"
# Audio stream info
AUDIO_CODEC = "audio_codec"
SAMPLE_RATE = "sample_rate"
AUDIO_CHANNELS = "audio_channels"
AUDIO_BITRATE = "audio_bitrate"
# Stream format info
STREAM_TYPE = "stream_type"
# Stream info timestamp
STREAM_INFO_UPDATED = "stream_info_updated"
# Client metadata fields
CONNECTED_AT = "connected_at"
LAST_ACTIVE = "last_active"
BYTES_SENT = "bytes_sent"
AVG_RATE_KBPS = "avg_rate_KBps"
CURRENT_RATE_KBPS = "current_rate_KBps"
IP_ADDRESS = "ip_address"
WORKER_ID = "worker_id"
CHUNKS_SENT = "chunks_sent"
STATS_UPDATED_AT = "stats_updated_at"
# TS packet constants
TS_PACKET_SIZE = 188
TS_SYNC_BYTE = 0x47
NULL_PID_HIGH = 0x1F
NULL_PID_LOW = 0xFF
+138
View File
@@ -0,0 +1,138 @@
"""
HTTP Stream Reader - Thread-based HTTP stream reader that writes to a pipe.
This allows us to use the same fetch_chunk() path for both transcode and HTTP streams.
"""
import threading
import os
import requests
from requests.adapters import HTTPAdapter
from .utils import get_logger
logger = get_logger()
class HTTPStreamReader:
"""Thread-based HTTP stream reader that writes to a pipe"""
def __init__(self, url, user_agent=None, chunk_size=8192):
self.url = url
self.user_agent = user_agent
self.chunk_size = chunk_size
self.session = None
self.response = None
self.thread = None
self.pipe_read = None
self.pipe_write = None
self.running = False
def start(self):
"""Start the HTTP stream reader thread"""
# Create a pipe (works on Windows and Unix)
self.pipe_read, self.pipe_write = os.pipe()
# Start the reader thread
self.running = True
self.thread = threading.Thread(target=self._read_stream, daemon=True)
self.thread.start()
logger.info(f"Started HTTP stream reader thread for {self.url}")
return self.pipe_read
def _read_stream(self):
"""Thread worker that reads HTTP stream and writes to pipe"""
try:
# Build headers
headers = {}
if self.user_agent:
headers['User-Agent'] = self.user_agent
logger.info(f"HTTP reader connecting to {self.url}")
# Create session
self.session = requests.Session()
# Disable retries for faster failure detection
adapter = HTTPAdapter(max_retries=0, pool_connections=1, pool_maxsize=1)
self.session.mount('http://', adapter)
self.session.mount('https://', adapter)
# Stream the URL
self.response = self.session.get(
self.url,
headers=headers,
stream=True,
timeout=(5, 30) # 5s connect, 30s read
)
if self.response.status_code != 200:
logger.error(f"HTTP {self.response.status_code} from {self.url}")
return
logger.info(f"HTTP reader connected successfully, streaming data...")
# Stream chunks to pipe
chunk_count = 0
for chunk in self.response.iter_content(chunk_size=self.chunk_size):
if not self.running:
break
if chunk:
try:
# Write binary data to pipe
os.write(self.pipe_write, chunk)
chunk_count += 1
# Log progress periodically
if chunk_count % 1000 == 0:
logger.debug(f"HTTP reader streamed {chunk_count} chunks")
except OSError as e:
logger.error(f"Pipe write error: {e}")
break
logger.info("HTTP stream ended")
except requests.exceptions.RequestException as e:
logger.error(f"HTTP reader request error: {e}")
except Exception as e:
logger.error(f"HTTP reader unexpected error: {e}", exc_info=True)
finally:
self.running = False
# Close write end of pipe to signal EOF
try:
if self.pipe_write is not None:
os.close(self.pipe_write)
self.pipe_write = None
except:
pass
def stop(self):
"""Stop the HTTP stream reader"""
logger.info("Stopping HTTP stream reader")
self.running = False
# Close response
if self.response:
try:
self.response.close()
except:
pass
# Close session
if self.session:
try:
self.session.close()
except:
pass
# Close write end of pipe
if self.pipe_write is not None:
try:
os.close(self.pipe_write)
self.pipe_write = None
except:
pass
# Wait for thread
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2.0)
+96
View File
@@ -0,0 +1,96 @@
"""
Defines Redis key patterns used throughout the TS proxy service.
Centralizing these key patterns makes it easier to maintain and change them if needed.
"""
class RedisKeys:
@staticmethod
def channel_metadata(channel_id):
"""Key for channel metadata hash"""
return f"ts_proxy:channel:{channel_id}:metadata"
@staticmethod
def buffer_index(channel_id):
"""Key for tracking buffer index"""
return f"ts_proxy:channel:{channel_id}:buffer:index"
@staticmethod
def buffer_chunk(channel_id, chunk_index):
"""Key for specific buffer chunk"""
return f"ts_proxy:channel:{channel_id}:buffer:chunk:{chunk_index}"
@staticmethod
def buffer_chunk_prefix(channel_id):
"""Prefix for buffer chunks"""
return f"ts_proxy:channel:{channel_id}:buffer:chunk:"
@staticmethod
def channel_stopping(channel_id):
"""Key indicating channel is stopping"""
return f"ts_proxy:channel:{channel_id}:stopping"
@staticmethod
def client_stop(channel_id, client_id):
"""Key requesting client stop"""
return f"ts_proxy:channel:{channel_id}:client:{client_id}:stop"
@staticmethod
def events_channel(channel_id):
"""PubSub channel for events"""
return f"ts_proxy:events:{channel_id}"
@staticmethod
def switch_request(channel_id):
"""Key for stream switch request"""
return f"ts_proxy:channel:{channel_id}:switch_request"
@staticmethod
def channel_owner(channel_id):
"""Key for storing channel owner worker ID"""
return f"ts_proxy:channel:{channel_id}:owner"
@staticmethod
def clients(channel_id):
"""Key for set of client IDs"""
return f"ts_proxy:channel:{channel_id}:clients"
@staticmethod
def last_client_disconnect(channel_id):
"""Key for last client disconnect timestamp"""
return f"ts_proxy:channel:{channel_id}:last_client_disconnect_time"
@staticmethod
def connection_attempt(channel_id):
"""Key for connection attempt timestamp"""
return f"ts_proxy:channel:{channel_id}:connection_attempt_time"
@staticmethod
def last_data(channel_id):
"""Key for last data timestamp"""
return f"ts_proxy:channel:{channel_id}:last_data"
@staticmethod
def switch_status(channel_id):
"""Key for stream switch status"""
return f"ts_proxy:channel:{channel_id}:switch_status"
@staticmethod
def worker_heartbeat(worker_id):
"""Key for worker heartbeat"""
return f"ts_proxy:worker:{worker_id}:heartbeat"
@staticmethod
def chunk_timestamps(channel_id):
"""Sorted set mapping chunk receive-timestamps (score) to chunk indices (member).
Used for time-based client positioning."""
return f"ts_proxy:channel:{channel_id}:buffer:chunk_timestamps"
@staticmethod
def transcode_active(channel_id):
"""Key indicating active transcode process"""
return f"ts_proxy:channel:{channel_id}:transcode_active"
@staticmethod
def client_metadata(channel_id, client_id):
"""Key for client metadata hash"""
return f"ts_proxy:channel:{channel_id}:clients:{client_id}"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,693 @@
"""
Channel service layer for handling business logic related to channel operations.
This separates business logic from HTTP handling in views.
"""
import logging
import time
import json
import re
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel, Stream
from apps.proxy.config import TSConfig as Config
from ..server import ProxyServer
from ..redis_keys import RedisKeys
from ..constants import EventType, ChannelState, ChannelMetadataField
from ..url_utils import get_stream_info_for_switch
from core.utils import log_system_event
from .log_parsers import LogParserFactory
logger = logging.getLogger("ts_proxy")
class ChannelService:
"""Service class for channel operations"""
@staticmethod
def initialize_channel(channel_id, stream_url, user_agent, transcode=False, stream_profile_value=None, stream_id=None, m3u_profile_id=None):
"""
Initialize a channel with the given parameters.
Args:
channel_id: UUID of the channel
stream_url: URL of the stream
user_agent: User agent for the stream connection
transcode: Whether to transcode the stream
stream_profile_value: Stream profile value to store in metadata
stream_id: ID of the stream being used
m3u_profile_id: ID of the M3U profile being used
Returns:
bool: Success status
"""
proxy_server = ProxyServer.get_instance()
# FIXED: First, ensure that Redis metadata including stream_id is set BEFORE channel initialization
# This ensures the stream ID is available when the StreamManager looks it up
if stream_id and proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
# Check if metadata already exists
if proxy_server.redis_client.exists(metadata_key):
# Just update the existing metadata with stream_id
proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STREAM_ID, str(stream_id))
logger.info(f"Pre-set stream ID {stream_id} in Redis for channel {channel_id}")
else:
# Create initial metadata with essential values
initial_metadata = {
ChannelMetadataField.STREAM_ID: str(stream_id),
"temp_init": str(time.time())
}
proxy_server.redis_client.hset(metadata_key, mapping=initial_metadata)
logger.info(f"Created initial metadata with stream_id {stream_id} for channel {channel_id}")
# Verify the stream_id was set
stream_id_value = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
if stream_id_value:
logger.debug(f"Verified stream_id {stream_id_value} is now set in Redis")
else:
logger.error(f"Failed to set stream_id {stream_id} in Redis before initialization")
# Now proceed with channel initialization
success = proxy_server.initialize_channel(stream_url, channel_id, user_agent, transcode, stream_id)
# Store additional metadata if initialization was successful
if success and proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
update_data = {}
if stream_profile_value:
update_data[ChannelMetadataField.STREAM_PROFILE] = stream_profile_value
if stream_id:
update_data[ChannelMetadataField.STREAM_ID] = str(stream_id)
if m3u_profile_id:
update_data[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
if update_data:
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
return success
@staticmethod
def change_stream_url(channel_id, new_url=None, user_agent=None, target_stream_id=None, m3u_profile_id=None):
"""
Change the URL of an existing stream.
Args:
channel_id: UUID of the channel
new_url: New stream URL (optional if target_stream_id is provided)
user_agent: Optional user agent to update
target_stream_id: Optional target stream ID to switch to
m3u_profile_id: Optional M3U profile ID to update
Returns:
dict: Result information including success status and diagnostics
"""
proxy_server = ProxyServer.get_instance()
# If no direct URL is provided but a target stream is, get URL from target stream
stream_id = None
if not new_url and target_stream_id:
stream_info = get_stream_info_for_switch(channel_id, target_stream_id)
if 'error' in stream_info:
return {
'status': 'error',
'message': stream_info['error']
}
new_url = stream_info['url']
user_agent = stream_info['user_agent']
stream_id = target_stream_id
# Extract M3U profile ID from stream info if available
if 'm3u_profile_id' in stream_info:
m3u_profile_id = stream_info['m3u_profile_id']
logger.info(f"Found M3U profile ID {m3u_profile_id} for stream ID {stream_id}")
elif target_stream_id:
# If we have both URL and target_stream_id, use the target_stream_id
stream_id = target_stream_id
# Check if channel exists
in_local_managers = channel_id in proxy_server.stream_managers
in_local_buffers = channel_id in proxy_server.stream_buffers
# Check Redis for keys
redis_keys = None
if proxy_server.redis_client:
try:
# This is inefficient but used for diagnostics - in production would use more targeted checks
redis_keys = proxy_server.redis_client.keys(f"ts_proxy:*:{channel_id}*")
redis_keys = [k for k in redis_keys] if redis_keys else []
except Exception as e:
logger.error(f"Error checking Redis keys: {e}")
# Check if channel exists using standard method
channel_exists = proxy_server.check_if_channel_exists(channel_id)
# Log detailed diagnostics
logger.info(f"Channel {channel_id} diagnostics: "
f"in_local_managers={in_local_managers}, "
f"in_local_buffers={in_local_buffers}, "
f"redis_keys_count={len(redis_keys) if redis_keys else 0}, "
f"channel_exists={channel_exists}")
if not channel_exists:
# Try to recover if Redis keys exist but channel check failed
if redis_keys:
logger.warning(f"Channel {channel_id} not detected but Redis keys exist. Forcing initialization.")
proxy_server.initialize_channel(new_url, channel_id, user_agent)
result = {
'status': 'recovered',
'message': 'Channel was recovered and initialized'
}
else:
logger.error(f"Channel {channel_id} not found in any worker or Redis")
return {
'status': 'error',
'message': 'Channel not found',
'diagnostics': {
'in_local_managers': in_local_managers,
'in_local_buffers': in_local_buffers,
'redis_keys': redis_keys,
}
}
else:
result = {'status': 'success'}
# If we're the owner, update directly
if proxy_server.am_i_owner(channel_id) and channel_id in proxy_server.stream_managers:
logger.info(f"This worker is the owner, changing stream URL for channel {channel_id}")
manager = proxy_server.stream_managers[channel_id]
old_url = manager.url
# Update the stream
success = manager.update_url(new_url, stream_id, m3u_profile_id)
logger.info(f"Stream URL changed from {old_url} to {new_url}, result: {success}")
# Update Redis metadata based on the actual outcome.
# On success, write the new values. On failure, restore whatever URL
# the manager will actually reconnect to (may be old_url if the
# exception happened before self.url was reassigned, or new_url if it
# happened after) so Redis never describes a URL that isn't in use.
if proxy_server.redis_client:
try:
if success:
ChannelService._update_channel_metadata(channel_id, new_url, user_agent, stream_id, m3u_profile_id)
else:
ChannelService._update_channel_metadata(channel_id, manager.url, user_agent)
result['metadata_updated'] = True
except Exception as e:
logger.error(f"Error updating Redis metadata: {e}", exc_info=True)
result['metadata_updated'] = False
result.update({
'direct_update': True,
'success': success,
'worker_id': proxy_server.worker_id
})
else:
# Not the owner: publish the switch event. The owner will update metadata
# after the actual switch attempt succeeds (or roll back on failure).
# All needed info (url, user_agent, stream_id, m3u_profile_id) is carried
# in the pubsub message, so there is no reason to pre-write metadata here.
logger.debug(f"This worker is not the owner, publishing stream switch event for channel {channel_id}")
if proxy_server.redis_client:
ChannelService._publish_stream_switch_event(channel_id, new_url, user_agent, stream_id, m3u_profile_id)
result.update({
'direct_update': False,
'event_published': True,
'worker_id': proxy_server.worker_id
})
else:
result.update({
'direct_update': False,
'event_published': False,
'error': 'Redis not available for pubsub'
})
return result
@staticmethod
def stop_channel(channel_id):
"""
Stop a channel and release all resources.
Args:
channel_id: UUID of the channel
Returns:
dict: Result information including previous state if available
"""
proxy_server = ProxyServer.get_instance()
# Check if channel exists
channel_exists = proxy_server.check_if_channel_exists(channel_id)
if not channel_exists:
logger.warning(f"Channel {channel_id} not found in any worker or Redis")
return {'status': 'error', 'message': 'Channel not found'}
# Get channel state information for result
channel_info = None
if proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
try:
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and 'state' in metadata:
state = metadata['state']
channel_info = {"state": state}
# Immediately mark as stopping in metadata so clients detect it faster
proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STATE, ChannelState.STOPPING)
proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STATE_CHANGED_AT, str(time.time()))
except Exception as e:
logger.error(f"Error fetching channel state: {e}")
# Set stopping flag with higher TTL to ensure it persists
if proxy_server.redis_client:
stop_key = RedisKeys.channel_stopping(channel_id)
proxy_server.redis_client.setex(stop_key, 60, "true") # Higher TTL of 60 seconds
logger.info(f"Set channel stopping flag with 60s TTL for channel {channel_id}")
# Broadcast stop event to all workers via PubSub
if proxy_server.redis_client:
ChannelService._publish_channel_stop_event(channel_id)
# Also stop locally to ensure this worker cleans up right away
local_result = proxy_server.stop_channel(channel_id)
else:
# No Redis, just stop locally
local_result = proxy_server.stop_channel(channel_id)
# Release the channel in the channel model if applicable
try:
channel = Channel.objects.get(uuid=channel_id)
model_released = channel.release_stream()
if model_released:
logger.info(f"Released channel {channel_id} stream allocation")
else:
logger.warning(f"Channel {channel_id}: release_stream found no keys to clean")
except (Channel.DoesNotExist, Exception):
logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash")
try:
stream = Stream.objects.get(stream_hash=channel_id)
model_released = stream.release_stream()
if model_released:
logger.info(f"Released stream {channel_id} stream allocation")
else:
logger.warning(f"Stream {channel_id}: release_stream found no keys to clean")
except (Stream.DoesNotExist, Exception) as e:
logger.error(f"No Channel or Stream found for {channel_id}: {e}")
model_released = False
return {
'status': 'success',
'message': 'Channel stop request sent',
'channel_id': channel_id,
'previous_state': channel_info,
'model_released': model_released,
'local_stop_result': local_result
}
@staticmethod
def stop_client(channel_id, client_id):
"""
Stop a specific client connection.
Args:
channel_id: UUID of the channel
client_id: ID of the client to stop
Returns:
dict: Result information
"""
logger.info(f"Request to stop client {client_id} on channel {channel_id}")
proxy_server = ProxyServer.get_instance()
# Set a Redis key for immediate detection
key_set = False
if proxy_server.redis_client:
stop_key = RedisKeys.client_stop(channel_id, client_id)
try:
proxy_server.redis_client.setex(stop_key, 30, "true") # 30 second TTL
logger.info(f"Set stop key for client {client_id}")
key_set = True
except Exception as e:
logger.error(f"Error setting client stop key: {e}")
# Check if channel exists
channel_exists = proxy_server.check_if_channel_exists(channel_id)
if not channel_exists:
logger.warning(f"Channel {channel_id} not found")
return {
'status': 'error',
'message': 'Channel not found',
'stop_key_set': key_set
}
# Try to stop locally if client is on this worker
local_client_stopped = False
if channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[channel_id]
with client_manager.lock:
if client_id in client_manager.clients:
client_manager.remove_client(client_id)
local_client_stopped = True
logger.info(f"Client {client_id} stopped locally on channel {channel_id}")
# If client wasn't found locally, broadcast stop event for other workers
event_published = False
if not local_client_stopped and proxy_server.redis_client:
try:
ChannelService._publish_client_stop_event(channel_id, client_id)
event_published = True
logger.info(f"Published stop request for client {client_id} on channel {channel_id}")
except Exception as e:
logger.error(f"Error publishing client stop event: {e}")
return {
'status': 'success',
'message': 'Client stop request processed',
'channel_id': channel_id,
'client_id': client_id,
'locally_processed': local_client_stopped,
'stop_key_set': key_set,
'event_published': event_published
}
@staticmethod
def validate_channel_state(channel_id):
"""
Validate if a channel is in a healthy state and has an active owner.
Args:
channel_id: UUID of the channel
Returns:
tuple: (valid, state, owner, details) - validity status, current state, owner, and diagnostic info
"""
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False, None, None, {"error": "Redis not available"}
try:
metadata_key = RedisKeys.channel_metadata(channel_id)
if not proxy_server.redis_client.exists(metadata_key):
return False, None, None, {"error": "No channel metadata"}
metadata = proxy_server.redis_client.hgetall(metadata_key)
# Extract state and owner
state = metadata.get(ChannelMetadataField.STATE, 'unknown')
owner = metadata.get(ChannelMetadataField.OWNER, 'unknown')
# Valid states indicate channel is running properly
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING]
if state not in valid_states:
return False, state, owner, {"error": f"Invalid state: {state}"}
# Check if owner is still active
owner_heartbeat_key = RedisKeys.worker_heartbeat(owner)
owner_alive = proxy_server.redis_client.exists(owner_heartbeat_key)
if not owner_alive:
return False, state, owner, {"error": "Owner not active"}
# Check for recent activity
last_data_key = RedisKeys.last_data(channel_id)
last_data = proxy_server.redis_client.get(last_data_key)
details = {
"state": state,
"owner": owner,
"owner_alive": owner_alive
}
if last_data:
last_data_time = float(last_data)
data_age = time.time() - last_data_time
details["last_data_age"] = data_age
# If no data for too long, consider invalid
if data_age > 30: # 30 seconds threshold
return False, state, owner, {"error": f"No data for {data_age:.1f}s", **details}
return True, state, owner, details
except Exception as e:
logger.error(f"Error validating channel state: {e}", exc_info=True)
return False, None, None, {"error": f"Exception: {str(e)}"}
@staticmethod
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None):
"""
Parse stream info from FFmpeg/VLC/Streamlink logs and store in Redis/DB.
Uses specialized parsers for each streaming tool.
"""
try:
# Use factory to parse the line based on stream type
parsed_data = LogParserFactory.parse(stream_type, stream_info_line)
if not parsed_data:
return
# Update Redis and database with parsed data
ChannelService._update_stream_info_in_redis(
channel_id,
parsed_data.get('video_codec'),
parsed_data.get('resolution'),
parsed_data.get('width'),
parsed_data.get('height'),
parsed_data.get('source_fps'),
parsed_data.get('pixel_format'),
parsed_data.get('video_bitrate'),
parsed_data.get('audio_codec'),
parsed_data.get('sample_rate'),
parsed_data.get('audio_channels'),
parsed_data.get('audio_bitrate'),
parsed_data.get('stream_type')
)
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
video_codec=parsed_data.get('video_codec'),
resolution=parsed_data.get('resolution'),
source_fps=parsed_data.get('source_fps'),
pixel_format=parsed_data.get('pixel_format'),
video_bitrate=parsed_data.get('video_bitrate'),
audio_codec=parsed_data.get('audio_codec'),
sample_rate=parsed_data.get('sample_rate'),
audio_channels=parsed_data.get('audio_channels'),
audio_bitrate=parsed_data.get('audio_bitrate'),
stream_type=parsed_data.get('stream_type')
)
except Exception as e:
logger.debug(f"Error parsing {stream_type} stream info: {e}")
@staticmethod
def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None, input_format=None):
"""Update stream info in Redis metadata"""
try:
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
metadata_key = RedisKeys.channel_metadata(channel_id)
update_data = {
ChannelMetadataField.STREAM_INFO_UPDATED: str(time.time())
}
# Video info
if codec is not None:
update_data[ChannelMetadataField.VIDEO_CODEC] = str(codec)
if resolution is not None:
update_data[ChannelMetadataField.RESOLUTION] = str(resolution)
if width is not None:
update_data[ChannelMetadataField.WIDTH] = str(width)
if height is not None:
update_data[ChannelMetadataField.HEIGHT] = str(height)
if fps is not None:
update_data[ChannelMetadataField.SOURCE_FPS] = str(round(fps, 2))
if pixel_format is not None:
update_data[ChannelMetadataField.PIXEL_FORMAT] = str(pixel_format)
if video_bitrate is not None:
update_data[ChannelMetadataField.VIDEO_BITRATE] = str(round(video_bitrate, 1))
# Audio info
if audio_codec is not None:
update_data[ChannelMetadataField.AUDIO_CODEC] = str(audio_codec)
if sample_rate is not None:
update_data[ChannelMetadataField.SAMPLE_RATE] = str(sample_rate)
if channels is not None:
update_data[ChannelMetadataField.AUDIO_CHANNELS] = str(channels)
if audio_bitrate is not None:
update_data[ChannelMetadataField.AUDIO_BITRATE] = str(round(audio_bitrate, 1))
if input_format is not None:
update_data[ChannelMetadataField.STREAM_TYPE] = str(input_format)
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
return True
except Exception as e:
logger.error(f"Error updating stream info in Redis: {e}")
return False
@staticmethod
def _update_stream_stats_in_db(stream_id, **stats):
"""Update stream stats in database"""
from django.db import connection
try:
from apps.channels.models import Stream
from django.utils import timezone
stream = Stream.objects.get(id=stream_id)
# Get existing stats or create new dict
current_stats = stream.stream_stats or {}
# Update with new stats
for key, value in stats.items():
if value is not None:
current_stats[key] = value
# Save updated stats and timestamp
stream.stream_stats = current_stats
stream.stream_stats_updated_at = timezone.now()
stream.save(update_fields=['stream_stats', 'stream_stats_updated_at'])
logger.debug(f"Updated stream stats in database for stream {stream_id}: {stats}")
return True
except Exception as e:
logger.error(f"Error updating stream stats in database for stream {stream_id}: {e}")
return False
finally:
# Always close database connection after update
try:
connection.close()
except Exception:
pass
# Helper methods for Redis operations
@staticmethod
def _update_channel_metadata(channel_id, url, user_agent=None, stream_id=None, m3u_profile_id=None):
"""Update channel metadata in Redis"""
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
metadata_key = RedisKeys.channel_metadata(channel_id)
# First check if the key exists and what type it is
key_type = proxy_server.redis_client.type(metadata_key)
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
# Build metadata update dict
metadata = {ChannelMetadataField.URL: url}
if user_agent:
metadata[ChannelMetadataField.USER_AGENT] = user_agent
if stream_id:
metadata[ChannelMetadataField.STREAM_ID] = str(stream_id)
if m3u_profile_id:
metadata[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
# Also update the stream switch time field
metadata[ChannelMetadataField.STREAM_SWITCH_TIME] = str(time.time())
# Use the appropriate method based on the key type
if key_type == 'hash':
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
elif key_type == 'none': # Key doesn't exist yet
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
else:
# If key exists with wrong type, delete it and recreate
proxy_server.redis_client.delete(metadata_key)
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
# Set switch request flag to ensure all workers see it
switch_key = RedisKeys.switch_request(channel_id)
proxy_server.redis_client.setex(switch_key, 30, url) # 30 second TTL
logger.debug(f"Updated metadata for channel {channel_id} in Redis")
return True
@staticmethod
def _publish_stream_switch_event(channel_id, new_url, user_agent=None, stream_id=None, m3u_profile_id=None):
"""Publish a stream switch event to Redis pubsub"""
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
switch_request = {
"event": EventType.STREAM_SWITCH,
"channel_id": str(channel_id),
"url": new_url,
"user_agent": user_agent,
"stream_id": stream_id,
"m3u_profile_id": m3u_profile_id,
"requester": proxy_server.worker_id,
"timestamp": time.time()
}
proxy_server.redis_client.publish(
RedisKeys.events_channel(channel_id),
json.dumps(switch_request)
)
return True
@staticmethod
def _publish_channel_stop_event(channel_id):
"""Publish a channel stop event to Redis pubsub"""
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
stop_request = {
"event": EventType.CHANNEL_STOP,
"channel_id": str(channel_id),
"requester_worker_id": proxy_server.worker_id,
"timestamp": time.time()
}
proxy_server.redis_client.publish(
RedisKeys.events_channel(channel_id),
json.dumps(stop_request)
)
logger.info(f"Published channel stop event for {channel_id}")
return True
@staticmethod
def _publish_client_stop_event(channel_id, client_id):
"""Publish a client stop event to Redis pubsub"""
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
stop_request = {
"event": EventType.CLIENT_STOP,
"channel_id": str(channel_id),
"client_id": client_id,
"requester_worker_id": proxy_server.worker_id,
"timestamp": time.time()
}
proxy_server.redis_client.publish(
RedisKeys.events_channel(channel_id),
json.dumps(stop_request)
)
return True
+410
View File
@@ -0,0 +1,410 @@
"""Log parsers for FFmpeg, Streamlink, and VLC output."""
import re
import logging
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class BaseLogParser(ABC):
"""Base class for log parsers"""
# Map of stream_type -> method_name that this parser handles
STREAM_TYPE_METHODS: Dict[str, str] = {}
@abstractmethod
def can_parse(self, line: str) -> Optional[str]:
"""
Check if this parser can handle the line.
Returns the stream_type if it can parse, None otherwise.
e.g., 'video', 'audio', 'vlc_video', 'vlc_audio', 'streamlink'
"""
pass
@abstractmethod
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
pass
@abstractmethod
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
pass
@abstractmethod
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
pass
class FFmpegLogParser(BaseLogParser):
"""Parser for FFmpeg log output"""
STREAM_TYPE_METHODS = {
'input': 'parse_input_format',
'video': 'parse_video_stream',
'audio': 'parse_audio_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is an FFmpeg line we can parse"""
lower = line.lower()
# Input format detection
if lower.startswith('input #'):
return 'input'
# Stream info (only during input phase, but we'll let stream_manager handle phase tracking)
if 'stream #' in lower:
if 'video:' in lower:
return 'video'
elif 'audio:' in lower:
return 'audio'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg input format (e.g., mpegts, hls)"""
try:
input_match = re.search(r'Input #\d+,\s*([^,]+)', line)
input_format = input_match.group(1).strip() if input_match else None
if input_format:
logger.debug(f"Input format info - Format: {input_format}")
return {'stream_type': input_format}
except Exception as e:
logger.debug(f"Error parsing FFmpeg input format: {e}")
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg video stream info"""
try:
result = {}
# Extract codec, resolution, fps, pixel format, bitrate
codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', line)
if codec_match:
result['video_codec'] = codec_match.group(1)
resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', line)
if fps_match:
result['source_fps'] = float(fps_match.group(1))
pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', line)
if pixel_format_match:
pf = pixel_format_match.group(1).strip()
if '(' in pf:
pf = pf.split('(')[0].strip()
result['pixel_format'] = pf
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line)
if bitrate_match:
result['video_bitrate'] = float(bitrate_match.group(1))
if result:
logger.info(f"Video stream info - Codec: {result.get('video_codec')}, "
f"Resolution: {result.get('resolution')}, "
f"Source FPS: {result.get('source_fps')}, "
f"Pixel Format: {result.get('pixel_format')}, "
f"Video Bitrate: {result.get('video_bitrate')} kb/s")
return result
except Exception as e:
logger.debug(f"Error parsing FFmpeg video stream info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg audio stream info"""
try:
result = {}
codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', line)
if codec_match:
result['audio_codec'] = codec_match.group(1)
sample_rate_match = re.search(r'(\d+)\s*Hz', line)
if sample_rate_match:
result['sample_rate'] = int(sample_rate_match.group(1))
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', line, re.IGNORECASE)
if channel_match:
result['audio_channels'] = channel_match.group(1)
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line)
if bitrate_match:
result['audio_bitrate'] = float(bitrate_match.group(1))
if result:
return result
except Exception as e:
logger.debug(f"Error parsing FFmpeg audio stream info: {e}")
return None
class VLCLogParser(BaseLogParser):
"""Parser for VLC log output"""
STREAM_TYPE_METHODS = {
'vlc_video': 'parse_video_stream',
'vlc_audio': 'parse_audio_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is a VLC line we can parse"""
lower = line.lower()
# VLC TS demux codec detection
if 'ts demux debug' in lower and 'type=' in lower:
if 'video' in lower:
return 'vlc_video'
elif 'audio' in lower:
return 'vlc_audio'
# VLC decoder output
if 'decoder' in lower and ('channels:' in lower or 'samplerate:' in lower or 'x' in line or 'fps' in lower):
if 'audio' in lower or 'channels:' in lower or 'samplerate:' in lower:
return 'vlc_audio'
else:
return 'vlc_video'
# VLC transcode output for resolution/FPS
if 'stream_out_transcode' in lower and ('source fps' in lower or ('source ' in lower and 'x' in line)):
return 'vlc_video'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse VLC TS demux output and decoder info for video"""
try:
lower = line.lower()
result = {}
# Codec detection from TS demux
video_codec_map = {
('avc', 'h.264', 'type=0x1b'): "h264",
('hevc', 'h.265', 'type=0x24'): "hevc",
('mpeg-2', 'type=0x02'): "mpeg2video",
('mpeg-4', 'type=0x10'): "mpeg4"
}
for patterns, codec in video_codec_map.items():
if any(p in lower for p in patterns):
result['video_codec'] = codec
break
# Extract FPS from transcode output: "source fps 30/1"
fps_fraction_match = re.search(r'source fps\s+(\d+)/(\d+)', lower)
if fps_fraction_match:
numerator = int(fps_fraction_match.group(1))
denominator = int(fps_fraction_match.group(2))
if denominator > 0:
result['source_fps'] = numerator / denominator
# Extract resolution from transcode output: "source 1280x720"
source_res_match = re.search(r'source\s+(\d{3,4})x(\d{3,4})', lower)
if source_res_match:
width = int(source_res_match.group(1))
height = int(source_res_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
else:
# Fallback: generic resolution pattern
resolution_match = re.search(r'(\d{3,4})x(\d{3,4})', line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
# Fallback: try to extract FPS from generic format
if 'source_fps' not in result:
fps_match = re.search(r'(\d+\.?\d*)\s*fps', lower)
if fps_match:
result['source_fps'] = float(fps_match.group(1))
return result if result else None
except Exception as e:
logger.debug(f"Error parsing VLC video stream info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse VLC TS demux output and decoder info for audio"""
try:
lower = line.lower()
result = {}
# Codec detection from TS demux
audio_codec_map = {
('type=0xf', 'adts'): "aac",
('type=0x03', 'type=0x04'): "mp3",
('type=0x06', 'type=0x81'): "ac3",
('type=0x0b', 'lpcm'): "pcm"
}
for patterns, codec in audio_codec_map.items():
if any(p in lower for p in patterns):
result['audio_codec'] = codec
break
# VLC decoder format: "AAC channels: 2 samplerate: 48000"
if 'channels:' in lower:
channels_match = re.search(r'channels:\s*(\d+)', lower)
if channels_match:
num_channels = int(channels_match.group(1))
# Convert number to name
channel_names = {1: 'mono', 2: 'stereo', 6: '5.1', 8: '7.1'}
result['audio_channels'] = channel_names.get(num_channels, str(num_channels))
if 'samplerate:' in lower:
samplerate_match = re.search(r'samplerate:\s*(\d+)', lower)
if samplerate_match:
result['sample_rate'] = int(samplerate_match.group(1))
# Try to extract sample rate (Hz format)
sample_rate_match = re.search(r'(\d+)\s*hz', lower)
if sample_rate_match and 'sample_rate' not in result:
result['sample_rate'] = int(sample_rate_match.group(1))
# Try to extract channels (word format)
if 'audio_channels' not in result:
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', lower)
if channel_match:
result['audio_channels'] = channel_match.group(1)
return result if result else None
except Exception as e:
logger.error(f"[VLC AUDIO PARSER] Error parsing VLC audio stream info: {e}")
return None
class StreamlinkLogParser(BaseLogParser):
"""Parser for Streamlink log output"""
STREAM_TYPE_METHODS = {
'streamlink': 'parse_video_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is a Streamlink line we can parse"""
lower = line.lower()
if 'opening stream:' in lower or 'available streams:' in lower:
return 'streamlink'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse Streamlink quality/resolution"""
try:
quality_match = re.search(r'(\d+p|\d+x\d+)', line)
if quality_match:
quality = quality_match.group(1)
if 'x' in quality:
resolution = quality
width, height = map(int, quality.split('x'))
else:
resolutions = {
'2160p': ('3840x2160', 3840, 2160),
'1080p': ('1920x1080', 1920, 1080),
'720p': ('1280x720', 1280, 720),
'480p': ('854x480', 854, 480),
'360p': ('640x360', 640, 360)
}
resolution, width, height = resolutions.get(quality, ('1920x1080', 1920, 1080))
return {
'video_codec': 'h264',
'resolution': resolution,
'width': width,
'height': height,
'pixel_format': 'yuv420p'
}
except Exception as e:
logger.debug(f"Error parsing Streamlink video info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
return None
class LogParserFactory:
"""Factory to get the appropriate log parser"""
_parsers = {
'ffmpeg': FFmpegLogParser(),
'vlc': VLCLogParser(),
'streamlink': StreamlinkLogParser()
}
@classmethod
def _get_parser_and_method(cls, stream_type: str) -> Optional[tuple[BaseLogParser, str]]:
"""Determine parser and method from stream_type"""
# Check each parser to see if it handles this stream_type
for parser in cls._parsers.values():
method_name = parser.STREAM_TYPE_METHODS.get(stream_type)
if method_name:
return (parser, method_name)
return None
@classmethod
def parse(cls, stream_type: str, line: str) -> Optional[Dict[str, Any]]:
"""
Parse a log line based on stream type.
Returns parsed data or None if parsing fails.
"""
result = cls._get_parser_and_method(stream_type)
if not result:
return None
parser, method_name = result
method = getattr(parser, method_name, None)
if method:
return method(line)
return None
@classmethod
def auto_parse(cls, line: str) -> Optional[tuple[str, Dict[str, Any]]]:
"""
Automatically detect which parser can handle this line and parse it.
Returns (stream_type, parsed_data) or None if no parser can handle it.
"""
# Try each parser to see if it can handle this line
for parser in cls._parsers.values():
stream_type = parser.can_parse(line)
if stream_type:
# Parser can handle this line, now parse it
parsed_data = cls.parse(stream_type, line)
if parsed_data:
return (stream_type, parsed_data)
return None
+546
View File
@@ -0,0 +1,546 @@
"""Buffer management for TS streams"""
import threading
import logging
import time
from collections import deque
from typing import Optional, Deque
import random
from apps.proxy.config import TSConfig as Config
from .redis_keys import RedisKeys
from .config_helper import ConfigHelper
from .constants import TS_PACKET_SIZE
from .utils import get_logger
import gevent.event
import gevent # Make sure this import is at the top
logger = get_logger()
class StreamBuffer:
"""Manages stream data buffering with optimized chunk storage"""
def __init__(self, channel_id=None, redis_client=None):
self.channel_id = channel_id
self.redis_client = redis_client
self.lock = threading.Lock()
self.index = 0
self.TS_PACKET_SIZE = TS_PACKET_SIZE
# STANDARDIZED KEYS: Use RedisKeys class instead of hardcoded patterns
self.buffer_index_key = RedisKeys.buffer_index(channel_id) if channel_id else ""
self.buffer_prefix = RedisKeys.buffer_chunk_prefix(channel_id) if channel_id else ""
self.chunk_ttl = ConfigHelper.redis_chunk_ttl()
# Initialize from Redis if available
if self.redis_client and channel_id:
try:
current_index = self.redis_client.get(self.buffer_index_key)
if current_index:
self.index = int(current_index)
logger.info(f"Initialized buffer from Redis with index {self.index}")
except Exception as e:
logger.error(f"Error initializing buffer from Redis: {e}")
self._write_buffer = bytearray()
self.target_chunk_size = ConfigHelper.get('BUFFER_CHUNK_SIZE', TS_PACKET_SIZE * 5644) # ~1MB default
# Sorted-set key for chunk receive-timestamps (time-based positioning)
self.chunk_timestamps_key = RedisKeys.chunk_timestamps(channel_id) if channel_id else ""
# Register Lua scripts once — subsequent calls use EVALSHA (just the
# SHA hash) instead of sending the full script text on every invocation.
if self.redis_client:
self._find_oldest_chunk_sha = self.redis_client.register_script(
self._FIND_OLDEST_CHUNK_LUA
)
self._find_chunk_by_time_sha = self.redis_client.register_script(
self._FIND_CHUNK_BY_TIME_LUA
)
else:
self._find_oldest_chunk_sha = None
self._find_chunk_by_time_sha = None
# Track timers for proper cleanup
self.stopping = False
self.fill_timers = []
self.chunk_available = gevent.event.Event()
def add_chunk(self, chunk):
"""Add data with optimized Redis storage and TS packet alignment"""
if not chunk:
return False
try:
# Accumulate partial packets between chunks
if not hasattr(self, '_partial_packet'):
self._partial_packet = bytearray()
# Lock the full operation to prevent race with reset_buffer_position
writes_done = 0
with self.lock:
# Combine with any previous partial packet
combined_data = bytearray(self._partial_packet) + bytearray(chunk)
# Calculate complete packets
complete_packets_size = (len(combined_data) // self.TS_PACKET_SIZE) * self.TS_PACKET_SIZE
if complete_packets_size == 0:
# Not enough data for a complete packet
self._partial_packet = combined_data
return True
# Split into complete packets and remainder
complete_packets = combined_data[:complete_packets_size]
self._partial_packet = combined_data[complete_packets_size:]
# Add completed packets to write buffer
self._write_buffer.extend(complete_packets)
# Only write to Redis when we have enough data for an optimized chunk
while len(self._write_buffer) >= self.target_chunk_size:
# Extract a full chunk
chunk_data = self._write_buffer[:self.target_chunk_size]
self._write_buffer = self._write_buffer[self.target_chunk_size:]
# Write optimized chunk to Redis
if self.redis_client:
chunk_index = self.redis_client.incr(self.buffer_index_key)
chunk_key = RedisKeys.buffer_chunk(self.channel_id, chunk_index)
self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(chunk_data))
# Record receive timestamp for time-based client positioning
if self.chunk_timestamps_key:
now = time.time()
self.redis_client.zadd(self.chunk_timestamps_key, {str(chunk_index): now})
# Prune entries whose chunks have expired from Redis
self.redis_client.zremrangebyscore(self.chunk_timestamps_key, '-inf', now - self.chunk_ttl)
self.redis_client.expire(self.chunk_timestamps_key, self.chunk_ttl)
# Update local tracking
self.index = chunk_index
writes_done += 1
if writes_done > 0:
logger.debug(f"Added {writes_done} chunks ({self.target_chunk_size} bytes each) to Redis for channel {self.channel_id} at index {self.index}")
self.chunk_available.set() # Signal that new data is available
self.chunk_available.clear() # Reset for next notification
return True
except Exception as e:
logger.error(f"Error adding chunk to buffer: {e}")
return False
def reset_buffer_position(self):
"""
Reset internal buffers for a clean stream transition (failover).
Called by stream_manager.update_url() when switching between FFmpeg
processes. Without this, _partial_packet from the old FFmpeg gets
concatenated with the first bytes from the new FFmpeg, creating
corrupted TS packets that break audio decoder sync in the client.
"""
try:
with self.lock:
old_write_size = len(self._write_buffer)
old_partial_size = len(getattr(self, '_partial_packet', b''))
self._write_buffer = bytearray()
if hasattr(self, '_partial_packet'):
self._partial_packet = bytearray()
if old_write_size > 0 or old_partial_size > 0:
logger.info(
f"Reset buffer position for channel {self.channel_id}: "
f"cleared {old_write_size} bytes from write buffer, "
f"{old_partial_size} bytes from partial packet"
)
else:
logger.debug(
f"Reset buffer position for channel {self.channel_id}: "
f"buffers were already clean"
)
except Exception as e:
logger.error(
f"Error resetting buffer position for channel {self.channel_id}: {e}"
)
def get_chunks(self, start_index=None):
"""Get chunks from the buffer with detailed logging"""
try:
request_id = f"req_{random.randint(1000, 9999)}"
logger.debug(f"[{request_id}] get_chunks called with start_index={start_index}")
if not self.redis_client:
logger.error("Redis not available, cannot retrieve chunks")
return []
# If no start_index provided, use most recent chunks
if start_index is None:
start_index = max(0, self.index - 10) # Start closer to current position
logger.debug(f"[{request_id}] No start_index provided, using {start_index}")
# Get current index from Redis
current_index = int(self.redis_client.get(self.buffer_index_key) or 0)
# Calculate range of chunks to retrieve
start_id = start_index + 1
chunks_behind = current_index - start_id
# Adaptive chunk retrieval based on how far behind
if chunks_behind > 100:
fetch_count = 15
logger.debug(f"[{request_id}] Client very behind ({chunks_behind} chunks), fetching {fetch_count}")
elif chunks_behind > 50:
fetch_count = 10
logger.debug(f"[{request_id}] Client moderately behind ({chunks_behind} chunks), fetching {fetch_count}")
elif chunks_behind > 20:
fetch_count = 5
logger.debug(f"[{request_id}] Client slightly behind ({chunks_behind} chunks), fetching {fetch_count}")
else:
fetch_count = 3
logger.debug(f"[{request_id}] Client up-to-date (only {chunks_behind} chunks behind), fetching {fetch_count}")
end_id = min(current_index + 1, start_id + fetch_count)
if start_id >= end_id:
logger.debug(f"[{request_id}] No new chunks to fetch (start_id={start_id}, end_id={end_id})")
return []
# Log the range we're retrieving
logger.debug(f"[{request_id}] Retrieving chunks {start_id} to {end_id-1} (total: {end_id-start_id})")
# Directly fetch from Redis using pipeline for efficiency
pipe = self.redis_client.pipeline()
for idx in range(start_id, end_id):
chunk_key = RedisKeys.buffer_chunk(self.channel_id, idx)
pipe.get(chunk_key)
results = pipe.execute()
# Process results
chunks = [result for result in results if result is not None]
# Count non-None results
found_chunks = len(chunks)
missing_chunks = len(results) - found_chunks
if missing_chunks > 0:
logger.debug(f"[{request_id}] Missing {missing_chunks}/{len(results)} chunks in Redis")
# Update local tracking
if chunks:
self.index = end_id - 1
# Final log message
chunk_sizes = [len(c) for c in chunks]
total_bytes = sum(chunk_sizes) if chunks else 0
logger.debug(f"[{request_id}] Returning {len(chunks)} chunks ({total_bytes} bytes)")
return chunks
except Exception as e:
logger.error(f"Error getting chunks from buffer: {e}", exc_info=True)
return []
def get_chunks_exact(self, start_index, count):
"""Get exactly the requested number of chunks from given index"""
try:
if not self.redis_client:
logger.error("Redis not available, cannot retrieve chunks")
return []
# Calculate range to retrieve
start_id = start_index + 1
end_id = start_id + count
# Get current buffer position
current_index = int(self.redis_client.get(self.buffer_index_key) or 0)
# If requesting beyond current buffer, return what we have
if start_id > current_index:
return []
# Cap end at current buffer position
end_id = min(end_id, current_index + 1)
# Directly fetch from Redis using pipeline
pipe = self.redis_client.pipeline()
for idx in range(start_id, end_id):
chunk_key = RedisKeys.buffer_chunk(self.channel_id, idx)
pipe.get(chunk_key)
results = pipe.execute()
# Filter out None results
chunks = [result for result in results if result is not None]
# Update local index if needed
if chunks and start_id + len(chunks) - 1 > self.index:
self.index = start_id + len(chunks) - 1
return chunks
except Exception as e:
logger.error(f"Error getting exact chunks: {e}", exc_info=True)
return []
def stop(self):
"""Stop the buffer and cancel all timers"""
# Set stopping flag first to prevent new timer creation
self.stopping = True
# Cancel all pending timers
timers_cancelled = 0
for timer in list(self.fill_timers):
try:
if timer and not timer.dead: # Changed from timer.is_alive()
timer.kill() # Changed from timer.cancel()
timers_cancelled += 1
except Exception as e:
logger.error(f"Error canceling timer: {e}")
if timers_cancelled:
logger.info(f"Cancelled {timers_cancelled} buffer timers for channel {self.channel_id}")
# Clear timer list
self.fill_timers.clear()
try:
# Flush any remaining data in the write buffer
if hasattr(self, '_write_buffer') and len(self._write_buffer) > 0:
# Ensure remaining data is aligned to TS packets
complete_size = (len(self._write_buffer) // 188) * 188
if complete_size > 0:
final_chunk = self._write_buffer[:complete_size]
# Write final chunk to Redis
with self.lock:
if self.redis_client:
try:
chunk_index = self.redis_client.incr(self.buffer_index_key)
chunk_key = f"{self.buffer_prefix}{chunk_index}"
self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(final_chunk))
self.index = chunk_index
logger.info(f"Flushed final chunk of {len(final_chunk)} bytes to Redis")
except Exception as e:
logger.error(f"Error flushing final chunk: {e}")
# Clear buffers
self._write_buffer = bytearray()
if hasattr(self, '_partial_packet'):
self._partial_packet = bytearray()
# Clean up the chunk timestamps sorted set
if self.redis_client and self.chunk_timestamps_key:
try:
self.redis_client.delete(self.chunk_timestamps_key)
except Exception as e:
logger.error(f"Error deleting chunk timestamps key: {e}")
except Exception as e:
logger.error(f"Error during buffer stop: {e}")
def get_optimized_client_data(self, client_index):
"""Get optimal amount of data for client streaming based on position and target size"""
# Define limits
MIN_CHUNKS = 3 # Minimum chunks to read for efficiency
MAX_CHUNKS = 20 # Safety limit to prevent memory spikes
TARGET_SIZE = 1024 * 1024 # Target ~1MB per response (typical media buffer)
MAX_SIZE = 2 * 1024 * 1024 # Hard cap at 2MB
# Calculate how far behind we are
chunks_behind = self.index - client_index
# Determine optimal chunk count
if chunks_behind <= MIN_CHUNKS:
# Not much data, retrieve what's available
chunk_count = max(1, chunks_behind)
elif chunks_behind <= MAX_CHUNKS:
# Reasonable amount behind, catch up completely
chunk_count = chunks_behind
else:
# Way behind, retrieve MAX_CHUNKS to avoid memory pressure
chunk_count = MAX_CHUNKS
# Retrieve chunks
chunks = self.get_chunks_exact(client_index, chunk_count)
# Check if we got significantly fewer chunks than expected (likely due to expiration)
# Only check if we expected multiple chunks and got none or very few
if chunk_count > 3 and len(chunks) == 0 and chunks_behind > 10:
# Chunks are missing - likely expired from Redis
# Return empty list to signal client should skip forward
logger.debug(f"Chunks missing for client at index {client_index}, buffer at {self.index} ({chunks_behind} behind)")
return [], client_index
# Check total size
total_size = sum(len(c) for c in chunks)
# If we're under target and have more chunks available, get more
if total_size < TARGET_SIZE and chunks_behind > chunk_count:
# Calculate how many more chunks we can get
additional = min(MAX_CHUNKS - chunk_count, chunks_behind - chunk_count)
more_chunks = self.get_chunks_exact(client_index + chunk_count, additional)
# Check if adding more would exceed MAX_SIZE
additional_size = sum(len(c) for c in more_chunks)
if total_size + additional_size <= MAX_SIZE:
chunks.extend(more_chunks)
chunk_count += len(more_chunks) # Fixed: count actual additional chunks retrieved
return chunks, client_index + chunk_count
# Lua script that runs an atomic binary search on the Redis server.
# Chunks expire in FIFO order (same TTL, sequential writes), so the
# alive range is contiguous: [oldest_surviving .. buffer_head].
# Binary search finds the boundary in O(log N) EXISTS calls with zero
# round-trips between steps and no TOCTOU races (Lua scripts are atomic).
#
# ARGV[1] = key prefix (e.g. "ts_proxy:channel:<id>:buffer:chunk:")
# ARGV[2] = low index (client_index + 1, first chunk the client needs)
# ARGV[3] = high index (buffer head, most recent chunk)
#
# Returns: the index of the oldest existing chunk, or -1 if none exist.
_FIND_OLDEST_CHUNK_LUA = """
local prefix = ARGV[1]
local low = tonumber(ARGV[2])
local high = tonumber(ARGV[3])
if redis.call('EXISTS', prefix .. high) == 0 then
return -1
end
local result = high
while low <= high do
local mid = math.floor((low + high) / 2)
if redis.call('EXISTS', prefix .. mid) == 1 then
result = mid
high = mid - 1
else
low = mid + 1
end
end
return result
"""
def find_oldest_available_chunk(self, client_index):
"""Find the oldest (lowest-index) chunk that still exists in Redis.
Executes an atomic Lua binary search on the Redis server — one
round-trip, ~log2(N) EXISTS calls, no TOCTOU between steps.
The actual read attempt (get_optimized_client_data) is what
authoritatively detects expiration; this method is best-effort
positioning that self-corrects on the next iteration if the found
chunk also expires before the client can read it.
Args:
client_index: The client's current local_index (last consumed chunk).
Returns:
int or None: The local_index value the client should jump to
(one before the first available chunk), or None if no
chunks are available at all.
"""
if not self.redis_client:
return None
low = client_index + 1 # First chunk the client needs
high = self.index # Latest chunk written
if low > high:
return None
try:
# Uses EVALSHA under the hood — sends only the SHA hash,
# not the full script text, on every call after the first.
result = self._find_oldest_chunk_sha(
args=[
RedisKeys.buffer_chunk_prefix(self.channel_id),
low,
high,
],
)
if result == -1:
return None
# Return result - 1 so local_index points to one before the
# first available chunk (matching the "last consumed" convention).
return int(result) - 1
except Exception as e:
logger.error(f"Error running find_oldest_chunk Lua script for channel {self.channel_id}: {e}")
return None
# ------------------------------------------------------------------
# Lua script: atomic reverse-scan of the chunk_timestamps sorted set.
# Finds the chunk whose receive-timestamp is closest to (but <=) a
# target wall-clock time. Returns the chunk index or -1.
#
# KEYS[1] = chunk_timestamps sorted-set key
# ARGV[1] = target timestamp (time.time() - desired_seconds_behind)
# ------------------------------------------------------------------
_FIND_CHUNK_BY_TIME_LUA = """
local ts_key = KEYS[1]
local target = tonumber(ARGV[1])
-- ZREVRANGEBYSCORE returns members with score <= target, highest first.
local result = redis.call('ZREVRANGEBYSCORE', ts_key, target, '-inf', 'LIMIT', 0, 1)
if #result == 0 then
return -1
end
return tonumber(result[1])
"""
def find_chunk_index_by_time(self, seconds_behind):
"""Find the chunk index that was received approximately *seconds_behind*
seconds ago.
Uses an atomic Lua script against the chunk_timestamps sorted set so
no data can expire between the lookup and the read.
Returns:
int or None: The chunk index to position the client at (this is
the *last consumed* convention, so the next read
starts at index+1). None if no suitable chunk
exists.
"""
if not self.redis_client or not self.chunk_timestamps_key:
return None
target_time = time.time() - seconds_behind
try:
result = self._find_chunk_by_time_sha(
keys=[self.chunk_timestamps_key],
args=[target_time],
)
if result is None or int(result) == -1:
# No chunk old enough — fall back to the oldest available chunk
oldest = self.redis_client.zrange(self.chunk_timestamps_key, 0, 0)
if oldest:
return max(0, int(oldest[0]) - 1) # "last consumed" convention
return None
# Return index - 1 so next read starts at that chunk
return max(0, int(result) - 1)
except Exception as e:
logger.error(f"Error in find_chunk_index_by_time for channel {self.channel_id}: {e}")
return None
# Add a new method to safely create timers
def schedule_timer(self, delay, callback, *args, **kwargs):
"""Schedule a timer and track it for proper cleanup"""
if self.stopping:
return None
# Replace threading.Timer with gevent.spawn_later for better compatibility
timer = gevent.spawn_later(delay, callback, *args, **kwargs)
self.fill_timers.append(timer)
return timer
+641
View File
@@ -0,0 +1,641 @@
"""
Stream generation and client-side handling for TS streams.
This module handles generating and delivering video streams to clients.
"""
import time
import logging
import threading
import gevent # Add this import at the top of your file
from apps.proxy.config import TSConfig as Config
from apps.channels.models import Channel, Stream
from core.utils import log_system_event
from .server import ProxyServer
from .utils import create_ts_packet, get_logger
from .redis_keys import RedisKeys
from .utils import get_logger
from .constants import ChannelMetadataField
from .config_helper import ConfigHelper # Add this import
logger = get_logger()
class StreamGenerator:
"""
Handles generating streams for clients, including initialization,
data delivery, and cleanup.
"""
def __init__(self, channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None):
"""
Initialize the stream generator with client and channel details.
Args:
channel_id: The UUID of the channel to stream
client_id: Unique ID for this client connection
client_ip: Client's IP address
client_user_agent: User agent string from client
channel_initializing: Whether the channel is still initializing
user: Authenticated user making the request
"""
self.channel_id = channel_id
self.client_id = client_id
self.client_ip = client_ip
self.client_user_agent = client_user_agent
self.channel_initializing = channel_initializing
self.user = user
# Performance and state tracking
self.stream_start_time = time.time()
self.bytes_sent = 0
self.chunks_sent = 0
self.local_index = 0
self.consecutive_empty = 0
# Add tracking for current transfer rate calculation
self.last_stats_time = time.time()
self.last_stats_bytes = 0
self.current_rate = 0.0
# TTL refresh tracking
self.last_ttl_refresh = time.time()
self.ttl_refresh_interval = 3 # Refresh TTL every 3 seconds of active streaming
# Cached proxy server reference
self.proxy_server = None
# Non-owner health check throttle: avoid Redis GET on every loop iteration
self._last_health_check_time = 0.0
self._last_health_check_result = False
self._health_check_interval = 2.0 # seconds
# Resource check throttle: Redis stop/state checks are expensive; throttle
# them while allowing cheap in-memory checks to run every iteration.
self._last_resource_check_time = 0.0
self._resource_check_interval = 1.0 # seconds
def generate(self):
"""
Generator function that produces the stream content for the client.
Handles initialization state, data delivery, and client disconnection.
Yields:
bytes: Chunks of TS stream data
"""
self.stream_start_time = time.time()
self.bytes_sent = 0
self.chunks_sent = 0
try:
logger.info(f"[{self.client_id}] Stream generator started, channel_ready={not self.channel_initializing}")
# First handle initialization if needed
if self.channel_initializing:
channel_ready = self._wait_for_initialization()
if not channel_ready:
# If initialization failed or timed out, we've already sent error packets
return
# Channel is now ready - start normal streaming
logger.info(f"[{self.client_id}] Channel {self.channel_id} ready, starting normal streaming")
# Reset start time for real streaming
self.stream_start_time = time.time()
# Setup streaming parameters and verify resources
if not self._setup_streaming():
return
# Log client connect event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'client_connect',
channel_id=self.channel_id,
channel_name=channel_obj.name,
client_ip=self.client_ip,
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
username=self.user.username if self.user else None
)
except Exception as e:
logger.error(f"Could not log client connect event: {e}")
# Main streaming loop
for chunk in self._stream_data_generator():
yield chunk
except Exception as e:
logger.error(f"[{self.client_id}] Stream error: {e}", exc_info=True)
finally:
self._cleanup()
def _wait_for_initialization(self):
"""Wait for channel initialization to complete, sending keepalive packets."""
initialization_start = time.time()
max_init_wait = ConfigHelper.client_wait_timeout()
keepalive_interval = 0.5
last_keepalive = 0
proxy_server = ProxyServer.get_instance()
# While init is happening, send keepalive packets
while time.time() - initialization_start < max_init_wait:
# Check if initialization has completed
if proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and 'state' in metadata:
state = metadata['state']
if state in ['waiting_for_clients', 'active']:
logger.info(f"[{self.client_id}] Channel {self.channel_id} now ready (state={state})")
return True
elif state in ['error', 'stopped', 'stopping']: # Added 'stopping' to error states
error_message = metadata.get('error_message', 'Unknown error')
logger.error(f"[{self.client_id}] Channel {self.channel_id} in error state: {state}, message: {error_message}")
# Send error packet before giving up
yield create_ts_packet('error', f"Error: {error_message}")
return False
else:
# Improved logging to track initialization progress
init_time = "unknown"
if 'init_time' in metadata:
try:
init_time_float = float(metadata['init_time'])
init_duration = time.time() - init_time_float
init_time = f"{init_duration:.1f}s ago"
except:
pass
# Still initializing - send keepalive if needed
if time.time() - last_keepalive >= keepalive_interval:
status_msg = f"Initializing: {state} (started {init_time})"
keepalive_packet = create_ts_packet('keepalive', status_msg)
logger.debug(f"[{self.client_id}] Sending keepalive packet during initialization, state={state}")
yield keepalive_packet
self.bytes_sent += len(keepalive_packet)
last_keepalive = time.time()
# Also check stopping key directly
stop_key = RedisKeys.channel_stopping(self.channel_id)
if proxy_server.redis_client.exists(stop_key):
logger.error(f"[{self.client_id}] Channel {self.channel_id} stopping flag detected during initialization")
yield create_ts_packet('error', "Error: Channel is stopping")
return False
# Wait a bit before checking again
gevent.sleep(0.1)
# Timed out waiting
logger.warning(f"[{self.client_id}] Timed out waiting for initialization")
yield create_ts_packet('error', "Error: Initialization timeout")
return False
def _setup_streaming(self):
"""Setup streaming parameters and check resources."""
proxy_server = ProxyServer.get_instance()
# Get buffer - stream manager may not exist in this worker
buffer = proxy_server.stream_buffers.get(self.channel_id)
stream_manager = proxy_server.stream_managers.get(self.channel_id)
if not buffer:
logger.error(f"[{self.client_id}] No buffer found for channel {self.channel_id}")
return False
# Client state tracking — determine start position
# When behind_seconds > 0, use time-based positioning to start
# the client that many seconds behind live.
# When behind_seconds == 0, start at live (buffer head).
behind_seconds = ConfigHelper.new_client_behind_seconds()
current_buffer_index = buffer.index
if behind_seconds > 0:
time_index = buffer.find_chunk_index_by_time(behind_seconds)
if time_index is not None:
self.local_index = max(0, time_index)
logger.info(
f"[{self.client_id}] Time-based positioning: "
f"{behind_seconds}s behind -> index {self.local_index} "
f"(buffer head at {current_buffer_index})"
)
else:
# Not enough buffer for the requested time — start as far
# back as possible (oldest available chunk).
oldest = buffer.find_oldest_available_chunk(0)
if oldest is not None:
self.local_index = max(0, oldest)
logger.info(
f"[{self.client_id}] Buffer shorter than {behind_seconds}s, "
f"starting at oldest available chunk {self.local_index} "
f"(buffer head at {current_buffer_index})"
)
else:
# No timestamp data at all — start at live
self.local_index = current_buffer_index
logger.info(
f"[{self.client_id}] No timestamp data, starting at live: "
f"index {self.local_index} (buffer head at {current_buffer_index})"
)
else:
# 0 = start at live (buffer head)
self.local_index = current_buffer_index
logger.info(
f"[{self.client_id}] Starting at live (behind_seconds=0): "
f"index {self.local_index} (buffer head at {current_buffer_index})"
)
# Store important objects as instance variables
self.proxy_server = proxy_server
self.buffer = buffer
self.stream_manager = stream_manager
self.last_yield_time = time.time()
self.empty_reads = 0
self.consecutive_empty = 0
self.is_owner_worker = proxy_server.am_i_owner(self.channel_id) if hasattr(proxy_server, 'am_i_owner') else True
logger.info(f"[{self.client_id}] Starting stream at index {self.local_index} (buffer at {buffer.index})")
return True
def _stream_data_generator(self):
"""Generate stream data chunks based on buffer contents."""
# Keepalive packets refresh last_yield_time, so _is_timeout() never fires
# during sustained stream failure. This timer enforces a wall-clock cap.
keepalive_start_time = None
# Main streaming loop
while True:
# Check if resources still exist
if not self._check_resources():
break
# Get chunks at client's position using improved strategy
chunks, next_index = self.buffer.get_optimized_client_data(self.local_index)
if chunks:
keepalive_start_time = None # Each recovery restarts the cap independently.
yield from self._process_chunks(chunks, next_index)
self.local_index = next_index
self.last_yield_time = time.time()
self.empty_reads = 0
self.consecutive_empty = 0
else:
# Handle no data condition (with possible keepalive packets)
self.empty_reads += 1
self.consecutive_empty += 1
# We got no data despite being behind the buffer head.
# The read itself is the authoritative signal — no separate
# existence check needed, avoiding TOCTOU races with Redis TTL.
chunks_behind = self.buffer.index - self.local_index
if chunks_behind > 0:
# Next chunk has expired — find the oldest chunk still in Redis
new_index = self.buffer.find_oldest_available_chunk(self.local_index)
if new_index is not None:
skipped = new_index - self.local_index
logger.warning(
f"[{self.client_id}] Next chunk expired (index {self.local_index + 1}), "
f"jumping to oldest available: {new_index + 1} "
f"(skipped {skipped} chunks, buffer head at {self.buffer.index})"
)
self.local_index = new_index
else:
# No chunks available at all — jump to near the buffer head
initial_behind = ConfigHelper.initial_behind_chunks()
new_index = max(self.local_index, self.buffer.index - initial_behind)
logger.warning(
f"[{self.client_id}] No chunks available in buffer, "
f"jumping to near buffer head: {new_index} "
f"(buffer head at {self.buffer.index})"
)
self.local_index = new_index
self.consecutive_empty = 0
continue # Retry immediately with the new position
if self._should_send_keepalive(self.local_index):
if keepalive_start_time is None:
keepalive_start_time = time.time()
max_keepalive = getattr(Config, 'MAX_KEEPALIVE_DURATION', 300)
if time.time() - keepalive_start_time > max_keepalive:
logger.warning(
f"[{self.client_id}] Keepalive duration exceeded {max_keepalive}s "
f"with no stream recovery, disconnecting"
)
break
keepalive_packet = create_ts_packet('keepalive')
logger.debug(f"[{self.client_id}] Sending keepalive packet while waiting at buffer head")
yield keepalive_packet
self.bytes_sent += len(keepalive_packet)
self.last_yield_time = time.time()
self.consecutive_empty = 0 # Reset consecutive counter but keep total empty_reads
# Update last_active so clients waiting during failover aren't flagged as ghosts
proxy_server = ProxyServer.get_instance()
if proxy_server and proxy_server.redis_client:
client_key = RedisKeys.client_metadata(self.channel_id, self.client_id)
proxy_server.redis_client.hset(client_key, "last_active", str(time.time()))
gevent.sleep(Config.KEEPALIVE_INTERVAL) # Replace time.sleep
else:
# Standard wait with backoff
sleep_time = min(0.1 * self.consecutive_empty, 1.0)
gevent.sleep(sleep_time) # Replace time.sleep
# Log empty reads periodically
if self.empty_reads % 50 == 0:
stream_status = "healthy" if (self.stream_manager and self.stream_manager.healthy) else "unknown"
logger.debug(f"[{self.client_id}] Waiting for chunks beyond {self.local_index} for channel: {self.channel_id} (buffer at {self.buffer.index}, stream: {stream_status})")
# Check for ghost clients
if self._is_ghost_client(self.local_index):
logger.warning(f"[{self.client_id}] Possible ghost client: buffer has advanced {self.buffer.index - self.local_index} chunks ahead but client stuck at {self.local_index}")
break
# Check for timeouts
if self._is_timeout():
break
def _check_resources(self):
"""Check if required resources still exist."""
proxy_server = self.proxy_server or ProxyServer.get_instance()
if self.channel_id not in proxy_server.stream_buffers:
logger.info(f"[{self.client_id}] Channel buffer no longer exists, terminating stream")
return False
if self.channel_id not in proxy_server.client_managers:
logger.info(f"[{self.client_id}] Client manager no longer exists, terminating stream")
return False
client_manager = proxy_server.client_managers[self.channel_id]
if self.client_id not in client_manager.clients:
logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream")
return False
# --- Redis checks: throttled to _resource_check_interval (default 1s) ---
# 3 Redis round-trips on every iteration is expensive at stream rates;
# stop/state signals change infrequently so a 1-second poll is sufficient.
if not proxy_server.redis_client:
return True
now = time.time()
if now - self._last_resource_check_time < self._resource_check_interval:
return True
self._last_resource_check_time = now
# Channel stop check
stop_key = RedisKeys.channel_stopping(self.channel_id)
if proxy_server.redis_client.exists(stop_key):
logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream")
return False
# Channel state in metadata
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and 'state' in metadata:
state = metadata['state']
if state in ['error', 'stopped', 'stopping']:
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
return False
# Client stop check
client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id)
if proxy_server.redis_client.exists(client_stop_key):
logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream")
return False
return True
def _process_chunks(self, chunks, next_index):
"""Process and yield chunks to the client."""
# Process and send chunks
total_size = sum(len(c) for c in chunks)
logger.debug(f"[{self.client_id}] Retrieved {len(chunks)} chunks ({total_size} bytes) from index {self.local_index+1} to {next_index}")
proxy_server = self.proxy_server or ProxyServer.get_instance()
# Send the chunks to the client
for chunk in chunks:
try:
yield chunk
self.bytes_sent += len(chunk)
self.chunks_sent += 1
logger.debug(f"[{self.client_id}] Sent chunk {self.chunks_sent} ({len(chunk)} bytes) for channel {self.channel_id} to client")
current_time = time.time()
# Calculate average rate (since stream start)
elapsed_total = current_time - self.stream_start_time
avg_rate = self.bytes_sent / elapsed_total / 1024 if elapsed_total > 0 else 0
# Calculate current rate (since last measurement)
elapsed_current = current_time - self.last_stats_time
bytes_since_last = self.bytes_sent - self.last_stats_bytes
if elapsed_current > 0:
self.current_rate = bytes_since_last / elapsed_current / 1024
# Update last stats values
self.last_stats_time = current_time
self.last_stats_bytes = self.bytes_sent
# Log every 10 chunks
if self.chunks_sent % 10 == 0:
logger.debug(f"[{self.client_id}] Stats: {self.chunks_sent} chunks, {self.bytes_sent/1024:.1f} KB, "
f"avg: {avg_rate:.1f} KB/s, current: {self.current_rate:.1f} KB/s")
# Store stats in Redis client metadata
if proxy_server.redis_client:
try:
client_key = RedisKeys.client_metadata(self.channel_id, self.client_id)
stats = {
ChannelMetadataField.CHUNKS_SENT: str(self.chunks_sent),
ChannelMetadataField.BYTES_SENT: str(self.bytes_sent),
ChannelMetadataField.AVG_RATE_KBPS: str(round(avg_rate, 1)),
ChannelMetadataField.CURRENT_RATE_KBPS: str(round(self.current_rate, 1)),
ChannelMetadataField.STATS_UPDATED_AT: str(current_time),
"last_active": str(current_time)
}
proxy_server.redis_client.hset(client_key, mapping=stats)
# Refresh TTL periodically while actively streaming
# This provides proof-of-life independent of heartbeat thread
if current_time - self.last_ttl_refresh > self.ttl_refresh_interval:
try:
# Refresh TTL on client key
proxy_server.redis_client.expire(client_key, Config.CLIENT_RECORD_TTL)
# Also refresh the client set TTL
client_set_key = f"ts_proxy:channel:{self.channel_id}:clients"
proxy_server.redis_client.expire(client_set_key, Config.CLIENT_RECORD_TTL)
self.last_ttl_refresh = current_time
logger.debug(f"[{self.client_id}] Refreshed client TTL (active streaming)")
except Exception as ttl_error:
logger.debug(f"[{self.client_id}] Failed to refresh TTL: {ttl_error}")
except Exception as e:
logger.warning(f"[{self.client_id}] Failed to store stats in Redis: {e}")
except Exception as e:
logger.error(f"[{self.client_id}] Error sending chunk to client: {e}")
raise # Re-raise to exit the generator
def _should_send_keepalive(self, local_index):
"""Determine if a keepalive packet should be sent."""
# Check if we're caught up to buffer head
at_buffer_head = local_index >= self.buffer.index
if not at_buffer_head or self.consecutive_empty < 5:
return False
if self.stream_manager is not None:
# Owner worker: use the in-memory health flag directly.
return not self.stream_manager.healthy
else:
# Non-owner worker: stream_manager only exists in the owner process.
# Approximate health from the Redis last_data timestamp; if stale
# beyond CONNECTION_TIMEOUT, send keepalives to prevent DVR timeout.
# Throttled: only re-query Redis every _health_check_interval seconds
# to avoid a Redis GET on every loop iteration during sustained waits.
now = time.time()
if now - self._last_health_check_time < self._health_check_interval:
return self._last_health_check_result
try:
proxy_server = self.proxy_server or ProxyServer.get_instance()
if proxy_server.redis_client:
raw = proxy_server.redis_client.get(RedisKeys.last_data(self.channel_id))
if raw:
age = now - float(raw)
timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10)
result = age >= timeout_threshold
else:
# No timestamp in Redis → key missing or expired → unhealthy
result = True
self._last_health_check_time = now
self._last_health_check_result = result
return result
except Exception:
pass
return False
def _is_ghost_client(self, local_index):
"""Check if this appears to be a ghost client (stuck but buffer advancing)."""
return self.consecutive_empty > 100 and self.buffer.index > local_index + 50
def _is_timeout(self):
"""Check if the stream has timed out."""
# Get a more generous timeout for stream switching
stream_timeout = ConfigHelper.stream_timeout()
failover_grace_period = ConfigHelper.failover_grace_period()
total_timeout = stream_timeout + failover_grace_period
# Disconnect after long inactivity
if time.time() - self.last_yield_time > total_timeout:
if self.stream_manager and not self.stream_manager.healthy:
# Check if stream manager is actively switching or reconnecting
if (hasattr(self.stream_manager, 'url_switching') and self.stream_manager.url_switching):
logger.info(f"[{self.client_id}] Stream switching in progress, giving more time")
return False
logger.warning(f"[{self.client_id}] No data for {total_timeout}s and stream unhealthy, disconnecting")
return True
elif not self.is_owner_worker and self.consecutive_empty > 100:
# Non-owner worker without data for too long
logger.warning(f"[{self.client_id}] Non-owner worker with no data for {total_timeout}s, disconnecting")
return True
return False
def _cleanup(self):
"""Clean up resources and report final statistics."""
# Client cleanup
elapsed = time.time() - self.stream_start_time
local_clients = 0
total_clients = 0
proxy_server = ProxyServer.get_instance()
# Release M3U profile stream allocation if this is the last client
stream_released = False
if proxy_server.redis_client:
try:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata:
stream_id_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
# Check if we're the last client
if self.channel_id in proxy_server.client_managers:
client_count = proxy_server.client_managers[self.channel_id].get_total_client_count()
# Only the last client or owner should release the stream
if client_count <= 1 and proxy_server.am_i_owner(self.channel_id):
try:
# Try Channel first (normal flow), fall back to Stream (preview flow)
try:
obj = Channel.objects.get(uuid=self.channel_id)
except (Channel.DoesNotExist, Exception):
obj = Stream.objects.get(stream_hash=self.channel_id)
stream_released = obj.release_stream()
if stream_released:
logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}")
else:
logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}")
except Exception as e:
logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}")
except Exception as e:
logger.error(f"[{self.client_id}] Error checking stream data for release: {e}")
if self.channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[self.channel_id]
local_clients = client_manager.remove_client(self.client_id)
total_clients = client_manager.get_total_client_count()
logger.info(f"[{self.client_id}] Disconnected after {elapsed:.2f}s (local: {local_clients}, total: {total_clients})")
# Log client disconnect event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'client_disconnect',
channel_id=self.channel_id,
channel_name=channel_obj.name,
client_ip=self.client_ip,
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
duration=round(elapsed, 2),
bytes_sent=self.bytes_sent,
username=self.user.username if self.user else None
)
except Exception as e:
logger.error(f"Could not log client disconnect event: {e}")
# Schedule channel shutdown if no clients left
self._schedule_channel_shutdown_if_needed(local_clients)
def _schedule_channel_shutdown_if_needed(self, local_clients):
"""
Schedule channel shutdown if there are no clients left and we're the owner.
"""
proxy_server = ProxyServer.get_instance()
# If no clients left and we're the owner, schedule shutdown using the config value
if local_clients == 0 and proxy_server.am_i_owner(self.channel_id):
logger.info(f"No local clients left for channel {self.channel_id}, scheduling shutdown")
def delayed_shutdown():
# Use the config setting instead of hardcoded value
shutdown_delay = ConfigHelper.channel_shutdown_delay() # Use ConfigHelper
logger.info(f"Waiting {shutdown_delay}s before checking if channel should be stopped")
gevent.sleep(shutdown_delay) # Replace time.sleep
# After delay, check global client count
if self.channel_id in proxy_server.client_managers:
total = proxy_server.client_managers[self.channel_id].get_total_client_count()
if total == 0:
logger.info(f"Shutting down channel {self.channel_id} as no clients connected")
proxy_server.stop_channel(self.channel_id)
else:
logger.info(f"Not shutting down channel {self.channel_id}, {total} clients still connected")
gevent.spawn(delayed_shutdown)
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None):
"""
Factory function to create a new stream generator.
Returns a function that can be passed to StreamingHttpResponse.
"""
generator = StreamGenerator(channel_id, client_id, client_ip, client_user_agent, channel_initializing, user=user)
return generator.generate
File diff suppressed because it is too large Load Diff
+566
View File
@@ -0,0 +1,566 @@
"""
Utilities for handling stream URLs and transformations.
"""
import logging
import regex
from typing import Optional, Tuple, List
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings, StreamProfile
from .utils import get_logger
from uuid import UUID
import requests
logger = get_logger()
def get_stream_object(id: str):
try:
logger.info(f"Fetching channel ID {id}")
return get_object_or_404(Channel, uuid=id)
except:
# UUID check failed, assume stream hash
logger.info(f"Fetching stream hash {id}")
return get_object_or_404(Stream, stream_hash=id)
def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]:
"""
Generate the appropriate stream URL for a channel or stream based on its profile settings.
Args:
channel_id: The UUID of the channel or stream hash
Returns:
Tuple[str, str, bool, Optional[int]]: (stream_url, user_agent, transcode_flag, profile_id)
"""
try:
channel_or_stream = get_stream_object(channel_id)
# Handle direct stream preview (custom streams)
if isinstance(channel_or_stream, Stream):
stream = channel_or_stream
logger.info(f"Previewing stream directly: {stream.id} ({stream.name})")
if not stream.m3u_account:
logger.error(f"Stream {stream.id} has no M3U account")
return None, None, False, None
# Use get_stream() to atomically reserve a slot and write the
# channel_stream / stream_profile Redis keys, matching the channel
# path so stream_name and stream_stats work correctly.
stream_id, profile_id, error_reason = stream.get_stream()
if not stream_id or not profile_id:
logger.error(f"No profile available for stream {stream.id}: {error_reason}")
return None, None, False, None
try:
profile = M3UAccountProfile.objects.get(id=profile_id)
m3u_account = stream.m3u_account
stream_user_agent = m3u_account.get_user_agent().user_agent
if stream_user_agent is None:
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern)
stream_profile = stream.get_stream_profile()
logger.debug(f"Using stream profile: {stream_profile.name}")
transcode = not stream_profile.is_proxy()
stream_profile_id = stream_profile.id
return stream_url, stream_user_agent, transcode, stream_profile_id
except Exception as e:
logger.error(f"Error generating stream URL for stream {stream.id}: {e}")
stream.release_stream()
return None, None, False, None
# Handle channel preview (existing logic)
channel = channel_or_stream
# Get stream and profile for this channel
# Note: get_stream now returns 3 values (stream_id, profile_id, error_reason)
stream_id, profile_id, error_reason = channel.get_stream()
if not stream_id or not profile_id:
logger.error(f"No stream available for channel {channel_id}: {error_reason}")
return None, None, False, None
# get_stream() allocated a connection slot - ensure it's released on any error
try:
# Look up the Stream and Profile objects
stream = Stream.objects.get(id=stream_id)
profile = M3UAccountProfile.objects.get(id=profile_id)
# Get the M3U account profile for URL pattern
m3u_profile = profile
# Get the appropriate user agent
m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id)
stream_user_agent = m3u_account.get_user_agent().user_agent
if stream_user_agent is None:
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
# Generate stream URL based on the selected profile
input_url = stream.url
stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern)
# Check if transcoding is needed
stream_profile = channel.get_stream_profile()
if stream_profile.is_proxy() or stream_profile is None:
transcode = False
else:
transcode = True
stream_profile_id = stream_profile.id
return stream_url, stream_user_agent, transcode, stream_profile_id
except Exception as e:
logger.error(f"Error generating stream URL for channel {channel_id}: {e}")
if not channel.release_stream():
logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error")
return None, None, False, None
except Exception as e:
logger.error(f"Error generating stream URL: {e}")
return None, None, False, None
def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> str:
"""
Transform a URL using regex pattern replacement.
Args:
input_url: The base URL to transform
search_pattern: The regex search pattern
replace_pattern: The replacement pattern
Returns:
str: The transformed URL
"""
try:
logger.debug("Executing URL pattern replacement:")
logger.debug(f" base URL: {input_url}")
logger.debug(f" search: {search_pattern}")
# Convert JS-style backreferences in replace pattern: $<name> -> \g<name>, $1 -> \1
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', replace_pattern)
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
logger.debug(f" replace: {replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
# Apply the transformation (regex module accepts JS-style (?<name>...) natively)
stream_url = regex.sub(search_pattern, safe_replace_pattern, input_url)
logger.info(f"Generated stream url: {stream_url}")
return stream_url
except Exception as e:
logger.error(f"Error transforming URL: {e}")
return input_url # Return original URL on error
def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] = None) -> dict:
"""
Get stream information for a channel switch, optionally to a specific stream ID.
Args:
channel_id: The UUID of the channel
target_stream_id: Optional specific stream ID to switch to
Returns:
dict: Stream information including URL, user agent and transcode flag
"""
try:
from core.utils import RedisClient
channel = get_object_or_404(Channel, uuid=channel_id)
redis_client = RedisClient.get_client()
# Use the target stream if specified, otherwise use current stream
if target_stream_id:
stream_id = target_stream_id
# Get the stream object
stream = get_object_or_404(Stream, pk=stream_id)
# Find compatible profile for this stream with connection availability check
m3u_account = stream.m3u_account
if not m3u_account:
return {'error': 'Stream has no M3U account'}
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
if not default_profile:
return {'error': 'M3U account has no default profile'}
# Check profiles in order: default first, then others
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
selected_profile = None
for profile in profiles:
# Check connection availability
if redis_client:
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
# Check if this channel is already using this profile
channel_using_profile = False
existing_stream_id = redis_client.get(f"channel_stream:{channel.id}")
if existing_stream_id:
# Decode bytes to string/int for proper Redis key lookup
existing_stream_id = existing_stream_id
existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}")
if existing_profile_id and int(existing_profile_id) == profile.id:
channel_using_profile = True
logger.debug(f"Channel {channel.id} already using profile {profile.id}")
# Calculate effective connections (subtract 1 if channel already using this profile)
effective_connections = current_connections - (1 if channel_using_profile else 0)
# Check if profile has available slots
if profile.max_streams == 0 or effective_connections < profile.max_streams:
selected_profile = profile
logger.debug(f"Selected profile {profile.id} with {effective_connections}/{profile.max_streams} effective connections (current: {current_connections}, already using: {channel_using_profile})")
break
else:
logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})")
else:
# No Redis available, assume first active profile is okay
selected_profile = profile
break
if not selected_profile:
return {'error': 'No profiles available with connection capacity'}
m3u_profile_id = selected_profile.id
else:
stream_id, m3u_profile_id, error_reason = channel.get_stream()
if stream_id is None or m3u_profile_id is None:
return {'error': error_reason or 'No stream assigned to channel'}
# Get the stream and profile objects directly
stream = get_object_or_404(Stream, pk=stream_id)
profile = get_object_or_404(M3UAccountProfile, pk=m3u_profile_id)
# Check connections left
m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id)
#connections_left = get_connections_left(m3u_profile_id)
#if connections_left <= 0:
#logger.warning(f"No connections left for M3U account {m3u_account.id}")
#return {'error': 'No connections left'}
# Get the user agent from the M3U account
user_agent = m3u_account.get_user_agent().user_agent
# Generate URL using the transform function directly
stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern)
# Get transcode info from the channel's stream profile
stream_profile = channel.get_stream_profile()
transcode = not (stream_profile.is_proxy() or stream_profile is None)
profile_value = stream_profile.id
return {
'url': stream_url,
'user_agent': user_agent,
'transcode': transcode,
'stream_profile': profile_value,
'stream_id': stream_id,
'm3u_profile_id': m3u_profile_id
}
except Exception as e:
logger.error(f"Error getting stream info for switch: {e}", exc_info=True)
return {'error': f'Error: {str(e)}'}
def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = None) -> List[dict]:
"""
Get alternative streams for a channel when the current stream fails.
Args:
channel_id: The UUID of the channel
current_stream_id: The currently failing stream ID to exclude
Returns:
List[dict]: List of stream information dictionaries with stream_id and profile_id
"""
try:
from core.utils import RedisClient
# Get channel object
channel = get_stream_object(channel_id)
if isinstance(channel, Stream):
logger.error(f"Stream is not a channel")
return []
redis_client = RedisClient.get_client()
logger.debug(f"Looking for alternate streams for channel {channel_id}, current stream ID: {current_stream_id}")
# Get all assigned streams for this channel using the correct ordering
streams = channel.streams.all().order_by('channelstream__order')
logger.debug(f"Channel {channel_id} has {streams.count()} total assigned streams")
if not streams.exists():
logger.warning(f"No streams assigned to channel {channel_id}")
return []
alternate_streams = []
# Process each stream in the user-defined order
for stream in streams:
logger.debug(f"Checking stream ID {stream.id} ({stream.name}) for channel {channel_id}")
# Skip the current failing stream
if current_stream_id and stream.id == current_stream_id:
logger.debug(f"Skipping current stream ID {current_stream_id}")
continue
# Find compatible profiles for this stream with connection checking
try:
m3u_account = stream.m3u_account
if not m3u_account:
logger.debug(f"Stream {stream.id} has no M3U account")
continue
if m3u_account.is_active == False:
logger.debug(f"M3U account {m3u_account.id} is inactive, skipping.")
continue
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
if not default_profile:
logger.debug(f"M3U account {m3u_account.id} has no default profile")
continue
# Check profiles in order with connection availability
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
selected_profile = None
for profile in profiles:
# Check connection availability
if redis_client:
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
# Check if this channel is already using this profile
channel_using_profile = False
existing_stream_id = redis_client.get(f"channel_stream:{channel.id}")
if existing_stream_id:
# Decode bytes to string/int for proper Redis key lookup
existing_stream_id = existing_stream_id
existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}")
if existing_profile_id and int(existing_profile_id) == profile.id:
channel_using_profile = True
logger.debug(f"Channel {channel.id} already using profile {profile.id}")
# Calculate effective connections (subtract 1 if channel already using this profile)
effective_connections = current_connections - (1 if channel_using_profile else 0)
# Check if profile has available slots
if profile.max_streams == 0 or effective_connections < profile.max_streams:
selected_profile = profile
logger.debug(f"Found available profile {profile.id} for stream {stream.id}: {effective_connections}/{profile.max_streams} effective (current: {current_connections}, already using: {channel_using_profile})")
break
else:
logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})")
else:
# No Redis available, assume first active profile is okay
selected_profile = profile
break
if selected_profile:
alternate_streams.append({
'stream_id': stream.id,
'profile_id': selected_profile.id,
'name': stream.name
})
else:
logger.debug(f"No available profiles for stream ID {stream.id}")
except Exception as inner_e:
logger.error(f"Error finding profiles for stream {stream.id}: {inner_e}")
continue
if alternate_streams:
stream_ids = ', '.join([str(s['stream_id']) for s in alternate_streams])
logger.info(f"Found {len(alternate_streams)} alternate streams with available connections for channel {channel_id}: [{stream_ids}]")
else:
logger.warning(f"No alternate streams with available connections found for channel {channel_id}")
return alternate_streams
except Exception as e:
logger.error(f"Error getting alternate streams for channel {channel_id}: {e}", exc_info=True)
return []
def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
"""
Validate if a stream URL is accessible without downloading the full content.
Note: UDP/RTP/RTSP streams are automatically considered valid as they cannot
be validated via HTTP methods.
Args:
url (str): The URL to validate
user_agent (str): User agent to use for the request
timeout (tuple): Connection and read timeout in seconds
Returns:
tuple: (is_valid, final_url, status_code, message)
"""
# Check if URL uses non-HTTP protocols (UDP/RTP/RTSP)
# These cannot be validated via HTTP methods, so we skip validation
if url.startswith(('udp://', 'rtp://', 'rtsp://')):
logger.info(f"Skipping HTTP validation for non-HTTP protocol: {url}")
return True, url, 200, "Non-HTTP protocol (UDP/RTP/RTSP) - validation skipped"
try:
# Create session with proper headers
session = requests.Session()
headers = {
'User-Agent': user_agent,
'Connection': 'close' # Don't keep connection alive
}
session.headers.update(headers)
# Make HEAD request first as it's faster and doesn't download content
head_request_success = True
try:
head_response = session.head(
url,
timeout=timeout,
allow_redirects=True
)
except requests.exceptions.RequestException as e:
head_request_success = False
logger.warning(f"Request error (HEAD), assuming HEAD not supported: {str(e)}")
# If HEAD not supported, server will return 405 or other error
if head_request_success and (200 <= head_response.status_code < 300):
# HEAD request successful
return True, url, head_response.status_code, "Valid (HEAD request)"
# Try a GET request with stream=True to avoid downloading all content
get_response = session.get(
url,
stream=True,
timeout=timeout,
allow_redirects=True
)
# IMPORTANT: Check status code first before checking content
if not (200 <= get_response.status_code < 300):
logger.warning(f"Stream validation failed with HTTP status {get_response.status_code}")
return False, url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}"
# Only check content if status code is valid
try:
chunk = next(get_response.iter_content(chunk_size=188*10))
is_valid = len(chunk) > 0
message = f"Valid (GET request, received {len(chunk)} bytes)"
except StopIteration:
is_valid = False
message = "Empty response from server"
# Check content type for additional validation
content_type = get_response.headers.get('Content-Type', '').lower()
# Expanded list of valid content types for streaming media
valid_content_types = [
'video/',
'audio/',
'mpegurl',
'octet-stream',
'mp2t',
'mp4',
'mpeg',
'dash+xml',
'application/mp4',
'application/mpeg',
'application/x-mpegurl',
'application/vnd.apple.mpegurl',
'application/ogg',
'm3u',
'playlist',
'binary/',
'rtsp',
'rtmp',
'hls',
'ts'
]
content_type_valid = any(type_str in content_type for type_str in valid_content_types)
# Always consider the stream valid if we got data, regardless of content type
# But add content type info to the message for debugging
if content_type:
content_type_msg = f" (Content-Type: {content_type}"
if content_type_valid:
content_type_msg += ", recognized as valid stream format)"
else:
content_type_msg += ", unrecognized but may still work)"
message += content_type_msg
# Clean up connection
get_response.close()
# If we have content, consider it valid even with unrecognized content type
return is_valid, url, get_response.status_code, message
except requests.exceptions.Timeout:
return False, url, 0, "Timeout connecting to stream"
except requests.exceptions.TooManyRedirects:
return False, url, 0, "Too many redirects"
except requests.exceptions.RequestException as e:
return False, url, 0, f"Request error: {str(e)}"
except Exception as e:
return False, url, 0, f"Validation error: {str(e)}"
finally:
if 'session' in locals():
session.close()
def get_connections_left(m3u_profile_id: int) -> int:
"""
Get the number of available connections left for an M3U profile.
Args:
m3u_profile_id: The ID of the M3U profile
Returns:
int: Number of connections available (0 if none available)
"""
try:
from core.utils import RedisClient
# Get the M3U profile
m3u_profile = M3UAccountProfile.objects.get(id=m3u_profile_id)
# If max_streams is 0, it means unlimited
if m3u_profile.max_streams == 0:
return 999999 # Return a large number to indicate unlimited
# Get Redis client
redis_client = RedisClient.get_client()
if not redis_client:
logger.warning("Redis not available, assuming connections available")
return max(0, m3u_profile.max_streams - 1) # Conservative estimate
# Check current connections for this specific profile
profile_connections_key = f"profile_connections:{m3u_profile_id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
# Calculate available connections
connections_left = max(0, m3u_profile.max_streams - current_connections)
logger.debug(f"M3U profile {m3u_profile_id}: {current_connections}/{m3u_profile.max_streams} used, {connections_left} available")
return connections_left
except M3UAccountProfile.DoesNotExist:
logger.error(f"M3U profile {m3u_profile_id} not found")
return 0
except Exception as e:
logger.error(f"Error getting connections left for M3U profile {m3u_profile_id}: {e}")
return 0
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = 'ts_proxy'
urlpatterns = [
path('stream/<str:channel_id>', views.stream_ts, name='stream'),
path('change_stream/<str:channel_id>', views.change_stream, name='change_stream'),
path('status', views.channel_status, name='channel_status'),
path('status/<str:channel_id>', views.channel_status, name='channel_status_detail'),
path('stop/<str:channel_id>', views.stop_channel, name='stop_channel'),
path('stop_client/<str:channel_id>', views.stop_client, name='stop_client'),
path('next_stream/<str:channel_id>', views.next_stream, name='next_stream'),
]
+116
View File
@@ -0,0 +1,116 @@
import logging
import re
from urllib.parse import urlparse
import inspect
logger = logging.getLogger("ts_proxy")
def detect_stream_type(url):
"""
Detect if stream URL is HLS, RTSP/RTP, UDP, or TS format.
Args:
url (str): The stream URL to analyze
Returns:
str: 'hls', 'rtsp', 'udp', or 'ts' depending on detected format
"""
if not url:
return 'unknown'
url_lower = url.lower()
# Check for UDP streams (requires FFmpeg)
if url_lower.startswith('udp://'):
return 'udp'
# Check for RTSP/RTP streams (requires FFmpeg)
if url_lower.startswith('rtsp://') or url_lower.startswith('rtp://'):
return 'rtsp'
# Look for common HLS indicators
if (url_lower.endswith('.m3u8') or
'.m3u8?' in url_lower or
'/playlist.m3u' in url_lower):
return 'hls'
# Additional HLS patterns
parsed = urlparse(url)
path = parsed.path.lower()
if ('playlist' in path and ('.m3u' in path or '.m3u8' in path)) or \
('manifest' in path and ('.m3u' in path or '.m3u8' in path)) or \
('master' in path and ('.m3u' in path or '.m3u8' in path)):
return 'hls'
# Default to TS
return 'ts'
def get_client_ip(request):
"""
Extract client IP address from request.
Handles cases where request is behind a proxy by checking X-Forwarded-For.
"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def create_ts_packet(packet_type='null', message=None):
"""
Create a Transport Stream (TS) packet for various purposes.
Args:
packet_type (str): Type of packet - 'null', 'error', 'keepalive', etc.
message (str): Optional message to include in packet payload
Returns:
bytes: A properly formatted 188-byte TS packet
"""
packet = bytearray(188)
# TS packet header
packet[0] = 0x47 # Sync byte
# PID - Use different PIDs based on packet type
if packet_type == 'error':
packet[1] = 0x1F # PID high bits
packet[2] = 0xFF # PID low bits
else: # null/keepalive packets
packet[1] = 0x1F # PID high bits (null packet)
packet[2] = 0xFF # PID low bits (null packet)
# Add message to payload if provided
if message:
msg_bytes = message
packet[4:4+min(len(msg_bytes), 180)] = msg_bytes[:180]
return bytes(packet)
def get_logger(component_name=None):
"""
Get a standardized logger with ts_proxy prefix and optional component name.
Args:
component_name (str, optional): Name of the component. If not provided,
will try to detect from the calling module.
Returns:
logging.Logger: A configured logger with standardized naming.
"""
if component_name:
logger_name = f"ts_proxy.{component_name}"
else:
# Try to get the calling module name if not explicitly specified
frame = inspect.currentframe().f_back
module = inspect.getmodule(frame)
if module:
# Extract just the filename without extension
module_name = module.__name__.split('.')[-1]
logger_name = f"ts_proxy.{module_name}"
else:
# Default if detection fails
logger_name = "ts_proxy"
return logging.getLogger(logger_name)
+962
View File
@@ -0,0 +1,962 @@
import json
import threading
import time
import random
import re
import pathlib
from django.http import StreamingHttpResponse, JsonResponse, HttpResponseRedirect, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import get_object_or_404
from apps.proxy.config import TSConfig as Config
from .server import ProxyServer
from .channel_status import ChannelStatus
from .stream_generator import create_stream_generator
from .utils import get_client_ip
from .redis_keys import RedisKeys
import logging
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from apps.accounts.models import User
from core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from apps.accounts.permissions import (
IsAdmin,
permission_classes_by_method,
permission_classes_by_action,
)
from .constants import ChannelState, EventType, StreamType, ChannelMetadataField
from .config_helper import ConfigHelper
from .services.channel_service import ChannelService
from core.utils import send_websocket_update
from .url_utils import (
generate_stream_url,
transform_url,
get_stream_info_for_switch,
get_stream_object,
get_alternate_streams,
)
from .utils import get_logger
from uuid import UUID
import gevent
from dispatcharr.utils import network_access_allowed
from apps.proxy.utils import check_user_stream_limits
logger = get_logger()
@api_view(["GET"])
@permission_classes([AllowAny])
def stream_ts(request, channel_id, user=None):
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
"""Stream TS data to client with immediate response and keep-alive packets during initialization"""
if user is None and hasattr(request, 'user') and request.user.is_authenticated:
user = request.user
channel = get_stream_object(channel_id)
client_user_agent = None
proxy_server = ProxyServer.get_instance()
connection_allocated = False # Track if connection slot was allocated via get_stream()
try:
# Generate a unique client ID
client_id = f"client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
client_ip = get_client_ip(request)
logger.info(f"[{client_id}] Requested stream for channel {channel_id}")
# Extract client user agent early
for header in ["HTTP_USER_AGENT", "User-Agent", "user-agent"]:
if header in request.META:
client_user_agent = request.META[header]
logger.debug(
f"[{client_id}] Client connected with user agent: {client_user_agent}"
)
break
if user:
if not check_user_stream_limits(user, client_id, media_id=channel_id):
return JsonResponse(
{"error": f"Stream limit exceeded ({user.stream_limit} concurrent streams allowed)"},
status=429
)
# Check if we need to reinitialize the channel
needs_initialization = True
channel_state = None
channel_initializing = False
# Get current channel state from Redis if available
if proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
if proxy_server.redis_client.exists(metadata_key):
metadata = proxy_server.redis_client.hgetall(metadata_key)
state_field = ChannelMetadataField.STATE
if state_field in metadata:
channel_state = metadata[state_field]
# Active/running states - channel is operational, don't reinitialize
if channel_state in [
ChannelState.ACTIVE,
ChannelState.WAITING_FOR_CLIENTS,
ChannelState.BUFFERING,
ChannelState.INITIALIZING,
ChannelState.CONNECTING,
ChannelState.STOPPING,
]:
needs_initialization = False
logger.debug(
f"[{client_id}] Channel {channel_id} in state {channel_state}, skipping initialization"
)
# Special handling for initializing/connecting states
if channel_state in [
ChannelState.INITIALIZING,
ChannelState.CONNECTING,
]:
channel_initializing = True
logger.debug(
f"[{client_id}] Channel {channel_id} is still initializing, client will wait"
)
# Terminal states - channel needs cleanup before reinitialization
elif channel_state in [
ChannelState.ERROR,
ChannelState.STOPPED,
]:
needs_initialization = True
logger.info(
f"[{client_id}] Channel {channel_id} in terminal state {channel_state}, will reinitialize"
)
# Unknown/empty state - check if owner is alive
else:
owner_field = ChannelMetadataField.OWNER
if owner_field in metadata:
owner = metadata[owner_field]
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
if proxy_server.redis_client.exists(owner_heartbeat_key):
# Owner is still active with unknown state - don't reinitialize
needs_initialization = False
logger.debug(
f"[{client_id}] Channel {channel_id} has active owner {owner}, skipping init"
)
else:
# Owner dead - needs reinitialization
needs_initialization = True
logger.warning(
f"[{client_id}] Channel {channel_id} owner {owner} is dead, will reinitialize"
)
# Start initialization if needed
if needs_initialization or not proxy_server.check_if_channel_exists(channel_id):
logger.info(f"[{client_id}] Starting channel {channel_id} initialization")
# Force cleanup of any previous instance if in terminal state
if channel_state in [
ChannelState.ERROR,
ChannelState.STOPPING,
ChannelState.STOPPED,
]:
logger.warning(
f"[{client_id}] Channel {channel_id} in state {channel_state}, forcing cleanup"
)
ChannelService.stop_channel(channel_id)
# Use fixed retry interval and timeout
retry_timeout = 3 # 3 seconds total timeout
retry_interval = 0.1 # 100ms between attempts
wait_start_time = time.time()
stream_url = None
stream_user_agent = None
transcode = False
profile_value = None
error_reason = None
attempt = 0
should_retry = True
# Try to get a stream with fixed interval retries
while should_retry and time.time() - wait_start_time < retry_timeout:
attempt += 1
stream_url, stream_user_agent, transcode, profile_value = (
generate_stream_url(channel_id)
)
if stream_url is not None:
logger.info(
f"[{client_id}] Successfully obtained stream for channel {channel_id} after {attempt} attempts"
)
break
# On first failure, check if the error is retryable
if attempt == 1:
_, _, error_reason = channel.get_stream()
if error_reason and "maximum connection limits" not in error_reason:
logger.warning(
f"[{client_id}] Can't retry - error not related to connection limits: {error_reason}"
)
should_retry = False
break
# Check if we have time remaining for another sleep cycle
elapsed_time = time.time() - wait_start_time
remaining_time = retry_timeout - elapsed_time
# If we don't have enough time for the next sleep interval, break
# but only after we've already made an attempt (the while condition will try one more time)
if remaining_time <= retry_interval:
logger.info(
f"[{client_id}] Insufficient time ({remaining_time:.1f}s) for another sleep cycle, will make one final attempt"
)
break
# Wait before retrying
logger.info(
f"[{client_id}] Waiting {retry_interval*1000:.0f}ms for a connection to become available (attempt {attempt}, {remaining_time:.1f}s remaining)"
)
gevent.sleep(retry_interval)
retry_interval += 0.025 # Increase wait time by 25ms for next attempt
# Make one final attempt if we still don't have a stream, should retry, and haven't exceeded timeout
if stream_url is None and should_retry and time.time() - wait_start_time < retry_timeout:
attempt += 1
logger.info(
f"[{client_id}] Making final attempt {attempt} at timeout boundary"
)
stream_url, stream_user_agent, transcode, profile_value = (
generate_stream_url(channel_id)
)
if stream_url is not None:
logger.info(
f"[{client_id}] Successfully obtained stream on final attempt for channel {channel_id}"
)
if stream_url is None:
# Release any connection slot that may have been allocated
# by the error-checking get_stream() call during retries
if not channel.release_stream():
logger.debug(f"[{client_id}] release_stream found no keys during failed init cleanup")
# Get the specific error message if available
wait_duration = f"{int(time.time() - wait_start_time)}s"
error_msg = (
error_reason
if error_reason
else "No available streams for this channel"
)
logger.info(
f"[{client_id}] Failed to obtain stream after {attempt} attempts over {wait_duration}: {error_msg}"
)
return JsonResponse(
{"error": error_msg, "waited": wait_duration}, status=503
) # 503 Service Unavailable is appropriate here
# generate_stream_url() called get_stream() which allocated a connection
# slot (INCR'd profile_connections) - track this for cleanup on error
if needs_initialization:
connection_allocated = True
# Read stream assignment from Redis (already set by generate_stream_url → get_stream).
# Avoid calling get_stream() again — (INCR profile counter)
# It could double-allocate if the keys were cleared by a concurrent release.
stream_id = None
m3u_profile_id = None
if proxy_server.redis_client:
stream_id_bytes = proxy_server.redis_client.get(f"channel_stream:{channel.id}")
if stream_id_bytes:
stream_id = int(stream_id_bytes)
profile_id_bytes = proxy_server.redis_client.get(f"stream_profile:{stream_id}")
if profile_id_bytes:
m3u_profile_id = int(profile_id_bytes)
logger.info(
f"Channel {channel_id} using stream ID {stream_id}, m3u account profile ID {m3u_profile_id}"
)
# Generate transcode command if needed
stream_profile = channel.get_stream_profile()
if stream_profile.is_redirect():
# Validate the stream URL before redirecting
from .url_utils import (
validate_stream_url,
get_alternate_streams,
get_stream_info_for_switch,
)
# Try initial URL
logger.info(f"[{client_id}] Validating redirect URL: {stream_url}")
is_valid, final_url, status_code, message = validate_stream_url(
stream_url, user_agent=stream_user_agent, timeout=(5, 5)
)
# If first URL doesn't validate, try alternates
if not is_valid:
logger.warning(
f"[{client_id}] Primary stream URL failed validation: {message}"
)
# Track tried streams to avoid loops
tried_streams = {stream_id}
# Get alternate streams
alternates = get_alternate_streams(channel_id, stream_id)
# Try each alternate until one works
for alt in alternates:
if alt["stream_id"] in tried_streams:
continue
tried_streams.add(alt["stream_id"])
# Get stream info
alt_info = get_stream_info_for_switch(
channel_id, alt["stream_id"]
)
if "error" in alt_info:
logger.warning(
f"[{client_id}] Error getting alternate stream info: {alt_info['error']}"
)
continue
# Validate the alternate URL
logger.info(
f"[{client_id}] Trying alternate stream #{alt['stream_id']}: {alt_info['url']}"
)
is_valid, final_url, status_code, message = validate_stream_url(
alt_info["url"],
user_agent=alt_info["user_agent"],
timeout=(5, 5),
)
if is_valid:
logger.info(
f"[{client_id}] Alternate stream #{alt['stream_id']} validated successfully"
)
break
else:
logger.warning(
f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}"
)
# Release stream lock before redirecting
if not channel.release_stream():
logger.warning(f"[{client_id}] Failed to release stream before redirect")
connection_allocated = False
# Final decision based on validation results
if is_valid:
logger.info(
f"[{client_id}] Redirecting to validated URL: {final_url} ({message})"
)
# For non-HTTP protocols (RTSP/RTP/UDP), we need to manually create the redirect
# because Django's HttpResponseRedirect blocks them for security
if final_url.startswith(('rtsp://', 'rtp://', 'udp://')):
logger.info(f"[{client_id}] Using manual redirect for non-HTTP protocol")
response = HttpResponse(status=301)
response['Location'] = final_url
return response
return HttpResponseRedirect(final_url)
else:
logger.error(
f"[{client_id}] All available redirect URLs failed validation"
)
return JsonResponse(
{"error": "All available streams failed validation"}, status=502
) # 502 Bad Gateway
# Initialize channel with the stream's user agent (not the client's)
success = ChannelService.initialize_channel(
channel_id,
stream_url,
stream_user_agent,
transcode,
profile_value,
stream_id,
m3u_profile_id,
)
if not success:
if connection_allocated:
if not channel.release_stream():
logger.warning(f"[{client_id}] Failed to release stream after init failure")
connection_allocated = False
return JsonResponse(
{"error": "Failed to initialize channel"}, status=500
)
# Channel initialized - cleanup lifecycle now owns the connection release
connection_allocated = False
# If we're the owner, wait for connection to establish
if proxy_server.am_i_owner(channel_id):
manager = proxy_server.stream_managers.get(channel_id)
if manager:
wait_start = time.time()
timeout = ConfigHelper.connection_timeout()
while not manager.connected:
if time.time() - wait_start > timeout:
proxy_server.stop_channel(channel_id)
return JsonResponse(
{"error": "Connection timeout"}, status=504
)
# Check if this manager should keep retrying or stop
if not manager.should_retry():
# Check channel state in Redis to make a better decision
metadata_key = RedisKeys.channel_metadata(channel_id)
current_state = None
if proxy_server.redis_client:
try:
state_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.STATE
)
if state_bytes:
current_state = state_bytes
logger.debug(
f"[{client_id}] Current state of channel {channel_id}: {current_state}"
)
except Exception as e:
logger.warning(
f"[{client_id}] Error getting channel state: {e}"
)
# Allow normal transitional states to continue
if current_state in [
ChannelState.INITIALIZING,
ChannelState.CONNECTING,
]:
logger.info(
f"[{client_id}] Channel {channel_id} is in {current_state} state, continuing to wait"
)
# Reset wait timer to allow the transition to complete
wait_start = time.time()
continue
# Check if we're switching URLs
if (
hasattr(manager, "url_switching")
and manager.url_switching
):
logger.info(
f"[{client_id}] Stream manager is currently switching URLs for channel {channel_id}"
)
# Reset wait timer to give the switch a chance
wait_start = time.time()
continue
# If we reach here, we've exhausted retries and the channel isn't in a valid transitional state
logger.warning(
f"[{client_id}] Channel {channel_id} failed to connect and is not in transitional state"
)
proxy_server.stop_channel(channel_id)
return JsonResponse(
{"error": "Failed to connect"}, status=502
)
gevent.sleep(
0.1
) # FIXED: Using gevent.sleep instead of time.sleep
logger.info(f"[{client_id}] Successfully initialized channel {channel_id}")
channel_initializing = True
# Register client - can do this regardless of initialization state
# Create local resources if needed
if (
channel_id not in proxy_server.stream_buffers
or channel_id not in proxy_server.client_managers
):
logger.debug(
f"[{client_id}] Channel {channel_id} exists in Redis but not initialized in this worker - initializing now"
)
# Get URL from Redis metadata
url = None
stream_user_agent = None # Initialize the variable
if proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
url_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.URL
)
ua_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.USER_AGENT
)
profile_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.STREAM_PROFILE
)
if url_bytes:
url = url_bytes
if ua_bytes:
stream_user_agent = ua_bytes
# Extract transcode setting from Redis
if profile_bytes:
profile_str = profile_bytes
use_transcode = (
profile_str == PROXY_PROFILE_NAME or profile_str == "None"
)
logger.debug(
f"Using profile '{profile_str}' for channel {channel_id}, transcode={use_transcode}"
)
else:
# Default settings when profile not found in Redis
profile_str = "None" # Default profile name
use_transcode = (
False # Default to direct streaming without transcoding
)
logger.debug(
f"No profile found in Redis for channel {channel_id}, defaulting to transcode={use_transcode}"
)
# Use client_user_agent as fallback if stream_user_agent is None
success = proxy_server.initialize_channel(
url, channel_id, stream_user_agent or client_user_agent, use_transcode
)
if not success:
logger.error(
f"[{client_id}] Failed to initialize channel {channel_id} locally"
)
return JsonResponse(
{"error": "Failed to initialize channel locally"}, status=500
)
logger.info(
f"[{client_id}] Successfully initialized channel {channel_id} locally"
)
# Register client
buffer = proxy_server.stream_buffers[channel_id]
client_manager = proxy_server.client_managers[channel_id]
client_manager.add_client(client_id, client_ip, client_user_agent, user)
logger.info(f"[{client_id}] Client registered with channel {channel_id}")
# Create a stream generator for this client
generate = create_stream_generator(
channel_id, client_id, client_ip, client_user_agent, channel_initializing, user=user
)
# Return the StreamingHttpResponse from the main function
response = StreamingHttpResponse(
streaming_content=generate(), content_type="video/mp2t"
)
response["Cache-Control"] = "no-cache"
return response
except Exception as e:
logger.error(f"Error in stream_ts: {e}", exc_info=True)
if connection_allocated:
try:
if not channel.release_stream():
logger.warning(f"[{client_id}] Failed to release stream in exception handler")
except Exception:
pass
return JsonResponse({"error": str(e)}, status=500)
@api_view(["GET"])
@permission_classes([AllowAny])
def stream_xc(request, username, password, channel_id):
user = get_object_or_404(User, username=username)
extension = pathlib.Path(channel_id).suffix
channel_id = pathlib.Path(channel_id).stem
custom_properties = user.custom_properties or {}
if "xc_password" not in custom_properties:
return Response({"error": "Invalid credentials"}, status=401)
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
if user.user_level < 10:
user_profile_count = user.channel_profiles.count()
# If user has ALL profiles or NO profiles, give unrestricted access
if user_profile_count == 0:
# No profile filtering - user sees all channels based on user_level
filters = {
"id": int(channel_id),
"user_level__lte": user.user_level
}
channel = Channel.objects.filter(**filters).first()
else:
# User has specific limited profiles assigned
filters = {
"id": int(channel_id),
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
}
channel = Channel.objects.filter(**filters).distinct().first()
if not channel:
return JsonResponse({"error": "Not found"}, status=404)
else:
channel = get_object_or_404(Channel, id=channel_id)
# @TODO: we've got the file 'type' via extension, support this when we support multiple outputs
return stream_ts(request._request, str(channel.uuid), user)
@csrf_exempt
@api_view(["POST"])
@permission_classes([IsAdmin])
def change_stream(request, channel_id):
"""Change stream URL for existing channel with enhanced diagnostics"""
proxy_server = ProxyServer.get_instance()
try:
data = json.loads(request.body)
new_url = data.get("url")
user_agent = data.get("user_agent")
stream_id = data.get("stream_id")
# If stream_id is provided, get the URL and user_agent from it
if stream_id:
logger.info(
f"Stream ID {stream_id} provided, looking up stream info for channel {channel_id}"
)
stream_info = get_stream_info_for_switch(channel_id, stream_id)
if "error" in stream_info:
return JsonResponse(
{"error": stream_info["error"], "stream_id": stream_id}, status=404
)
# Use the info from the stream
new_url = stream_info["url"]
user_agent = stream_info["user_agent"]
m3u_profile_id = stream_info.get("m3u_profile_id")
# Stream ID will be passed to change_stream_url later
elif not new_url:
return JsonResponse(
{"error": "Either url or stream_id must be provided"}, status=400
)
logger.info(
f"Attempting to change stream for channel {channel_id} to {new_url}"
)
# Use the service layer instead of direct implementation
# Pass stream_id to ensure proper connection tracking
result = ChannelService.change_stream_url(
channel_id, new_url, user_agent, stream_id, m3u_profile_id
)
# Get the stream manager before updating URL
stream_manager = proxy_server.stream_managers.get(channel_id)
# If we have a stream manager, reset its tried_stream_ids when manually changing streams
if stream_manager:
# Reset tried streams when manually switching URL via API
stream_manager.tried_stream_ids = set()
logger.debug(
f"Reset tried stream IDs for channel {channel_id} during manual stream change"
)
if result.get("status") == "error":
return JsonResponse(
{
"error": result.get("message", "Unknown error"),
"diagnostics": result.get("diagnostics", {}),
},
status=404,
)
# Format response based on whether it was a direct update or event-based
response_data = {
"message": "Stream changed successfully",
"channel": channel_id,
"url": new_url,
"owner": result.get("direct_update", False),
"worker_id": proxy_server.worker_id,
}
# Include stream_id in response if it was used
if stream_id:
response_data["stream_id"] = stream_id
return JsonResponse(response_data)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
except Exception as e:
logger.error(f"Failed to change stream: {e}", exc_info=True)
return JsonResponse({"error": str(e)}, status=500)
@api_view(["GET"])
@permission_classes([IsAdmin])
def channel_status(request, channel_id=None):
"""
Returns status information about channels with detail level based on request:
- /status/ returns basic summary of all channels
- /status/{channel_id} returns detailed info about specific channel
"""
proxy_server = ProxyServer.get_instance()
try:
# Check if Redis is available
if not proxy_server.redis_client:
return JsonResponse({"error": "Redis connection not available"}, status=500)
# Handle single channel or all channels
if channel_id:
# Detailed info for specific channel
channel_info = ChannelStatus.get_detailed_channel_info(channel_id)
if channel_info:
return JsonResponse(channel_info)
else:
return JsonResponse(
{"error": f"Channel {channel_id} not found"}, status=404
)
else:
# Basic info for all channels
channel_pattern = "ts_proxy:channel:*:metadata"
all_channels = []
# Extract channel IDs from keys
cursor = 0
while True:
cursor, keys = proxy_server.redis_client.scan(
cursor, match=channel_pattern
)
for key in keys:
channel_id_match = re.search(
r"ts_proxy:channel:(.*):metadata", key
)
if channel_id_match:
ch_id = channel_id_match.group(1)
channel_info = ChannelStatus.get_basic_channel_info(ch_id)
if channel_info:
all_channels.append(channel_info)
if cursor == 0:
break
# Send WebSocket update with the stats
# Format it the same way the original Celery task did
send_websocket_update(
"updates",
"update",
{
"success": True,
"type": "channel_stats",
"stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})
}
)
return JsonResponse({"channels": all_channels, "count": len(all_channels)})
except Exception as e:
logger.error(f"Error in channel_status: {e}", exc_info=True)
return JsonResponse({"error": str(e)}, status=500)
@csrf_exempt
@api_view(["POST", "DELETE"])
@permission_classes([IsAdmin])
def stop_channel(request, channel_id):
"""Stop a channel and release all associated resources using PubSub events"""
try:
logger.info(f"Request to stop channel {channel_id} received")
# Use the service layer instead of direct implementation
result = ChannelService.stop_channel(channel_id)
if result.get("status") == "error":
return JsonResponse(
{"error": result.get("message", "Unknown error")}, status=404
)
return JsonResponse(
{
"message": "Channel stop request sent",
"channel_id": channel_id,
"previous_state": result.get("previous_state"),
}
)
except Exception as e:
logger.error(f"Failed to stop channel: {e}", exc_info=True)
return JsonResponse({"error": str(e)}, status=500)
@csrf_exempt
@api_view(["POST"])
@permission_classes([IsAdmin])
def stop_client(request, channel_id):
"""Stop a specific client connection using existing client management"""
try:
# Parse request body to get client ID
data = json.loads(request.body)
client_id = data.get("client_id")
if not client_id:
return JsonResponse({"error": "No client_id provided"}, status=400)
# Use the service layer instead of direct implementation
result = ChannelService.stop_client(channel_id, client_id)
if result.get("status") == "error":
return JsonResponse({"error": result.get("message")}, status=404)
return JsonResponse(
{
"message": "Client stop request processed",
"channel_id": channel_id,
"client_id": client_id,
"locally_processed": result.get("locally_processed", False),
}
)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
except Exception as e:
logger.error(f"Failed to stop client: {e}", exc_info=True)
return JsonResponse({"error": str(e)}, status=500)
@csrf_exempt
@api_view(["POST"])
@permission_classes([IsAdmin])
def next_stream(request, channel_id):
"""Switch to the next available stream for a channel"""
proxy_server = ProxyServer.get_instance()
try:
logger.info(
f"Request to switch to next stream for channel {channel_id} received"
)
# Check if the channel exists
channel = get_stream_object(channel_id)
# First check if channel is active in Redis
current_stream_id = None
profile_id = None
if proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
if proxy_server.redis_client.exists(metadata_key):
# Get current stream ID from Redis
stream_id_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.STREAM_ID
)
if stream_id_bytes:
current_stream_id = int(stream_id_bytes)
logger.info(
f"Found current stream ID {current_stream_id} in Redis for channel {channel_id}"
)
# Get M3U profile from Redis if available
profile_id_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.M3U_PROFILE
)
if profile_id_bytes:
profile_id = int(profile_id_bytes)
logger.info(
f"Found M3U profile ID {profile_id} in Redis for channel {channel_id}"
)
if not current_stream_id:
# Channel is not running
return JsonResponse(
{"error": "No current stream found for channel"}, status=404
)
# Get all streams for this channel in their defined order
streams = list(channel.streams.all().order_by("channelstream__order"))
if len(streams) <= 1:
return JsonResponse(
{
"error": "No alternate streams available for this channel",
"current_stream_id": current_stream_id,
},
status=404,
)
# Find the current stream's position in the list
current_index = None
for i, stream in enumerate(streams):
if stream.id == current_stream_id:
current_index = i
break
if current_index is None:
logger.warning(
f"Current stream ID {current_stream_id} not found in channel's streams list"
)
# Fall back to the first stream that's not the current one
next_stream = next((s for s in streams if s.id != current_stream_id), None)
if not next_stream:
return JsonResponse(
{
"error": "Could not find current stream in channel list",
"current_stream_id": current_stream_id,
},
status=404,
)
else:
# Get the next stream in the rotation (with wrap-around)
next_index = (current_index + 1) % len(streams)
next_stream = streams[next_index]
next_stream_id = next_stream.id
logger.info(
f"Rotating to next stream ID {next_stream_id} for channel {channel_id}"
)
# Get full stream info including URL for the next stream
stream_info = get_stream_info_for_switch(channel_id, next_stream_id)
if "error" in stream_info:
return JsonResponse(
{
"error": stream_info["error"],
"current_stream_id": current_stream_id,
"next_stream_id": next_stream_id,
},
status=404,
)
# Now use the ChannelService to change the stream URL
result = ChannelService.change_stream_url(
channel_id,
stream_info["url"],
stream_info["user_agent"],
next_stream_id,
stream_info.get("m3u_profile_id"),
)
if result.get("status") == "error":
return JsonResponse(
{
"error": result.get("message", "Unknown error"),
"diagnostics": result.get("diagnostics", {}),
"current_stream_id": current_stream_id,
"next_stream_id": next_stream_id,
},
status=404,
)
# Format success response
response_data = {
"message": "Stream switched to next available",
"channel": channel_id,
"previous_stream_id": current_stream_id,
"new_stream_id": next_stream_id,
"new_url": stream_info["url"],
"owner": result.get("direct_update", False),
"worker_id": proxy_server.worker_id,
}
return JsonResponse(response_data)
except Exception as e:
logger.error(f"Failed to switch to next stream: {e}", exc_info=True)
return JsonResponse({"error": str(e)}, status=500)