This commit is contained in:
SergeantPanda 2025-03-20 18:11:09 -05:00
commit ce0fd15191
26 changed files with 2533 additions and 997 deletions

View file

@ -1,34 +1,45 @@
# apps/channels/tasks.py
import logging
import os
import re
from celery import shared_task
from rapidfuzz import fuzz
from sentence_transformers import SentenceTransformer, util
from django.conf import settings
from django.db import transaction
from apps.channels.models import Channel
from apps.epg.models import EPGData
from core.models import CoreSettings # to retrieve "preferred-region" setting
from apps.epg.models import EPGData, EPGSource
from core.models import CoreSettings
from apps.epg.tasks import parse_programs_for_tvg_id # <-- we import our new helper
logger = logging.getLogger(__name__)
# Load the model once at module level
# Load the sentence-transformers model once at the module level
SENTENCE_MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
st_model = SentenceTransformer(SENTENCE_MODEL_NAME)
MODEL_PATH = os.path.join(settings.MEDIA_ROOT, "models", "all-MiniLM-L6-v2")
os.makedirs(MODEL_PATH, exist_ok=True)
# Threshold constants
BEST_FUZZY_THRESHOLD = 70
# If not present locally, download:
if not os.path.exists(os.path.join(MODEL_PATH, "config.json")):
logger.info(f"Local model not found in {MODEL_PATH}; downloading from {SENTENCE_MODEL_NAME}...")
st_model = SentenceTransformer(SENTENCE_MODEL_NAME, cache_folder=MODEL_PATH)
else:
logger.info(f"Loading local model from {MODEL_PATH}")
st_model = SentenceTransformer(MODEL_PATH)
# Thresholds
BEST_FUZZY_THRESHOLD = 85
LOWER_FUZZY_THRESHOLD = 40
EMBED_SIM_THRESHOLD = 0.65
# Common extraneous words
# Words we remove to help with fuzzy + embedding matching
COMMON_EXTRANEOUS_WORDS = [
"tv", "channel", "network", "television",
"east", "west", "hd", "uhd", "us", "usa", "not", "24/7",
"east", "west", "hd", "uhd", "24/7",
"1080p", "720p", "540p", "480p",
"arabic", "latino", "film", "movie", "movies"
"film", "movie", "movies"
]
def normalize_name(name: str) -> str:
@ -43,21 +54,12 @@ def normalize_name(name: str) -> str:
if not name:
return ""
# Lowercase
norm = name.lower()
# Remove bracketed text
norm = re.sub(r"\[.*?\]", "", norm)
norm = re.sub(r"\(.*?\)", "", norm)
# Remove punctuation except word chars/spaces
norm = re.sub(r"[^\w\s]", "", norm)
# Remove extraneous tokens
tokens = norm.split()
tokens = [t for t in tokens if t not in COMMON_EXTRANEOUS_WORDS]
# Rejoin
norm = " ".join(tokens).strip()
return norm
@ -65,36 +67,33 @@ def normalize_name(name: str) -> str:
def match_epg_channels():
"""
Goes through all Channels and tries to find a matching EPGData row by:
1) If channel.tvg_id is valid in EPGData, skip
2) If channel has a tvg_id but not found in EPGData, attempt direct EPGData lookup
3) Otherwise do name-based fuzzy ratio pass:
- add region-based bonus if region code is found in the EPG row
- if fuzzy >= BEST_FUZZY_THRESHOLD => accept
- if fuzzy in [LOWER_FUZZY_THRESHOLD..BEST_FUZZY_THRESHOLD) => do embedding check
- else skip
4) Log summary
1) If channel.tvg_id is valid in EPGData, skip.
2) If channel has a tvg_id but not found in EPGData, attempt direct EPGData lookup.
3) Otherwise, perform name-based fuzzy matching with optional region-based bonus.
4) If a match is found, we set channel.tvg_id and also parse its programs
from the cached EPG file (parse_programs_for_tvg_id).
5) Summarize and log results.
"""
logger.info("Starting EPG matching logic...")
# Try to get user's preferred region from CoreSettings
# Attempt to retrieve a "preferred-region" if configured
try:
region_obj = CoreSettings.objects.get(key="preferred-region")
region_code = region_obj.value.strip().lower() # e.g. "us"
region_code = region_obj.value.strip().lower()
except CoreSettings.DoesNotExist:
region_code = None
# 1) Gather EPG rows
# Gather EPGData rows so we can do fuzzy matching in memory
all_epg = list(EPGData.objects.all())
epg_rows = []
for e in all_epg:
epg_rows.append({
"epg_id": e.id,
"tvg_id": e.tvg_id or "", # e.g. "Fox News.us"
"tvg_id": e.tvg_id or "",
"raw_name": e.name,
"norm_name": normalize_name(e.name),
})
# 2) Pre-encode embeddings if possible
epg_embeddings = None
if any(row["norm_name"] for row in epg_rows):
epg_embeddings = st_model.encode(
@ -104,80 +103,97 @@ def match_epg_channels():
matched_channels = []
source = EPGSource.objects.filter(is_active=True).first()
epg_file_path = getattr(source, 'file_path', None) if source else None
with transaction.atomic():
for chan in Channel.objects.all():
# A) Skip if channel.tvg_id is valid
# A) Skip if channel.tvg_id is already valid
if chan.tvg_id and EPGData.objects.filter(tvg_id=chan.tvg_id).exists():
continue
# B) If channel has a tvg_id but not in EPG, do direct lookup
# B) If channel has a tvg_id that doesn't exist in EPGData, do direct check
if chan.tvg_id:
epg_match = EPGData.objects.filter(tvg_id=chan.tvg_id).first()
if epg_match:
logger.info(
f"Channel {chan.id} '{chan.name}' => found EPG by tvg_id={chan.tvg_id}"
)
logger.info(f"Channel {chan.id} '{chan.name}' => EPG found by tvg_id={chan.tvg_id}")
continue
# C) No valid tvg_id => name-based matching
# C) Perform name-based fuzzy matching
fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.name
norm_chan = normalize_name(fallback_name)
if not norm_chan:
logger.info(
f"Channel {chan.id} '{chan.name}' => empty after normalization, skipping"
)
logger.info(f"Channel {chan.id} '{chan.name}' => empty after normalization, skipping")
continue
best_score = 0
best_epg = None
for row in epg_rows:
if not row["norm_name"]:
continue
# Base fuzzy ratio
base_score = fuzz.ratio(norm_chan, row["norm_name"])
# If we have a region_code, add a small bonus if the epg row has that region
# e.g. tvg_id or raw_name might contain ".us" or "us"
bonus = 0
# Region-based bonus/penalty
combined_text = row["tvg_id"].lower() + " " + row["raw_name"].lower()
dot_regions = re.findall(r'\.([a-z]{2})', combined_text)
if region_code:
# example: if region_code is "us" and row["tvg_id"] ends with ".us"
# or row["raw_name"] has "us" in it, etc.
# We'll do a naive check:
combined_text = row["tvg_id"].lower() + " " + row["raw_name"].lower()
if region_code in combined_text:
bonus = 15 # pick a small bonus
if dot_regions:
if region_code in dot_regions:
bonus = 30 # bigger bonus if .us or .ca matches
else:
bonus = -15
elif region_code in combined_text:
bonus = 15
score = base_score + bonus
logger.debug(
f"Channel {chan.id} '{fallback_name}' => EPG row {row['epg_id']}: "
f"raw_name='{row['raw_name']}', norm_name='{row['norm_name']}', "
f"combined_text='{combined_text}', dot_regions={dot_regions}, "
f"base_score={base_score}, bonus={bonus}, total_score={score}"
)
if score > best_score:
best_score = score
best_epg = row
# If no best match was found, skip
if not best_epg:
logger.info(f"Channel {chan.id} '{fallback_name}' => no EPG match at all.")
continue
# E) Decide acceptance
# If best_score is above BEST_FUZZY_THRESHOLD => direct accept
if best_score >= BEST_FUZZY_THRESHOLD:
# Accept
chan.tvg_id = best_epg["tvg_id"]
chan.save()
# Attempt to parse program data for this channel
if epg_file_path:
parse_programs_for_tvg_id(epg_file_path, best_epg["tvg_id"])
logger.info(f"Loaded program data for tvg_id={best_epg['tvg_id']}")
matched_channels.append((chan.id, fallback_name, best_epg["tvg_id"]))
logger.info(
f"Channel {chan.id} '{fallback_name}' => matched tvg_id={best_epg['tvg_id']} (score={best_score})"
f"Channel {chan.id} '{fallback_name}' => matched tvg_id={best_epg['tvg_id']} "
f"(score={best_score})"
)
# If best_score is in the “middle range,” do embedding check
elif best_score >= LOWER_FUZZY_THRESHOLD and epg_embeddings is not None:
# borderline => do embedding
chan_embedding = st_model.encode(norm_chan, convert_to_tensor=True)
sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0]
top_index = int(sim_scores.argmax())
top_value = float(sim_scores[top_index])
if top_value >= EMBED_SIM_THRESHOLD:
matched_epg = epg_rows[top_index]
chan.tvg_id = matched_epg["tvg_id"]
chan.save()
if epg_file_path:
parse_programs_for_tvg_id(epg_file_path, matched_epg["tvg_id"])
logger.info(f"Loaded program data for tvg_id={matched_epg['tvg_id']}")
matched_channels.append((chan.id, fallback_name, matched_epg["tvg_id"]))
logger.info(
f"Channel {chan.id} '{fallback_name}' => matched EPG tvg_id={matched_epg['tvg_id']} "
@ -189,12 +205,11 @@ def match_epg_channels():
f"cos-sim={top_value:.2f} < {EMBED_SIM_THRESHOLD}, skipping"
)
else:
# no match
logger.info(
f"Channel {chan.id} '{fallback_name}' => fuzzy={best_score} < {LOWER_FUZZY_THRESHOLD}, skipping"
f"Channel {chan.id} '{fallback_name}' => fuzzy={best_score} < "
f"{LOWER_FUZZY_THRESHOLD}, skipping"
)
# Final summary
total_matched = len(matched_channels)
if total_matched:
logger.info(f"Match Summary: {total_matched} channel(s) matched.")

View file

@ -4,3 +4,7 @@ class EpgConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.epg'
verbose_name = "EPG Management"
def ready(self):
# Import signals to ensure they get registered
import apps.epg.signals

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-19 16:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='file_path',
field=models.CharField(blank=True, max_length=1024, null=True),
),
]

View file

@ -11,6 +11,7 @@ class EPGSource(models.Model):
url = models.URLField(blank=True, null=True) # For XMLTV
api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct
is_active = models.BooleanField(default=True)
file_path = models.CharField(max_length=1024, blank=True, null=True)
def __str__(self):
return self.name

10
apps/epg/signals.py Normal file
View file

@ -0,0 +1,10 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import EPGSource
from .tasks import refresh_epg_data
@receiver(post_save, sender=EPGSource)
def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs):
# Trigger refresh only if the source is newly created and active
if created and instance.is_active:
refresh_epg_data.delay()

View file

@ -1,29 +1,40 @@
# apps/epg/tasks.py
import logging
import gzip # <-- New import for gzip support
from celery import shared_task
from .models import EPGSource, EPGData, ProgramData
from django.utils import timezone
import gzip
import os
import uuid
import requests
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone as dt_timezone
from celery import shared_task
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from .models import EPGSource, EPGData, ProgramData
logger = logging.getLogger(__name__)
@shared_task
def refresh_epg_data():
logger.info("Starting refresh_epg_data task.")
active_sources = EPGSource.objects.filter(is_active=True)
logger.debug(f"Found {active_sources.count()} active EPGSource(s).")
for source in active_sources:
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
if source.source_type == 'xmltv':
fetch_xmltv(source)
elif source.source_type == 'schedules_direct':
fetch_schedules_direct(source)
logger.info("Finished refresh_epg_data task.")
return "EPG data refreshed."
def fetch_xmltv(source):
logger.info(f"Fetching XMLTV data from source: {source.name}")
try:
@ -31,71 +42,123 @@ def fetch_xmltv(source):
response.raise_for_status()
logger.debug("XMLTV data fetched successfully.")
# If the URL ends with '.gz', decompress the response content
if source.url.lower().endswith('.gz'):
logger.debug("Detected .gz file. Decompressing...")
decompressed_bytes = gzip.decompress(response.content)
xml_data = decompressed_bytes.decode('utf-8')
else:
xml_data = response.text
# Decide on file extension
file_ext = ".gz" if source.url.lower().endswith('.gz') else ".xml"
filename = f"{source.name}_{uuid.uuid4().hex[:8]}{file_ext}"
root = ET.fromstring(xml_data)
logger.debug("Parsed XMLTV XML content.")
# Build full path in MEDIA_ROOT/cached_epg
epg_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg")
os.makedirs(epg_dir, exist_ok=True)
file_path = os.path.join(epg_dir, filename)
# Group programmes by their tvg_id from the XMLTV file
programmes_by_channel = {}
for programme in root.findall('programme'):
start_time = parse_xmltv_time(programme.get('start'))
stop_time = parse_xmltv_time(programme.get('stop'))
tvg_id = programme.get('channel')
title = programme.findtext('title', default='No Title')
desc = programme.findtext('desc', default='')
# Save raw data
with open(file_path, 'wb') as f:
f.write(response.content)
logger.info(f"Cached EPG file saved to {file_path}")
programmes_by_channel.setdefault(tvg_id, []).append({
'start_time': start_time,
'end_time': stop_time,
'title': title,
'description': desc,
'tvg_id': tvg_id,
})
# Save the file_path on the EPGSource instance so it can be retrieved later.
source.file_path = file_path
source.save(update_fields=['file_path'])
# Process each group regardless of channel existence.
for tvg_id, programmes in programmes_by_channel.items():
# Create (or get) an EPGData record using the tvg_id.
epg_data, created = EPGData.objects.get_or_create(
tvg_id=tvg_id,
defaults={'name': tvg_id} # Use tvg_id as a fallback name
)
if created:
logger.info(f"Created new EPGData for tvg_id '{tvg_id}'.")
else:
logger.debug(f"Found existing EPGData for tvg_id '{tvg_id}'.")
# If you store the path on EPGSource, do so here:
# source.file_path = file_path
# source.save(update_fields=['file_path'])
# Now parse <channel> blocks only
parse_channels_only(file_path)
logger.info(f"Processing {len(programmes)} programme(s) for tvg_id '{tvg_id}'.")
with transaction.atomic():
for prog in programmes:
obj, created = ProgramData.objects.update_or_create(
epg=epg_data,
start_time=prog['start_time'],
title=prog['title'],
defaults={
'end_time': prog['end_time'],
'description': prog['description'],
'sub_title': '',
'tvg_id': tvg_id,
}
)
if created:
logger.info(f"Created ProgramData '{prog['title']}' for tvg_id '{tvg_id}'.")
else:
logger.info(f"Updated ProgramData '{prog['title']}' for tvg_id '{tvg_id}'.")
except Exception as e:
logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True)
def parse_channels_only(file_path):
logger.info(f"Parsing channels from EPG file: {file_path}")
# Read entire file (decompress if .gz)
if file_path.endswith('.gz'):
with open(file_path, 'rb') as gz_file:
decompressed = gzip.decompress(gz_file.read())
xml_data = decompressed.decode('utf-8')
else:
with open(file_path, 'r', encoding='utf-8') as xml_file:
xml_data = xml_file.read()
root = ET.fromstring(xml_data)
channels = root.findall('channel')
logger.info(f"Found {len(channels)} <channel> entries in {file_path}")
with transaction.atomic():
for channel_elem in channels:
tvg_id = channel_elem.get('id', '').strip()
if not tvg_id:
continue # skip blank/invalid IDs
display_name = channel_elem.findtext('display-name', default=tvg_id).strip()
epg_obj, created = EPGData.objects.get_or_create(
tvg_id=tvg_id,
defaults={'name': display_name}
)
if not created:
# Optionally update if new name is different
if epg_obj.name != display_name:
epg_obj.name = display_name
epg_obj.save()
logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}")
logger.info("Finished parsing channel info.")
def parse_programs_for_tvg_id(file_path, tvg_id):
logger.info(f"Parsing <programme> for tvg_id={tvg_id} from {file_path}")
# Read entire file (decompress if .gz)
if file_path.endswith('.gz'):
with open(file_path, 'rb') as gz_file:
decompressed = gzip.decompress(gz_file.read())
xml_data = decompressed.decode('utf-8')
else:
with open(file_path, 'r', encoding='utf-8') as xml_file:
xml_data = xml_file.read()
root = ET.fromstring(xml_data)
# Retrieve the EPGData record
try:
epg_obj = EPGData.objects.get(tvg_id=tvg_id)
except EPGData.DoesNotExist:
logger.warning(f"No EPGData record found for tvg_id={tvg_id}")
return
# Find only <programme> elements for this tvg_id
matched_programmes = [p for p in root.findall('programme') if p.get('channel') == tvg_id]
logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={tvg_id}")
with transaction.atomic():
for prog in matched_programmes:
start_time = parse_xmltv_time(prog.get('start'))
end_time = parse_xmltv_time(prog.get('stop'))
title = prog.findtext('title', default='No Title')
desc = prog.findtext('desc', default='')
obj, created = ProgramData.objects.update_or_create(
epg=epg_obj,
start_time=start_time,
title=title,
defaults={
'end_time': end_time,
'description': desc,
'sub_title': '',
'tvg_id': tvg_id,
}
)
if created:
logger.debug(f"Created ProgramData: {title} [{start_time} - {end_time}]")
logger.info(f"Completed program parsing for tvg_id={tvg_id}.")
def fetch_schedules_direct(source):
logger.info(f"Fetching Schedules Direct data from source: {source.name}")
try:
# NOTE: Provide the correct api_url for Schedules Direct.
api_url = ''
headers = {
'Content-Type': 'application/json',
@ -117,7 +180,6 @@ def fetch_schedules_direct(source):
schedules = sched_response.json()
logger.debug(f"Fetched schedules: {schedules}")
# Create (or get) an EPGData record using the tvg_id.
epg_data, created = EPGData.objects.get_or_create(
tvg_id=tvg_id,
defaults={'name': tvg_id}
@ -149,6 +211,10 @@ def fetch_schedules_direct(source):
except Exception as e:
logger.error(f"Error fetching Schedules Direct data from {source.name}: {e}", exc_info=True)
# -------------------------------
# Helper parse functions
# -------------------------------
def parse_xmltv_time(time_str):
try:
dt_obj = datetime.strptime(time_str[:14], '%Y%m%d%H%M%S')
@ -166,6 +232,7 @@ def parse_xmltv_time(time_str):
logger.error(f"Error parsing XMLTV time '{time_str}': {e}", exc_info=True)
raise
def parse_schedules_direct_time(time_str):
try:
dt_obj = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')

View file

@ -39,7 +39,7 @@ class DiscoverAPIView(APIView):
responses={200: openapi.Response("HDHR Discovery JSON")}
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
base_url = request.build_absolute_uri('/').rstrip('/')
device = HDHRDevice.objects.first()
if not device:
@ -81,7 +81,7 @@ class LineupAPIView(APIView):
{
"GuideNumber": str(ch.channel_number),
"GuideName": ch.name,
"URL": request.build_absolute_uri(f"/output/stream/{ch.id}")
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
}
for ch in channels
]
@ -115,7 +115,7 @@ class HDHRDeviceXMLAPIView(APIView):
responses={200: openapi.Response("HDHR Device XML")}
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
base_url = request.build_absolute_uri('/').rstrip('/')
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<root>

View file

@ -39,7 +39,7 @@ class DiscoverAPIView(APIView):
responses={200: openapi.Response("HDHR Discovery JSON")}
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
base_url = request.build_absolute_uri('/').rstrip('/')
device = HDHRDevice.objects.first()
if not device:
@ -81,7 +81,7 @@ class LineupAPIView(APIView):
{
"GuideNumber": str(ch.channel_number),
"GuideName": ch.name,
"URL": request.build_absolute_uri(f"/output/stream/{ch.id}")
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
}
for ch in channels
]
@ -115,7 +115,7 @@ class HDHRDeviceXMLAPIView(APIView):
responses={200: openapi.Response("HDHR Device XML")}
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
base_url = request.build_absolute_uri('/').rstrip('/')
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<root>

View file

@ -23,34 +23,37 @@ class HLSConfig(BaseConfig):
class TSConfig(BaseConfig):
"""Configuration settings for TS proxy"""
# Connection settings
CONNECTION_TIMEOUT = 10 # seconds to wait for initial connection
MAX_RETRIES = 3 # maximum connection retry attempts
# Buffer settings
INITIAL_BEHIND_CHUNKS = 4 # How many chunks behind to start a client (4 chunks = ~1MB)
CHUNK_BATCH_SIZE = 5 # How many chunks to fetch in one batch
KEEPALIVE_INTERVAL = 0.5 # Seconds between keepalive packets when at buffer head
# Streaming settings
TARGET_BITRATE = 8000000 # Target bitrate (8 Mbps)
STREAM_TIMEOUT = 10 # Disconnect after this many seconds of no data
HEALTH_CHECK_INTERVAL = 5 # Check stream health every N seconds
# Resource management
CLEANUP_INTERVAL = 60 # Check for inactive channels every 60 seconds
CHANNEL_SHUTDOWN_DELAY = 0 # How long to wait after last client before shutdown (seconds)
# Client tracking settings
CLIENT_RECORD_TTL = 5 # How long client records persist in Redis (seconds). Client will be considered MIA after this time.
CLEANUP_CHECK_INTERVAL = 1 # How often to check for disconnected clients (seconds)
CHANNEL_INIT_GRACE_PERIOD = 5 # How long to wait for first client after initialization (seconds)
CLIENT_HEARTBEAT_INTERVAL = 1 # How often to send client heartbeats (seconds)
GHOST_CLIENT_MULTIPLIER = 5.0 # How many heartbeat intervals before client considered ghost (5 would mean 5 secondsif heartbeat interval is 1)
# TS packets are 188 bytes
# Make chunk size a multiple of TS packet size for perfect alignment
# ~1MB is ideal for streaming (matches typical media buffer sizes)
BUFFER_CHUNK_SIZE = 188 * 1361 # ~256KB
# Maximum number of stream switch attempts before giving up
MAX_STREAM_SWITCHES = 10

View file

@ -2,6 +2,8 @@ import logging
import time
import re
from . import proxy_server
from .redis_keys import RedisKeys
from .constants import TS_PACKET_SIZE
logger = logging.getLogger("ts_proxy")
@ -9,22 +11,15 @@ class ChannelStatus:
def get_detailed_channel_info(channel_id):
# Get channel metadata
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata = proxy_server.redis_client.hgetall(metadata_key)
if not metadata:
return None
# Get detailed info - existing implementation
# Get channel metadata
metadata_key = f"ts_proxy:channel:{channel_id}: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_value = proxy_server.redis_client.get(f"ts_proxy:channel:{channel_id}:buffer:index")
buffer_index_key = RedisKeys.buffer_index(channel_id)
buffer_index_value = proxy_server.redis_client.get(buffer_index_key)
info = {
'channel_id': channel_id,
@ -33,8 +28,6 @@ class ChannelStatus:
'profile': metadata.get(b'profile', b'unknown').decode('utf-8'),
'started_at': metadata.get(b'init_time', b'0').decode('utf-8'),
'owner': metadata.get(b'owner', b'unknown').decode('utf-8'),
# Properly decode the buffer index value
'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0,
}
@ -50,7 +43,7 @@ class ChannelStatus:
info['uptime'] = time.time() - created_at
# Get client information
client_set_key = f"ts_proxy:channel:{channel_id}:clients"
client_set_key = RedisKeys.clients(channel_id)
client_ids = proxy_server.redis_client.smembers(client_set_key)
clients = []
@ -97,7 +90,7 @@ class ChannelStatus:
# Check if the keys exist before getting
for i in range(info['buffer_index']-sample_chunks+1, info['buffer_index']+1):
chunk_key = f"ts_proxy:channel:{channel_id}:buffer:chunk:{i}"
chunk_key = RedisKeys.buffer_chunk(channel_id, i)
# Check if key exists first
if proxy_server.redis_client.exists(chunk_key):
@ -135,9 +128,9 @@ class ChannelStatus:
buffer_stats['total_sample_bytes'] = total_data
# Add TS packet analysis
total_ts_packets = total_data // 188
total_ts_packets = total_data // TS_PACKET_SIZE
buffer_stats['estimated_ts_packets'] = total_ts_packets
buffer_stats['is_ts_aligned'] = all(size % 188 == 0 for size in chunk_sizes)
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 = []
@ -161,7 +154,7 @@ class ChannelStatus:
buffer_stats['diagnostics']['exception'] = str(e)
# Add TTL information to see if chunks are expiring
chunk_ttl_key = f"ts_proxy:channel:{channel_id}:buffer:chunk:{info['buffer_index']}"
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
@ -182,17 +175,18 @@ class ChannelStatus:
# Function for basic channel info (used for all channels summary)
def get_basic_channel_info(channel_id):
# Get channel metadata
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if not metadata:
return None
# Basic channel info only - omit diagnostics and details
buffer_index_value = proxy_server.redis_client.get(f"ts_proxy:channel:{channel_id}:buffer:index")
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 = f"ts_proxy:channel:{channel_id}:clients"
client_set_key = RedisKeys.clients(channel_id)
client_count = proxy_server.redis_client.scard(client_set_key) or 0
# Calculate uptime
@ -218,7 +212,6 @@ class ChannelStatus:
# Get concise client information
clients = []
client_set_key = f"ts_proxy:channel:{channel_id}:clients"
client_ids = proxy_server.redis_client.smembers(client_set_key)
# Process only if we have clients and keep it limited

View file

@ -6,6 +6,9 @@ import time
import json
from typing import Set, Optional
from apps.proxy.config import TSConfig as Config
from .constants import EventType
from .config_helper import ConfigHelper
from .redis_keys import RedisKeys
logger = logging.getLogger("ts_proxy")
@ -21,9 +24,9 @@ class ClientManager:
self.worker_id = worker_id # Store worker ID as instance variable
# STANDARDIZED KEYS: Move client set under channel namespace
self.client_set_key = f"ts_proxy:channel:{channel_id}:clients"
self.client_ttl = getattr(Config, 'CLIENT_RECORD_TTL', 60)
self.heartbeat_interval = getattr(Config, 'CLIENT_HEARTBEAT_INTERVAL', 10)
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 = {}
# Start heartbeat thread for local clients
@ -143,7 +146,7 @@ class ClientManager:
self._registered_clients.add(client_id)
# FIX: Consistent key naming - note the 's' in 'clients'
# Use a function to get the client key
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
# Prepare client data
@ -172,13 +175,14 @@ class ClientManager:
self.redis_client.expire(self.client_set_key, self.client_ttl)
# Clear any initialization timer
self.redis_client.delete(f"ts_proxy:channel:{self.channel_id}:init_time")
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": "client_connected",
"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",
@ -192,7 +196,7 @@ class ClientManager:
logger.debug(f"No user agent provided for client {client_id}")
self.redis_client.publish(
f"ts_proxy:events:{self.channel_id}",
RedisKeys.events_channel(self.channel_id), # Use RedisKeys instead of string
json.dumps(event_data)
)
@ -233,21 +237,21 @@ class ClientManager:
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 = f"ts_proxy:channel:{self.channel_id}:last_client_disconnect_time"
disconnect_key = RedisKeys.last_client_disconnect(self.channel_id)
self.redis_client.setex(disconnect_key, 60, str(time.time()))
self._notify_owner_of_activity()
# Publish client disconnected event
event_data = json.dumps({
"event": "client_disconnected",
"event": EventType.CLIENT_DISCONNECTED, # Use constant instead of string
"channel_id": self.channel_id,
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time(),
"remaining_clients": remaining
})
self.redis_client.publish(f"ts_proxy:events:{self.channel_id}", event_data)
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
total_clients = self.get_total_client_count()
logger.info(f"Client disconnected: {client_id} (local: {len(self.clients)}, total: {total_clients})")

View file

@ -0,0 +1,75 @@
"""
Helper module to access configuration values with proper defaults.
"""
import logging
from apps.proxy.config import TSConfig as Config
logger = logging.getLogger("ts_proxy")
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 ConfigHelper.get('CHANNEL_SHUTDOWN_DELAY', 5)
@staticmethod
def initial_behind_chunks():
"""Get number of chunks to start behind"""
return ConfigHelper.get('INITIAL_BEHIND_CHUNKS', 10)
@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 ConfigHelper.get('REDIS_CHUNK_TTL', 60)
@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)

View file

@ -0,0 +1,42 @@
"""
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"
# 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"
TS = "ts"
UNKNOWN = "unknown"
# TS packet constants
TS_PACKET_SIZE = 188
TS_SYNC_BYTE = 0x47
NULL_PID_HIGH = 0x1F
NULL_PID_LOW = 0xFF

View file

@ -0,0 +1,80 @@
"""
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"

View file

@ -21,6 +21,9 @@ from apps.channels.models import Channel
from .stream_manager import StreamManager
from .stream_buffer import StreamBuffer
from .client_manager import ClientManager
from .redis_keys import RedisKeys
from .constants import ChannelState, EventType, StreamType
from .config_helper import ConfigHelper
logger = logging.getLogger("ts_proxy")
@ -87,25 +90,24 @@ class ProxyServer:
if channel_id and event_type:
# For owner, update client status immediately
if self.am_i_owner(channel_id):
if event_type == "client_connected":
logger.debug(f"Owner received client_connected event for channel {channel_id}")
if event_type == EventType.CLIENT_CONNECTED:
logger.debug(f"Owner received {EventType.CLIENT_CONNECTED} event for channel {channel_id}")
# Reset any disconnect timer
# RENAMED: no_clients_since → last_client_disconnect_time
disconnect_key = f"ts_proxy:channel:{channel_id}:last_client_disconnect_time"
disconnect_key = RedisKeys.last_client_disconnect(channel_id)
self.redis_client.delete(disconnect_key)
elif event_type == "client_disconnected":
logger.debug(f"Owner received client_disconnected event for channel {channel_id}")
elif event_type == EventType.CLIENT_DISCONNECTED:
logger.debug(f"Owner received {EventType.CLIENT_DISCONNECTED} event for channel {channel_id}")
# Check if any clients remain
if channel_id in self.client_managers:
# VERIFY REDIS CLIENT COUNT DIRECTLY
client_set_key = f"ts_proxy:channel:{channel_id}:clients"
client_set_key = RedisKeys.clients(channel_id)
total = self.redis_client.scard(client_set_key) or 0
if total == 0:
logger.debug(f"No clients left after disconnect event - stopping channel {channel_id}")
# Set the disconnect timer for other workers to see
disconnect_key = f"ts_proxy:channel:{channel_id}:last_client_disconnect_time"
disconnect_key = RedisKeys.last_client_disconnect(channel_id)
self.redis_client.setex(disconnect_key, 60, str(time.time()))
# Get configured shutdown delay or default
@ -126,8 +128,8 @@ class ProxyServer:
self.stop_channel(channel_id)
elif event_type == "stream_switch":
logger.info(f"Owner received stream switch request for channel {channel_id}")
elif event_type == EventType.STREAM_SWITCH:
logger.info(f"Owner received {EventType.STREAM_SWITCH} request for channel {channel_id}")
# Handle stream switch request
new_url = data.get("url")
user_agent = data.get("user_agent")
@ -135,13 +137,13 @@ class ProxyServer:
if new_url and channel_id in self.stream_managers:
# Update metadata in Redis
if self.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
self.redis_client.hset(metadata_key, "url", new_url)
if user_agent:
self.redis_client.hset(metadata_key, "user_agent", user_agent)
# Set switch status
status_key = f"ts_proxy:channel:{channel_id}:switch_status"
status_key = RedisKeys.switch_status(channel_id)
self.redis_client.set(status_key, "switching")
# Perform the stream switch
@ -153,7 +155,7 @@ class ProxyServer:
# Publish confirmation
switch_result = {
"event": "stream_switched",
"event": EventType.STREAM_SWITCHED, # Use constant instead of string
"channel_id": channel_id,
"success": True,
"url": new_url,
@ -172,7 +174,7 @@ class ProxyServer:
# Publish failure
switch_result = {
"event": "stream_switched",
"event": EventType.STREAM_SWITCHED,
"channel_id": channel_id,
"success": False,
"url": new_url,
@ -182,15 +184,15 @@ class ProxyServer:
f"ts_proxy:events:{channel_id}",
json.dumps(switch_result)
)
elif event_type == "channel_stop":
logger.info(f"Received channel stop event for channel {channel_id}")
elif event_type == EventType.CHANNEL_STOP:
logger.info(f"Received {EventType.CHANNEL_STOP} event for channel {channel_id}")
# First mark channel as stopping in Redis
if self.redis_client:
# Set stopping state in metadata
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
if self.redis_client.exists(metadata_key):
self.redis_client.hset(metadata_key, mapping={
"state": "stopping",
"state": ChannelState.STOPPING,
"state_changed_at": str(time.time())
})
@ -202,7 +204,7 @@ class ProxyServer:
# Acknowledge stop by publishing a response
stop_response = {
"event": "channel_stopped",
"event": EventType.CHANNEL_STOPPED,
"channel_id": channel_id,
"worker_id": self.worker_id,
"timestamp": time.time()
@ -211,7 +213,7 @@ class ProxyServer:
f"ts_proxy:events:{channel_id}",
json.dumps(stop_response)
)
elif event_type == "client_stop":
elif event_type == EventType.CLIENT_STOP:
client_id = data.get("client_id")
if client_id and channel_id:
logger.info(f"Received request to stop client {client_id} on channel {channel_id}")
@ -225,7 +227,7 @@ class ProxyServer:
# Set a Redis key for the generator to detect
if self.redis_client:
stop_key = f"ts_proxy:channel:{channel_id}:client:{client_id}:stop"
stop_key = RedisKeys.client_stop(channel_id, client_id)
self.redis_client.setex(stop_key, 30, "true") # 30 second TTL
logger.info(f"Set stop key for client {client_id}")
except Exception as e:
@ -246,7 +248,7 @@ class ProxyServer:
return None
try:
lock_key = f"ts_proxy:channel:{channel_id}:owner"
lock_key = RedisKeys.channel_owner(channel_id)
owner = self.redis_client.get(lock_key)
if owner:
return owner.decode('utf-8')
@ -267,7 +269,7 @@ class ProxyServer:
try:
# Create a lock key with proper namespace
lock_key = f"ts_proxy:channel:{channel_id}:owner"
lock_key = RedisKeys.channel_owner(channel_id)
# Use Redis SETNX for atomic locking - only succeeds if the key doesn't exist
acquired = self.redis_client.setnx(lock_key, self.worker_id)
@ -299,7 +301,7 @@ class ProxyServer:
return
try:
lock_key = f"ts_proxy:channel:{channel_id}:owner"
lock_key = RedisKeys.channel_owner(channel_id)
# Only delete if we're the current owner to prevent race conditions
current = self.redis_client.get(lock_key)
@ -315,7 +317,7 @@ class ProxyServer:
return False
try:
lock_key = f"ts_proxy:channel:{channel_id}:owner"
lock_key = RedisKeys.channel_owner(channel_id)
current = self.redis_client.get(lock_key)
# Only extend if we're still the owner
@ -327,7 +329,7 @@ class ProxyServer:
logger.error(f"Error extending ownership: {e}")
return False
def initialize_channel(self, url, channel_id, user_agent=None, transcode=False):
def initialize_channel(self, url, channel_id, user_agent=None, transcode=False, stream_id=None):
"""Initialize a channel without redundant active key"""
try:
# Create buffer and client manager instances
@ -345,10 +347,11 @@ class ProxyServer:
# Get channel URL from Redis if available
channel_url = url
channel_user_agent = user_agent
channel_stream_id = stream_id # Store the stream ID
# First check if channel metadata already exists
existing_metadata = None
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
if self.redis_client:
existing_metadata = self.redis_client.hgetall(metadata_key)
@ -363,6 +366,14 @@ class ProxyServer:
if ua_bytes:
channel_user_agent = ua_bytes.decode('utf-8')
# Get stream ID from metadata if not provided
if not channel_stream_id and b'stream_id' in existing_metadata:
try:
channel_stream_id = int(existing_metadata[b'stream_id'].decode('utf-8'))
logger.debug(f"Found stream_id {channel_stream_id} in metadata for channel {channel_id}")
except (ValueError, TypeError) as e:
logger.debug(f"Could not parse stream_id from metadata: {e}")
# Check if channel is already owned
current_owner = self.get_channel_owner(channel_id)
@ -412,14 +423,28 @@ class ProxyServer:
"init_time": str(time.time()),
"last_active": str(time.time()),
"owner": self.worker_id,
"state": "initializing" # Only the owner sets this initial state
"state": ChannelState.INITIALIZING # Use constant instead of string literal
}
if channel_user_agent:
metadata["user_agent"] = channel_user_agent
# Set channel metadata
# CRITICAL FIX: Make sure stream_id is always set in metadata and properly logged
if channel_stream_id:
metadata["stream_id"] = str(channel_stream_id)
logger.info(f"Storing stream_id {channel_stream_id} in metadata for channel {channel_id}")
else:
logger.warning(f"No stream_id provided for channel {channel_id} during initialization")
# Set channel metadata BEFORE creating the StreamManager
self.redis_client.hset(metadata_key, mapping=metadata)
self.redis_client.expire(metadata_key, 30) # 1 hour TTL
self.redis_client.expire(metadata_key, 3600) # Increased TTL from 30 seconds to 1 hour
# Verify the stream_id was set correctly in Redis
stream_id_value = self.redis_client.hget(metadata_key, "stream_id")
if stream_id_value:
logger.info(f"Verified stream_id {stream_id_value.decode('utf-8')} is set in Redis for channel {channel_id}")
else:
logger.warning(f"Failed to set stream_id in Redis for channel {channel_id}")
# Create stream buffer
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
@ -427,8 +452,15 @@ class ProxyServer:
self.stream_buffers[channel_id] = buffer
# Only the owner worker creates the actual stream manager
stream_manager = StreamManager(channel_id, channel_url, buffer, user_agent=channel_user_agent, transcode=transcode)
logger.debug(f"Created StreamManager for channel {channel_id}")
stream_manager = StreamManager(
channel_id,
channel_url,
buffer,
user_agent=channel_user_agent,
transcode=transcode,
stream_id=channel_stream_id # Pass stream ID to the manager
)
logger.info(f"Created StreamManager for channel {channel_id} with stream ID {channel_stream_id}")
self.stream_managers[channel_id] = stream_manager
# Create client manager with channel_id, redis_client AND worker_id
@ -447,16 +479,16 @@ class ProxyServer:
# If we're the owner, we need to set the channel state rather than starting a grace period immediately
if self.am_i_owner(channel_id):
self.update_channel_state(channel_id, "connecting", {
self.update_channel_state(channel_id, ChannelState.CONNECTING, {
"init_time": str(time.time()),
"owner": self.worker_id
})
# Set connection attempt start time
attempt_key = f"ts_proxy:channel:{channel_id}:connection_attempt_time"
attempt_key = RedisKeys.connection_attempt(channel_id)
self.redis_client.setex(attempt_key, 60, str(time.time()))
logger.info(f"Channel {channel_id} in connecting state - will start grace period after connection")
logger.info(f"Channel {channel_id} in {ChannelState.CONNECTING} state - will start grace period after connection")
return True
except Exception as e:
@ -466,7 +498,10 @@ class ProxyServer:
return False
def check_if_channel_exists(self, channel_id):
"""Check if a channel exists using standardized key structure"""
"""
Check if a channel exists and is in a valid state.
Enhanced to detect zombie channels after server restarts.
"""
# Check local memory first
if channel_id in self.stream_managers or channel_id in self.stream_buffers:
return True
@ -474,35 +509,105 @@ class ProxyServer:
# Check Redis using the standard key pattern
if self.redis_client:
# Primary check - look for channel metadata
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
# If metadata exists, return true
# If metadata exists, validate it's in a healthy state
if self.redis_client.exists(metadata_key):
return True
metadata = self.redis_client.hgetall(metadata_key)
# Get channel state and owner
state = metadata.get(b'state', b'unknown').decode('utf-8')
owner = metadata.get(b'owner', b'').decode('utf-8')
# States that indicate the channel is running properly
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING]
# If the channel is in a valid state, check if the owner is still active
if state in valid_states:
# Check if owner still exists by checking heartbeat
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
owner_alive = self.redis_client.exists(owner_heartbeat_key)
if owner_alive:
return True
else:
# This is a zombie channel - owner is gone but metadata still exists
logger.warning(f"Detected zombie channel {channel_id} - owner {owner} is no longer active")
self._clean_zombie_channel(channel_id, metadata)
return False
elif state in [ChannelState.STOPPING, ChannelState.STOPPED, ChannelState.ERROR]:
# These states indicate the channel should be reinitialized
logger.info(f"Channel {channel_id} exists but in terminal state: {state}")
return False
else:
# Unknown or initializing state, check how long it's been in this state
if b'state_changed_at' in metadata:
state_changed_at = float(metadata[b'state_changed_at'].decode('utf-8'))
state_age = time.time() - state_changed_at
# If in initializing state for too long, consider it stale
if state_age > 60: # 60 seconds threshold
logger.warning(f"Channel {channel_id} stuck in {state} state for {state_age:.1f}s - treating as zombie")
self._clean_zombie_channel(channel_id, metadata)
return False
# Otherwise assume it's still in progress
return True
# Additional checks if metadata doesn't exist
additional_keys = [
f"ts_proxy:channel:{channel_id}:clients",
f"ts_proxy:channel:{channel_id}:buffer:index",
f"ts_proxy:channel:{channel_id}:owner"
RedisKeys.clients(channel_id),
RedisKeys.buffer_index(channel_id),
RedisKeys.channel_owner(channel_id)
]
for key in additional_keys:
if self.redis_client.exists(key):
return True
# Found orphaned keys without metadata - clean them up
logger.warning(f"Found orphaned keys for channel {channel_id} without metadata - cleaning up")
self._clean_redis_keys(channel_id)
return False
return False
def _clean_zombie_channel(self, channel_id, metadata=None):
"""Clean up a zombie channel (channel with Redis keys but no active owner)"""
try:
logger.info(f"Cleaning up zombie channel {channel_id}")
# If we have metadata, log details for debugging
if metadata:
state = metadata.get(b'state', b'unknown').decode('utf-8')
owner = metadata.get(b'owner', b'unknown').decode('utf-8')
logger.info(f"Zombie channel details - state: {state}, owner: {owner}")
# Clean up Redis keys
self._clean_redis_keys(channel_id)
# Force release resources in the Channel model
try:
from apps.channels.models import Channel
channel = Channel.objects.get(uuid=channel_id)
channel.release_stream()
logger.info(f"Released stream allocation for zombie channel {channel_id}")
except Exception as e:
logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}")
return True
except Exception as e:
logger.error(f"Error cleaning zombie channel {channel_id}: {e}", exc_info=True)
return False
def stop_channel(self, channel_id):
"""Stop a channel with proper ownership handling"""
try:
logger.info(f"Stopping channel {channel_id}")
# First set a stopping key that clients will check
if self.redis_client:
stop_key = f"ts_proxy:channel:{channel_id}:stopping"
stop_key = RedisKeys.channel_stopping(channel_id)
self.redis_client.setex(stop_key, 10, "true")
# Only stop the actual stream manager if we're the owner
if self.am_i_owner(channel_id):
logger.info(f"This worker ({self.worker_id}) is the owner - closing provider connection")
@ -544,7 +649,7 @@ class ProxyServer:
if channel_id in self.stream_managers:
del self.stream_managers[channel_id]
logger.info(f"Removed stream manager for channel {channel_id}")
# Stop buffer and ensure all its timers are cancelled - SAFE CHECK HERE
if channel_id in self.stream_buffers:
buffer = self.stream_buffers[channel_id]
@ -555,7 +660,7 @@ class ProxyServer:
logger.debug(f"Buffer for channel {channel_id} properly stopped")
except Exception as e:
logger.error(f"Error stopping buffer: {e}")
# Save reference and check again before deleting
try:
if channel_id in self.stream_buffers: # Check again to prevent race conditions
@ -563,7 +668,7 @@ class ProxyServer:
logger.info(f"Removed stream buffer for channel {channel_id}")
except KeyError:
logger.debug(f"Buffer for channel {channel_id} already removed")
# Clean up client manager - SAFE CHECK HERE TOO
if channel_id in self.client_managers:
try:
@ -571,10 +676,10 @@ class ProxyServer:
logger.info(f"Removed client manager for channel {channel_id}")
except KeyError:
logger.debug(f"Client manager for channel {channel_id} already removed")
# Clean up Redis keys
self._clean_redis_keys(channel_id)
return True
except Exception as e:
logger.error(f"Error stopping channel {channel_id}: {e}", exc_info=True)
@ -594,8 +699,8 @@ class ProxyServer:
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]:
# Removed reference to non-existent fetch_threads collection
for collection in [self.stream_managers, self.stream_buffers, self.client_managers]:
collection.pop(channel_id, None)
def shutdown(self) -> None:
@ -608,6 +713,11 @@ class ProxyServer:
def cleanup_task():
while True:
try:
# Send worker heartbeat first
if self.redis_client:
worker_heartbeat_key = f"ts_proxy:worker:{self.worker_id}:heartbeat"
self.redis_client.setex(worker_heartbeat_key, 30, str(time.time()))
# Refresh channel registry
self.refresh_channel_registry()
@ -624,7 +734,7 @@ class ProxyServer:
# Get channel state from metadata hash
channel_state = "unknown"
if self.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = self.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
channel_state = metadata[b'state'].decode('utf-8')
@ -640,7 +750,7 @@ class ProxyServer:
logger.info(f"Channel {channel_id} has {total_clients} clients, state: {channel_state}")
# If in connecting or waiting_for_clients state, check grace period
if channel_state in ["connecting", "waiting_for_clients"]:
if channel_state in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]:
# Get connection ready time from metadata
connection_ready_time = None
if metadata and b'connection_ready_time' in metadata:
@ -650,13 +760,13 @@ class ProxyServer:
pass
# If still connecting, give it more time
if channel_state == "connecting":
if channel_state == ChannelState.CONNECTING:
logger.debug(f"Channel {channel_id} still connecting - not checking for clients yet")
continue
# If waiting for clients, check grace period
if connection_ready_time:
grace_period = getattr(Config, 'CHANNEL_INIT_GRACE_PERIOD', 20)
grace_period = ConfigHelper.get('CHANNEL_INIT_GRACE_PERIOD', 20)
time_since_ready = time.time() - connection_ready_time
# Add this debug log
@ -678,15 +788,15 @@ class ProxyServer:
old_state = "unknown"
if metadata and b'state' in metadata:
old_state = metadata[b'state'].decode('utf-8')
if self.update_channel_state(channel_id, "active", {
if self.update_channel_state(channel_id, ChannelState.ACTIVE, {
"grace_period_ended_at": str(time.time()),
"clients_at_activation": str(total_clients)
}):
logger.info(f"Channel {channel_id} activated with {total_clients} clients after grace period")
# If active and no clients, start normal shutdown procedure
elif channel_state not in ["connecting", "waiting_for_clients"] and total_clients == 0:
elif channel_state not in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS] and total_clients == 0:
# Check if there's a pending no-clients timeout
disconnect_key = f"ts_proxy:channel:{channel_id}:last_client_disconnect_time"
disconnect_key = RedisKeys.last_client_disconnect(channel_id)
disconnect_time = None
if self.redis_client:
@ -704,7 +814,7 @@ class ProxyServer:
if self.redis_client:
self.redis_client.setex(disconnect_key, 60, str(current_time))
logger.warning(f"No clients detected for channel {channel_id}, starting shutdown timer")
elif current_time - disconnect_time > getattr(Config, 'CHANNEL_SHUTDOWN_DELAY', 5):
elif current_time - disconnect_time > ConfigHelper.channel_shutdown_delay():
# We've had no clients for the shutdown delay period
logger.warning(f"No clients for {current_time - disconnect_time:.1f}s, stopping channel {channel_id}")
self.stop_channel(channel_id)
@ -712,7 +822,7 @@ class ProxyServer:
# Still in shutdown delay period
logger.debug(f"Channel {channel_id} shutdown timer: "
f"{current_time - disconnect_time:.1f}s of "
f"{getattr(Config, 'CHANNEL_SHUTDOWN_DELAY', 5)}s elapsed")
f"{ConfigHelper.channel_shutdown_delay()}s elapsed")
else:
# There are clients or we're still connecting - clear any disconnect timestamp
if self.redis_client:
@ -723,21 +833,21 @@ class ProxyServer:
# For channels we don't own, check if they've been stopped/cleaned up in Redis
if self.redis_client:
# Method 1: Check for stopping key
stop_key = f"ts_proxy:channel:{channel_id}:stopping"
stop_key = RedisKeys.channel_stopping(channel_id)
if self.redis_client.exists(stop_key):
logger.debug(f"Non-owner cleanup: Channel {channel_id} has stopping flag in Redis, cleaning up local resources")
self._cleanup_local_resources(channel_id)
continue
# Method 2: Check if owner still exists
owner_key = f"ts_proxy:channel:{channel_id}:owner"
owner_key = RedisKeys.channel_owner(channel_id)
if not self.redis_client.exists(owner_key):
logger.debug(f"Non-owner cleanup: Channel {channel_id} has no owner in Redis, cleaning up local resources")
self._cleanup_local_resources(channel_id)
continue
# Method 3: Check if metadata still exists
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
if not self.redis_client.exists(metadata_key):
logger.debug(f"Non-owner cleanup: Channel {channel_id} has no metadata in Redis, cleaning up local resources")
self._cleanup_local_resources(channel_id)
@ -752,12 +862,12 @@ class ProxyServer:
except Exception as e:
logger.error(f"Error in cleanup thread: {e}", exc_info=True)
time.sleep(getattr(Config, 'CLEANUP_CHECK_INTERVAL', 1))
time.sleep(ConfigHelper.cleanup_check_interval())
thread = threading.Thread(target=cleanup_task, daemon=True)
thread.name = "ts-proxy-cleanup"
thread.start()
logger.info(f"Started TS proxy cleanup thread (interval: {getattr(Config, 'CLEANUP_CHECK_INTERVAL', 3)}s)")
logger.info(f"Started TS proxy cleanup thread (interval: {ConfigHelper.cleanup_check_interval()}s)")
def _check_orphaned_channels(self):
"""Check for orphaned channels in Redis (owner worker crashed)"""
@ -782,7 +892,7 @@ class ProxyServer:
if not owner:
# Check if there are any clients
client_set_key = f"ts_proxy:channel:{channel_id}:clients"
client_set_key = RedisKeys.clients(channel_id)
client_count = self.redis_client.scard(client_set_key) or 0
if client_count > 0:
@ -811,7 +921,7 @@ class ProxyServer:
# Define key patterns to scan for
patterns = [
f"ts_proxy:channel:{channel_id}:*", # All channel keys
f"ts_proxy:events:{channel_id}" # Event channel
RedisKeys.events_channel(channel_id) # Event channel
]
total_deleted = 0
@ -843,7 +953,7 @@ class ProxyServer:
# Refresh registry entries for channels we own
for channel_id in list(self.stream_buffers.keys()):
# Use standard key pattern
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
# Update activity timestamp in metadata only
self.redis_client.hset(metadata_key, "last_active", str(time.time()))
@ -856,7 +966,7 @@ class ProxyServer:
return False
try:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
# Get current state for logging
current_state = None

View file

@ -0,0 +1,484 @@
"""
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
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel
from apps.proxy.config import TSConfig as Config
from .. import proxy_server
from ..redis_keys import RedisKeys
from ..constants import EventType, ChannelState
from ..url_utils import get_stream_info_for_switch
logger = logging.getLogger("ts_proxy")
class ChannelService:
"""Service class for channel operations"""
@staticmethod
def initialize_channel(channel_id, stream_url, user_agent, transcode=False, profile_value=None, stream_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
profile_value: Stream profile value to store in metadata
stream_id: ID of the stream being used
Returns:
bool: Success status
"""
# 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, "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 = {
"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, "stream_id")
if stream_id_value:
logger.info(f"Verified stream_id {stream_id_value.decode('utf-8')} 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 profile_value:
update_data["profile"] = profile_value
if stream_id:
update_data["stream_id"] = str(stream_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):
"""
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
Returns:
dict: Result information including success status and diagnostics
"""
# If no direct URL is provided but a target stream is, get URL from target stream
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']
# 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.decode('utf-8') 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'}
# Update metadata in Redis regardless of ownership
if proxy_server.redis_client:
try:
ChannelService._update_channel_metadata(channel_id, new_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
# 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)
logger.info(f"Stream URL changed from {old_url} to {new_url}, result: {success}")
result.update({
'direct_update': True,
'success': success,
'worker_id': proxy_server.worker_id
})
else:
# If we're not the owner, publish an event for the owner to pick up
logger.info(f"Not the owner, requesting URL change via Redis PubSub")
if proxy_server.redis_client:
ChannelService._publish_stream_switch_event(channel_id, new_url, user_agent)
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
"""
# 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 b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
channel_info = {"state": state}
except Exception as e:
logger.error(f"Error fetching channel state: {e}")
# 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)
channel.release_stream()
logger.info(f"Released channel {channel_id} stream allocation")
model_released = True
except Channel.DoesNotExist:
logger.warning(f"Could not find Channel model for UUID {channel_id}")
model_released = False
except Exception as e:
logger.error(f"Error releasing channel stream: {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}")
# 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
"""
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(b'state', b'unknown').decode('utf-8')
owner = metadata.get(b'owner', b'unknown').decode('utf-8')
# 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 = f"ts_proxy:worker:{owner}:heartbeat"
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.decode('utf-8'))
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)}"}
# Helper methods for Redis operations
@staticmethod
def _update_channel_metadata(channel_id, url, user_agent=None):
"""Update channel metadata in Redis"""
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).decode('utf-8')
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
# Use the appropriate method based on the key type
if key_type == 'hash':
proxy_server.redis_client.hset(metadata_key, "url", url)
if user_agent:
proxy_server.redis_client.hset(metadata_key, "user_agent", user_agent)
elif key_type == 'none': # Key doesn't exist yet
# Create new hash with all required fields
metadata = {"url": url}
if user_agent:
metadata["user_agent"] = user_agent
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)
metadata = {"url": url}
if user_agent:
metadata["user_agent"] = user_agent
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.info(f"Updated metadata for channel {channel_id} in Redis")
return True
@staticmethod
def _publish_stream_switch_event(channel_id, new_url, user_agent=None):
"""Publish a stream switch event to Redis pubsub"""
if not proxy_server.redis_client:
return False
switch_request = {
"event": EventType.STREAM_SWITCH, # Use constant instead of string
"channel_id": channel_id,
"url": new_url,
"user_agent": user_agent,
"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"""
if not proxy_server.redis_client:
return False
stop_request = {
"event": EventType.CHANNEL_STOP, # Use constant instead of string
"channel_id": 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"""
if not proxy_server.redis_client:
return False
stop_request = {
"event": EventType.CLIENT_STOP, # Use constant instead of string
"channel_id": 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

View file

@ -7,6 +7,9 @@ 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
logger = logging.getLogger("ts_proxy")
@ -18,13 +21,13 @@ class StreamBuffer:
self.redis_client = redis_client
self.lock = threading.Lock()
self.index = 0
self.TS_PACKET_SIZE = 188
self.TS_PACKET_SIZE = TS_PACKET_SIZE
# STANDARDIZED KEYS: Move buffer keys under channel namespace
self.buffer_index_key = f"ts_proxy:channel:{channel_id}:buffer:index"
self.buffer_prefix = f"ts_proxy:channel:{channel_id}:buffer:chunk:"
# 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 = getattr(Config, 'REDIS_CHUNK_TTL', 60)
self.chunk_ttl = ConfigHelper.redis_chunk_ttl()
# Initialize from Redis if available
if self.redis_client and channel_id:
@ -37,7 +40,7 @@ class StreamBuffer:
logger.error(f"Error initializing buffer from Redis: {e}")
self._write_buffer = bytearray()
self.target_chunk_size = getattr(Config, 'BUFFER_CHUNK_SIZE', 188 * 5644) # ~1MB default
self.target_chunk_size = ConfigHelper.get('BUFFER_CHUNK_SIZE', TS_PACKET_SIZE * 5644) # ~1MB default
# Track timers for proper cleanup
self.stopping = False
@ -57,7 +60,7 @@ class StreamBuffer:
combined_data = bytearray(self._partial_packet) + bytearray(chunk)
# Calculate complete packets
complete_packets_size = (len(combined_data) // 188) * 188
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
@ -82,7 +85,7 @@ class StreamBuffer:
# Write optimized chunk to Redis
if self.redis_client:
chunk_index = self.redis_client.incr(self.buffer_index_key)
chunk_key = f"{self.buffer_prefix}{chunk_index}"
chunk_key = RedisKeys.buffer_chunk(self.channel_id, chunk_index)
self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(chunk_data))
# Update local tracking
@ -146,7 +149,7 @@ class StreamBuffer:
# Directly fetch from Redis using pipeline for efficiency
pipe = self.redis_client.pipeline()
for idx in range(start_id, end_id):
chunk_key = f"{self.buffer_prefix}{idx}"
chunk_key = RedisKeys.buffer_chunk(self.channel_id, idx)
pipe.get(chunk_key)
results = pipe.execute()
@ -200,7 +203,7 @@ class StreamBuffer:
# Directly fetch from Redis using pipeline
pipe = self.redis_client.pipeline()
for idx in range(start_id, end_id):
chunk_key = f"{self.buffer_prefix}{idx}"
chunk_key = RedisKeys.buffer_chunk(self.channel_id, idx)
pipe.get(chunk_key)
results = pipe.execute()
@ -222,7 +225,7 @@ class StreamBuffer:
"""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):
@ -232,10 +235,10 @@ class StreamBuffer:
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()
@ -315,7 +318,7 @@ class StreamBuffer:
"""Schedule a timer and track it for proper cleanup"""
if self.stopping:
return None
timer = threading.Timer(delay, callback, args=args, kwargs=kwargs)
timer.daemon = True
timer.start()

View file

@ -0,0 +1,344 @@
"""
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
from apps.proxy.config import TSConfig as Config
from . import proxy_server
from .utils import create_ts_packet
from .redis_keys import RedisKeys
logger = logging.getLogger("ts_proxy")
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):
"""
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
"""
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
# 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
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
# 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 = getattr(Config, 'CLIENT_WAIT_TIMEOUT', 30)
keepalive_interval = 0.5
last_keepalive = 0
# 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 b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
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']:
error_message = metadata.get(b'error_message', b'Unknown error').decode('utf-8')
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:
# Still initializing - send keepalive if needed
if time.time() - last_keepalive >= keepalive_interval:
status_msg = f"Initializing: {state}"
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()
# Wait a bit before checking again
time.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."""
# 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 - use config for initial position
initial_behind = getattr(Config, 'INITIAL_BEHIND_CHUNKS', 10)
current_buffer_index = buffer.index
self.local_index = max(0, current_buffer_index - initial_behind)
# Store important objects as instance variables
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."""
bytes_sent = 0
chunks_sent = 0
stream_start_time = time.time()
local_index = self.local_index
# 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(local_index)
if chunks:
yield from self._process_chunks(chunks, next_index, bytes_sent, chunks_sent, stream_start_time)
local_index = next_index
self.local_index = local_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
if self._should_send_keepalive(local_index):
keepalive_packet = create_ts_packet('keepalive')
logger.debug(f"[{self.client_id}] Sending keepalive packet while waiting at buffer head")
yield keepalive_packet
bytes_sent += len(keepalive_packet)
self.last_yield_time = time.time()
self.consecutive_empty = 0 # Reset consecutive counter but keep total empty_reads
time.sleep(Config.KEEPALIVE_INTERVAL)
else:
# Standard wait with backoff
sleep_time = min(0.1 * self.consecutive_empty, 1.0)
time.sleep(sleep_time)
# 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 {local_index} (buffer at {self.buffer.index}, stream: {stream_status})")
# Check for ghost clients
if self._is_ghost_client(local_index):
logger.warning(f"[{self.client_id}] Possible ghost client: buffer has advanced {self.buffer.index - local_index} chunks ahead but client stuck at {local_index}")
break
# Check for timeouts
if self._is_timeout():
break
def _check_resources(self):
"""Check if required resources still exist."""
# Enhanced resource checks
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
# Check if this specific client has been stopped (Redis keys, etc.)
if proxy_server.redis_client:
# 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
# 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
# Also check if client has been removed from client_manager
if self.channel_id in proxy_server.client_managers:
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
return True
def _process_chunks(self, chunks, next_index, bytes_sent, chunks_sent, stream_start_time):
"""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}")
# Send the chunks to the client
for chunk in chunks:
try:
yield chunk
bytes_sent += len(chunk)
chunks_sent += 1
# Log every 100 chunks for visibility
if chunks_sent % 100 == 0:
elapsed = time.time() - stream_start_time
rate = bytes_sent / elapsed / 1024 if elapsed > 0 else 0
logger.info(f"[{self.client_id}] Stats: {chunks_sent} chunks, {bytes_sent/1024:.1f}KB, {rate:.1f}KB/s")
except Exception as e:
logger.error(f"[{self.client_id}] Error sending chunk to client: {e}")
raise # Re-raise to exit the generator
return bytes_sent, chunks_sent
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 we're at buffer head and no data is coming, send keepalive
stream_healthy = self.stream_manager.healthy if self.stream_manager else True
return at_buffer_head and not stream_healthy and self.consecutive_empty >= 5
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."""
# Disconnect after long inactivity
if time.time() - self.last_yield_time > Config.STREAM_TIMEOUT:
if self.stream_manager and not self.stream_manager.healthy:
logger.warning(f"[{self.client_id}] No data for {Config.STREAM_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 {Config.STREAM_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
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})")
# 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.
"""
# 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 = getattr(Config, 'CHANNEL_SHUTDOWN_DELAY', 5)
logger.info(f"Waiting {shutdown_delay}s before checking if channel should be stopped")
time.sleep(shutdown_delay)
# 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")
shutdown_thread = threading.Thread(target=delayed_shutdown)
shutdown_thread.daemon = True
shutdown_thread.start()
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False):
"""
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)
return generator.generate

View file

@ -3,6 +3,7 @@
import threading
import logging
import time
import socket
import requests
import subprocess
from typing import Optional, List
@ -13,13 +14,17 @@ from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings
from .stream_buffer import StreamBuffer
from .utils import detect_stream_type
from .redis_keys import RedisKeys
from .constants import ChannelState, EventType, StreamType, TS_PACKET_SIZE
from .config_helper import ConfigHelper
from .url_utils import get_alternate_streams, get_stream_info_for_switch
logger = logging.getLogger("ts_proxy")
class StreamManager:
"""Manages a connection to a TS stream without using raw sockets"""
def __init__(self, channel_id, url, buffer, user_agent=None, transcode=False):
def __init__(self, channel_id, url, buffer, user_agent=None, transcode=False, stream_id=None):
# Basic properties
self.channel_id = channel_id
self.url = url
@ -27,7 +32,7 @@ class StreamManager:
self.running = True
self.connected = False
self.retry_count = 0
self.max_retries = Config.MAX_RETRIES
self.max_retries = ConfigHelper.max_retries()
self.current_response = None
self.current_session = None
self.url_switching = False
@ -43,13 +48,45 @@ class StreamManager:
# Stream health monitoring
self.last_data_time = time.time()
self.healthy = True
self.health_check_interval = Config.HEALTH_CHECK_INTERVAL
self.chunk_size = getattr(Config, 'CHUNK_SIZE', 8192)
self.health_check_interval = ConfigHelper.get('HEALTH_CHECK_INTERVAL', 5)
self.chunk_size = ConfigHelper.chunk_size()
# Add to your __init__ method
self._buffer_check_timers = []
self.stopping = False
# Add tracking for tried streams and current stream
self.current_stream_id = stream_id
self.tried_stream_ids = set()
# IMPROVED LOGGING: Better handle and track stream ID
if stream_id:
self.tried_stream_ids.add(stream_id)
logger.info(f"Initialized stream manager for channel {buffer.channel_id} with stream ID {stream_id}")
else:
# Try to get stream ID from Redis metadata if available
if hasattr(buffer, 'redis_client') and buffer.redis_client:
try:
metadata_key = RedisKeys.channel_metadata(channel_id)
# Log all metadata for debugging purposes
metadata = buffer.redis_client.hgetall(metadata_key)
if metadata:
logger.debug(f"Redis metadata for channel {channel_id}: {metadata}")
# Try to get stream_id specifically
stream_id_bytes = buffer.redis_client.hget(metadata_key, "stream_id")
if stream_id_bytes:
self.current_stream_id = int(stream_id_bytes.decode('utf-8'))
self.tried_stream_ids.add(self.current_stream_id)
logger.info(f"Loaded stream ID {self.current_stream_id} from Redis for channel {buffer.channel_id}")
else:
logger.warning(f"No stream_id found in Redis for channel {channel_id}")
except Exception as e:
logger.warning(f"Error loading stream ID from Redis: {e}")
else:
logger.warning(f"Unable to get stream ID for channel {channel_id} - stream switching may not work correctly")
logger.info(f"Initialized stream manager for channel {buffer.channel_id}")
def _create_session(self):
@ -77,20 +114,16 @@ class StreamManager:
return session
def run(self):
"""Main execution loop using HTTP streaming with improved connection handling"""
"""Main execution loop using HTTP streaming with improved connection handling and stream switching"""
# Add a stop flag to the class properties
self.stop_requested = False
# Add tracking for stream switching attempts
stream_switch_attempts = 0
# Get max stream switches from config using the helper method
max_stream_switches = ConfigHelper.max_stream_switches() # Prevent infinite switching loops
try:
# Check stream type before connecting
stream_type = detect_stream_type(self.url)
if self.transcode == False and stream_type == 'hls':
logger.info(f"Detected HLS stream: {self.url}")
logger.info(f"HLS streams will be handled with FFmpeg for now - future version will support HLS natively")
# Enable transcoding for HLS streams
self.transcode = True
# We'll override the stream profile selection with ffmpeg in the transcoding section
self.force_ffmpeg = True
# Start health monitor thread
health_thread = threading.Thread(target=self._monitor_health, daemon=True)
@ -98,200 +131,265 @@ class StreamManager:
logger.info(f"Starting stream for URL: {self.url}")
while self.running:
if self.transcode:
if self.url_switching:
logger.debug("Skipping connection attempt during URL switch")
time.sleep(.1)
continue
# Generate transcode command
logger.debug(f"Building transcode command for channel {self.channel_id}")
channel = get_object_or_404(Channel, uuid=self.channel_id)
# Main stream switching loop - we'll try different streams if needed
while self.running and stream_switch_attempts <= max_stream_switches:
# Check stream type before connecting
stream_type = detect_stream_type(self.url)
if self.transcode == False and stream_type == StreamType.HLS:
logger.info(f"Detected HLS stream: {self.url}")
logger.info(f"HLS streams will be handled with FFmpeg for now - future version will support HLS natively")
# Enable transcoding for HLS streams
self.transcode = True
# We'll override the stream profile selection with ffmpeg in the transcoding section
self.force_ffmpeg = True
# Reset connection retry count for this specific URL
self.retry_count = 0
url_failed = False
if self.url_switching:
logger.debug("Skipping connection attempt during URL switch")
time.sleep(0.1)
continue
# Connection retry loop for current URL
while self.running and self.retry_count < self.max_retries and not url_failed:
# Use FFmpeg specifically for HLS streams
if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg:
from core.models import StreamProfile
try:
stream_profile = StreamProfile.objects.get(name='ffmpeg', locked=True)
logger.info("Using FFmpeg stream profile for HLS content")
except StreamProfile.DoesNotExist:
# Fall back to channel's profile if FFmpeg not found
stream_profile = channel.get_stream_profile()
logger.warning("FFmpeg profile not found, using channel default profile")
else:
stream_profile = channel.get_stream_profile()
logger.info(f"Connection attempt {self.retry_count + 1}/{self.max_retries} for URL: {self.url}")
self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent)
# Start command process for transcoding
logger.debug(f"Starting transcode process: {self.transcode_cmd}")
self.transcode_process = subprocess.Popen(
self.transcode_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, # Suppress FFmpeg logs
bufsize=188 * 64 # Buffer optimized for TS packets
)
self.socket = self.transcode_process.stdout # Read from FFmpeg output
self.connected = True
if self.socket is not None:
# Set channel state to waiting for clients
self._set_waiting_for_clients()
# Main fetch loop
while self.running and self.connected:
if self.fetch_chunk():
self.last_data_time = time.time()
else:
if not self.running:
break
time.sleep(0.1)
else:
# Handle connection based on whether we transcode or not
connection_result = False
try:
# Using direct HTTP streaming
if self.url_switching:
logger.debug("Skipping connection attempt during URL switch")
time.sleep(.1)
continue
logger.debug(f"Using TS Proxy to connect to stream: {self.url}")
# Create new session for each connection attempt
session = self._create_session()
self.current_session = session
# Stream the URL with proper timeout handling
response = session.get(
self.url,
stream=True,
timeout=(10, 60) # 10s connect timeout, 60s read timeout
)
self.current_response = response
if response.status_code == 200:
self.connected = True
self.healthy = True
logger.info(f"Successfully connected to stream source")
# Set channel state to waiting for clients
self._set_waiting_for_clients()
# Process the stream in chunks with improved error handling
try:
chunk_count = 0
for chunk in response.iter_content(chunk_size=self.chunk_size):
# Check if we've been asked to stop
if self.stop_requested:
logger.info(f"Stream loop for channel {self.channel_id} stopping due to request")
break
if chunk:
# Add chunk to buffer with TS packet alignment
success = self.buffer.add_chunk(chunk)
if success:
self.last_data_time = time.time()
chunk_count += 1
# Update last data timestamp in Redis
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
last_data_key = f"ts_proxy:channel:{self.buffer.channel_id}:last_data"
self.buffer.redis_client.set(last_data_key, str(time.time()), ex=60)
except (AttributeError, ConnectionError) as e:
if self.stop_requested:
logger.debug(f"Expected connection error during shutdown: {e}")
elif hasattr(self, 'url_switching') and self.url_switching:
# This is expected during URL switching, just log at debug level
logger.debug(f"Expected connection error during URL switch: {e}")
else:
# Unexpected error during normal operation
logger.error(f"Unexpected stream error: {e}")
except Exception as e:
# Handle the specific 'NoneType' object has no attribute 'read' error
if "'NoneType' object has no attribute 'read'" in str(e):
logger.warning(f"Connection closed by server (read {chunk_count} chunks before disconnect)")
else:
# Re-raise unexpected AttributeErrors
logger.error(f"Unexpected AttributeError: {e}")
raise
if self.transcode:
connection_result = self._establish_transcode_connection()
else:
logger.error(f"Failed to connect to stream: HTTP {response.status_code}")
time.sleep(2)
connection_result = self._establish_http_connection()
except requests.exceptions.ReadTimeout:
logger.warning("Read timeout - server stopped sending data")
if connection_result:
# Store connection start time to measure success duration
connection_start_time = time.time()
# Successfully connected - read stream data until disconnect/error
self._process_stream_data()
# If we get here, the connection was closed/failed
# Reset stream switch attempts if the connection lasted longer than threshold
# This indicates we had a stable connection for a while before failing
connection_duration = time.time() - connection_start_time
stable_connection_threshold = 30 # 30 seconds threshold
if connection_duration > stable_connection_threshold:
logger.info(f"Stream was stable for {connection_duration:.1f} seconds, resetting switch attempts counter")
stream_switch_attempts = 0
# Connection failed or ended - decide what to do next
if self.stop_requested or not self.running:
# Normal shutdown requested
return
# Connection failed, increment retry count
self.retry_count += 1
self.connected = False
time.sleep(1)
except requests.RequestException as e:
logger.error(f"HTTP request error: {e}")
# If we've reached max retries, mark this URL as failed
if self.retry_count >= self.max_retries:
url_failed = True
logger.warning(f"Maximum retry attempts ({self.max_retries}) reached for URL: {self.url}")
else:
# Wait with exponential backoff before retrying
timeout = min(.25 ** self.retry_count, 3) # Cap at 3 seconds
logger.info(f"Reconnecting in {timeout} seconds... (attempt {self.retry_count}/{self.max_retries})")
time.sleep(timeout)
except Exception as e:
logger.error(f"Connection error: {e}", exc_info=True)
self.retry_count += 1
self.connected = False
time.sleep(5)
finally:
# Clean up response and session
if self.current_response:
try:
self.current_response.close()
except Exception as e:
logger.debug(f"Error closing response: {e}")
self.current_response = None
if self.retry_count >= self.max_retries:
url_failed = True
else:
# Wait with exponential backoff before retrying
timeout = min(2 ** self.retry_count, 10)
logger.info(f"Reconnecting in {timeout} seconds after error... (attempt {self.retry_count}/{self.max_retries})")
time.sleep(timeout)
if self.current_session:
try:
self.current_session.close()
except Exception as e:
logger.debug(f"Error closing session: {e}")
self.current_session = None
# If URL failed and we're still running, try switching to another stream
if url_failed and self.running:
logger.info(f"URL {self.url} failed after {self.retry_count} attempts, trying next stream")
# Connection retry logic
if self.running and not self.connected:
self.retry_count += 1
if self.retry_count > self.max_retries:
logger.error(f"Maximum retry attempts ({self.max_retries}) exceeded")
# Try to switch to next stream
switch_result = self._try_next_stream()
if switch_result:
# Successfully switched to a new stream, continue with the new URL
stream_switch_attempts += 1
logger.info(f"Successfully switched to new URL: {self.url} (switch attempt {stream_switch_attempts}/{max_stream_switches})")
# Reset retry count for the new stream - important for the loop to work correctly
self.retry_count = 0
# Continue outer loop with new URL - DON'T add a break statement here
else:
# No more streams to try
logger.error(f"Failed to find alternative streams after {stream_switch_attempts} attempts")
break
timeout = min(2 ** self.retry_count, 30)
# When a connection fails and reconnect is needed:
self.reconnecting = True
# Cancel all existing buffer timers during reconnect
for timer in list(self._buffer_check_timers):
try:
if timer and timer.is_alive():
timer.cancel()
except Exception as e:
logger.error(f"Error canceling buffer timer: {e}")
self._buffer_check_timers = []
logger.info(f"Reconnecting in {timeout} seconds... (attempt {self.retry_count})")
time.sleep(timeout)
self.reconnecting = False # Reset flag after sleep
elif not self.running:
# Normal shutdown was requested
break
except Exception as e:
logger.error(f"Stream error: {e}", exc_info=True)
finally:
self.connected = False
if self.socket:
try:
self._close_socket()
except:
pass
if self.current_response:
try:
self.current_response.close()
except:
pass
if self.current_session:
try:
self.current_session.close()
except:
pass
self._close_all_connections()
logger.info(f"Stream manager stopped")
def _establish_transcode_connection(self):
"""Establish a connection using transcoding"""
try:
logger.debug(f"Building transcode command for channel {self.channel_id}")
channel = get_object_or_404(Channel, uuid=self.channel_id)
# Use FFmpeg specifically for HLS streams
if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg:
from core.models import StreamProfile
try:
stream_profile = StreamProfile.objects.get(name='ffmpeg', locked=True)
logger.info("Using FFmpeg stream profile for HLS content")
except StreamProfile.DoesNotExist:
# Fall back to channel's profile if FFmpeg not found
stream_profile = channel.get_stream_profile()
logger.warning("FFmpeg profile not found, using channel default profile")
else:
stream_profile = channel.get_stream_profile()
# Build and start transcode command
self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent)
logger.debug(f"Starting transcode process: {self.transcode_cmd}")
self.transcode_process = subprocess.Popen(
self.transcode_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, # Suppress FFmpeg logs
bufsize=188 * 64 # Buffer optimized for TS packets
)
self.socket = self.transcode_process.stdout # Read from FFmpeg output
self.connected = True
# Set channel state to waiting for clients
self._set_waiting_for_clients()
return True
except Exception as e:
logger.error(f"Error establishing transcode connection: {e}", exc_info=True)
self._close_socket()
return False
def _establish_http_connection(self):
"""Establish a direct HTTP connection to the stream"""
try:
logger.debug(f"Using TS Proxy to connect to stream: {self.url}")
# Create new session for each connection attempt
session = self._create_session()
self.current_session = session
# Stream the URL with proper timeout handling
response = session.get(
self.url,
stream=True,
timeout=(10, 60) # 10s connect timeout, 60s read timeout
)
self.current_response = response
if response.status_code == 200:
self.connected = True
self.healthy = True
logger.info(f"Successfully connected to stream source")
# Set channel state to waiting for clients
self._set_waiting_for_clients()
return True
else:
logger.error(f"Failed to connect to stream: HTTP {response.status_code}")
self._close_connection()
return False
except requests.exceptions.RequestException as e:
logger.error(f"HTTP request error: {e}")
self._close_connection()
return False
except Exception as e:
logger.error(f"Error establishing HTTP connection: {e}", exc_info=True)
self._close_connection()
return False
def _process_stream_data(self):
"""Process stream data until disconnect or error"""
try:
if self.transcode:
# Handle transcoded stream data
while self.running and self.connected:
if self.fetch_chunk():
self.last_data_time = time.time()
else:
if not self.running:
break
time.sleep(0.1)
else:
# Handle direct HTTP connection
chunk_count = 0
try:
for chunk in self.current_response.iter_content(chunk_size=self.chunk_size):
# Check if we've been asked to stop
if self.stop_requested or self.url_switching:
break
if chunk:
# Add chunk to buffer with TS packet alignment
success = self.buffer.add_chunk(chunk)
if success:
self.last_data_time = time.time()
chunk_count += 1
# Update last data timestamp in Redis
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
last_data_key = RedisKeys.last_data(self.buffer.channel_id)
self.buffer.redis_client.set(last_data_key, str(time.time()), ex=60)
except (AttributeError, ConnectionError) as e:
if self.stop_requested or self.url_switching:
logger.debug(f"Expected connection error during shutdown/URL switch: {e}")
else:
logger.error(f"Unexpected stream error: {e}")
raise
except Exception as e:
logger.error(f"Error processing stream data: {e}", exc_info=True)
# If we exit the loop, connection is closed or failed
self.connected = False
def _close_all_connections(self):
"""Close all connection resources"""
if self.socket:
try:
self._close_socket()
except Exception as e:
logger.debug(f"Error closing socket: {e}")
if self.current_response:
try:
self.current_response.close()
except Exception as e:
logger.debug(f"Error closing response: {e}")
if self.current_session:
try:
self.current_session.close()
except Exception as e:
logger.debug(f"Error closing session: {e}")
# Clear references
self.socket = None
self.current_response = None
self.current_session = None
self.transcode_process = None
def stop(self):
"""Stop the stream manager and cancel all timers"""
# Add at the beginning of your stop method
@ -355,6 +453,9 @@ class StreamManager:
# Reset retry counter to allow immediate reconnect
self.retry_count = 0
# Reset tried streams when manually switching URL
self.tried_stream_ids = set()
# Also reset buffer position to prevent stale data after URL change
if hasattr(self.buffer, 'reset_buffer_position'):
try:
@ -411,16 +512,19 @@ class StreamManager:
logger.debug(f"Error closing session: {e}")
self.current_session = None
# Keep backward compatibility - let's create an alias to the new method
def _close_socket(self):
"""Backward compatibility wrapper for _close_connection"""
if self.current_response:
return self._close_connection()
"""Close socket and transcode resources as needed"""
# First try to use _close_connection for HTTP resources
if self.current_response or self.current_session:
self._close_connection()
return
# Otherwise handle socket and transcode resources
if self.socket:
try:
self.socket.close()
except Exception as e:
logging.debug(f"Error closing socket: {e}")
logger.debug(f"Error closing socket: {e}")
pass
self.socket = None
@ -431,7 +535,7 @@ class StreamManager:
self.transcode_process.terminate()
self.transcode_process.wait()
except Exception as e:
logging.debug(f"Error terminating transcode process: {e}")
logger.debug(f"Error terminating transcode process: {e}")
pass
self.transcode_process = None
@ -466,7 +570,7 @@ class StreamManager:
# Update last data timestamp in Redis if successful
if success and hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
last_data_key = f"ts_proxy:channel:{self.buffer.channel_id}:last_data"
last_data_key = RedisKeys.last_data(self.buffer.channel_id)
self.buffer.redis_client.set(last_data_key, str(time.time()), ex=60)
return True
@ -491,7 +595,7 @@ class StreamManager:
if channel_id and redis_client:
current_time = str(time.time())
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
# Check current state first
current_state = None
@ -503,16 +607,16 @@ class StreamManager:
logger.error(f"Error checking current state: {e}")
# Only update if not already past connecting
if not current_state or current_state in ["initializing", "connecting"]:
if not current_state or current_state in [ChannelState.INITIALIZING, ChannelState.CONNECTING]:
# NEW CODE: Check if buffer has enough chunks
current_buffer_index = getattr(self.buffer, 'index', 0)
initial_chunks_needed = getattr(Config, 'INITIAL_BEHIND_CHUNKS', 10)
initial_chunks_needed = ConfigHelper.initial_behind_chunks()
if current_buffer_index < initial_chunks_needed:
# Not enough buffer yet - set to connecting state if not already
if current_state != "connecting":
if current_state != ChannelState.CONNECTING:
update_data = {
"state": "connecting",
"state": ChannelState.CONNECTING,
"state_changed_at": current_time
}
redis_client.hset(metadata_key, mapping=update_data)
@ -526,7 +630,7 @@ class StreamManager:
# We have enough buffer, proceed with state change
update_data = {
"state": "waiting_for_clients",
"state": ChannelState.WAITING_FOR_CLIENTS,
"connection_ready_time": current_time,
"state_changed_at": current_time,
"buffer_chunks": str(current_buffer_index)
@ -534,8 +638,8 @@ class StreamManager:
redis_client.hset(metadata_key, mapping=update_data)
# Get configured grace period or default
grace_period = getattr(Config, 'CHANNEL_INIT_GRACE_PERIOD', 20)
logger.info(f"STREAM MANAGER: Updated channel {channel_id} state: {current_state or 'None'}waiting_for_clients with {current_buffer_index} buffer chunks")
grace_period = ConfigHelper.get('CHANNEL_INIT_GRACE_PERIOD', 20)
logger.info(f"STREAM MANAGER: Updated channel {channel_id} state: {current_state or 'None'}{ChannelState.WAITING_FOR_CLIENTS} with {current_buffer_index} buffer chunks")
logger.info(f"Started initial connection grace period ({grace_period}s) for channel {channel_id}")
else:
logger.debug(f"Not changing state: channel {channel_id} already in {current_state} state")
@ -575,3 +679,89 @@ class StreamManager:
except Exception as e:
logger.error(f"Error in buffer check: {e}")
def _try_next_stream(self):
"""
Try to switch to the next available stream for this channel.
Returns:
bool: True if successfully switched to a new stream, False otherwise
"""
try:
logger.info(f"Trying to find alternative stream for channel {self.channel_id}, current stream ID: {self.current_stream_id}")
# Get alternate streams excluding the current one
alternate_streams = get_alternate_streams(self.channel_id, self.current_stream_id)
logger.info(f"Found {len(alternate_streams)} potential alternate streams for channel {self.channel_id}")
# Filter out streams we've already tried
untried_streams = [s for s in alternate_streams if s['stream_id'] not in self.tried_stream_ids]
if untried_streams:
ids_to_try = ', '.join([str(s['stream_id']) for s in untried_streams])
logger.info(f"Found {len(untried_streams)} untried streams for channel {self.channel_id}: [{ids_to_try}]")
else:
logger.warning(f"No untried streams available for channel {self.channel_id}, tried: {self.tried_stream_ids}")
if not untried_streams:
# Check if we have streams but they've all been tried
if alternate_streams and len(self.tried_stream_ids) > 0:
logger.warning(f"All {len(alternate_streams)} alternate streams have been tried for channel {self.channel_id}")
return False
# Get the next stream to try
next_stream = untried_streams[0]
stream_id = next_stream['stream_id']
# Add to tried streams
self.tried_stream_ids.add(stream_id)
# Get stream info including URL
logger.info(f"Trying next stream ID {stream_id} for channel {self.channel_id}")
stream_info = get_stream_info_for_switch(self.channel_id, stream_id)
if 'error' in stream_info or not stream_info.get('url'):
logger.error(f"Error getting info for stream {stream_id}: {stream_info.get('error', 'No URL')}")
return False
# Update URL and user agent
new_url = stream_info['url']
new_user_agent = stream_info['user_agent']
new_transcode = stream_info['transcode']
logger.info(f"Switching from URL {self.url} to {new_url} for channel {self.channel_id}")
# Update stream ID tracking
self.current_stream_id = stream_id
# Store the new user agent and transcode settings
self.user_agent = new_user_agent
self.transcode = new_transcode
# Update stream metadata in Redis
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
self.buffer.redis_client.hset(metadata_key, mapping={
"url": new_url,
"user_agent": new_user_agent,
"profile": stream_info['profile'],
"stream_id": str(stream_id),
"stream_switch_time": str(time.time()),
"stream_switch_reason": "max_retries_exceeded"
})
# Log the switch
logger.info(f"Stream metadata updated for channel {self.channel_id} to stream ID {stream_id}")
# IMPORTANT: Just update the URL, don't stop the channel or release resources
switch_result = self.update_url(new_url)
if not switch_result:
logger.error(f"Failed to update URL for stream ID {stream_id}")
return False
logger.info(f"Successfully switched to stream ID {stream_id} with URL {new_url}")
return True
except Exception as e:
logger.error(f"Error trying next stream for channel {self.channel_id}: {e}", exc_info=True)
return False

View file

@ -0,0 +1,246 @@
"""
Utilities for handling stream URLs and transformations.
"""
import logging
import re
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
logger = logging.getLogger("ts_proxy")
def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]:
"""
Generate the appropriate stream URL for a channel based on its profile settings.
Args:
channel_id: The UUID of the channel
Returns:
Tuple[str, str, bool]: (stream_url, user_agent, transcode_flag)
"""
# Get channel and related objects
channel = get_object_or_404(Channel, uuid=channel_id)
stream_id, profile_id = channel.get_stream()
if stream_id is None or profile_id is None:
logger.error(f"No stream assigned to channel {channel_id}")
return None, None, False
# Get the M3U account profile for URL pattern
stream = get_object_or_404(Stream, pk=stream_id)
profile = get_object_or_404(M3UAccountProfile, pk=profile_id)
# Get the appropriate user agent
m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id)
stream_user_agent = UserAgent.objects.get(id=m3u_account.user_agent.id).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, profile.search_pattern, 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
# Get profile name as string
profile_value = str(stream_profile)
return stream_url, stream_user_agent, transcode, profile_value
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}")
# Handle backreferences in the replacement pattern
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern)
logger.debug(f" replace: {replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
# Apply the transformation
stream_url = re.sub(search_pattern, safe_replace_pattern, input_url)
logger.debug(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:
channel = get_object_or_404(Channel, uuid=channel_id)
# 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
profiles = M3UAccountProfile.objects.filter(m3u_account=stream.m3u_account)
if not profiles.exists():
# Try to get default profile
default_profile = M3UAccountProfile.objects.filter(
m3u_account=stream.m3u_account,
is_default=True
).first()
if default_profile:
profile_id = default_profile.id
else:
logger.error(f"No profile found for stream {stream_id}")
return {'error': 'No profile found for stream'}
else:
# Use first available profile
profile_id = profiles.first().id
else:
stream_id, profile_id = channel.get_stream()
if stream_id is None or profile_id is None:
return {'error': '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=profile_id)
# Get the user agent from the M3U account
m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id)
user_agent = UserAgent.objects.get(id=m3u_account.user_agent.id).user_agent
if not user_agent:
user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()).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 = str(stream_profile)
return {
'url': stream_url,
'user_agent': user_agent,
'transcode': transcode,
'profile': profile_value,
'stream_id': stream_id,
'profile_id': 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:
# Get channel object
channel = get_object_or_404(Channel, uuid=channel_id)
logger.debug(f"Looking for alternate streams for channel {channel_id}, current stream ID: {current_stream_id}")
# Get all assigned streams for this channel
streams = channel.streams.all()
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
for stream in streams:
# Log each stream we're checking
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
# FIX: Looking at the error message, M3UAccountProfile doesn't have a 'stream' field
# We need to find which field relates M3UAccountProfile to Stream
try:
# Check if we can find profiles via m3u_account
profiles = M3UAccountProfile.objects.filter(m3u_account=stream.m3u_account)
if not profiles.exists():
logger.debug(f"No profiles found via m3u_account for stream {stream.id}")
# Fallback to the default profile of the account
default_profile = M3UAccountProfile.objects.filter(
m3u_account=stream.m3u_account,
is_default=True
).first()
if default_profile:
profiles = [default_profile]
else:
logger.warning(f"No default profile found for m3u_account {stream.m3u_account.id}")
continue
# Get first compatible profile
profile = profiles.first()
if profile:
logger.debug(f"Found compatible profile ID {profile.id} for stream ID {stream.id}")
alternate_streams.append({
'stream_id': stream.id,
'profile_id': profile.id,
'name': stream.name
})
else:
logger.debug(f"No compatible profile found 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 for channel {channel_id}: [{stream_ids}]")
else:
logger.warning(f"No alternate streams 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 []

View file

@ -34,4 +34,47 @@ def detect_stream_type(url):
return 'hls'
# Default to TS
return '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.encode('utf-8')
packet[4:4+min(len(msg_bytes), 180)] = msg_bytes[:180]
return bytes(packet)

View file

@ -5,30 +5,28 @@ import random
import re
from django.http import StreamingHttpResponse, JsonResponse, HttpResponseRedirect
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_GET
from django.shortcuts import get_object_or_404
from apps.proxy.config import TSConfig as Config
from . import proxy_server
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 core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from .constants import ChannelState, EventType, StreamType
from .config_helper import ConfigHelper
from .services.channel_service import ChannelService
from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch
# Configure logging properly
logger = logging.getLogger("ts_proxy")
def get_client_ip(request):
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
@api_view(['GET'])
def stream_ts(request, channel_id):
"""Stream TS data to client with immediate response and keep-alive packets during initialization"""
@ -49,60 +47,60 @@ def stream_ts(request, channel_id):
logger.debug(f"[{client_id}] Client connected with user agent: {client_user_agent}")
break
# Check if we need to reinitialize the channel
needs_initialization = True
channel_state = None
# 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)
if b'state' in metadata:
channel_state = metadata[b'state'].decode('utf-8')
# Only skip initialization if channel is in a healthy state
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS]
if channel_state in valid_states:
# Verify the owner is still active
if b'owner' in metadata:
owner = metadata[b'owner'].decode('utf-8')
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
if proxy_server.redis_client.exists(owner_heartbeat_key):
# Owner is active and channel is in good state
needs_initialization = False
logger.info(f"[{client_id}] Channel {channel_id} in state {channel_state} with active owner {owner}")
# Start initialization if needed
channel_initializing = False
if not proxy_server.check_if_channel_exists(channel_id):
if needs_initialization or not proxy_server.check_if_channel_exists(channel_id):
# Force cleanup of any previous instance
if channel_state in [ChannelState.ERROR, ChannelState.STOPPING, ChannelState.STOPPED]:
logger.warning(f"[{client_id}] Channel {channel_id} in state {channel_state}, forcing cleanup")
proxy_server.stop_channel(channel_id)
# Initialize the channel (but don't wait for completion)
logger.info(f"[{client_id}] Starting channel {channel_id} initialization")
# Get stream details from channel model
stream_id, profile_id = channel.get_stream()
if stream_id is None or profile_id is None:
# Use the utility function to get stream URL and settings
stream_url, stream_user_agent, transcode, profile_value = generate_stream_url(channel_id)
if stream_url is None:
return JsonResponse({'error': 'Channel not available'}, status=404)
# Load in necessary objects for the stream
logger.info(f"Fetching stream ID {stream_id}")
stream = get_object_or_404(Stream, pk=stream_id)
logger.info(f"Fetching profile ID {profile_id}")
profile = get_object_or_404(M3UAccountProfile, pk=profile_id)
# Load in the user-agent for the STREAM connection (not client)
m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id)
stream_user_agent = UserAgent.objects.get(id=m3u_account.user_agent.id).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}")
else:
logger.debug(f"User agent found for account: {stream_user_agent}")
# Generate stream URL based on the selected profile
input_url = stream.url
logger.debug("Executing the following pattern replacement:")
logger.debug(f" search: {profile.search_pattern}")
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern)
logger.debug(f" replace: {profile.replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
stream_url = re.sub(profile.search_pattern, safe_replace_pattern, input_url)
logger.debug(f"Generated stream url: {stream_url}")
# Get the stream ID from the channel
stream_id, profile_id = channel.get_stream()
logger.info(f"Channel {channel_id} using stream ID {stream_id}, profile ID {profile_id}")
# Generate transcode command if needed
stream_profile = channel.get_stream_profile()
if stream_profile.is_redirect():
return HttpResponseRedirect(stream_url)
# Need to check if profile is transcoded
logger.debug(f"Using profile {stream_profile} for stream {stream_id}")
if stream_profile.is_proxy() or stream_profile is None:
transcode = False
else:
transcode = True
# Initialize channel with the stream's user agent (not the client's)
success = proxy_server.initialize_channel(stream_url, channel_id, stream_user_agent, transcode)
if proxy_server.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
profile_value = str(stream_profile)
proxy_server.redis_client.hset(metadata_key, "profile", profile_value)
success = ChannelService.initialize_channel(
channel_id, stream_url, stream_user_agent, transcode, profile_value, stream_id
)
if not success:
return JsonResponse({'error': 'Failed to initialize channel'}, status=500)
@ -111,8 +109,9 @@ def stream_ts(request, 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 > Config.CONNECTION_TIMEOUT:
if time.time() - wait_start > timeout:
proxy_server.stop_channel(channel_id)
return JsonResponse({'error': 'Connection timeout'}, status=504)
if not manager.should_retry():
@ -134,7 +133,7 @@ def stream_ts(request, channel_id):
stream_user_agent = None # Initialize the variable
if proxy_server.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
metadata_key = RedisKeys.channel_metadata(channel_id)
url_bytes = proxy_server.redis_client.hget(metadata_key, "url")
ua_bytes = proxy_server.redis_client.hget(metadata_key, "user_agent")
profile_bytes = proxy_server.redis_client.hget(metadata_key, "profile")
@ -168,291 +167,18 @@ def stream_ts(request, channel_id):
client_manager.add_client(client_id, client_ip, client_user_agent)
logger.info(f"[{client_id}] Client registered with channel {channel_id}")
# Define a single generate function
def generate():
stream_start_time = time.time()
bytes_sent = 0
chunks_sent = 0
# Create a stream generator for this client
generate = create_stream_generator(
channel_id, client_id, client_ip, client_user_agent, channel_initializing
)
# Keep track of initialization state
initialization_start = time.time()
max_init_wait = getattr(Config, 'CLIENT_WAIT_TIMEOUT', 30)
channel_ready = not channel_initializing
keepalive_interval = 0.5
last_keepalive = 0
try:
logger.info(f"[{client_id}] Stream generator started, channel_ready={channel_ready}")
# Wait for initialization to complete if needed
if not channel_ready:
# 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 = f"ts_proxy:channel:{channel_id}:metadata"
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if state in ['waiting_for_clients', 'active']:
logger.info(f"[{client_id}] Channel {channel_id} now ready (state={state})")
channel_ready = True
break
elif state in ['error', 'stopped']:
error_message = metadata.get(b'error_message', b'Unknown error').decode('utf-8')
logger.error(f"[{client_id}] Channel {channel_id} in error state: {state}, message: {error_message}")
# Send error in a comment TS packet before giving up
error_packet = bytearray(188)
error_packet[0] = 0x47 # Sync byte
error_packet[1] = 0x1F # PID high bits
error_packet[2] = 0xFF # PID low bits
error_msg = f"Error: {error_message}".encode('utf-8')
error_packet[4:4+min(len(error_msg), 180)] = error_msg[:180]
yield bytes(error_packet)
return
else:
# Still initializing - send keepalive if needed
if time.time() - last_keepalive >= keepalive_interval:
keepalive_packet = bytearray(188)
keepalive_packet[0] = 0x47 # Sync byte
keepalive_packet[1] = 0x1F # PID high bits (null packet)
keepalive_packet[2] = 0xFF # PID low bits (null packet)
# Add status info in packet payload (will be ignored by players)
status_msg = f"Initializing: {state}".encode('utf-8')
keepalive_packet[4:4+min(len(status_msg), 180)] = status_msg[:180]
logger.debug(f"[{client_id}] Sending keepalive packet during initialization, state={state}")
yield bytes(keepalive_packet)
bytes_sent += len(keepalive_packet)
last_keepalive = time.time()
# Wait a bit before checking again (don't send too many keepalives)
time.sleep(0.1)
# Check if we timed out waiting
if not channel_ready:
logger.warning(f"[{client_id}] Timed out waiting for initialization")
error_packet = bytearray(188)
error_packet[0] = 0x47 # Sync byte
error_packet[1] = 0x1F # PID high bits
error_packet[2] = 0xFF # PID low bits
error_msg = f"Error: Initialization timeout".encode('utf-8')
error_packet[4:4+min(len(error_msg), 180)] = error_msg[:180]
yield bytes(error_packet)
return
# Channel is now ready - original streaming code goes here
logger.info(f"[{client_id}] Channel {channel_id} ready, starting normal streaming")
# Reset start time for real streaming
stream_start_time = time.time()
# Get buffer - stream manager may not exist in this worker
buffer = proxy_server.stream_buffers.get(channel_id)
stream_manager = proxy_server.stream_managers.get(channel_id)
if not buffer:
logger.error(f"[{client_id}] No buffer found for channel {channel_id}")
return
# Client state tracking - use config for initial position
initial_behind = getattr(Config, 'INITIAL_BEHIND_CHUNKS', 10)
current_buffer_index = buffer.index
local_index = max(0, current_buffer_index - initial_behind)
logger.debug(f"[{client_id}] Buffer at {current_buffer_index}, starting {initial_behind} chunks behind at index {local_index}")
initial_position = local_index
last_yield_time = time.time()
empty_reads = 0
bytes_sent = 0
chunks_sent = 0
stream_start_time = time.time()
consecutive_empty = 0 # Track consecutive empty reads
# Timing parameters from config
ts_packet_size = 188
target_bitrate = Config.TARGET_BITRATE
packets_per_second = target_bitrate / (8 * ts_packet_size)
logger.info(f"[{client_id}] Starting stream at index {local_index} (buffer at {buffer.index})")
# Check if we're the owner worker
is_owner_worker = proxy_server.am_i_owner(channel_id) if hasattr(proxy_server, 'am_i_owner') else True
# Main streaming loop
while True:
# Enhanced resource checks
if channel_id not in proxy_server.stream_buffers:
logger.info(f"[{client_id}] Channel buffer no longer exists, terminating stream")
break
if channel_id not in proxy_server.client_managers:
logger.info(f"[{client_id}] Client manager no longer exists, terminating stream")
break
# Check if this specific client has been stopped
if proxy_server.redis_client:
# Channel stop check
stop_key = f"ts_proxy:channel:{channel_id}:stopping"
if proxy_server.redis_client.exists(stop_key):
logger.info(f"[{client_id}] Detected channel stop signal, terminating stream")
break
# Client stop check - NEW
client_stop_key = f"ts_proxy:channel:{channel_id}:client:{client_id}:stop"
if proxy_server.redis_client.exists(client_stop_key):
logger.info(f"[{client_id}] Detected client stop signal, terminating stream")
break
# Also check if client has been removed from client_manager
if channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[channel_id]
if client_id not in client_manager.clients:
logger.info(f"[{client_id}] Client no longer in client manager, terminating stream")
break
# Get chunks at client's position using improved strategy
chunks, next_index = buffer.get_optimized_client_data(local_index)
if chunks:
empty_reads = 0
consecutive_empty = 0
# Process and send chunks
total_size = sum(len(c) for c in chunks)
logger.debug(f"[{client_id}] Retrieved {len(chunks)} chunks ({total_size} bytes) from index {local_index+1} to {next_index}")
# CRITICAL FIX: Actually send the chunks to the client
for chunk in chunks:
try:
# This is the crucial line that was likely missing
yield chunk
bytes_sent += len(chunk)
chunks_sent += 1
# Log every 100 chunks for visibility
if chunks_sent % 100 == 0:
elapsed = time.time() - stream_start_time
rate = bytes_sent / elapsed / 1024 if elapsed > 0 else 0
logger.info(f"[{client_id}] Stats: {chunks_sent} chunks, {bytes_sent/1024:.1f}KB, {rate:.1f}KB/s")
except Exception as e:
logger.error(f"[{client_id}] Error sending chunk to client: {e}")
raise # Re-raise to exit the generator
# Update index after successfully sending all chunks
local_index = next_index
last_yield_time = time.time()
else:
# No chunks available
empty_reads += 1
consecutive_empty += 1
# Check if we're caught up to buffer head
at_buffer_head = local_index >= buffer.index
# If we're at buffer head and no data is coming, send keepalive
# Only check stream manager health if it exists
stream_healthy = stream_manager.healthy if stream_manager else True
if at_buffer_head and not stream_healthy and consecutive_empty >= 5:
# Create a null TS packet as keepalive (188 bytes filled with padding)
# This prevents VLC from hitting EOF
keepalive_packet = bytearray(188)
keepalive_packet[0] = 0x47 # Sync byte
keepalive_packet[1] = 0x1F # PID high bits (null packet)
keepalive_packet[2] = 0xFF # PID low bits (null packet)
logger.debug(f"[{client_id}] Sending keepalive packet while waiting at buffer head")
yield bytes(keepalive_packet)
bytes_sent += len(keepalive_packet)
last_yield_time = time.time()
consecutive_empty = 0 # Reset consecutive counter but keep total empty_reads
time.sleep(Config.KEEPALIVE_INTERVAL)
else:
# Standard wait
sleep_time = min(0.1 * consecutive_empty, 1.0) # Progressive backoff up to 1s
time.sleep(sleep_time)
# Log empty reads periodically
if empty_reads % 50 == 0:
stream_status = "healthy" if (stream_manager and stream_manager.healthy) else "unknown"
logger.debug(f"[{client_id}] Waiting for chunks beyond {local_index} (buffer at {buffer.index}, stream: {stream_status})")
# CRITICAL FIX: Check for client disconnect during wait periods
# Django/WSGI might not immediately detect disconnections, but we can check periodically
if consecutive_empty > 10: # After some number of empty reads
if hasattr(request, 'META') and request.META.get('wsgi.input'):
try:
# Try to check if the connection is still alive
available = request.META['wsgi.input'].read(0)
if available is None: # Connection closed
logger.info(f"[{client_id}] Detected client disconnect during wait")
break
except Exception:
# Error reading from connection, likely closed
logger.info(f"[{client_id}] Connection error, client likely disconnected")
break
# Disconnect after long inactivity
# For non-owner workers, we're more lenient with timeout
if time.time() - last_yield_time > Config.STREAM_TIMEOUT:
if stream_manager and not stream_manager.healthy:
logger.warning(f"[{client_id}] No data for {Config.STREAM_TIMEOUT}s and stream unhealthy, disconnecting")
break
elif not is_owner_worker and consecutive_empty > 100:
# Non-owner worker without data for too long
logger.warning(f"[{client_id}] Non-owner worker with no data for {Config.STREAM_TIMEOUT}s, disconnecting")
break
# ADD THIS: Check if worker has more recent chunks but still stuck
# This can indicate the client is disconnected but we're not detecting it
if consecutive_empty > 100 and buffer.index > local_index + 50:
logger.warning(f"[{client_id}] Possible ghost client: buffer has advanced {buffer.index - local_index} chunks ahead but client stuck at {local_index}")
break
except Exception as e:
logger.error(f"[{client_id}] Stream error: {e}", exc_info=True)
finally:
# Client cleanup
elapsed = time.time() - stream_start_time
local_clients = 0
if channel_id in proxy_server.client_managers:
local_clients = proxy_server.client_managers[channel_id].remove_client(client_id)
total_clients = proxy_server.client_managers[channel_id].get_total_client_count()
logger.info(f"[{client_id}] Disconnected after {elapsed:.2f}s, {bytes_sent/1024:.1f}KB in {chunks_sent} chunks (local: {local_clients}, total: {total_clients})")
# 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(channel_id):
logger.info(f"No local clients left for channel {channel_id}, scheduling shutdown")
def delayed_shutdown():
# Use the config setting instead of hardcoded value
shutdown_delay = getattr(Config, 'CHANNEL_SHUTDOWN_DELAY', 5)
logger.info(f"Waiting {shutdown_delay}s before checking if channel should be stopped")
time.sleep(shutdown_delay)
# After delay, check global client count
if channel_id in proxy_server.client_managers:
total = proxy_server.client_managers[channel_id].get_total_client_count()
if total == 0:
logger.info(f"Shutting down channel {channel_id} as no clients connected")
proxy_server.stop_channel(channel_id)
else:
logger.info(f"Not shutting down channel {channel_id}, {total} clients still connected")
shutdown_thread = threading.Thread(target=delayed_shutdown)
shutdown_thread.daemon = True
shutdown_thread.start()
# IMPORTANT: Return the StreamingHttpResponse from the main function
# Return the StreamingHttpResponse from the main function
response = StreamingHttpResponse(
streaming_content=generate(),
content_type='video/mp2t'
)
response['Cache-Control'] = 'no-cache'
return response # This now properly returns from stream_ts
return response
except Exception as e:
logger.error(f"Error in stream_ts: {e}", exc_info=True)
@ -473,91 +199,17 @@ def change_stream(request, channel_id):
logger.info(f"Attempting to change stream URL for channel {channel_id} to {new_url}")
# Enhanced channel detection
in_local_managers = channel_id in proxy_server.stream_managers
in_local_buffers = channel_id in proxy_server.stream_buffers
# Use the service layer instead of direct implementation
result = ChannelService.change_stream_url(channel_id, new_url, user_agent)
# First check Redis directly before using our wrapper method
redis_keys = None
if proxy_server.redis_client:
try:
redis_keys = proxy_server.redis_client.keys(f"ts_proxy:*:{channel_id}*")
redis_keys = [k.decode('utf-8') for k in redis_keys] if redis_keys else []
except Exception as e:
logger.error(f"Error checking Redis keys: {e}")
if result.get('status') == 'error':
return JsonResponse({
'error': result.get('message', 'Unknown error'),
'diagnostics': result.get('diagnostics', {})
}, status=404)
# Now use our standard check
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:
# If channel doesn't exist but we found Redis keys, force initialize it
if redis_keys:
logger.warning(f"Channel {channel_id} not detected by check_if_channel_exists but Redis keys exist. Forcing initialization.")
proxy_server.initialize_channel(new_url, channel_id, user_agent)
else:
logger.error(f"Channel {channel_id} not found in any worker or Redis")
return JsonResponse({
'error': 'Channel not found',
'diagnostics': {
'in_local_managers': in_local_managers,
'in_local_buffers': in_local_buffers,
'redis_keys': redis_keys,
}
}, status=404)
# Update metadata in Redis regardless of ownership - this ensures URL is updated
# even if the owner worker is handling another request
if proxy_server.redis_client:
try:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
# First check if the key exists and what type it is
key_type = proxy_server.redis_client.type(metadata_key).decode('utf-8')
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
# Use the appropriate method based on the key type
if key_type == 'hash':
proxy_server.redis_client.hset(metadata_key, "url", new_url)
if user_agent:
proxy_server.redis_client.hset(metadata_key, "user_agent", user_agent)
elif key_type == 'none': # Key doesn't exist yet
# Create new hash with all required fields
metadata = {"url": new_url}
if user_agent:
metadata["user_agent"] = user_agent
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)
metadata = {"url": new_url}
if user_agent:
metadata["user_agent"] = user_agent
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
# Set switch request flag to ensure all workers see it
switch_key = f"ts_proxy:channel:{channel_id}:switch_request"
proxy_server.redis_client.setex(switch_key, 30, new_url) # 30 second TTL
logger.info(f"Updated metadata for channel {channel_id} in Redis")
except Exception as e:
logger.error(f"Error updating Redis metadata: {e}", exc_info=True)
# 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
result = manager.update_url(new_url)
logger.info(f"Stream URL changed from {old_url} to {new_url}, result: {result}")
# Format response based on whether it was a direct update or event-based
if result.get('direct_update'):
return JsonResponse({
'message': 'Stream URL updated',
'channel': channel_id,
@ -565,25 +217,7 @@ def change_stream(request, channel_id):
'owner': True,
'worker_id': proxy_server.worker_id
})
# If we're not the owner, publish an event for the owner to pick up
else:
logger.info(f"This worker is not the owner, requesting URL change via Redis PubSub")
# Publish switch request event
switch_request = {
"event": "stream_switch",
"channel_id": channel_id,
"url": new_url,
"user_agent": user_agent,
"requester": proxy_server.worker_id,
"timestamp": time.time()
}
proxy_server.redis_client.publish(
f"ts_proxy:events:{channel_id}",
json.dumps(switch_request)
)
return JsonResponse({
'message': 'Stream URL change requested',
'channel': channel_id,
@ -653,61 +287,16 @@ def stop_channel(request, channel_id):
try:
logger.info(f"Request to stop channel {channel_id} received")
# 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 JsonResponse({'error': 'Channel not found'}, status=404)
# Use the service layer instead of direct implementation
result = ChannelService.stop_channel(channel_id)
# Get channel state information for response
channel_info = None
if proxy_server.redis_client:
metadata_key = f"ts_proxy:channel:{channel_id}:metadata"
try:
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
channel_info = {"state": state}
except Exception as e:
logger.error(f"Error fetching channel state: {e}")
# Broadcast stop event to all workers via PubSub
if proxy_server.redis_client:
stop_request = {
"event": "channel_stop",
"channel_id": channel_id,
"requester_worker_id": proxy_server.worker_id,
"timestamp": time.time()
}
# Publish the stop event
proxy_server.redis_client.publish(
f"ts_proxy:events:{channel_id}",
json.dumps(stop_request)
)
logger.info(f"Published channel stop event for {channel_id}")
# Also stop locally to ensure this worker cleans up right away
result = proxy_server.stop_channel(channel_id)
else:
# No Redis, just stop locally
result = proxy_server.stop_channel(channel_id)
# Release the channel in the channel model if applicable
try:
channel = Channel.objects.get(uuid=channel_id)
channel.release_stream()
logger.info(f"Released channel {channel_id} stream allocation")
except Channel.DoesNotExist:
logger.warning(f"Could not find Channel model for UUID {channel_id}")
except Exception as e:
logger.error(f"Error releasing channel stream: {e}")
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': channel_info
'previous_state': result.get('previous_state')
})
except Exception as e:
@ -727,55 +316,17 @@ def stop_client(request, channel_id):
if not client_id:
return JsonResponse({'error': 'No client_id provided'}, status=400)
logger.info(f"Request to stop client {client_id} on channel {channel_id}")
# Use the service layer instead of direct implementation
result = ChannelService.stop_client(channel_id, client_id)
# Set a Redis key for the generator to detect regardless of whether client is local
if proxy_server.redis_client:
stop_key = f"ts_proxy:channel:{channel_id}:client:{client_id}:stop"
proxy_server.redis_client.setex(stop_key, 30, "true") # 30 second TTL
logger.info(f"Set stop key for client {client_id}")
# 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 JsonResponse({'error': 'Channel not found'}, status=404)
# Two-part approach:
# 1. Handle locally if client is on this worker
# 2. Use events to inform other workers if needed
local_client_stopped = False
if channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[channel_id]
# Use the existing remove_client method directly
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
if not local_client_stopped and proxy_server.redis_client:
stop_request = {
"event": "client_stop",
"channel_id": channel_id,
"client_id": client_id,
"requester_worker_id": proxy_server.worker_id,
"timestamp": time.time()
}
proxy_server.redis_client.publish(
f"ts_proxy:events:{channel_id}",
json.dumps(stop_request)
)
logger.info(f"Published stop request for client {client_id} on channel {channel_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': local_client_stopped
'locally_processed': result.get('locally_processed', False)
})
except json.JSONDecodeError:

View file

@ -142,6 +142,8 @@ class StreamProfile(models.Model):
DEFAULT_USER_AGENT_KEY= slugify("Default User-Agent")
DEFAULT_STREAM_PROFILE_KEY = slugify("Default Stream Profile")
PREFERRED_REGION_KEY = slugify("Preferred Region")
class CoreSettings(models.Model):
key = models.CharField(
@ -166,3 +168,12 @@ class CoreSettings(models.Model):
@classmethod
def get_default_stream_profile_id(cls):
return cls.objects.get(key=DEFAULT_STREAM_PROFILE_KEY).value
@classmethod
def get_preferred_region(cls):
"""Retrieve the preferred region setting (or return None if not found)."""
try:
return cls.objects.get(key=PREFERRED_REGION_KEY).value
except cls.DoesNotExist:
return None

View file

@ -71,6 +71,7 @@
},
{
"model": "core.coresettings",
"pk": 3,
"fields": {
"key": "preferred-region",
"name": "Preferred Region",

View file

@ -370,9 +370,6 @@ const ChannelsTable = ({}) => {
}
};
//
// The new "Match EPG" button logic
//
const matchEpg = async () => {
try {
// Hit our new endpoint that triggers the fuzzy matching Celery task
@ -387,6 +384,7 @@ const ChannelsTable = ({}) => {
}
};
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);

View file

@ -5,6 +5,7 @@ import * as Yup from 'yup';
import API from '../api';
import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
import {
Button,
@ -20,28 +21,270 @@ const SettingsPage = () => {
const { userAgents } = useUserAgentsStore();
const { profiles: streamProfiles } = useStreamProfilesStore();
// Add your region choices here:
const regionChoices = [
{ value: 'us', label: 'US' },
{ value: 'uk', label: 'UK' },
{ value: 'nl', label: 'NL' },
{ value: 'de', label: 'DE' },
// Add more if needed
];
const regionChoices = [
{ value: 'ad', label: 'AD' },
{ value: 'ae', label: 'AE' },
{ value: 'af', label: 'AF' },
{ value: 'ag', label: 'AG' },
{ value: 'ai', label: 'AI' },
{ value: 'al', label: 'AL' },
{ value: 'am', label: 'AM' },
{ value: 'ao', label: 'AO' },
{ value: 'aq', label: 'AQ' },
{ value: 'ar', label: 'AR' },
{ value: 'as', label: 'AS' },
{ value: 'at', label: 'AT' },
{ value: 'au', label: 'AU' },
{ value: 'aw', label: 'AW' },
{ value: 'ax', label: 'AX' },
{ value: 'az', label: 'AZ' },
{ value: 'ba', label: 'BA' },
{ value: 'bb', label: 'BB' },
{ value: 'bd', label: 'BD' },
{ value: 'be', label: 'BE' },
{ value: 'bf', label: 'BF' },
{ value: 'bg', label: 'BG' },
{ value: 'bh', label: 'BH' },
{ value: 'bi', label: 'BI' },
{ value: 'bj', label: 'BJ' },
{ value: 'bl', label: 'BL' },
{ value: 'bm', label: 'BM' },
{ value: 'bn', label: 'BN' },
{ value: 'bo', label: 'BO' },
{ value: 'bq', label: 'BQ' },
{ value: 'br', label: 'BR' },
{ value: 'bs', label: 'BS' },
{ value: 'bt', label: 'BT' },
{ value: 'bv', label: 'BV' },
{ value: 'bw', label: 'BW' },
{ value: 'by', label: 'BY' },
{ value: 'bz', label: 'BZ' },
{ value: 'ca', label: 'CA' },
{ value: 'cc', label: 'CC' },
{ value: 'cd', label: 'CD' },
{ value: 'cf', label: 'CF' },
{ value: 'cg', label: 'CG' },
{ value: 'ch', label: 'CH' },
{ value: 'ci', label: 'CI' },
{ value: 'ck', label: 'CK' },
{ value: 'cl', label: 'CL' },
{ value: 'cm', label: 'CM' },
{ value: 'cn', label: 'CN' },
{ value: 'co', label: 'CO' },
{ value: 'cr', label: 'CR' },
{ value: 'cu', label: 'CU' },
{ value: 'cv', label: 'CV' },
{ value: 'cw', label: 'CW' },
{ value: 'cx', label: 'CX' },
{ value: 'cy', label: 'CY' },
{ value: 'cz', label: 'CZ' },
{ value: 'de', label: 'DE' },
{ value: 'dj', label: 'DJ' },
{ value: 'dk', label: 'DK' },
{ value: 'dm', label: 'DM' },
{ value: 'do', label: 'DO' },
{ value: 'dz', label: 'DZ' },
{ value: 'ec', label: 'EC' },
{ value: 'ee', label: 'EE' },
{ value: 'eg', label: 'EG' },
{ value: 'eh', label: 'EH' },
{ value: 'er', label: 'ER' },
{ value: 'es', label: 'ES' },
{ value: 'et', label: 'ET' },
{ value: 'fi', label: 'FI' },
{ value: 'fj', label: 'FJ' },
{ value: 'fk', label: 'FK' },
{ value: 'fm', label: 'FM' },
{ value: 'fo', label: 'FO' },
{ value: 'fr', label: 'FR' },
{ value: 'ga', label: 'GA' },
{ value: 'gb', label: 'GB' },
{ value: 'gd', label: 'GD' },
{ value: 'ge', label: 'GE' },
{ value: 'gf', label: 'GF' },
{ value: 'gg', label: 'GG' },
{ value: 'gh', label: 'GH' },
{ value: 'gi', label: 'GI' },
{ value: 'gl', label: 'GL' },
{ value: 'gm', label: 'GM' },
{ value: 'gn', label: 'GN' },
{ value: 'gp', label: 'GP' },
{ value: 'gq', label: 'GQ' },
{ value: 'gr', label: 'GR' },
{ value: 'gs', label: 'GS' },
{ value: 'gt', label: 'GT' },
{ value: 'gu', label: 'GU' },
{ value: 'gw', label: 'GW' },
{ value: 'gy', label: 'GY' },
{ value: 'hk', label: 'HK' },
{ value: 'hm', label: 'HM' },
{ value: 'hn', label: 'HN' },
{ value: 'hr', label: 'HR' },
{ value: 'ht', label: 'HT' },
{ value: 'hu', label: 'HU' },
{ value: 'id', label: 'ID' },
{ value: 'ie', label: 'IE' },
{ value: 'il', label: 'IL' },
{ value: 'im', label: 'IM' },
{ value: 'in', label: 'IN' },
{ value: 'io', label: 'IO' },
{ value: 'iq', label: 'IQ' },
{ value: 'ir', label: 'IR' },
{ value: 'is', label: 'IS' },
{ value: 'it', label: 'IT' },
{ value: 'je', label: 'JE' },
{ value: 'jm', label: 'JM' },
{ value: 'jo', label: 'JO' },
{ value: 'jp', label: 'JP' },
{ value: 'ke', label: 'KE' },
{ value: 'kg', label: 'KG' },
{ value: 'kh', label: 'KH' },
{ value: 'ki', label: 'KI' },
{ value: 'km', label: 'KM' },
{ value: 'kn', label: 'KN' },
{ value: 'kp', label: 'KP' },
{ value: 'kr', label: 'KR' },
{ value: 'kw', label: 'KW' },
{ value: 'ky', label: 'KY' },
{ value: 'kz', label: 'KZ' },
{ value: 'la', label: 'LA' },
{ value: 'lb', label: 'LB' },
{ value: 'lc', label: 'LC' },
{ value: 'li', label: 'LI' },
{ value: 'lk', label: 'LK' },
{ value: 'lr', label: 'LR' },
{ value: 'ls', label: 'LS' },
{ value: 'lt', label: 'LT' },
{ value: 'lu', label: 'LU' },
{ value: 'lv', label: 'LV' },
{ value: 'ly', label: 'LY' },
{ value: 'ma', label: 'MA' },
{ value: 'mc', label: 'MC' },
{ value: 'md', label: 'MD' },
{ value: 'me', label: 'ME' },
{ value: 'mf', label: 'MF' },
{ value: 'mg', label: 'MG' },
{ value: 'mh', label: 'MH' },
{ value: 'ml', label: 'ML' },
{ value: 'mm', label: 'MM' },
{ value: 'mn', label: 'MN' },
{ value: 'mo', label: 'MO' },
{ value: 'mp', label: 'MP' },
{ value: 'mq', label: 'MQ' },
{ value: 'mr', label: 'MR' },
{ value: 'ms', label: 'MS' },
{ value: 'mt', label: 'MT' },
{ value: 'mu', label: 'MU' },
{ value: 'mv', label: 'MV' },
{ value: 'mw', label: 'MW' },
{ value: 'mx', label: 'MX' },
{ value: 'my', label: 'MY' },
{ value: 'mz', label: 'MZ' },
{ value: 'na', label: 'NA' },
{ value: 'nc', label: 'NC' },
{ value: 'ne', label: 'NE' },
{ value: 'nf', label: 'NF' },
{ value: 'ng', label: 'NG' },
{ value: 'ni', label: 'NI' },
{ value: 'nl', label: 'NL' },
{ value: 'no', label: 'NO' },
{ value: 'np', label: 'NP' },
{ value: 'nr', label: 'NR' },
{ value: 'nu', label: 'NU' },
{ value: 'nz', label: 'NZ' },
{ value: 'om', label: 'OM' },
{ value: 'pa', label: 'PA' },
{ value: 'pe', label: 'PE' },
{ value: 'pf', label: 'PF' },
{ value: 'pg', label: 'PG' },
{ value: 'ph', label: 'PH' },
{ value: 'pk', label: 'PK' },
{ value: 'pl', label: 'PL' },
{ value: 'pm', label: 'PM' },
{ value: 'pn', label: 'PN' },
{ value: 'pr', label: 'PR' },
{ value: 'ps', label: 'PS' },
{ value: 'pt', label: 'PT' },
{ value: 'pw', label: 'PW' },
{ value: 'py', label: 'PY' },
{ value: 'qa', label: 'QA' },
{ value: 're', label: 'RE' },
{ value: 'ro', label: 'RO' },
{ value: 'rs', label: 'RS' },
{ value: 'ru', label: 'RU' },
{ value: 'rw', label: 'RW' },
{ value: 'sa', label: 'SA' },
{ value: 'sb', label: 'SB' },
{ value: 'sc', label: 'SC' },
{ value: 'sd', label: 'SD' },
{ value: 'se', label: 'SE' },
{ value: 'sg', label: 'SG' },
{ value: 'sh', label: 'SH' },
{ value: 'si', label: 'SI' },
{ value: 'sj', label: 'SJ' },
{ value: 'sk', label: 'SK' },
{ value: 'sl', label: 'SL' },
{ value: 'sm', label: 'SM' },
{ value: 'sn', label: 'SN' },
{ value: 'so', label: 'SO' },
{ value: 'sr', label: 'SR' },
{ value: 'ss', label: 'SS' },
{ value: 'st', label: 'ST' },
{ value: 'sv', label: 'SV' },
{ value: 'sx', label: 'SX' },
{ value: 'sy', label: 'SY' },
{ value: 'sz', label: 'SZ' },
{ value: 'tc', label: 'TC' },
{ value: 'td', label: 'TD' },
{ value: 'tf', label: 'TF' },
{ value: 'tg', label: 'TG' },
{ value: 'th', label: 'TH' },
{ value: 'tj', label: 'TJ' },
{ value: 'tk', label: 'TK' },
{ value: 'tl', label: 'TL' },
{ value: 'tm', label: 'TM' },
{ value: 'tn', label: 'TN' },
{ value: 'to', label: 'TO' },
{ value: 'tr', label: 'TR' },
{ value: 'tt', label: 'TT' },
{ value: 'tv', label: 'TV' },
{ value: 'tw', label: 'TW' },
{ value: 'tz', label: 'TZ' },
{ value: 'ua', label: 'UA' },
{ value: 'ug', label: 'UG' },
{ value: 'um', label: 'UM' },
{ value: 'us', label: 'US' },
{ value: 'uy', label: 'UY' },
{ value: 'uz', label: 'UZ' },
{ value: 'va', label: 'VA' },
{ value: 'vc', label: 'VC' },
{ value: 've', label: 'VE' },
{ value: 'vg', label: 'VG' },
{ value: 'vi', label: 'VI' },
{ value: 'vn', label: 'VN' },
{ value: 'vu', label: 'VU' },
{ value: 'wf', label: 'WF' },
{ value: 'ws', label: 'WS' },
{ value: 'ye', label: 'YE' },
{ value: 'yt', label: 'YT' },
{ value: 'za', label: 'ZA' },
{ value: 'zm', label: 'ZM' },
{ value: 'zw', label: 'ZW' }
];
const formik = useFormik({
initialValues: {
'default-user-agent': `${settings['default-user-agent'].id}`,
'default-stream-profile': `${settings['default-stream-profile'].id}`,
// 'preferred-region': '',
'preferred-region': settings['preferred-region']?.value || 'us',
},
validationSchema: Yup.object({
'default-user-agent': Yup.string().required('User-Agent is required'),
'default-stream-profile': Yup.string().required(
'Stream Profile is required'
),
// The region is optional or required as you prefer
// 'preferred-region': Yup.string().required('Region is required'),
'preferred-region': Yup.string().required('Region is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log(values);
@ -121,18 +364,18 @@ const SettingsPage = () => {
label: option.name,
}))}
/>
{/* <Select
labelId="region-label"
id={settings['preferred-region'].id}
name={settings['preferred-region'].key}
label={settings['preferred-region'].name}
<NativeSelect
id={settings['preferred-region']?.id || 'preferred-region'}
name={settings['preferred-region']?.key || 'preferred-region'}
label={settings['preferred-region']?.name || 'Preferred Region'}
value={formik.values['preferred-region'] || ''}
onChange={formik.handleChange}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/> */}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button