diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index c1d16e1b..f9b4992b 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -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.") diff --git a/apps/epg/apps.py b/apps/epg/apps.py index 1a8fa4ff..a6e7a8ed 100644 --- a/apps/epg/apps.py +++ b/apps/epg/apps.py @@ -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 diff --git a/apps/epg/migrations/0002_epgsource_file_path.py b/apps/epg/migrations/0002_epgsource_file_path.py new file mode 100644 index 00000000..acc9c83e --- /dev/null +++ b/apps/epg/migrations/0002_epgsource_file_path.py @@ -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), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index 7a6a49b4..206791a6 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -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 diff --git a/apps/epg/signals.py b/apps/epg/signals.py new file mode 100644 index 00000000..c8f4a62c --- /dev/null +++ b/apps/epg/signals.py @@ -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() diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 4d62f556..dd6bb2a0 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -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 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)} 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 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 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') diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 7eda441c..409ecbfd 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -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""" diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 7eda441c..409ecbfd 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -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""" diff --git a/apps/proxy/config.py b/apps/proxy/config.py index ffa0b559..28d3b872 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -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 + diff --git a/apps/proxy/ts_proxy/channel_status.py b/apps/proxy/ts_proxy/channel_status.py index c9c896e0..cc8a1e5b 100644 --- a/apps/proxy/ts_proxy/channel_status.py +++ b/apps/proxy/ts_proxy/channel_status.py @@ -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 diff --git a/apps/proxy/ts_proxy/client_manager.py b/apps/proxy/ts_proxy/client_manager.py index 178b7a9d..ed5868a9 100644 --- a/apps/proxy/ts_proxy/client_manager.py +++ b/apps/proxy/ts_proxy/client_manager.py @@ -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})") diff --git a/apps/proxy/ts_proxy/config_helper.py b/apps/proxy/ts_proxy/config_helper.py new file mode 100644 index 00000000..5a16581a --- /dev/null +++ b/apps/proxy/ts_proxy/config_helper.py @@ -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) diff --git a/apps/proxy/ts_proxy/constants.py b/apps/proxy/ts_proxy/constants.py new file mode 100644 index 00000000..56caacbe --- /dev/null +++ b/apps/proxy/ts_proxy/constants.py @@ -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 diff --git a/apps/proxy/ts_proxy/redis_keys.py b/apps/proxy/ts_proxy/redis_keys.py new file mode 100644 index 00000000..ebbcbc24 --- /dev/null +++ b/apps/proxy/ts_proxy/redis_keys.py @@ -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" diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index ea3dfd95..aa2e7ffc 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -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 diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py new file mode 100644 index 00000000..e00e680d --- /dev/null +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -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 diff --git a/apps/proxy/ts_proxy/stream_buffer.py b/apps/proxy/ts_proxy/stream_buffer.py index 4f1bcb64..7bf4ba21 100644 --- a/apps/proxy/ts_proxy/stream_buffer.py +++ b/apps/proxy/ts_proxy/stream_buffer.py @@ -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() diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py new file mode 100644 index 00000000..dcaae961 --- /dev/null +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -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 diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index ae9c104e..93ea4960 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -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 + + diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py new file mode 100644 index 00000000..e478c1c0 --- /dev/null +++ b/apps/proxy/ts_proxy/url_utils.py @@ -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 [] diff --git a/apps/proxy/ts_proxy/utils.py b/apps/proxy/ts_proxy/utils.py index 46240aac..d8c96464 100644 --- a/apps/proxy/ts_proxy/utils.py +++ b/apps/proxy/ts_proxy/utils.py @@ -34,4 +34,47 @@ def detect_stream_type(url): return 'hls' # Default to TS - return 'ts' \ No newline at end of file + 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) \ No newline at end of file diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index f7424f38..75ef4fc5 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -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: diff --git a/core/models.py b/core/models.py index 081537e6..86ea2623 100644 --- a/core/models.py +++ b/core/models.py @@ -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 + diff --git a/fixtures.json b/fixtures.json index 85912634..2d42f84e 100644 --- a/fixtures.json +++ b/fixtures.json @@ -71,6 +71,7 @@ }, { "model": "core.coresettings", + "pk": 3, "fields": { "key": "preferred-region", "name": "Preferred Region", diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index e570463f..eef3bfb5 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -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); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 48a5968c..338e5ce9 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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, }))} /> - {/*