mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr
This commit is contained in:
commit
ce0fd15191
26 changed files with 2533 additions and 997 deletions
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
apps/epg/migrations/0002_epgsource_file_path.py
Normal file
18
apps/epg/migrations/0002_epgsource_file_path.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.6 on 2025-03-19 16:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='file_path',
|
||||
field=models.CharField(blank=True, max_length=1024, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -11,6 +11,7 @@ class EPGSource(models.Model):
|
|||
url = models.URLField(blank=True, null=True) # For XMLTV
|
||||
api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct
|
||||
is_active = models.BooleanField(default=True)
|
||||
file_path = models.CharField(max_length=1024, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
|||
10
apps/epg/signals.py
Normal file
10
apps/epg/signals.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from .models import EPGSource
|
||||
from .tasks import refresh_epg_data
|
||||
|
||||
@receiver(post_save, sender=EPGSource)
|
||||
def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs):
|
||||
# Trigger refresh only if the source is newly created and active
|
||||
if created and instance.is_active:
|
||||
refresh_epg_data.delay()
|
||||
|
|
@ -1,29 +1,40 @@
|
|||
# apps/epg/tasks.py
|
||||
|
||||
import logging
|
||||
import gzip # <-- New import for gzip support
|
||||
from celery import shared_task
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
from django.utils import timezone
|
||||
import gzip
|
||||
import os
|
||||
import uuid
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def refresh_epg_data():
|
||||
logger.info("Starting refresh_epg_data task.")
|
||||
active_sources = EPGSource.objects.filter(is_active=True)
|
||||
logger.debug(f"Found {active_sources.count()} active EPGSource(s).")
|
||||
|
||||
for source in active_sources:
|
||||
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
|
||||
if source.source_type == 'xmltv':
|
||||
fetch_xmltv(source)
|
||||
elif source.source_type == 'schedules_direct':
|
||||
fetch_schedules_direct(source)
|
||||
|
||||
logger.info("Finished refresh_epg_data task.")
|
||||
return "EPG data refreshed."
|
||||
|
||||
|
||||
def fetch_xmltv(source):
|
||||
logger.info(f"Fetching XMLTV data from source: {source.name}")
|
||||
try:
|
||||
|
|
@ -31,71 +42,123 @@ def fetch_xmltv(source):
|
|||
response.raise_for_status()
|
||||
logger.debug("XMLTV data fetched successfully.")
|
||||
|
||||
# If the URL ends with '.gz', decompress the response content
|
||||
if source.url.lower().endswith('.gz'):
|
||||
logger.debug("Detected .gz file. Decompressing...")
|
||||
decompressed_bytes = gzip.decompress(response.content)
|
||||
xml_data = decompressed_bytes.decode('utf-8')
|
||||
else:
|
||||
xml_data = response.text
|
||||
# Decide on file extension
|
||||
file_ext = ".gz" if source.url.lower().endswith('.gz') else ".xml"
|
||||
filename = f"{source.name}_{uuid.uuid4().hex[:8]}{file_ext}"
|
||||
|
||||
root = ET.fromstring(xml_data)
|
||||
logger.debug("Parsed XMLTV XML content.")
|
||||
# Build full path in MEDIA_ROOT/cached_epg
|
||||
epg_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg")
|
||||
os.makedirs(epg_dir, exist_ok=True)
|
||||
file_path = os.path.join(epg_dir, filename)
|
||||
|
||||
# Group programmes by their tvg_id from the XMLTV file
|
||||
programmes_by_channel = {}
|
||||
for programme in root.findall('programme'):
|
||||
start_time = parse_xmltv_time(programme.get('start'))
|
||||
stop_time = parse_xmltv_time(programme.get('stop'))
|
||||
tvg_id = programme.get('channel')
|
||||
title = programme.findtext('title', default='No Title')
|
||||
desc = programme.findtext('desc', default='')
|
||||
# Save raw data
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
logger.info(f"Cached EPG file saved to {file_path}")
|
||||
|
||||
programmes_by_channel.setdefault(tvg_id, []).append({
|
||||
'start_time': start_time,
|
||||
'end_time': stop_time,
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'tvg_id': tvg_id,
|
||||
})
|
||||
# Save the file_path on the EPGSource instance so it can be retrieved later.
|
||||
source.file_path = file_path
|
||||
source.save(update_fields=['file_path'])
|
||||
|
||||
# Process each group regardless of channel existence.
|
||||
for tvg_id, programmes in programmes_by_channel.items():
|
||||
# Create (or get) an EPGData record using the tvg_id.
|
||||
epg_data, created = EPGData.objects.get_or_create(
|
||||
tvg_id=tvg_id,
|
||||
defaults={'name': tvg_id} # Use tvg_id as a fallback name
|
||||
)
|
||||
if created:
|
||||
logger.info(f"Created new EPGData for tvg_id '{tvg_id}'.")
|
||||
else:
|
||||
logger.debug(f"Found existing EPGData for tvg_id '{tvg_id}'.")
|
||||
# If you store the path on EPGSource, do so here:
|
||||
# source.file_path = file_path
|
||||
# source.save(update_fields=['file_path'])
|
||||
|
||||
# Now parse <channel> blocks only
|
||||
parse_channels_only(file_path)
|
||||
|
||||
logger.info(f"Processing {len(programmes)} programme(s) for tvg_id '{tvg_id}'.")
|
||||
with transaction.atomic():
|
||||
for prog in programmes:
|
||||
obj, created = ProgramData.objects.update_or_create(
|
||||
epg=epg_data,
|
||||
start_time=prog['start_time'],
|
||||
title=prog['title'],
|
||||
defaults={
|
||||
'end_time': prog['end_time'],
|
||||
'description': prog['description'],
|
||||
'sub_title': '',
|
||||
'tvg_id': tvg_id,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
logger.info(f"Created ProgramData '{prog['title']}' for tvg_id '{tvg_id}'.")
|
||||
else:
|
||||
logger.info(f"Updated ProgramData '{prog['title']}' for tvg_id '{tvg_id}'.")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True)
|
||||
|
||||
|
||||
def parse_channels_only(file_path):
|
||||
logger.info(f"Parsing channels from EPG file: {file_path}")
|
||||
|
||||
# Read entire file (decompress if .gz)
|
||||
if file_path.endswith('.gz'):
|
||||
with open(file_path, 'rb') as gz_file:
|
||||
decompressed = gzip.decompress(gz_file.read())
|
||||
xml_data = decompressed.decode('utf-8')
|
||||
else:
|
||||
with open(file_path, 'r', encoding='utf-8') as xml_file:
|
||||
xml_data = xml_file.read()
|
||||
|
||||
root = ET.fromstring(xml_data)
|
||||
channels = root.findall('channel')
|
||||
|
||||
logger.info(f"Found {len(channels)} <channel> entries in {file_path}")
|
||||
with transaction.atomic():
|
||||
for channel_elem in channels:
|
||||
tvg_id = channel_elem.get('id', '').strip()
|
||||
if not tvg_id:
|
||||
continue # skip blank/invalid IDs
|
||||
|
||||
display_name = channel_elem.findtext('display-name', default=tvg_id).strip()
|
||||
|
||||
epg_obj, created = EPGData.objects.get_or_create(
|
||||
tvg_id=tvg_id,
|
||||
defaults={'name': display_name}
|
||||
)
|
||||
if not created:
|
||||
# Optionally update if new name is different
|
||||
if epg_obj.name != display_name:
|
||||
epg_obj.name = display_name
|
||||
epg_obj.save()
|
||||
logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}")
|
||||
|
||||
logger.info("Finished parsing channel info.")
|
||||
|
||||
|
||||
def parse_programs_for_tvg_id(file_path, tvg_id):
|
||||
logger.info(f"Parsing <programme> for tvg_id={tvg_id} from {file_path}")
|
||||
|
||||
# Read entire file (decompress if .gz)
|
||||
if file_path.endswith('.gz'):
|
||||
with open(file_path, 'rb') as gz_file:
|
||||
decompressed = gzip.decompress(gz_file.read())
|
||||
xml_data = decompressed.decode('utf-8')
|
||||
else:
|
||||
with open(file_path, 'r', encoding='utf-8') as xml_file:
|
||||
xml_data = xml_file.read()
|
||||
|
||||
root = ET.fromstring(xml_data)
|
||||
# Retrieve the EPGData record
|
||||
try:
|
||||
epg_obj = EPGData.objects.get(tvg_id=tvg_id)
|
||||
except EPGData.DoesNotExist:
|
||||
logger.warning(f"No EPGData record found for tvg_id={tvg_id}")
|
||||
return
|
||||
|
||||
# Find only <programme> elements for this tvg_id
|
||||
matched_programmes = [p for p in root.findall('programme') if p.get('channel') == tvg_id]
|
||||
logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={tvg_id}")
|
||||
|
||||
with transaction.atomic():
|
||||
for prog in matched_programmes:
|
||||
start_time = parse_xmltv_time(prog.get('start'))
|
||||
end_time = parse_xmltv_time(prog.get('stop'))
|
||||
title = prog.findtext('title', default='No Title')
|
||||
desc = prog.findtext('desc', default='')
|
||||
|
||||
obj, created = ProgramData.objects.update_or_create(
|
||||
epg=epg_obj,
|
||||
start_time=start_time,
|
||||
title=title,
|
||||
defaults={
|
||||
'end_time': end_time,
|
||||
'description': desc,
|
||||
'sub_title': '',
|
||||
'tvg_id': tvg_id,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
logger.debug(f"Created ProgramData: {title} [{start_time} - {end_time}]")
|
||||
logger.info(f"Completed program parsing for tvg_id={tvg_id}.")
|
||||
|
||||
|
||||
def fetch_schedules_direct(source):
|
||||
logger.info(f"Fetching Schedules Direct data from source: {source.name}")
|
||||
try:
|
||||
# NOTE: Provide the correct api_url for Schedules Direct.
|
||||
api_url = ''
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -117,7 +180,6 @@ def fetch_schedules_direct(source):
|
|||
schedules = sched_response.json()
|
||||
logger.debug(f"Fetched schedules: {schedules}")
|
||||
|
||||
# Create (or get) an EPGData record using the tvg_id.
|
||||
epg_data, created = EPGData.objects.get_or_create(
|
||||
tvg_id=tvg_id,
|
||||
defaults={'name': tvg_id}
|
||||
|
|
@ -149,6 +211,10 @@ def fetch_schedules_direct(source):
|
|||
except Exception as e:
|
||||
logger.error(f"Error fetching Schedules Direct data from {source.name}: {e}", exc_info=True)
|
||||
|
||||
|
||||
# -------------------------------
|
||||
# Helper parse functions
|
||||
# -------------------------------
|
||||
def parse_xmltv_time(time_str):
|
||||
try:
|
||||
dt_obj = datetime.strptime(time_str[:14], '%Y%m%d%H%M%S')
|
||||
|
|
@ -166,6 +232,7 @@ def parse_xmltv_time(time_str):
|
|||
logger.error(f"Error parsing XMLTV time '{time_str}': {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def parse_schedules_direct_time(time_str):
|
||||
try:
|
||||
dt_obj = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class DiscoverAPIView(APIView):
|
|||
responses={200: openapi.Response("HDHR Discovery JSON")}
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
|
||||
base_url = request.build_absolute_uri('/').rstrip('/')
|
||||
device = HDHRDevice.objects.first()
|
||||
|
||||
if not device:
|
||||
|
|
@ -81,7 +81,7 @@ class LineupAPIView(APIView):
|
|||
{
|
||||
"GuideNumber": str(ch.channel_number),
|
||||
"GuideName": ch.name,
|
||||
"URL": request.build_absolute_uri(f"/output/stream/{ch.id}")
|
||||
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
|
||||
}
|
||||
for ch in channels
|
||||
]
|
||||
|
|
@ -115,7 +115,7 @@ class HDHRDeviceXMLAPIView(APIView):
|
|||
responses={200: openapi.Response("HDHR Device XML")}
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
|
||||
base_url = request.build_absolute_uri('/').rstrip('/')
|
||||
|
||||
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class DiscoverAPIView(APIView):
|
|||
responses={200: openapi.Response("HDHR Discovery JSON")}
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
|
||||
base_url = request.build_absolute_uri('/').rstrip('/')
|
||||
device = HDHRDevice.objects.first()
|
||||
|
||||
if not device:
|
||||
|
|
@ -81,7 +81,7 @@ class LineupAPIView(APIView):
|
|||
{
|
||||
"GuideNumber": str(ch.channel_number),
|
||||
"GuideName": ch.name,
|
||||
"URL": request.build_absolute_uri(f"/output/stream/{ch.id}")
|
||||
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
|
||||
}
|
||||
for ch in channels
|
||||
]
|
||||
|
|
@ -115,7 +115,7 @@ class HDHRDeviceXMLAPIView(APIView):
|
|||
responses={200: openapi.Response("HDHR Device XML")}
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
|
||||
base_url = request.build_absolute_uri('/').rstrip('/')
|
||||
|
||||
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
|
|
|||
75
apps/proxy/ts_proxy/config_helper.py
Normal file
75
apps/proxy/ts_proxy/config_helper.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""
|
||||
Helper module to access configuration values with proper defaults.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
|
||||
logger = logging.getLogger("ts_proxy")
|
||||
|
||||
class ConfigHelper:
|
||||
"""
|
||||
Helper class for accessing configuration values with sensible defaults.
|
||||
This simplifies code and ensures consistent defaults across the application.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get(name, default=None):
|
||||
"""Get a configuration value with a default fallback"""
|
||||
return getattr(Config, name, default)
|
||||
|
||||
# Commonly used configuration values
|
||||
@staticmethod
|
||||
def connection_timeout():
|
||||
"""Get connection timeout in seconds"""
|
||||
return ConfigHelper.get('CONNECTION_TIMEOUT', 10)
|
||||
|
||||
@staticmethod
|
||||
def client_wait_timeout():
|
||||
"""Get client wait timeout in seconds"""
|
||||
return ConfigHelper.get('CLIENT_WAIT_TIMEOUT', 30)
|
||||
|
||||
@staticmethod
|
||||
def stream_timeout():
|
||||
"""Get stream timeout in seconds"""
|
||||
return ConfigHelper.get('STREAM_TIMEOUT', 60)
|
||||
|
||||
@staticmethod
|
||||
def channel_shutdown_delay():
|
||||
"""Get channel shutdown delay in seconds"""
|
||||
return ConfigHelper.get('CHANNEL_SHUTDOWN_DELAY', 5)
|
||||
|
||||
@staticmethod
|
||||
def initial_behind_chunks():
|
||||
"""Get number of chunks to start behind"""
|
||||
return ConfigHelper.get('INITIAL_BEHIND_CHUNKS', 10)
|
||||
|
||||
@staticmethod
|
||||
def keepalive_interval():
|
||||
"""Get keepalive interval in seconds"""
|
||||
return ConfigHelper.get('KEEPALIVE_INTERVAL', 0.5)
|
||||
|
||||
@staticmethod
|
||||
def cleanup_check_interval():
|
||||
"""Get cleanup check interval in seconds"""
|
||||
return ConfigHelper.get('CLEANUP_CHECK_INTERVAL', 3)
|
||||
|
||||
@staticmethod
|
||||
def redis_chunk_ttl():
|
||||
"""Get Redis chunk TTL in seconds"""
|
||||
return ConfigHelper.get('REDIS_CHUNK_TTL', 60)
|
||||
|
||||
@staticmethod
|
||||
def chunk_size():
|
||||
"""Get chunk size in bytes"""
|
||||
return ConfigHelper.get('CHUNK_SIZE', 8192)
|
||||
|
||||
@staticmethod
|
||||
def max_retries():
|
||||
"""Get maximum retry attempts"""
|
||||
return ConfigHelper.get('MAX_RETRIES', 3)
|
||||
|
||||
@staticmethod
|
||||
def max_stream_switches():
|
||||
"""Get maximum number of stream switch attempts"""
|
||||
return ConfigHelper.get('MAX_STREAM_SWITCHES', 10)
|
||||
42
apps/proxy/ts_proxy/constants.py
Normal file
42
apps/proxy/ts_proxy/constants.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""
|
||||
Constants used throughout the TS Proxy application.
|
||||
Centralizing constants makes it easier to maintain and modify them.
|
||||
"""
|
||||
|
||||
# Redis related constants
|
||||
REDIS_KEY_PREFIX = "ts_proxy"
|
||||
REDIS_TTL_DEFAULT = 3600 # 1 hour
|
||||
REDIS_TTL_SHORT = 60 # 1 minute
|
||||
REDIS_TTL_MEDIUM = 300 # 5 minutes
|
||||
|
||||
# Channel states
|
||||
class ChannelState:
|
||||
INITIALIZING = "initializing"
|
||||
CONNECTING = "connecting"
|
||||
WAITING_FOR_CLIENTS = "waiting_for_clients"
|
||||
ACTIVE = "active"
|
||||
ERROR = "error"
|
||||
STOPPING = "stopping"
|
||||
STOPPED = "stopped"
|
||||
|
||||
# Event types
|
||||
class EventType:
|
||||
STREAM_SWITCH = "stream_switch"
|
||||
STREAM_SWITCHED = "stream_switched"
|
||||
CHANNEL_STOP = "channel_stop"
|
||||
CHANNEL_STOPPED = "channel_stopped"
|
||||
CLIENT_CONNECTED = "client_connected"
|
||||
CLIENT_DISCONNECTED = "client_disconnected"
|
||||
CLIENT_STOP = "client_stop"
|
||||
|
||||
# Stream types
|
||||
class StreamType:
|
||||
HLS = "hls"
|
||||
TS = "ts"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
# TS packet constants
|
||||
TS_PACKET_SIZE = 188
|
||||
TS_SYNC_BYTE = 0x47
|
||||
NULL_PID_HIGH = 0x1F
|
||||
NULL_PID_LOW = 0xFF
|
||||
80
apps/proxy/ts_proxy/redis_keys.py
Normal file
80
apps/proxy/ts_proxy/redis_keys.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""
|
||||
Defines Redis key patterns used throughout the TS proxy service.
|
||||
Centralizing these key patterns makes it easier to maintain and change them if needed.
|
||||
"""
|
||||
|
||||
class RedisKeys:
|
||||
@staticmethod
|
||||
def channel_metadata(channel_id):
|
||||
"""Key for channel metadata hash"""
|
||||
return f"ts_proxy:channel:{channel_id}:metadata"
|
||||
|
||||
@staticmethod
|
||||
def buffer_index(channel_id):
|
||||
"""Key for tracking buffer index"""
|
||||
return f"ts_proxy:channel:{channel_id}:buffer:index"
|
||||
|
||||
@staticmethod
|
||||
def buffer_chunk(channel_id, chunk_index):
|
||||
"""Key for specific buffer chunk"""
|
||||
return f"ts_proxy:channel:{channel_id}:buffer:chunk:{chunk_index}"
|
||||
|
||||
@staticmethod
|
||||
def buffer_chunk_prefix(channel_id):
|
||||
"""Prefix for buffer chunks"""
|
||||
return f"ts_proxy:channel:{channel_id}:buffer:chunk:"
|
||||
|
||||
@staticmethod
|
||||
def channel_stopping(channel_id):
|
||||
"""Key indicating channel is stopping"""
|
||||
return f"ts_proxy:channel:{channel_id}:stopping"
|
||||
|
||||
@staticmethod
|
||||
def client_stop(channel_id, client_id):
|
||||
"""Key requesting client stop"""
|
||||
return f"ts_proxy:channel:{channel_id}:client:{client_id}:stop"
|
||||
|
||||
@staticmethod
|
||||
def events_channel(channel_id):
|
||||
"""PubSub channel for events"""
|
||||
return f"ts_proxy:events:{channel_id}"
|
||||
|
||||
@staticmethod
|
||||
def switch_request(channel_id):
|
||||
"""Key for stream switch request"""
|
||||
return f"ts_proxy:channel:{channel_id}:switch_request"
|
||||
|
||||
@staticmethod
|
||||
def channel_owner(channel_id):
|
||||
"""Key for storing channel owner worker ID"""
|
||||
return f"ts_proxy:channel:{channel_id}:owner"
|
||||
|
||||
@staticmethod
|
||||
def clients(channel_id):
|
||||
"""Key for set of client IDs"""
|
||||
return f"ts_proxy:channel:{channel_id}:clients"
|
||||
|
||||
@staticmethod
|
||||
def last_client_disconnect(channel_id):
|
||||
"""Key for last client disconnect timestamp"""
|
||||
return f"ts_proxy:channel:{channel_id}:last_client_disconnect_time"
|
||||
|
||||
@staticmethod
|
||||
def connection_attempt(channel_id):
|
||||
"""Key for connection attempt timestamp"""
|
||||
return f"ts_proxy:channel:{channel_id}:connection_attempt_time"
|
||||
|
||||
@staticmethod
|
||||
def last_data(channel_id):
|
||||
"""Key for last data timestamp"""
|
||||
return f"ts_proxy:channel:{channel_id}:last_data"
|
||||
|
||||
@staticmethod
|
||||
def switch_status(channel_id):
|
||||
"""Key for stream switch status"""
|
||||
return f"ts_proxy:channel:{channel_id}:switch_status"
|
||||
|
||||
@staticmethod
|
||||
def worker_heartbeat(worker_id):
|
||||
"""Key for worker heartbeat"""
|
||||
return f"ts_proxy:worker:{worker_id}:heartbeat"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
484
apps/proxy/ts_proxy/services/channel_service.py
Normal file
484
apps/proxy/ts_proxy/services/channel_service.py
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
"""
|
||||
Channel service layer for handling business logic related to channel operations.
|
||||
This separates business logic from HTTP handling in views.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.channels.models import Channel
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
from .. import proxy_server
|
||||
from ..redis_keys import RedisKeys
|
||||
from ..constants import EventType, ChannelState
|
||||
from ..url_utils import get_stream_info_for_switch
|
||||
|
||||
logger = logging.getLogger("ts_proxy")
|
||||
|
||||
class ChannelService:
|
||||
"""Service class for channel operations"""
|
||||
|
||||
@staticmethod
|
||||
def initialize_channel(channel_id, stream_url, user_agent, transcode=False, profile_value=None, stream_id=None):
|
||||
"""
|
||||
Initialize a channel with the given parameters.
|
||||
|
||||
Args:
|
||||
channel_id: UUID of the channel
|
||||
stream_url: URL of the stream
|
||||
user_agent: User agent for the stream connection
|
||||
transcode: Whether to transcode the stream
|
||||
profile_value: Stream profile value to store in metadata
|
||||
stream_id: ID of the stream being used
|
||||
|
||||
Returns:
|
||||
bool: Success status
|
||||
"""
|
||||
# FIXED: First, ensure that Redis metadata including stream_id is set BEFORE channel initialization
|
||||
# This ensures the stream ID is available when the StreamManager looks it up
|
||||
if stream_id and proxy_server.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
# Check if metadata already exists
|
||||
if proxy_server.redis_client.exists(metadata_key):
|
||||
# Just update the existing metadata with stream_id
|
||||
proxy_server.redis_client.hset(metadata_key, "stream_id", str(stream_id))
|
||||
logger.info(f"Pre-set stream ID {stream_id} in Redis for channel {channel_id}")
|
||||
else:
|
||||
# Create initial metadata with essential values
|
||||
initial_metadata = {
|
||||
"stream_id": str(stream_id),
|
||||
"temp_init": str(time.time())
|
||||
}
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=initial_metadata)
|
||||
logger.info(f"Created initial metadata with stream_id {stream_id} for channel {channel_id}")
|
||||
|
||||
# Verify the stream_id was set
|
||||
stream_id_value = proxy_server.redis_client.hget(metadata_key, "stream_id")
|
||||
if stream_id_value:
|
||||
logger.info(f"Verified stream_id {stream_id_value.decode('utf-8')} is now set in Redis")
|
||||
else:
|
||||
logger.error(f"Failed to set stream_id {stream_id} in Redis before initialization")
|
||||
|
||||
# Now proceed with channel initialization
|
||||
success = proxy_server.initialize_channel(stream_url, channel_id, user_agent, transcode, stream_id)
|
||||
|
||||
# Store additional metadata if initialization was successful
|
||||
if success and proxy_server.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
update_data = {}
|
||||
if profile_value:
|
||||
update_data["profile"] = profile_value
|
||||
if stream_id:
|
||||
update_data["stream_id"] = str(stream_id)
|
||||
|
||||
if update_data:
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
|
||||
|
||||
return success
|
||||
|
||||
@staticmethod
|
||||
def change_stream_url(channel_id, new_url=None, user_agent=None, target_stream_id=None):
|
||||
"""
|
||||
Change the URL of an existing stream.
|
||||
|
||||
Args:
|
||||
channel_id: UUID of the channel
|
||||
new_url: New stream URL (optional if target_stream_id is provided)
|
||||
user_agent: Optional user agent to update
|
||||
target_stream_id: Optional target stream ID to switch to
|
||||
|
||||
Returns:
|
||||
dict: Result information including success status and diagnostics
|
||||
"""
|
||||
# If no direct URL is provided but a target stream is, get URL from target stream
|
||||
if not new_url and target_stream_id:
|
||||
stream_info = get_stream_info_for_switch(channel_id, target_stream_id)
|
||||
if 'error' in stream_info:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': stream_info['error']
|
||||
}
|
||||
new_url = stream_info['url']
|
||||
user_agent = stream_info['user_agent']
|
||||
|
||||
# Check if channel exists
|
||||
in_local_managers = channel_id in proxy_server.stream_managers
|
||||
in_local_buffers = channel_id in proxy_server.stream_buffers
|
||||
|
||||
# Check Redis for keys
|
||||
redis_keys = None
|
||||
if proxy_server.redis_client:
|
||||
try:
|
||||
# This is inefficient but used for diagnostics - in production would use more targeted checks
|
||||
redis_keys = proxy_server.redis_client.keys(f"ts_proxy:*:{channel_id}*")
|
||||
redis_keys = [k.decode('utf-8') for k in redis_keys] if redis_keys else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking Redis keys: {e}")
|
||||
|
||||
# Check if channel exists using standard method
|
||||
channel_exists = proxy_server.check_if_channel_exists(channel_id)
|
||||
|
||||
# Log detailed diagnostics
|
||||
logger.info(f"Channel {channel_id} diagnostics: "
|
||||
f"in_local_managers={in_local_managers}, "
|
||||
f"in_local_buffers={in_local_buffers}, "
|
||||
f"redis_keys_count={len(redis_keys) if redis_keys else 0}, "
|
||||
f"channel_exists={channel_exists}")
|
||||
|
||||
if not channel_exists:
|
||||
# Try to recover if Redis keys exist but channel check failed
|
||||
if redis_keys:
|
||||
logger.warning(f"Channel {channel_id} not detected but Redis keys exist. Forcing initialization.")
|
||||
proxy_server.initialize_channel(new_url, channel_id, user_agent)
|
||||
result = {
|
||||
'status': 'recovered',
|
||||
'message': 'Channel was recovered and initialized'
|
||||
}
|
||||
else:
|
||||
logger.error(f"Channel {channel_id} not found in any worker or Redis")
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Channel not found',
|
||||
'diagnostics': {
|
||||
'in_local_managers': in_local_managers,
|
||||
'in_local_buffers': in_local_buffers,
|
||||
'redis_keys': redis_keys,
|
||||
}
|
||||
}
|
||||
else:
|
||||
result = {'status': 'success'}
|
||||
|
||||
# Update metadata in Redis regardless of ownership
|
||||
if proxy_server.redis_client:
|
||||
try:
|
||||
ChannelService._update_channel_metadata(channel_id, new_url, user_agent)
|
||||
result['metadata_updated'] = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating Redis metadata: {e}", exc_info=True)
|
||||
result['metadata_updated'] = False
|
||||
|
||||
# If we're the owner, update directly
|
||||
if proxy_server.am_i_owner(channel_id) and channel_id in proxy_server.stream_managers:
|
||||
logger.info(f"This worker is the owner, changing stream URL for channel {channel_id}")
|
||||
manager = proxy_server.stream_managers[channel_id]
|
||||
old_url = manager.url
|
||||
|
||||
# Update the stream
|
||||
success = manager.update_url(new_url)
|
||||
logger.info(f"Stream URL changed from {old_url} to {new_url}, result: {success}")
|
||||
|
||||
result.update({
|
||||
'direct_update': True,
|
||||
'success': success,
|
||||
'worker_id': proxy_server.worker_id
|
||||
})
|
||||
else:
|
||||
# If we're not the owner, publish an event for the owner to pick up
|
||||
logger.info(f"Not the owner, requesting URL change via Redis PubSub")
|
||||
if proxy_server.redis_client:
|
||||
ChannelService._publish_stream_switch_event(channel_id, new_url, user_agent)
|
||||
result.update({
|
||||
'direct_update': False,
|
||||
'event_published': True,
|
||||
'worker_id': proxy_server.worker_id
|
||||
})
|
||||
else:
|
||||
result.update({
|
||||
'direct_update': False,
|
||||
'event_published': False,
|
||||
'error': 'Redis not available for pubsub'
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def stop_channel(channel_id):
|
||||
"""
|
||||
Stop a channel and release all resources.
|
||||
|
||||
Args:
|
||||
channel_id: UUID of the channel
|
||||
|
||||
Returns:
|
||||
dict: Result information including previous state if available
|
||||
"""
|
||||
# Check if channel exists
|
||||
channel_exists = proxy_server.check_if_channel_exists(channel_id)
|
||||
if not channel_exists:
|
||||
logger.warning(f"Channel {channel_id} not found in any worker or Redis")
|
||||
return {'status': 'error', 'message': 'Channel not found'}
|
||||
|
||||
# Get channel state information for result
|
||||
channel_info = None
|
||||
if proxy_server.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
try:
|
||||
metadata = proxy_server.redis_client.hgetall(metadata_key)
|
||||
if metadata and b'state' in metadata:
|
||||
state = metadata[b'state'].decode('utf-8')
|
||||
channel_info = {"state": state}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching channel state: {e}")
|
||||
|
||||
# Broadcast stop event to all workers via PubSub
|
||||
if proxy_server.redis_client:
|
||||
ChannelService._publish_channel_stop_event(channel_id)
|
||||
|
||||
# Also stop locally to ensure this worker cleans up right away
|
||||
local_result = proxy_server.stop_channel(channel_id)
|
||||
else:
|
||||
# No Redis, just stop locally
|
||||
local_result = proxy_server.stop_channel(channel_id)
|
||||
|
||||
# Release the channel in the channel model if applicable
|
||||
try:
|
||||
channel = Channel.objects.get(uuid=channel_id)
|
||||
channel.release_stream()
|
||||
logger.info(f"Released channel {channel_id} stream allocation")
|
||||
model_released = True
|
||||
except Channel.DoesNotExist:
|
||||
logger.warning(f"Could not find Channel model for UUID {channel_id}")
|
||||
model_released = False
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing channel stream: {e}")
|
||||
model_released = False
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Channel stop request sent',
|
||||
'channel_id': channel_id,
|
||||
'previous_state': channel_info,
|
||||
'model_released': model_released,
|
||||
'local_stop_result': local_result
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def stop_client(channel_id, client_id):
|
||||
"""
|
||||
Stop a specific client connection.
|
||||
|
||||
Args:
|
||||
channel_id: UUID of the channel
|
||||
client_id: ID of the client to stop
|
||||
|
||||
Returns:
|
||||
dict: Result information
|
||||
"""
|
||||
logger.info(f"Request to stop client {client_id} on channel {channel_id}")
|
||||
|
||||
# Set a Redis key for immediate detection
|
||||
key_set = False
|
||||
if proxy_server.redis_client:
|
||||
stop_key = RedisKeys.client_stop(channel_id, client_id)
|
||||
try:
|
||||
proxy_server.redis_client.setex(stop_key, 30, "true") # 30 second TTL
|
||||
logger.info(f"Set stop key for client {client_id}")
|
||||
key_set = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting client stop key: {e}")
|
||||
|
||||
# Check if channel exists
|
||||
channel_exists = proxy_server.check_if_channel_exists(channel_id)
|
||||
if not channel_exists:
|
||||
logger.warning(f"Channel {channel_id} not found")
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Channel not found',
|
||||
'stop_key_set': key_set
|
||||
}
|
||||
|
||||
# Try to stop locally if client is on this worker
|
||||
local_client_stopped = False
|
||||
if channel_id in proxy_server.client_managers:
|
||||
client_manager = proxy_server.client_managers[channel_id]
|
||||
with client_manager.lock:
|
||||
if client_id in client_manager.clients:
|
||||
client_manager.remove_client(client_id)
|
||||
local_client_stopped = True
|
||||
logger.info(f"Client {client_id} stopped locally on channel {channel_id}")
|
||||
|
||||
# If client wasn't found locally, broadcast stop event for other workers
|
||||
event_published = False
|
||||
if not local_client_stopped and proxy_server.redis_client:
|
||||
try:
|
||||
ChannelService._publish_client_stop_event(channel_id, client_id)
|
||||
event_published = True
|
||||
logger.info(f"Published stop request for client {client_id} on channel {channel_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error publishing client stop event: {e}")
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Client stop request processed',
|
||||
'channel_id': channel_id,
|
||||
'client_id': client_id,
|
||||
'locally_processed': local_client_stopped,
|
||||
'stop_key_set': key_set,
|
||||
'event_published': event_published
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_channel_state(channel_id):
|
||||
"""
|
||||
Validate if a channel is in a healthy state and has an active owner.
|
||||
|
||||
Args:
|
||||
channel_id: UUID of the channel
|
||||
|
||||
Returns:
|
||||
tuple: (valid, state, owner, details) - validity status, current state, owner, and diagnostic info
|
||||
"""
|
||||
if not proxy_server.redis_client:
|
||||
return False, None, None, {"error": "Redis not available"}
|
||||
|
||||
try:
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
if not proxy_server.redis_client.exists(metadata_key):
|
||||
return False, None, None, {"error": "No channel metadata"}
|
||||
|
||||
metadata = proxy_server.redis_client.hgetall(metadata_key)
|
||||
|
||||
# Extract state and owner
|
||||
state = metadata.get(b'state', b'unknown').decode('utf-8')
|
||||
owner = metadata.get(b'owner', b'unknown').decode('utf-8')
|
||||
|
||||
# Valid states indicate channel is running properly
|
||||
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING]
|
||||
|
||||
if state not in valid_states:
|
||||
return False, state, owner, {"error": f"Invalid state: {state}"}
|
||||
|
||||
# Check if owner is still active
|
||||
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
|
||||
owner_alive = proxy_server.redis_client.exists(owner_heartbeat_key)
|
||||
|
||||
if not owner_alive:
|
||||
return False, state, owner, {"error": "Owner not active"}
|
||||
|
||||
# Check for recent activity
|
||||
last_data_key = RedisKeys.last_data(channel_id)
|
||||
last_data = proxy_server.redis_client.get(last_data_key)
|
||||
|
||||
details = {
|
||||
"state": state,
|
||||
"owner": owner,
|
||||
"owner_alive": owner_alive
|
||||
}
|
||||
|
||||
if last_data:
|
||||
last_data_time = float(last_data.decode('utf-8'))
|
||||
data_age = time.time() - last_data_time
|
||||
details["last_data_age"] = data_age
|
||||
|
||||
# If no data for too long, consider invalid
|
||||
if data_age > 30: # 30 seconds threshold
|
||||
return False, state, owner, {"error": f"No data for {data_age:.1f}s", **details}
|
||||
|
||||
return True, state, owner, details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating channel state: {e}", exc_info=True)
|
||||
return False, None, None, {"error": f"Exception: {str(e)}"}
|
||||
|
||||
# Helper methods for Redis operations
|
||||
|
||||
@staticmethod
|
||||
def _update_channel_metadata(channel_id, url, user_agent=None):
|
||||
"""Update channel metadata in Redis"""
|
||||
if not proxy_server.redis_client:
|
||||
return False
|
||||
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
|
||||
# First check if the key exists and what type it is
|
||||
key_type = proxy_server.redis_client.type(metadata_key).decode('utf-8')
|
||||
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
|
||||
|
||||
# Use the appropriate method based on the key type
|
||||
if key_type == 'hash':
|
||||
proxy_server.redis_client.hset(metadata_key, "url", url)
|
||||
if user_agent:
|
||||
proxy_server.redis_client.hset(metadata_key, "user_agent", user_agent)
|
||||
elif key_type == 'none': # Key doesn't exist yet
|
||||
# Create new hash with all required fields
|
||||
metadata = {"url": url}
|
||||
if user_agent:
|
||||
metadata["user_agent"] = user_agent
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
|
||||
else:
|
||||
# If key exists with wrong type, delete it and recreate
|
||||
proxy_server.redis_client.delete(metadata_key)
|
||||
metadata = {"url": url}
|
||||
if user_agent:
|
||||
metadata["user_agent"] = user_agent
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
|
||||
|
||||
# Set switch request flag to ensure all workers see it
|
||||
switch_key = RedisKeys.switch_request(channel_id)
|
||||
proxy_server.redis_client.setex(switch_key, 30, url) # 30 second TTL
|
||||
|
||||
logger.info(f"Updated metadata for channel {channel_id} in Redis")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _publish_stream_switch_event(channel_id, new_url, user_agent=None):
|
||||
"""Publish a stream switch event to Redis pubsub"""
|
||||
if not proxy_server.redis_client:
|
||||
return False
|
||||
|
||||
switch_request = {
|
||||
"event": EventType.STREAM_SWITCH, # Use constant instead of string
|
||||
"channel_id": channel_id,
|
||||
"url": new_url,
|
||||
"user_agent": user_agent,
|
||||
"requester": proxy_server.worker_id,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
proxy_server.redis_client.publish(
|
||||
RedisKeys.events_channel(channel_id),
|
||||
json.dumps(switch_request)
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _publish_channel_stop_event(channel_id):
|
||||
"""Publish a channel stop event to Redis pubsub"""
|
||||
if not proxy_server.redis_client:
|
||||
return False
|
||||
|
||||
stop_request = {
|
||||
"event": EventType.CHANNEL_STOP, # Use constant instead of string
|
||||
"channel_id": channel_id,
|
||||
"requester_worker_id": proxy_server.worker_id,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
proxy_server.redis_client.publish(
|
||||
RedisKeys.events_channel(channel_id),
|
||||
json.dumps(stop_request)
|
||||
)
|
||||
|
||||
logger.info(f"Published channel stop event for {channel_id}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _publish_client_stop_event(channel_id, client_id):
|
||||
"""Publish a client stop event to Redis pubsub"""
|
||||
if not proxy_server.redis_client:
|
||||
return False
|
||||
|
||||
stop_request = {
|
||||
"event": EventType.CLIENT_STOP, # Use constant instead of string
|
||||
"channel_id": channel_id,
|
||||
"client_id": client_id,
|
||||
"requester_worker_id": proxy_server.worker_id,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
proxy_server.redis_client.publish(
|
||||
RedisKeys.events_channel(channel_id),
|
||||
json.dumps(stop_request)
|
||||
)
|
||||
return True
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
344
apps/proxy/ts_proxy/stream_generator.py
Normal file
344
apps/proxy/ts_proxy/stream_generator.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""
|
||||
Stream generation and client-side handling for TS streams.
|
||||
This module handles generating and delivering video streams to clients.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
from . import proxy_server
|
||||
from .utils import create_ts_packet
|
||||
from .redis_keys import RedisKeys
|
||||
|
||||
logger = logging.getLogger("ts_proxy")
|
||||
|
||||
class StreamGenerator:
|
||||
"""
|
||||
Handles generating streams for clients, including initialization,
|
||||
data delivery, and cleanup.
|
||||
"""
|
||||
|
||||
def __init__(self, channel_id, client_id, client_ip, client_user_agent, channel_initializing=False):
|
||||
"""
|
||||
Initialize the stream generator with client and channel details.
|
||||
|
||||
Args:
|
||||
channel_id: The UUID of the channel to stream
|
||||
client_id: Unique ID for this client connection
|
||||
client_ip: Client's IP address
|
||||
client_user_agent: User agent string from client
|
||||
channel_initializing: Whether the channel is still initializing
|
||||
"""
|
||||
self.channel_id = channel_id
|
||||
self.client_id = client_id
|
||||
self.client_ip = client_ip
|
||||
self.client_user_agent = client_user_agent
|
||||
self.channel_initializing = channel_initializing
|
||||
|
||||
# Performance and state tracking
|
||||
self.stream_start_time = time.time()
|
||||
self.bytes_sent = 0
|
||||
self.chunks_sent = 0
|
||||
self.local_index = 0
|
||||
self.consecutive_empty = 0
|
||||
|
||||
def generate(self):
|
||||
"""
|
||||
Generator function that produces the stream content for the client.
|
||||
Handles initialization state, data delivery, and client disconnection.
|
||||
|
||||
Yields:
|
||||
bytes: Chunks of TS stream data
|
||||
"""
|
||||
self.stream_start_time = time.time()
|
||||
self.bytes_sent = 0
|
||||
self.chunks_sent = 0
|
||||
|
||||
try:
|
||||
logger.info(f"[{self.client_id}] Stream generator started, channel_ready={not self.channel_initializing}")
|
||||
|
||||
# First handle initialization if needed
|
||||
if self.channel_initializing:
|
||||
channel_ready = self._wait_for_initialization()
|
||||
if not channel_ready:
|
||||
# If initialization failed or timed out, we've already sent error packets
|
||||
return
|
||||
|
||||
# Channel is now ready - start normal streaming
|
||||
logger.info(f"[{self.client_id}] Channel {self.channel_id} ready, starting normal streaming")
|
||||
|
||||
# Reset start time for real streaming
|
||||
self.stream_start_time = time.time()
|
||||
|
||||
# Setup streaming parameters and verify resources
|
||||
if not self._setup_streaming():
|
||||
return
|
||||
|
||||
# Main streaming loop
|
||||
for chunk in self._stream_data_generator():
|
||||
yield chunk
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.client_id}] Stream error: {e}", exc_info=True)
|
||||
finally:
|
||||
self._cleanup()
|
||||
|
||||
def _wait_for_initialization(self):
|
||||
"""Wait for channel initialization to complete, sending keepalive packets."""
|
||||
initialization_start = time.time()
|
||||
max_init_wait = getattr(Config, 'CLIENT_WAIT_TIMEOUT', 30)
|
||||
keepalive_interval = 0.5
|
||||
last_keepalive = 0
|
||||
|
||||
# While init is happening, send keepalive packets
|
||||
while time.time() - initialization_start < max_init_wait:
|
||||
# Check if initialization has completed
|
||||
if proxy_server.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(self.channel_id)
|
||||
metadata = proxy_server.redis_client.hgetall(metadata_key)
|
||||
|
||||
if metadata and b'state' in metadata:
|
||||
state = metadata[b'state'].decode('utf-8')
|
||||
if state in ['waiting_for_clients', 'active']:
|
||||
logger.info(f"[{self.client_id}] Channel {self.channel_id} now ready (state={state})")
|
||||
return True
|
||||
elif state in ['error', 'stopped']:
|
||||
error_message = metadata.get(b'error_message', b'Unknown error').decode('utf-8')
|
||||
logger.error(f"[{self.client_id}] Channel {self.channel_id} in error state: {state}, message: {error_message}")
|
||||
# Send error packet before giving up
|
||||
yield create_ts_packet('error', f"Error: {error_message}")
|
||||
return False
|
||||
else:
|
||||
# Still initializing - send keepalive if needed
|
||||
if time.time() - last_keepalive >= keepalive_interval:
|
||||
status_msg = f"Initializing: {state}"
|
||||
keepalive_packet = create_ts_packet('keepalive', status_msg)
|
||||
logger.debug(f"[{self.client_id}] Sending keepalive packet during initialization, state={state}")
|
||||
yield keepalive_packet
|
||||
self.bytes_sent += len(keepalive_packet)
|
||||
last_keepalive = time.time()
|
||||
|
||||
# Wait a bit before checking again
|
||||
time.sleep(0.1)
|
||||
|
||||
# Timed out waiting
|
||||
logger.warning(f"[{self.client_id}] Timed out waiting for initialization")
|
||||
yield create_ts_packet('error', "Error: Initialization timeout")
|
||||
return False
|
||||
|
||||
def _setup_streaming(self):
|
||||
"""Setup streaming parameters and check resources."""
|
||||
# Get buffer - stream manager may not exist in this worker
|
||||
buffer = proxy_server.stream_buffers.get(self.channel_id)
|
||||
stream_manager = proxy_server.stream_managers.get(self.channel_id)
|
||||
|
||||
if not buffer:
|
||||
logger.error(f"[{self.client_id}] No buffer found for channel {self.channel_id}")
|
||||
return False
|
||||
|
||||
# Client state tracking - use config for initial position
|
||||
initial_behind = getattr(Config, 'INITIAL_BEHIND_CHUNKS', 10)
|
||||
current_buffer_index = buffer.index
|
||||
self.local_index = max(0, current_buffer_index - initial_behind)
|
||||
|
||||
# Store important objects as instance variables
|
||||
self.buffer = buffer
|
||||
self.stream_manager = stream_manager
|
||||
self.last_yield_time = time.time()
|
||||
self.empty_reads = 0
|
||||
self.consecutive_empty = 0
|
||||
self.is_owner_worker = proxy_server.am_i_owner(self.channel_id) if hasattr(proxy_server, 'am_i_owner') else True
|
||||
|
||||
logger.info(f"[{self.client_id}] Starting stream at index {self.local_index} (buffer at {buffer.index})")
|
||||
return True
|
||||
|
||||
def _stream_data_generator(self):
|
||||
"""Generate stream data chunks based on buffer contents."""
|
||||
bytes_sent = 0
|
||||
chunks_sent = 0
|
||||
stream_start_time = time.time()
|
||||
local_index = self.local_index
|
||||
|
||||
# Main streaming loop
|
||||
while True:
|
||||
# Check if resources still exist
|
||||
if not self._check_resources():
|
||||
break
|
||||
|
||||
# Get chunks at client's position using improved strategy
|
||||
chunks, next_index = self.buffer.get_optimized_client_data(local_index)
|
||||
|
||||
if chunks:
|
||||
yield from self._process_chunks(chunks, next_index, bytes_sent, chunks_sent, stream_start_time)
|
||||
local_index = next_index
|
||||
self.local_index = local_index
|
||||
self.last_yield_time = time.time()
|
||||
self.empty_reads = 0
|
||||
self.consecutive_empty = 0
|
||||
else:
|
||||
# Handle no data condition (with possible keepalive packets)
|
||||
self.empty_reads += 1
|
||||
self.consecutive_empty += 1
|
||||
|
||||
if self._should_send_keepalive(local_index):
|
||||
keepalive_packet = create_ts_packet('keepalive')
|
||||
logger.debug(f"[{self.client_id}] Sending keepalive packet while waiting at buffer head")
|
||||
yield keepalive_packet
|
||||
bytes_sent += len(keepalive_packet)
|
||||
self.last_yield_time = time.time()
|
||||
self.consecutive_empty = 0 # Reset consecutive counter but keep total empty_reads
|
||||
time.sleep(Config.KEEPALIVE_INTERVAL)
|
||||
else:
|
||||
# Standard wait with backoff
|
||||
sleep_time = min(0.1 * self.consecutive_empty, 1.0)
|
||||
time.sleep(sleep_time)
|
||||
|
||||
# Log empty reads periodically
|
||||
if self.empty_reads % 50 == 0:
|
||||
stream_status = "healthy" if (self.stream_manager and self.stream_manager.healthy) else "unknown"
|
||||
logger.debug(f"[{self.client_id}] Waiting for chunks beyond {local_index} (buffer at {self.buffer.index}, stream: {stream_status})")
|
||||
|
||||
# Check for ghost clients
|
||||
if self._is_ghost_client(local_index):
|
||||
logger.warning(f"[{self.client_id}] Possible ghost client: buffer has advanced {self.buffer.index - local_index} chunks ahead but client stuck at {local_index}")
|
||||
break
|
||||
|
||||
# Check for timeouts
|
||||
if self._is_timeout():
|
||||
break
|
||||
|
||||
def _check_resources(self):
|
||||
"""Check if required resources still exist."""
|
||||
# Enhanced resource checks
|
||||
if self.channel_id not in proxy_server.stream_buffers:
|
||||
logger.info(f"[{self.client_id}] Channel buffer no longer exists, terminating stream")
|
||||
return False
|
||||
|
||||
if self.channel_id not in proxy_server.client_managers:
|
||||
logger.info(f"[{self.client_id}] Client manager no longer exists, terminating stream")
|
||||
return False
|
||||
|
||||
# Check if this specific client has been stopped (Redis keys, etc.)
|
||||
if proxy_server.redis_client:
|
||||
# Channel stop check
|
||||
stop_key = RedisKeys.channel_stopping(self.channel_id)
|
||||
if proxy_server.redis_client.exists(stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream")
|
||||
return False
|
||||
|
||||
# Client stop check
|
||||
client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id)
|
||||
if proxy_server.redis_client.exists(client_stop_key):
|
||||
logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream")
|
||||
return False
|
||||
|
||||
# Also check if client has been removed from client_manager
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
client_manager = proxy_server.client_managers[self.channel_id]
|
||||
if self.client_id not in client_manager.clients:
|
||||
logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _process_chunks(self, chunks, next_index, bytes_sent, chunks_sent, stream_start_time):
|
||||
"""Process and yield chunks to the client."""
|
||||
# Process and send chunks
|
||||
total_size = sum(len(c) for c in chunks)
|
||||
logger.debug(f"[{self.client_id}] Retrieved {len(chunks)} chunks ({total_size} bytes) from index {self.local_index+1} to {next_index}")
|
||||
|
||||
# Send the chunks to the client
|
||||
for chunk in chunks:
|
||||
try:
|
||||
yield chunk
|
||||
bytes_sent += len(chunk)
|
||||
chunks_sent += 1
|
||||
|
||||
# Log every 100 chunks for visibility
|
||||
if chunks_sent % 100 == 0:
|
||||
elapsed = time.time() - stream_start_time
|
||||
rate = bytes_sent / elapsed / 1024 if elapsed > 0 else 0
|
||||
logger.info(f"[{self.client_id}] Stats: {chunks_sent} chunks, {bytes_sent/1024:.1f}KB, {rate:.1f}KB/s")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.client_id}] Error sending chunk to client: {e}")
|
||||
raise # Re-raise to exit the generator
|
||||
|
||||
return bytes_sent, chunks_sent
|
||||
|
||||
def _should_send_keepalive(self, local_index):
|
||||
"""Determine if a keepalive packet should be sent."""
|
||||
# Check if we're caught up to buffer head
|
||||
at_buffer_head = local_index >= self.buffer.index
|
||||
|
||||
# If we're at buffer head and no data is coming, send keepalive
|
||||
stream_healthy = self.stream_manager.healthy if self.stream_manager else True
|
||||
return at_buffer_head and not stream_healthy and self.consecutive_empty >= 5
|
||||
|
||||
def _is_ghost_client(self, local_index):
|
||||
"""Check if this appears to be a ghost client (stuck but buffer advancing)."""
|
||||
return self.consecutive_empty > 100 and self.buffer.index > local_index + 50
|
||||
|
||||
def _is_timeout(self):
|
||||
"""Check if the stream has timed out."""
|
||||
# Disconnect after long inactivity
|
||||
if time.time() - self.last_yield_time > Config.STREAM_TIMEOUT:
|
||||
if self.stream_manager and not self.stream_manager.healthy:
|
||||
logger.warning(f"[{self.client_id}] No data for {Config.STREAM_TIMEOUT}s and stream unhealthy, disconnecting")
|
||||
return True
|
||||
elif not self.is_owner_worker and self.consecutive_empty > 100:
|
||||
# Non-owner worker without data for too long
|
||||
logger.warning(f"[{self.client_id}] Non-owner worker with no data for {Config.STREAM_TIMEOUT}s, disconnecting")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cleanup(self):
|
||||
"""Clean up resources and report final statistics."""
|
||||
# Client cleanup
|
||||
elapsed = time.time() - self.stream_start_time
|
||||
local_clients = 0
|
||||
total_clients = 0
|
||||
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
client_manager = proxy_server.client_managers[self.channel_id]
|
||||
local_clients = client_manager.remove_client(self.client_id)
|
||||
total_clients = client_manager.get_total_client_count()
|
||||
logger.info(f"[{self.client_id}] Disconnected after {elapsed:.2f}s (local: {local_clients}, total: {total_clients})")
|
||||
|
||||
# Schedule channel shutdown if no clients left
|
||||
self._schedule_channel_shutdown_if_needed(local_clients)
|
||||
|
||||
def _schedule_channel_shutdown_if_needed(self, local_clients):
|
||||
"""
|
||||
Schedule channel shutdown if there are no clients left and we're the owner.
|
||||
"""
|
||||
# If no clients left and we're the owner, schedule shutdown using the config value
|
||||
if local_clients == 0 and proxy_server.am_i_owner(self.channel_id):
|
||||
logger.info(f"No local clients left for channel {self.channel_id}, scheduling shutdown")
|
||||
|
||||
def delayed_shutdown():
|
||||
# Use the config setting instead of hardcoded value
|
||||
shutdown_delay = getattr(Config, 'CHANNEL_SHUTDOWN_DELAY', 5)
|
||||
logger.info(f"Waiting {shutdown_delay}s before checking if channel should be stopped")
|
||||
time.sleep(shutdown_delay)
|
||||
|
||||
# After delay, check global client count
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
total = proxy_server.client_managers[self.channel_id].get_total_client_count()
|
||||
if total == 0:
|
||||
logger.info(f"Shutting down channel {self.channel_id} as no clients connected")
|
||||
proxy_server.stop_channel(self.channel_id)
|
||||
else:
|
||||
logger.info(f"Not shutting down channel {self.channel_id}, {total} clients still connected")
|
||||
|
||||
shutdown_thread = threading.Thread(target=delayed_shutdown)
|
||||
shutdown_thread.daemon = True
|
||||
shutdown_thread.start()
|
||||
|
||||
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False):
|
||||
"""
|
||||
Factory function to create a new stream generator.
|
||||
Returns a function that can be passed to StreamingHttpResponse.
|
||||
"""
|
||||
generator = StreamGenerator(channel_id, client_id, client_ip, client_user_agent, channel_initializing)
|
||||
return generator.generate
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
246
apps/proxy/ts_proxy/url_utils.py
Normal file
246
apps/proxy/ts_proxy/url_utils.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"""
|
||||
Utilities for handling stream URLs and transformations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Tuple, List
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.channels.models import Channel, Stream
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from core.models import UserAgent, CoreSettings
|
||||
|
||||
logger = logging.getLogger("ts_proxy")
|
||||
|
||||
def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]:
|
||||
"""
|
||||
Generate the appropriate stream URL for a channel based on its profile settings.
|
||||
|
||||
Args:
|
||||
channel_id: The UUID of the channel
|
||||
|
||||
Returns:
|
||||
Tuple[str, str, bool]: (stream_url, user_agent, transcode_flag)
|
||||
"""
|
||||
# Get channel and related objects
|
||||
channel = get_object_or_404(Channel, uuid=channel_id)
|
||||
stream_id, profile_id = channel.get_stream()
|
||||
|
||||
if stream_id is None or profile_id is None:
|
||||
logger.error(f"No stream assigned to channel {channel_id}")
|
||||
return None, None, False
|
||||
|
||||
# Get the M3U account profile for URL pattern
|
||||
stream = get_object_or_404(Stream, pk=stream_id)
|
||||
profile = get_object_or_404(M3UAccountProfile, pk=profile_id)
|
||||
|
||||
# Get the appropriate user agent
|
||||
m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id)
|
||||
stream_user_agent = UserAgent.objects.get(id=m3u_account.user_agent.id).user_agent
|
||||
|
||||
if stream_user_agent is None:
|
||||
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
|
||||
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
|
||||
|
||||
# Generate stream URL based on the selected profile
|
||||
input_url = stream.url
|
||||
stream_url = transform_url(input_url, profile.search_pattern, profile.replace_pattern)
|
||||
|
||||
# Check if transcoding is needed
|
||||
stream_profile = channel.get_stream_profile()
|
||||
if stream_profile.is_proxy() or stream_profile is None:
|
||||
transcode = False
|
||||
else:
|
||||
transcode = True
|
||||
|
||||
# Get profile name as string
|
||||
profile_value = str(stream_profile)
|
||||
|
||||
return stream_url, stream_user_agent, transcode, profile_value
|
||||
|
||||
def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> str:
|
||||
"""
|
||||
Transform a URL using regex pattern replacement.
|
||||
|
||||
Args:
|
||||
input_url: The base URL to transform
|
||||
search_pattern: The regex search pattern
|
||||
replace_pattern: The replacement pattern
|
||||
|
||||
Returns:
|
||||
str: The transformed URL
|
||||
"""
|
||||
try:
|
||||
logger.debug("Executing URL pattern replacement:")
|
||||
logger.debug(f" base URL: {input_url}")
|
||||
logger.debug(f" search: {search_pattern}")
|
||||
|
||||
# Handle backreferences in the replacement pattern
|
||||
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern)
|
||||
logger.debug(f" replace: {replace_pattern}")
|
||||
logger.debug(f" safe replace: {safe_replace_pattern}")
|
||||
|
||||
# Apply the transformation
|
||||
stream_url = re.sub(search_pattern, safe_replace_pattern, input_url)
|
||||
logger.debug(f"Generated stream url: {stream_url}")
|
||||
|
||||
return stream_url
|
||||
except Exception as e:
|
||||
logger.error(f"Error transforming URL: {e}")
|
||||
return input_url # Return original URL on error
|
||||
|
||||
def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] = None) -> dict:
|
||||
"""
|
||||
Get stream information for a channel switch, optionally to a specific stream ID.
|
||||
|
||||
Args:
|
||||
channel_id: The UUID of the channel
|
||||
target_stream_id: Optional specific stream ID to switch to
|
||||
|
||||
Returns:
|
||||
dict: Stream information including URL, user agent and transcode flag
|
||||
"""
|
||||
try:
|
||||
channel = get_object_or_404(Channel, uuid=channel_id)
|
||||
|
||||
# Use the target stream if specified, otherwise use current stream
|
||||
if target_stream_id:
|
||||
stream_id = target_stream_id
|
||||
|
||||
# Get the stream object
|
||||
stream = get_object_or_404(Stream, pk=stream_id)
|
||||
|
||||
# Find compatible profile for this stream
|
||||
profiles = M3UAccountProfile.objects.filter(m3u_account=stream.m3u_account)
|
||||
|
||||
if not profiles.exists():
|
||||
# Try to get default profile
|
||||
default_profile = M3UAccountProfile.objects.filter(
|
||||
m3u_account=stream.m3u_account,
|
||||
is_default=True
|
||||
).first()
|
||||
|
||||
if default_profile:
|
||||
profile_id = default_profile.id
|
||||
else:
|
||||
logger.error(f"No profile found for stream {stream_id}")
|
||||
return {'error': 'No profile found for stream'}
|
||||
else:
|
||||
# Use first available profile
|
||||
profile_id = profiles.first().id
|
||||
else:
|
||||
stream_id, profile_id = channel.get_stream()
|
||||
if stream_id is None or profile_id is None:
|
||||
return {'error': 'No stream assigned to channel'}
|
||||
|
||||
# Get the stream and profile objects directly
|
||||
stream = get_object_or_404(Stream, pk=stream_id)
|
||||
profile = get_object_or_404(M3UAccountProfile, pk=profile_id)
|
||||
|
||||
# Get the user agent from the M3U account
|
||||
m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id)
|
||||
user_agent = UserAgent.objects.get(id=m3u_account.user_agent.id).user_agent
|
||||
if not user_agent:
|
||||
user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id()).user_agent
|
||||
|
||||
# Generate URL using the transform function directly
|
||||
stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern)
|
||||
|
||||
# Get transcode info from the channel's stream profile
|
||||
stream_profile = channel.get_stream_profile()
|
||||
transcode = not (stream_profile.is_proxy() or stream_profile is None)
|
||||
profile_value = str(stream_profile)
|
||||
|
||||
return {
|
||||
'url': stream_url,
|
||||
'user_agent': user_agent,
|
||||
'transcode': transcode,
|
||||
'profile': profile_value,
|
||||
'stream_id': stream_id,
|
||||
'profile_id': profile_id
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stream info for switch: {e}", exc_info=True)
|
||||
return {'error': f'Error: {str(e)}'}
|
||||
|
||||
def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = None) -> List[dict]:
|
||||
"""
|
||||
Get alternative streams for a channel when the current stream fails.
|
||||
|
||||
Args:
|
||||
channel_id: The UUID of the channel
|
||||
current_stream_id: The currently failing stream ID to exclude
|
||||
|
||||
Returns:
|
||||
List[dict]: List of stream information dictionaries with stream_id and profile_id
|
||||
"""
|
||||
try:
|
||||
# Get channel object
|
||||
channel = get_object_or_404(Channel, uuid=channel_id)
|
||||
logger.debug(f"Looking for alternate streams for channel {channel_id}, current stream ID: {current_stream_id}")
|
||||
|
||||
# Get all assigned streams for this channel
|
||||
streams = channel.streams.all()
|
||||
logger.debug(f"Channel {channel_id} has {streams.count()} total assigned streams")
|
||||
|
||||
if not streams.exists():
|
||||
logger.warning(f"No streams assigned to channel {channel_id}")
|
||||
return []
|
||||
|
||||
alternate_streams = []
|
||||
|
||||
# Process each stream
|
||||
for stream in streams:
|
||||
# Log each stream we're checking
|
||||
logger.debug(f"Checking stream ID {stream.id} ({stream.name}) for channel {channel_id}")
|
||||
|
||||
# Skip the current failing stream
|
||||
if current_stream_id and stream.id == current_stream_id:
|
||||
logger.debug(f"Skipping current stream ID {current_stream_id}")
|
||||
continue
|
||||
|
||||
# Find compatible profiles for this stream
|
||||
# FIX: Looking at the error message, M3UAccountProfile doesn't have a 'stream' field
|
||||
# We need to find which field relates M3UAccountProfile to Stream
|
||||
try:
|
||||
# Check if we can find profiles via m3u_account
|
||||
profiles = M3UAccountProfile.objects.filter(m3u_account=stream.m3u_account)
|
||||
if not profiles.exists():
|
||||
logger.debug(f"No profiles found via m3u_account for stream {stream.id}")
|
||||
# Fallback to the default profile of the account
|
||||
default_profile = M3UAccountProfile.objects.filter(
|
||||
m3u_account=stream.m3u_account,
|
||||
is_default=True
|
||||
).first()
|
||||
if default_profile:
|
||||
profiles = [default_profile]
|
||||
else:
|
||||
logger.warning(f"No default profile found for m3u_account {stream.m3u_account.id}")
|
||||
continue
|
||||
|
||||
# Get first compatible profile
|
||||
profile = profiles.first()
|
||||
if profile:
|
||||
logger.debug(f"Found compatible profile ID {profile.id} for stream ID {stream.id}")
|
||||
|
||||
alternate_streams.append({
|
||||
'stream_id': stream.id,
|
||||
'profile_id': profile.id,
|
||||
'name': stream.name
|
||||
})
|
||||
else:
|
||||
logger.debug(f"No compatible profile found for stream ID {stream.id}")
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Error finding profiles for stream {stream.id}: {inner_e}")
|
||||
continue
|
||||
|
||||
if alternate_streams:
|
||||
stream_ids = ', '.join([str(s['stream_id']) for s in alternate_streams])
|
||||
logger.info(f"Found {len(alternate_streams)} alternate streams for channel {channel_id}: [{stream_ids}]")
|
||||
else:
|
||||
logger.warning(f"No alternate streams found for channel {channel_id}")
|
||||
|
||||
return alternate_streams
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting alternate streams for channel {channel_id}: {e}", exc_info=True)
|
||||
return []
|
||||
|
|
@ -34,4 +34,47 @@ def detect_stream_type(url):
|
|||
return 'hls'
|
||||
|
||||
# Default to TS
|
||||
return 'ts'
|
||||
return 'ts'
|
||||
|
||||
def get_client_ip(request):
|
||||
"""
|
||||
Extract client IP address from request.
|
||||
Handles cases where request is behind a proxy by checking X-Forwarded-For.
|
||||
"""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
def create_ts_packet(packet_type='null', message=None):
|
||||
"""
|
||||
Create a Transport Stream (TS) packet for various purposes.
|
||||
|
||||
Args:
|
||||
packet_type (str): Type of packet - 'null', 'error', 'keepalive', etc.
|
||||
message (str): Optional message to include in packet payload
|
||||
|
||||
Returns:
|
||||
bytes: A properly formatted 188-byte TS packet
|
||||
"""
|
||||
packet = bytearray(188)
|
||||
|
||||
# TS packet header
|
||||
packet[0] = 0x47 # Sync byte
|
||||
|
||||
# PID - Use different PIDs based on packet type
|
||||
if packet_type == 'error':
|
||||
packet[1] = 0x1F # PID high bits
|
||||
packet[2] = 0xFF # PID low bits
|
||||
else: # null/keepalive packets
|
||||
packet[1] = 0x1F # PID high bits (null packet)
|
||||
packet[2] = 0xFF # PID low bits (null packet)
|
||||
|
||||
# Add message to payload if provided
|
||||
if message:
|
||||
msg_bytes = message.encode('utf-8')
|
||||
packet[4:4+min(len(msg_bytes), 180)] = msg_bytes[:180]
|
||||
|
||||
return bytes(packet)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@
|
|||
},
|
||||
{
|
||||
"model": "core.coresettings",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"key": "preferred-region",
|
||||
"name": "Preferred Region",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as Yup from 'yup';
|
|||
import API from '../api';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -20,28 +21,270 @@ const SettingsPage = () => {
|
|||
const { userAgents } = useUserAgentsStore();
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
|
||||
// Add your region choices here:
|
||||
const regionChoices = [
|
||||
{ value: 'us', label: 'US' },
|
||||
{ value: 'uk', label: 'UK' },
|
||||
{ value: 'nl', label: 'NL' },
|
||||
{ value: 'de', label: 'DE' },
|
||||
// Add more if needed
|
||||
];
|
||||
const regionChoices = [
|
||||
{ value: 'ad', label: 'AD' },
|
||||
{ value: 'ae', label: 'AE' },
|
||||
{ value: 'af', label: 'AF' },
|
||||
{ value: 'ag', label: 'AG' },
|
||||
{ value: 'ai', label: 'AI' },
|
||||
{ value: 'al', label: 'AL' },
|
||||
{ value: 'am', label: 'AM' },
|
||||
{ value: 'ao', label: 'AO' },
|
||||
{ value: 'aq', label: 'AQ' },
|
||||
{ value: 'ar', label: 'AR' },
|
||||
{ value: 'as', label: 'AS' },
|
||||
{ value: 'at', label: 'AT' },
|
||||
{ value: 'au', label: 'AU' },
|
||||
{ value: 'aw', label: 'AW' },
|
||||
{ value: 'ax', label: 'AX' },
|
||||
{ value: 'az', label: 'AZ' },
|
||||
{ value: 'ba', label: 'BA' },
|
||||
{ value: 'bb', label: 'BB' },
|
||||
{ value: 'bd', label: 'BD' },
|
||||
{ value: 'be', label: 'BE' },
|
||||
{ value: 'bf', label: 'BF' },
|
||||
{ value: 'bg', label: 'BG' },
|
||||
{ value: 'bh', label: 'BH' },
|
||||
{ value: 'bi', label: 'BI' },
|
||||
{ value: 'bj', label: 'BJ' },
|
||||
{ value: 'bl', label: 'BL' },
|
||||
{ value: 'bm', label: 'BM' },
|
||||
{ value: 'bn', label: 'BN' },
|
||||
{ value: 'bo', label: 'BO' },
|
||||
{ value: 'bq', label: 'BQ' },
|
||||
{ value: 'br', label: 'BR' },
|
||||
{ value: 'bs', label: 'BS' },
|
||||
{ value: 'bt', label: 'BT' },
|
||||
{ value: 'bv', label: 'BV' },
|
||||
{ value: 'bw', label: 'BW' },
|
||||
{ value: 'by', label: 'BY' },
|
||||
{ value: 'bz', label: 'BZ' },
|
||||
{ value: 'ca', label: 'CA' },
|
||||
{ value: 'cc', label: 'CC' },
|
||||
{ value: 'cd', label: 'CD' },
|
||||
{ value: 'cf', label: 'CF' },
|
||||
{ value: 'cg', label: 'CG' },
|
||||
{ value: 'ch', label: 'CH' },
|
||||
{ value: 'ci', label: 'CI' },
|
||||
{ value: 'ck', label: 'CK' },
|
||||
{ value: 'cl', label: 'CL' },
|
||||
{ value: 'cm', label: 'CM' },
|
||||
{ value: 'cn', label: 'CN' },
|
||||
{ value: 'co', label: 'CO' },
|
||||
{ value: 'cr', label: 'CR' },
|
||||
{ value: 'cu', label: 'CU' },
|
||||
{ value: 'cv', label: 'CV' },
|
||||
{ value: 'cw', label: 'CW' },
|
||||
{ value: 'cx', label: 'CX' },
|
||||
{ value: 'cy', label: 'CY' },
|
||||
{ value: 'cz', label: 'CZ' },
|
||||
{ value: 'de', label: 'DE' },
|
||||
{ value: 'dj', label: 'DJ' },
|
||||
{ value: 'dk', label: 'DK' },
|
||||
{ value: 'dm', label: 'DM' },
|
||||
{ value: 'do', label: 'DO' },
|
||||
{ value: 'dz', label: 'DZ' },
|
||||
{ value: 'ec', label: 'EC' },
|
||||
{ value: 'ee', label: 'EE' },
|
||||
{ value: 'eg', label: 'EG' },
|
||||
{ value: 'eh', label: 'EH' },
|
||||
{ value: 'er', label: 'ER' },
|
||||
{ value: 'es', label: 'ES' },
|
||||
{ value: 'et', label: 'ET' },
|
||||
{ value: 'fi', label: 'FI' },
|
||||
{ value: 'fj', label: 'FJ' },
|
||||
{ value: 'fk', label: 'FK' },
|
||||
{ value: 'fm', label: 'FM' },
|
||||
{ value: 'fo', label: 'FO' },
|
||||
{ value: 'fr', label: 'FR' },
|
||||
{ value: 'ga', label: 'GA' },
|
||||
{ value: 'gb', label: 'GB' },
|
||||
{ value: 'gd', label: 'GD' },
|
||||
{ value: 'ge', label: 'GE' },
|
||||
{ value: 'gf', label: 'GF' },
|
||||
{ value: 'gg', label: 'GG' },
|
||||
{ value: 'gh', label: 'GH' },
|
||||
{ value: 'gi', label: 'GI' },
|
||||
{ value: 'gl', label: 'GL' },
|
||||
{ value: 'gm', label: 'GM' },
|
||||
{ value: 'gn', label: 'GN' },
|
||||
{ value: 'gp', label: 'GP' },
|
||||
{ value: 'gq', label: 'GQ' },
|
||||
{ value: 'gr', label: 'GR' },
|
||||
{ value: 'gs', label: 'GS' },
|
||||
{ value: 'gt', label: 'GT' },
|
||||
{ value: 'gu', label: 'GU' },
|
||||
{ value: 'gw', label: 'GW' },
|
||||
{ value: 'gy', label: 'GY' },
|
||||
{ value: 'hk', label: 'HK' },
|
||||
{ value: 'hm', label: 'HM' },
|
||||
{ value: 'hn', label: 'HN' },
|
||||
{ value: 'hr', label: 'HR' },
|
||||
{ value: 'ht', label: 'HT' },
|
||||
{ value: 'hu', label: 'HU' },
|
||||
{ value: 'id', label: 'ID' },
|
||||
{ value: 'ie', label: 'IE' },
|
||||
{ value: 'il', label: 'IL' },
|
||||
{ value: 'im', label: 'IM' },
|
||||
{ value: 'in', label: 'IN' },
|
||||
{ value: 'io', label: 'IO' },
|
||||
{ value: 'iq', label: 'IQ' },
|
||||
{ value: 'ir', label: 'IR' },
|
||||
{ value: 'is', label: 'IS' },
|
||||
{ value: 'it', label: 'IT' },
|
||||
{ value: 'je', label: 'JE' },
|
||||
{ value: 'jm', label: 'JM' },
|
||||
{ value: 'jo', label: 'JO' },
|
||||
{ value: 'jp', label: 'JP' },
|
||||
{ value: 'ke', label: 'KE' },
|
||||
{ value: 'kg', label: 'KG' },
|
||||
{ value: 'kh', label: 'KH' },
|
||||
{ value: 'ki', label: 'KI' },
|
||||
{ value: 'km', label: 'KM' },
|
||||
{ value: 'kn', label: 'KN' },
|
||||
{ value: 'kp', label: 'KP' },
|
||||
{ value: 'kr', label: 'KR' },
|
||||
{ value: 'kw', label: 'KW' },
|
||||
{ value: 'ky', label: 'KY' },
|
||||
{ value: 'kz', label: 'KZ' },
|
||||
{ value: 'la', label: 'LA' },
|
||||
{ value: 'lb', label: 'LB' },
|
||||
{ value: 'lc', label: 'LC' },
|
||||
{ value: 'li', label: 'LI' },
|
||||
{ value: 'lk', label: 'LK' },
|
||||
{ value: 'lr', label: 'LR' },
|
||||
{ value: 'ls', label: 'LS' },
|
||||
{ value: 'lt', label: 'LT' },
|
||||
{ value: 'lu', label: 'LU' },
|
||||
{ value: 'lv', label: 'LV' },
|
||||
{ value: 'ly', label: 'LY' },
|
||||
{ value: 'ma', label: 'MA' },
|
||||
{ value: 'mc', label: 'MC' },
|
||||
{ value: 'md', label: 'MD' },
|
||||
{ value: 'me', label: 'ME' },
|
||||
{ value: 'mf', label: 'MF' },
|
||||
{ value: 'mg', label: 'MG' },
|
||||
{ value: 'mh', label: 'MH' },
|
||||
{ value: 'ml', label: 'ML' },
|
||||
{ value: 'mm', label: 'MM' },
|
||||
{ value: 'mn', label: 'MN' },
|
||||
{ value: 'mo', label: 'MO' },
|
||||
{ value: 'mp', label: 'MP' },
|
||||
{ value: 'mq', label: 'MQ' },
|
||||
{ value: 'mr', label: 'MR' },
|
||||
{ value: 'ms', label: 'MS' },
|
||||
{ value: 'mt', label: 'MT' },
|
||||
{ value: 'mu', label: 'MU' },
|
||||
{ value: 'mv', label: 'MV' },
|
||||
{ value: 'mw', label: 'MW' },
|
||||
{ value: 'mx', label: 'MX' },
|
||||
{ value: 'my', label: 'MY' },
|
||||
{ value: 'mz', label: 'MZ' },
|
||||
{ value: 'na', label: 'NA' },
|
||||
{ value: 'nc', label: 'NC' },
|
||||
{ value: 'ne', label: 'NE' },
|
||||
{ value: 'nf', label: 'NF' },
|
||||
{ value: 'ng', label: 'NG' },
|
||||
{ value: 'ni', label: 'NI' },
|
||||
{ value: 'nl', label: 'NL' },
|
||||
{ value: 'no', label: 'NO' },
|
||||
{ value: 'np', label: 'NP' },
|
||||
{ value: 'nr', label: 'NR' },
|
||||
{ value: 'nu', label: 'NU' },
|
||||
{ value: 'nz', label: 'NZ' },
|
||||
{ value: 'om', label: 'OM' },
|
||||
{ value: 'pa', label: 'PA' },
|
||||
{ value: 'pe', label: 'PE' },
|
||||
{ value: 'pf', label: 'PF' },
|
||||
{ value: 'pg', label: 'PG' },
|
||||
{ value: 'ph', label: 'PH' },
|
||||
{ value: 'pk', label: 'PK' },
|
||||
{ value: 'pl', label: 'PL' },
|
||||
{ value: 'pm', label: 'PM' },
|
||||
{ value: 'pn', label: 'PN' },
|
||||
{ value: 'pr', label: 'PR' },
|
||||
{ value: 'ps', label: 'PS' },
|
||||
{ value: 'pt', label: 'PT' },
|
||||
{ value: 'pw', label: 'PW' },
|
||||
{ value: 'py', label: 'PY' },
|
||||
{ value: 'qa', label: 'QA' },
|
||||
{ value: 're', label: 'RE' },
|
||||
{ value: 'ro', label: 'RO' },
|
||||
{ value: 'rs', label: 'RS' },
|
||||
{ value: 'ru', label: 'RU' },
|
||||
{ value: 'rw', label: 'RW' },
|
||||
{ value: 'sa', label: 'SA' },
|
||||
{ value: 'sb', label: 'SB' },
|
||||
{ value: 'sc', label: 'SC' },
|
||||
{ value: 'sd', label: 'SD' },
|
||||
{ value: 'se', label: 'SE' },
|
||||
{ value: 'sg', label: 'SG' },
|
||||
{ value: 'sh', label: 'SH' },
|
||||
{ value: 'si', label: 'SI' },
|
||||
{ value: 'sj', label: 'SJ' },
|
||||
{ value: 'sk', label: 'SK' },
|
||||
{ value: 'sl', label: 'SL' },
|
||||
{ value: 'sm', label: 'SM' },
|
||||
{ value: 'sn', label: 'SN' },
|
||||
{ value: 'so', label: 'SO' },
|
||||
{ value: 'sr', label: 'SR' },
|
||||
{ value: 'ss', label: 'SS' },
|
||||
{ value: 'st', label: 'ST' },
|
||||
{ value: 'sv', label: 'SV' },
|
||||
{ value: 'sx', label: 'SX' },
|
||||
{ value: 'sy', label: 'SY' },
|
||||
{ value: 'sz', label: 'SZ' },
|
||||
{ value: 'tc', label: 'TC' },
|
||||
{ value: 'td', label: 'TD' },
|
||||
{ value: 'tf', label: 'TF' },
|
||||
{ value: 'tg', label: 'TG' },
|
||||
{ value: 'th', label: 'TH' },
|
||||
{ value: 'tj', label: 'TJ' },
|
||||
{ value: 'tk', label: 'TK' },
|
||||
{ value: 'tl', label: 'TL' },
|
||||
{ value: 'tm', label: 'TM' },
|
||||
{ value: 'tn', label: 'TN' },
|
||||
{ value: 'to', label: 'TO' },
|
||||
{ value: 'tr', label: 'TR' },
|
||||
{ value: 'tt', label: 'TT' },
|
||||
{ value: 'tv', label: 'TV' },
|
||||
{ value: 'tw', label: 'TW' },
|
||||
{ value: 'tz', label: 'TZ' },
|
||||
{ value: 'ua', label: 'UA' },
|
||||
{ value: 'ug', label: 'UG' },
|
||||
{ value: 'um', label: 'UM' },
|
||||
{ value: 'us', label: 'US' },
|
||||
{ value: 'uy', label: 'UY' },
|
||||
{ value: 'uz', label: 'UZ' },
|
||||
{ value: 'va', label: 'VA' },
|
||||
{ value: 'vc', label: 'VC' },
|
||||
{ value: 've', label: 'VE' },
|
||||
{ value: 'vg', label: 'VG' },
|
||||
{ value: 'vi', label: 'VI' },
|
||||
{ value: 'vn', label: 'VN' },
|
||||
{ value: 'vu', label: 'VU' },
|
||||
{ value: 'wf', label: 'WF' },
|
||||
{ value: 'ws', label: 'WS' },
|
||||
{ value: 'ye', label: 'YE' },
|
||||
{ value: 'yt', label: 'YT' },
|
||||
{ value: 'za', label: 'ZA' },
|
||||
{ value: 'zm', label: 'ZM' },
|
||||
{ value: 'zw', label: 'ZW' }
|
||||
];
|
||||
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
'default-user-agent': `${settings['default-user-agent'].id}`,
|
||||
'default-stream-profile': `${settings['default-stream-profile'].id}`,
|
||||
// 'preferred-region': '',
|
||||
'preferred-region': settings['preferred-region']?.value || 'us',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
'default-user-agent': Yup.string().required('User-Agent is required'),
|
||||
'default-stream-profile': Yup.string().required(
|
||||
'Stream Profile is required'
|
||||
),
|
||||
// The region is optional or required as you prefer
|
||||
// 'preferred-region': Yup.string().required('Region is required'),
|
||||
'preferred-region': Yup.string().required('Region is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
console.log(values);
|
||||
|
|
@ -121,18 +364,18 @@ const SettingsPage = () => {
|
|||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
{/* <Select
|
||||
labelId="region-label"
|
||||
id={settings['preferred-region'].id}
|
||||
name={settings['preferred-region'].key}
|
||||
label={settings['preferred-region'].name}
|
||||
<NativeSelect
|
||||
id={settings['preferred-region']?.id || 'preferred-region'}
|
||||
name={settings['preferred-region']?.key || 'preferred-region'}
|
||||
label={settings['preferred-region']?.name || 'Preferred Region'}
|
||||
value={formik.values['preferred-region'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
}))}
|
||||
/> */}
|
||||
/>
|
||||
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue