Dispatcharr/apps/proxy/ts_proxy/server.py

624 lines
24 KiB
Python

"""
Transport Stream (TS) Proxy Server
Handles live TS stream proxying with support for:
- Stream switching
- Buffer management
- Multiple client connections
- Connection state tracking
"""
import requests
import threading
import logging
import socket
from collections import deque
import time
from typing import Optional, Set, Deque, Dict
from apps.proxy.config import TSConfig as Config
class StreamManager:
"""Manages a connection to a TS stream with continuity tracking"""
def __init__(self, url, buffer, user_agent=None):
self.url = url
self.buffer = buffer
self.running = True
self.connected = False
self.socket = None
self.ready_event = threading.Event()
self.retry_count = 0
self.max_retries = 3
# User agent for connection
self.user_agent = user_agent or 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
# TS packet handling
self.TS_PACKET_SIZE = 188
self.recv_buffer = bytearray()
self.sync_found = False
self.continuity_counters = {} # Track continuity counters for each PID
# Buffer management
self._last_buffer_check = time.time()
logging.info(f"Initialized stream manager for channel {buffer.channel_id}")
def _create_session(self) -> requests.Session:
"""Create and configure requests session"""
session = requests.Session()
session.headers.update({
'User-Agent': self.user_agent,
'Connection': 'keep-alive'
})
return session
def update_url(self, new_url: str) -> bool:
"""Update stream URL and signal connection change"""
if new_url != self.url:
logging.info(f"Stream switch initiated: {self.url} -> {new_url}")
self.url = new_url
self.connected = False
self.ready_event.set()
return True
return False
def should_retry(self) -> bool:
"""Check if connection retry is allowed"""
return self.retry_count < self.max_retries
def stop(self) -> None:
"""Clean shutdown of stream manager"""
self.running = False
if self.socket:
self.socket.close()
def _process_complete_packets(self):
"""Process only complete TS packets from buffer with continuity tracking"""
try:
# Find sync byte if needed
if not self.sync_found and len(self.recv_buffer) >= 376:
for i in range(min(188, len(self.recv_buffer) - 188)):
# Look for at least two sync bytes (0x47) at 188-byte intervals
if (self.recv_buffer[i] == 0x47 and
self.recv_buffer[i + 188] == 0x47):
# Trim buffer to start at first sync byte
self.recv_buffer = self.recv_buffer[i:]
self.sync_found = True
logging.debug(f"TS sync found at position {i}")
break
# If sync not found, keep last 188 bytes and return
if not self.sync_found:
if len(self.recv_buffer) > 188:
self.recv_buffer = self.recv_buffer[-188:]
return False
# If we don't have a complete packet yet, wait for more data
if len(self.recv_buffer) < 188:
return False
# Calculate how many complete packets we have
packet_count = len(self.recv_buffer) // 188
if packet_count == 0:
return False
# Extract and process packets with continuity correction
processed_buffer = bytearray()
for i in range(packet_count):
packet_start = i * 188
packet = bytearray(self.recv_buffer[packet_start:packet_start + 188])
# Check sync byte
if packet[0] == 0x47:
# Extract PID (13 bits from bytes 1-2)
pid = ((packet[1] & 0x1F) << 8) | packet[2]
# Only process continuity for packets that have it
# Skip adaptation-only packets and null packets
if (packet[3] & 0x10) and pid != 0x1FFF: # Has payload and not null packet
# Extract continuity counter (4 bits from byte 3)
cc = packet[3] & 0x0F
# Correct continuity counter if we're tracking this PID
if pid in self.continuity_counters:
expected = (self.continuity_counters[pid] + 1) & 0x0F
if cc != expected:
# Rewrite continuity counter to maintain sequence
packet[3] = (packet[3] & 0xF0) | expected
# Update counter for next time
self.continuity_counters[pid] = packet[3] & 0x0F
# Add processed packet to buffer
processed_buffer.extend(packet)
# Keep remaining data in buffer
self.recv_buffer = self.recv_buffer[packet_count * 188:]
# Send processed packets to buffer
if processed_buffer:
self.buffer.add_chunk(bytes(processed_buffer))
return True
return False
except Exception as e:
logging.error(f"Error processing TS packets: {e}")
self.sync_found = False # Reset sync state on error
return False
def _process_ts_data(self, chunk):
"""Process received data and add to buffer"""
if not chunk:
return False
# Add to existing buffer
self.recv_buffer.extend(chunk)
# Process complete packets now
return self._process_complete_packets()
def run(self):
"""Main execution loop for stream manager"""
try:
# Establish network connection
import socket
import requests
logging.info(f"Starting stream for URL: {self.url}")
# Parse URL
if self.url.startswith("http"):
# HTTP connection
session = self._create_session()
try:
# Create an initial connection to get socket
response = session.get(self.url, stream=True)
if response.status_code == 200:
self.connected = True
self.socket = response.raw._fp.fp.raw
# Main fetch loop
while self.running:
if not self.fetch_chunk():
if not self.running:
break
time.sleep(1)
continue
else:
logging.error(f"Failed to connect to stream: HTTP {response.status_code}")
finally:
session.close()
else:
# Direct socket connection (UDP/TCP)
logging.error(f"Unsupported URL scheme: {self.url}")
except Exception as e:
logging.error(f"Stream error: {e}")
self._close_socket()
finally:
self._close_socket()
logging.info("Stream manager stopped")
def _close_socket(self):
"""Close the socket connection safely"""
if self.socket:
try:
self.socket.close()
except Exception:
pass
self.socket = None
self.connected = False
def fetch_chunk(self):
"""Fetch data from socket with improved buffer management"""
if not self.connected or not self.socket:
return False
try:
# SocketIO objects use read instead of recv and don't support settimeout
try:
# Try to read data chunk - use a multiple of TS packet size
if hasattr(self.socket, 'recv'):
chunk = self.socket.recv(188 * 64) # Standard socket
else:
chunk = self.socket.read(188 * 64) # SocketIO object
except AttributeError:
# Fall back to read() if recv() isn't available
chunk = self.socket.read(188 * 64)
if not chunk:
# Connection closed by server
logging.warning("Server closed connection")
self._close_socket()
self.connected = False
return False
# Process this chunk
self._process_ts_data(chunk)
# Memory management - clear any internal buffers periodically
current_time = time.time()
if current_time - self._last_buffer_check > 60: # Check every minute
self._last_buffer_check = current_time
if len(self.recv_buffer) > 188 * 1024: # If buffer is extremely large
logging.warning(f"Receive buffer unusually large ({len(self.recv_buffer)} bytes), trimming")
# Keep only recent data, aligned to TS packet boundary
keep_size = 188 * 128 # Keep reasonable buffer
self.recv_buffer = self.recv_buffer[-keep_size:]
return True
except (socket.timeout, socket.error) as e:
# Socket error
logging.error(f"Socket error: {e}")
self._close_socket()
self.connected = False
return False
except Exception as e:
logging.error(f"Error in fetch_chunk: {e}")
return False
class StreamBuffer:
"""Manages stream data buffering using Redis for persistence"""
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 = 188
# Redis keys
self.buffer_index_key = f"ts_proxy:buffer:{channel_id}:index"
self.buffer_prefix = f"ts_proxy:buffer:{channel_id}:chunk:"
# Expiration time for chunks
self.chunk_ttl = getattr(Config, 'REDIS_CHUNK_TTL', 60) # Default 60 seconds
# Local tracking for performance
self.local_cache = {}
self.local_cache_size = 50 # Keep last 50 chunks in local memory
def add_chunk(self, chunk):
"""Add a chunk to the buffer"""
if not chunk:
return False
try:
# Ensure chunk is properly aligned with TS packets
if len(chunk) % self.TS_PACKET_SIZE != 0:
logging.warning(f"Received non-aligned chunk of size {len(chunk)}")
aligned_size = (len(chunk) // self.TS_PACKET_SIZE) * self.TS_PACKET_SIZE
if (aligned_size == 0):
return False
chunk = chunk[:aligned_size]
with self.lock:
# Increment index atomically
if self.redis_client:
# Use Redis to store and track chunks
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, chunk)
# Update local tracking
self.index = chunk_index
self.local_cache[chunk_index] = chunk
# Trim local cache if needed
if len(self.local_cache) > self.local_cache_size:
oldest = min(self.local_cache.keys())
del self.local_cache[oldest]
return True
else:
# Fallback to local storage if Redis not available
logging.warning("Redis not available, using local storage only")
self.index += 1
self.local_cache[self.index] = chunk
if len(self.local_cache) > self.local_cache_size:
oldest = min(self.local_cache.keys())
del self.local_cache[oldest]
return True
except Exception as e:
logging.error(f"Error adding chunk to buffer: {e}")
return False
def get_chunks(self, start_index=None):
"""Get chunks from the buffer starting at the given index"""
try:
if not self.redis_client:
# Local fallback
chunks = []
with self.lock:
for idx in sorted(self.local_cache.keys()):
if start_index is None or idx > start_index:
chunks.append(self.local_cache[idx])
return chunks
# If no start_index provided, use most recent chunks
if start_index is None:
start_index = max(0, self.index - 50)
# Get current index from Redis
current_index = int(self.redis_client.get(self.buffer_index_key) or 0)
# Calculate range of chunks to retrieve (up to 50 at a time)
start_id = start_index + 1
end_id = min(current_index + 1, start_id + 50)
if start_id >= end_id:
return []
# Retrieve chunks in a pipeline
pipe = self.redis_client.pipeline()
for idx in range(start_id, end_id):
chunk_key = f"{self.buffer_prefix}{idx}"
pipe.get(chunk_key)
results = pipe.execute()
chunks = [r for r in results if r]
# Update local tracking
if chunks:
self.index = end_id - 1
return chunks
except Exception as e:
logging.error(f"Error getting chunks from buffer: {e}")
return []
class ClientManager:
"""Manages active client connections"""
def __init__(self):
self.active_clients: Set[int] = set()
self.lock: threading.Lock = threading.Lock()
self.last_client_time: float = time.time()
self.cleanup_timer: Optional[threading.Timer] = None
self._proxy_server = None
self._channel_id = None
def start_cleanup_timer(self, proxy_server, channel_id):
"""Start timer to cleanup idle channels"""
self._proxy_server = proxy_server
self._channel_id = channel_id
if self.cleanup_timer:
self.cleanup_timer.cancel()
self.cleanup_timer = threading.Timer(
Config.CLIENT_TIMEOUT,
self._cleanup_idle_channel,
args=[proxy_server, channel_id]
)
self.cleanup_timer.daemon = True
self.cleanup_timer.start()
def _cleanup_idle_channel(self, proxy_server, channel_id):
"""Stop channel if no clients connected"""
with self.lock:
if not self.active_clients:
logging.info(f"No clients connected for {Config.CLIENT_TIMEOUT}s, stopping channel {channel_id}")
proxy_server.stop_channel(channel_id)
def add_client(self, client_id: int) -> None:
"""Add new client connection"""
with self.lock:
self.active_clients.add(client_id)
self.last_client_time = time.time() # Reset the timer
if self.cleanup_timer:
self.cleanup_timer.cancel() # Cancel existing timer
self.start_cleanup_timer(self._proxy_server, self._channel_id) # Restart timer
logging.info(f"New client connected: {client_id} (total: {len(self.active_clients)})")
def remove_client(self, client_id: int) -> int:
"""Remove client and return remaining count"""
with self.lock:
self.active_clients.remove(client_id)
remaining = len(self.active_clients)
logging.info(f"Client disconnected: {client_id} (remaining: {remaining})")
return remaining
class StreamFetcher:
"""Handles stream data fetching"""
def __init__(self, manager: StreamManager, buffer: StreamBuffer):
self.manager = manager
self.buffer = buffer
def fetch_loop(self) -> None:
"""Main fetch loop for stream data"""
while self.manager.running:
try:
if not self._handle_connection():
continue
with self.manager.session.get(self.manager.url, stream=True) as response:
if response.status_code == 200:
self._handle_successful_connection()
self._process_stream(response)
except requests.exceptions.RequestException as e:
self._handle_connection_error(e)
def _handle_connection(self) -> bool:
"""Handle connection state and retries"""
if not self.manager.connected:
if not self.manager.should_retry():
logging.error(f"Failed to connect after {self.manager.max_retries} attempts")
return False
if not self.manager.running:
return False
self.manager.retry_count += 1
logging.info(f"Connecting to stream: {self.manager.url} "
f"(attempt {self.manager.retry_count}/{self.manager.max_retries})")
return True
def _handle_successful_connection(self) -> None:
"""Handle successful stream connection"""
if not self.manager.connected:
logging.info("Stream connected successfully")
self.manager.connected = True
self.manager.retry_count = 0
def _process_stream(self, response: requests.Response) -> None:
"""Process incoming stream data"""
for chunk in response.iter_content(chunk_size=Config.CHUNK_SIZE):
if not self.manager.running:
logging.info("Stream fetch stopped - shutting down")
return
if chunk:
if self.manager.ready_event.is_set():
logging.info("Stream switch in progress, closing connection")
self.manager.ready_event.clear()
break
with self.buffer.lock:
self.buffer.buffer.append(chunk)
self.buffer.index += 1
def _handle_connection_error(self, error: Exception) -> None:
"""Handle stream connection errors"""
logging.error(f"Stream connection error: {error}")
self.manager.connected = False
if not self.manager.running:
return
logging.info(f"Attempting to reconnect in {Config.RECONNECT_DELAY} seconds...")
if not wait_for_running(self.manager, Config.RECONNECT_DELAY):
return
def wait_for_running(manager: StreamManager, delay: float) -> bool:
"""Wait while checking manager running state"""
start = time.time()
while time.time() - start < delay:
if not manager.running:
return False
threading.Event().wait(0.1)
return True
class ProxyServer:
"""Manages TS proxy server instance"""
def __init__(self):
self.stream_managers = {} # Maps channel_id to StreamManager
self.stream_buffers = {} # Maps channel_id to StreamBuffer
self.client_managers = {} # Maps channel_id to ClientManager
self.fetch_threads = {} # Add this missing attribute
# Initialize Redis connection
try:
import redis
from django.conf import settings
redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0')
self.redis_client = redis.from_url(redis_url)
logging.info(f"Connected to Redis at {redis_url}")
except Exception as e:
self.redis_client = None
logging.error(f"Failed to connect to Redis: {e}")
def initialize_channel(self, url, channel_id):
"""Initialize a channel"""
try:
# Clean up any existing Redis entries for this channel
if self.redis_client:
# Delete index key
index_key = f"ts_proxy:buffer:{channel_id}:index"
self.redis_client.delete(index_key)
# Delete all chunks for this channel
pattern = f"ts_proxy:buffer:{channel_id}:chunk:*"
cursor = 0
while True:
cursor, keys = self.redis_client.scan(cursor, pattern, 100)
if keys:
self.redis_client.delete(*keys)
if cursor == 0:
break
# Register this channel as active
self.redis_client.set(f"ts_proxy:active_channel:{channel_id}", "1", ex=60)
# Create buffer and stream manager
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = buffer
stream_manager = StreamManager(url, buffer)
self.stream_managers[channel_id] = stream_manager
# FIX: Create client manager without passing channel_id
client_manager = ClientManager() # No arguments here
self.client_managers[channel_id] = client_manager
# Set up the cleanup timer afterwards
client_manager.start_cleanup_timer(self, channel_id)
# Start stream manager
threading.Thread(target=stream_manager.run, daemon=True).start()
return True
except Exception as e:
logging.error(f"Error initializing channel: {e}")
return False
def stop_channel(self, channel_id):
"""Stop channel and clean up resources"""
try:
# Stop stream manager
if channel_id in self.stream_managers:
self.stream_managers[channel_id].stop()
del self.stream_managers[channel_id]
# Remove buffer
if channel_id in self.stream_buffers:
del self.stream_buffers[channel_id]
# Cancel cleanup timer
if channel_id in self.client_managers:
if self.client_managers[channel_id].cleanup_timer:
self.client_managers[channel_id].cleanup_timer.cancel()
del self.client_managers[channel_id]
# Clean up fetch thread if exists
if channel_id in self.fetch_threads:
if self.fetch_threads[channel_id].is_alive():
# We can't forcibly terminate threads in Python,
# but we can make sure they're not referenced
pass
del self.fetch_threads[channel_id]
# Remove Redis active channel marker
if self.redis_client:
self.redis_client.delete(f"ts_proxy:active_channel:{channel_id}")
logging.info(f"Stopped channel {channel_id}")
return True
except Exception as e:
logging.error(f"Error stopping channel {channel_id}: {e}")
return False
def _cleanup_channel(self, channel_id: str) -> None:
"""Remove channel resources"""
for collection in [self.stream_managers, self.stream_buffers,
self.client_managers, self.fetch_threads]:
collection.pop(channel_id, None)
def shutdown(self) -> None:
"""Stop all channels and cleanup"""
for channel_id in list(self.stream_managers.keys()):
self.stop_channel(channel_id)