Dispatcharr/core/views.py
SergeantPanda c68249c314
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
Base Image Build / prepare (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
fix: replace fork()-based subprocess with posix_spawn at all uWSGI spawn sites
gevent registers a pthread_atfork handler that never yields. Any
subprocess.Popen/run call in a uWSGI greenlet hangs indefinitely at
fork() before the child process even starts.

- dispatcharr/gevent_patch.py: new; monkey.patch_all() + psycogreen,
  loaded via uWSGI import= in all four ini files
- live_proxy/input/manager.py: posix_spawn + _SpawnedProcess; removed
  dead forwarder code
- live_proxy/input/http_streamer.py: O_NONBLOCK on relay pipe + EAGAIN
  retry (blocking write to a full pipe stalls the gevent hub)
- live_proxy/utils.py: new posix_spawn_proc() helper with O_NONBLOCK
  stdin, shared by both output managers
- live_proxy/output/fmp4/manager.py, output/profile/manager.py:
  posix_spawn_proc(); _write_all() treats EAGAIN (None) as cooperative
  select.select wait instead of fatal error
- core/views.py (stream_view): posix_spawn; fixed pre-existing bug
  where return StreamingHttpResponse(...) was indented inside
  stream_generator, making the success path always return None
- connect/handlers/script.py: _posix_run() with posix_spawn +
  cooperative select reads + non-blocking waitpid; fixes deadlock when
  a script integration fires on a uWSGI-context event (client_connect
  fires in the live proxy, not Celery)
2026-05-15 16:45:09 -05:00

214 lines
9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# core/views.py
import os
import signal
from shlex import split as shlex_split
import sys
import logging
import regex
import redis
from django.conf import settings
from django.http import StreamingHttpResponse, HttpResponseServerError
from django.shortcuts import render
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccountProfile
from core.models import StreamProfile, CoreSettings
# Import the persistent lock (the “real” lock)
from dispatcharr.persistent_lock import PersistentLock
# Configure logging to output to the console.
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logger = logging.getLogger(__name__)
def settings_view(request):
"""
Renders the settings page.
"""
return render(request, 'settings.html')
def stream_view(request, channel_uuid):
"""
Streams the first available stream for the given channel.
It uses the channels assigned StreamProfile.
A persistent Redis lock is used to prevent concurrent streaming on the same channel.
"""
try:
redis_host = getattr(settings, "REDIS_HOST", "localhost")
redis_port = int(getattr(settings, "REDIS_PORT", 6379))
redis_db = int(getattr(settings, "REDIS_DB", "0"))
redis_password = getattr(settings, "REDIS_PASSWORD", "")
redis_user = getattr(settings, "REDIS_USER", "")
ssl_params = getattr(settings, "REDIS_SSL_PARAMS", {})
redis_client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
**ssl_params
)
# Retrieve the channel by the provided stream_id.
channel = Channel.objects.get(uuid=channel_uuid)
logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.name)
# Ensure the channel has at least one stream.
if not channel.streams.exists():
logger.error("No streams found for channel ID=%s", channel.id)
return HttpResponseServerError("No stream found for this channel.")
active_stream = None
m3u_account = None
active_profile = None
lock_key = None
persistent_lock = None
streams = channel.streams.all().order_by('channelstream__order')
logger.debug(f'Found {len(streams)} streams for channel {channel.channel_number}')
for stream in streams:
# Get the first available stream.
logger.debug("Checking stream: ID=%s, Name=%s", stream.id, stream.name)
# Retrieve the M3U account associated with the stream.
m3u_account = stream.m3u_account
logger.debug("Stream M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name)
# Use the custom URL if available; otherwise, use the standard URL.
input_url = stream.url
logger.debug("Input URL: %s", input_url)
# Determine which profile we can use.
m3u_profiles = m3u_account.profiles.all()
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
profiles = [obj for obj in m3u_profiles if not obj.is_default]
# -- Loop through profiles and pick the first active one --
for profile in [default_profile] + profiles:
logger.debug(f'Checking profile {profile.name}...')
if not profile.is_active:
logger.debug('Profile is not active, skipping.')
continue
logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available')
stream_index = 0
max_streams = profile.max_streams
if max_streams == 0:
max_streams = 999999 # maybe a better way than just hardcoding a high number...
while stream_index < max_streams:
stream_index += 1
lock_key = f"lock:{profile.id}:{stream_index}"
persistent_lock = PersistentLock(redis_client, lock_key, lock_timeout=120)
logger.debug(f'Attempting to acquire lock: {lock_key}')
if not persistent_lock.acquire():
logger.error(f"Could not acquire persistent lock for profile {profile.id} index {stream_index}, currently in use.")
persistent_lock = None
continue
break
if persistent_lock is not None:
logger.debug(f'Successfully acquired lock: {lock_key}')
active_profile = M3UAccountProfile.objects.get(id=profile.id)
break
if active_profile is None or persistent_lock is None:
logger.exception("No available profiles for the stream")
continue
logger.debug(f"Found available stream profile: stream={stream.name}, profile={profile.name}")
break
if not active_profile:
logger.exception("No available streams for this channel")
return HttpResponseServerError("No available streams for this channel")
logger.debug(f"Using M3U profile ID={active_profile.id} (ignoring viewer count limits)")
# Prepare the pattern replacement.
logger.debug("Executing the following pattern replacement:")
logger.debug(f" search: {active_profile.search_pattern}")
# Convert JS-style backreferences in replace: $<name> -> \g<name>, $1 -> \1
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', active_profile.replace_pattern)
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
logger.debug(f" replace: {active_profile.replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
# regex module accepts JS-style (?<name>...) named groups natively
stream_url = regex.sub(active_profile.search_pattern, safe_replace_pattern, input_url)
logger.debug(f"Generated stream url: {stream_url}")
# Get the stream profile set on the channel.
stream_profile = channel.stream_profile
if not stream_profile:
logger.error("No stream profile set for channel ID=%s, using default", channel.id)
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
logger.debug("Stream profile used: %s", stream_profile.name)
# Determine the user agent to use.
user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
logger.debug("User agent: %s", user_agent)
# Substitute placeholders in the parameters template.
parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url)
logger.debug("Formatted parameters: %s", parameters)
# Build the final command.
cmd = [stream_profile.command] + shlex_split(parameters)
logger.debug("Executing command: %s", cmd)
try:
stdout_r, stdout_w = os.pipe()
devnull_r = os.open(os.devnull, os.O_RDONLY)
devnull_w = os.open(os.devnull, os.O_WRONLY)
file_actions = [
(os.POSIX_SPAWN_DUP2, devnull_r, 0),
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
(os.POSIX_SPAWN_DUP2, devnull_w, 2),
(os.POSIX_SPAWN_CLOSE, devnull_r),
(os.POSIX_SPAWN_CLOSE, devnull_w),
(os.POSIX_SPAWN_CLOSE, stdout_w),
(os.POSIX_SPAWN_CLOSE, stdout_r),
]
proc_pid = os.posix_spawn(cmd[0], cmd, dict(os.environ), file_actions=file_actions)
for fd in (devnull_r, devnull_w, stdout_w):
os.close(fd)
proc_stdout = os.fdopen(stdout_r, 'rb')
except Exception as e:
persistent_lock.release()
logger.exception("Error starting stream for channel ID=%s", channel_uuid)
return HttpResponseServerError(f"Error starting stream: {e}")
except Exception as e:
logger.exception("Error preparing stream for channel ID=%s", channel_uuid)
return HttpResponseServerError(f"Error preparing stream: {e}")
def stream_generator(stdout_file, pid, persistent_lock):
try:
while True:
chunk = stdout_file.read(8192)
if not chunk:
break
yield chunk
finally:
try:
os.kill(pid, signal.SIGTERM)
logger.debug("Streaming process terminated for channel ID=%s", channel.id)
except OSError:
pass
try:
os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
pass
stdout_file.close()
persistent_lock.release()
logger.debug("Persistent lock released for channel ID=%s", channel.id)
return StreamingHttpResponse(
stream_generator(proc_stdout, proc_pid, persistent_lock),
content_type="video/MP2T"
)