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
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:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user