From 6b0becce62753c82dc63c2b834202ddd1013040a Mon Sep 17 00:00:00 2001 From: dekzter Date: Tue, 4 Mar 2025 20:02:40 -0500 Subject: [PATCH 01/31] first attempt to switch to uwsgi --- docker/Dockerfile | 6 + docker/entrypoint.sh | 243 +++++------------------------ docker/init/01-user-setup.sh | 19 +++ docker/init/02-postgres.sh | 52 ++++++ docker/init/03-init-dispatcharr.sh | 6 + docker/nginx.conf | 27 +++- docker/uwsgi.ini | 40 +++++ requirements.txt | 3 +- 8 files changed, 184 insertions(+), 212 deletions(-) create mode 100644 docker/init/01-user-setup.sh create mode 100644 docker/init/02-postgres.sh create mode 100644 docker/init/03-init-dispatcharr.sh create mode 100644 docker/uwsgi.ini diff --git a/docker/Dockerfile b/docker/Dockerfile index 1a011943..b7fc0dcf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,9 +7,13 @@ ENV PATH="/dispatcharrpy/bin:$PATH" \ RUN apt-get update && \ apt-get install -y --no-install-recommends \ + build-essential \ curl \ gcc \ git \ + libpcre3 \ + libpcre3-dev \ + python3-dev \ wget && \ echo "=== setting up nodejs ===" && \ curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh && \ @@ -20,6 +24,7 @@ RUN apt-get update && \ python -m pip install virtualenv && \ virtualenv /dispatcharrpy && \ git clone https://github.com/Dispatcharr/Dispatcharr /app && \ + git checkout --track origin/uwsgi && \ cd /app && \ pip install --no-cache-dir -r requirements.txt && \ python manage.py collectstatic --noinput && \ @@ -45,6 +50,7 @@ RUN apt-get update && \ ffmpeg \ gnupg2 \ gpg \ + libpcre3 \ libpq-dev \ lsb-release \ nginx \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 35b30142..8a5ac8c1 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -27,227 +27,56 @@ echo_with_timestamp() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" } -# Global variables -if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then - echo "export PATH=$PATH" >> /etc/profile.d/dispatcharr.sh - echo "export VIRTUAL_ENV=$VIRTUAL_ENV" >> /etc/profile.d/dispatcharr.sh - echo "export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE" >> /etc/profile.d/dispatcharr.sh - echo "export PYTHONUNBUFFERED=$PYTHONUNBUFFERED" >> /etc/profile.d/dispatcharr.sh -fi - -chmod +x /etc/profile.d/dispatcharr.sh - -# Dispatcharr variables -export ADMIN_PORT=5656 - # Set PostgreSQL environment variables export POSTGRES_DB=${POSTGRES_DB:-dispatcharr} export POSTGRES_USER=${POSTGRES_USER:-dispatch} export POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-secret} export POSTGRES_HOST=${POSTGRES_HOST:-localhost} export POSTGRES_PORT=${POSTGRES_PORT:-5432} -export PGDATA=${PGDATA:-/app/data/db} -export PG_BINDIR="/usr/lib/postgresql/14/bin" -# Set up user details -export PUID=${PUID:-1000} -export PGID=${PGID:-1000} - -# Set up initial django admin -export DJANGO_SUPERUSER_USERNAME=${DEFAULT_USERNAME:-admin} -export DJANGO_SUPERUSER_PASSWORD=${DEFAULT_PASSWORD:-admin} -export DJANGO_SUPERUSER_EMAIL=${DEFAULT_EMAIL:-admin@dispatcharr.local} - - -# Echo environment variables for debugging -echo_with_timestamp "POSTGRES_DB: $POSTGRES_DB" -echo_with_timestamp "POSTGRES_USER: $POSTGRES_USER" -echo_with_timestamp "POSTGRES_PASSWORD: $POSTGRES_PASSWORD" -echo_with_timestamp "POSTGRES_HOST: $POSTGRES_HOST" -echo_with_timestamp "POSTGRES_PORT: $POSTGRES_PORT" - -# Create group if it doesn't exist -if ! getent group "$PGID" >/dev/null 2>&1; then - groupadd -g "$PGID" mygroup -fi -# Create user if it doesn't exist -if ! getent passwd $PUID > /dev/null 2>&1; then - useradd -u $PUID -g $PGID -m $POSTGRES_USER -else - existing_user=$(getent passwd $PUID | cut -d: -f1) - if [ "$existing_user" != "$POSTGRES_USER" ]; then - usermod -l $POSTGRES_USER -g $PGID "$existing_user" - fi +# Global variables, stored so other users inherit them +if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then + echo "export PATH=$PATH" >> /etc/profile.d/dispatcharr.sh + echo "export VIRTUAL_ENV=$VIRTUAL_ENV" >> /etc/profile.d/dispatcharr.sh + echo "export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE" >> /etc/profile.d/dispatcharr.sh + echo "export PYTHONUNBUFFERED=$PYTHONUNBUFFERED" >> /etc/profile.d/dispatcharr.sh + echo "export POSTGRES_DB=$POSTGRES_DB" >> /etc/profile.d/dispatcharr.sh + echo "export POSTGRES_USER=$POSTGRES_USER" >> /etc/profile.d/dispatcharr.sh + echo "export POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> /etc/profile.d/dispatcharr.sh + echo "export POSTGRES_HOST=$POSTGRES_HOST" >> /etc/profile.d/dispatcharr.sh + echo "export POSTGRES_PORT=$POSTGRES_PORT" >> /etc/profile.d/dispatcharr.sh fi -# If running in development mode, install and start frontend -if [ "$DISPATCHARR_ENV" = "dev" ]; then - echo "πŸš€ Development Mode - Setting up Frontend..." +chmod +x /etc/profile.d/dispatcharr.sh - # Install Node.js - echo "=== setting up nodejs ===" - curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh - bash /tmp/nodesource_setup.sh - apt-get update - apt-get install -y --no-install-recommends \ - nodejs +# Run init scripts +bash /app/docker/init/01-user-setup.sh +bash /app/docker/init/02-postgres.sh - # Install frontend dependencies - cd /app/frontend && npm install - cd /app +# Start PostgreSQL +echo "Starting Postgres..." +su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data start -w -t 300 -o '-c port=${POSTGRES_PORT}'" +# Wait for PostgreSQL to be ready +until su - postgres -c "/usr/lib/postgresql/14/bin/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + echo_with_timestamp "Waiting for PostgreSQL to be ready..." + sleep 1 +done +postgres_pid=$(su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') +echo "βœ… Postgres started with PID $postgres_pid" +pids+=("$postgres_pid") - # Start React development server - echo "πŸš€ Starting React Dev Server..." - cd /app/frontend - su - $POSTGRES_USER -c "cd /app/frontend && /app/frontend/node_modules/pm2/bin/pm2 --name dev-server start npm -- run start" - ./node_modules/pm2/bin/pm2 logs & - react_pid=$(cat /home/dispatch/.pm2/pids/dev-server*) - echo "βœ… React started with PID $react_pid" - pids+=("$react_pid") - cd /app -else - echo "πŸš€ Starting nginx..." - nginx - nginx_pid=$(pgrep nginx | sort | head -n1) - echo "βœ… nginx started with PID $nginx_pid" - pids+=("$nginx_pid") -fi +echo "πŸš€ Starting nginx..." +nginx +nginx_pid=$(pgrep nginx | sort | head -n1) +echo "βœ… nginx started with PID $nginx_pid" +pids+=("$nginx_pid") -# If running in `dev` or `aio`, start Postgres, Redis, and Celery -if [ "$DISPATCHARR_ENV" = "dev" ] || [ "$DISPATCHARR_ENV" = "aio" ]; then - echo "πŸš€ Running Postgres, Redis, and Celery for '$DISPATCHARR_ENV'..." +echo "πŸš€ Starting uwsgi..." +su - $POSTGRES_USER -c "cd /app && uwsgi --ini /app/docker/uwsgi.ini &" +uwsgi_pid=$(pgrep uwsgi | sort | head -n1) +echo "βœ… uwsgi started with PID $uwsgi_pid" +pids+=("$uwsgi_pid") - # Initialize PostgreSQL database - if [ -z "$(ls -A "$PGDATA")" ]; then - echo_with_timestamp "Initializing PostgreSQL database..." - mkdir -p "$PGDATA" - chown -R postgres:postgres "$PGDATA" - chmod 700 "$PGDATA" - - # Initialize PostgreSQL - su - postgres -c "$PG_BINDIR/initdb -D $PGDATA" - # Configure PostgreSQL - echo "host all all 0.0.0.0/0 md5" >> "$PGDATA/pg_hba.conf" - echo "listen_addresses='*'" >> "$PGDATA/postgresql.conf" - fi - - # Start Redis - echo "πŸš€ Starting Redis..." - su - $POSTGRES_USER -c "redis-server --daemonize no &" - sleep 1 # Give Redis time to start - redis_pid=$(pgrep -x redis-server) - if [ -n "$redis_pid" ]; then - echo "βœ… Redis started with PID $redis_pid" - pids+=("$redis_pid") - else - echo "❌ Redis failed to start!" - fi - - # Start Celery - echo "πŸš€ Starting Celery..." - su - $POSTGRES_USER -c "cd /app && celery -A dispatcharr worker -l info &" - celery_pid=$(pgrep -x celery) - echo "βœ… Celery started with PID $celery_pid" - pids+=("$celery_pid") - - # Start PostgreSQL - echo "Starting Postgres..." - su - postgres -c "$PG_BINDIR/pg_ctl -D $PGDATA start -w -t 300 -o '-c port=${POSTGRES_PORT}'" - # Wait for PostgreSQL to be ready - until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do - echo_with_timestamp "Waiting for PostgreSQL to be ready..." - sleep 1 - done - postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D $PGDATA status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') - echo "βœ… Postgres started with PID $postgres_pid" - pids+=("$postgres_pid") - - # Setup database if needed - if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then - # Create PostgreSQL database - echo_with_timestamp "Creating PostgreSQL database..." - su - postgres -c "createdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" - - # Create user, set ownership, and grant privileges - echo_with_timestamp "Creating PostgreSQL user..." - su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" </dev/null 2>&1; then - echo_with_timestamp "ERROR: PostgreSQL is running but the database is not accessible. Exiting..." - exit 1 - else - echo_with_timestamp "PostgreSQL database is accessible." - fi -fi - -# Run Django commands -cd /app -echo_with_timestamp "Running Django commands..." -python manage.py migrate --noinput || true -python manage.py collectstatic --noinput || true - -# Always start Gunicorn -echo "πŸš€ Starting Gunicorn..." -su - $POSTGRES_USER -c "cd /app && gunicorn --workers=4 --worker-class=gevent --timeout=300 --bind 0.0.0.0:${ADMIN_PORT} dispatcharr.wsgi:application &" -gunicorn_pid=$(pgrep -x gunicorn | sort | head -n1) -echo "βœ… Gunicorn started with PID $gunicorn_pid" -pids+=("$gunicorn_pid") - -# Log PIDs -echo "πŸ“ Process PIDs: ${pids[*]}" - -echo " - - %%%% - %%%%%%%%%%% - %%%%%%%%%%%%%%% - %%%% %%%%%%%%%% - %%%%% %%%%%%%%%% - @%%%% %%%%%%%%%% - %%%% * %%%%%%%%%% - %%%% **** %%%%%%%%%% - %%%% ******* %%%%%%%% - %%%% *********** %%%%%% - %%%% ************** %%%% - %%%% ************* % - %%%% ********** @%%% % - %%%% ******* %%%%%% - %%%% **** %%%%%%%%%% - %%%% %%%%%%%%%% - %%%% %%%%%%%%%% - %%%% %%%%%%%%% - %%%% %%%%%%%%%@ - %%%%%%%%% - @%%%%%%%%%% - %%%% - -DISPACTHARR HAS SUCCESSFULLY STARTED -" # Wait for at least one process to exit and log the process that exited first if [ ${#pids[@]} -gt 0 ]; then echo "⏳ Waiting for processes to exit..." diff --git a/docker/init/01-user-setup.sh b/docker/init/01-user-setup.sh new file mode 100644 index 00000000..43878903 --- /dev/null +++ b/docker/init/01-user-setup.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Set up user details +export PUID=${PUID:-1000} +export PGID=${PGID:-1000} + +# Create group if it doesn't exist +if ! getent group "$PGID" >/dev/null 2>&1; then + groupadd -g "$PGID" mygroup +fi +# Create user if it doesn't exist +if ! getent passwd $PUID > /dev/null 2>&1; then + useradd -u $PUID -g $PGID -m $POSTGRES_USER +else + existing_user=$(getent passwd $PUID | cut -d: -f1) + if [ "$existing_user" != "$POSTGRES_USER" ]; then + usermod -l $POSTGRES_USER -g $PGID "$existing_user" + fi +fi diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh new file mode 100644 index 00000000..36c76270 --- /dev/null +++ b/docker/init/02-postgres.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +# Inwitialize PostgreSQL database +if [ -z "$(ls -A "/data")" ]; then + echo_with_timestamp "Initializing PostgreSQL database..." + mkdir -p "/data" + chown -R postgres:postgres "/data" + chmod 700 "/data" + + # Initialize PostgreSQL + su - postgres -c "/usr/lib/postgresql/14/bin/initdb -D /data" + # Configure PostgreSQL + echo "host all all 0.0.0.0/0 md5" >> "/data/pg_hba.conf" + echo "listen_addresses='*'" >> "/data/postgresql.conf" + + # Start PostgreSQL + echo "Starting Postgres..." + su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data start -w -t 300 -o '-c port=${POSTGRES_PORT}'" + # Wait for PostgreSQL to be ready + until su - postgres -c "/usr/lib/postgresql/14/bin/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + echo_with_timestamp "Waiting for PostgreSQL to be ready..." + sleep 1 + done + + postgres_pid=$(su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') + + # Setup database if needed + if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then + # Create PostgreSQL database + echo_with_timestamp "Creating PostgreSQL database..." + su - postgres -c "createdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" + + # Create user, set ownership, and grant privileges + echo_with_timestamp "Creating PostgreSQL user..." + su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" < Date: Wed, 5 Mar 2025 08:33:12 -0500 Subject: [PATCH 02/31] init npm / node on dev environment setup, run npm server (need to make this configurable), more init items for dispatcharr --- docker/Dockerfile | 1 + docker/entrypoint.sh | 20 +++++++++++++------- docker/init/02-postgres.sh | 3 +++ docker/init/03-init-dispatcharr.sh | 1 + docker/init/99-init-dev.sh | 17 +++++++++++++++++ docker/uwsgi.ini | 1 + 6 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 docker/init/99-init-dev.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index b7fc0dcf..c33f24bf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -24,6 +24,7 @@ RUN apt-get update && \ python -m pip install virtualenv && \ virtualenv /dispatcharrpy && \ git clone https://github.com/Dispatcharr/Dispatcharr /app && \ + cd /app && \ git checkout --track origin/uwsgi && \ cd /app && \ pip install --no-cache-dir -r requirements.txt && \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8a5ac8c1..32cf7ac0 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -50,8 +50,10 @@ fi chmod +x /etc/profile.d/dispatcharr.sh # Run init scripts -bash /app/docker/init/01-user-setup.sh -bash /app/docker/init/02-postgres.sh +echo "Starting init process..." +. /app/docker/init/01-user-setup.sh +. /app/docker/init/02-postgres.sh +. /app/docker/init/03-init-dispatcharr.sh # Start PostgreSQL echo "Starting Postgres..." @@ -65,11 +67,15 @@ postgres_pid=$(su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D /data stat echo "βœ… Postgres started with PID $postgres_pid" pids+=("$postgres_pid") -echo "πŸš€ Starting nginx..." -nginx -nginx_pid=$(pgrep nginx | sort | head -n1) -echo "βœ… nginx started with PID $nginx_pid" -pids+=("$nginx_pid") +if [ "$DISPATCHARR_ENV" = "dev" ]; then + . /app/docker/init/99-init-dev.sh +else + echo "πŸš€ Starting nginx..." + nginx + nginx_pid=$(pgrep nginx | sort | head -n1) + echo "βœ… nginx started with PID $nginx_pid" + pids+=("$nginx_pid") +fi echo "πŸš€ Starting uwsgi..." su - $POSTGRES_USER -c "cd /app && uwsgi --ini /app/docker/uwsgi.ini &" diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index 36c76270..ddaa15b7 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -49,4 +49,7 @@ EOF fi kill $postgres_pid + while kill -0 $postgres_pid; do + sleep 1 + done fi diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 87520a9e..55f697bc 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -3,4 +3,5 @@ # Required so both uwsgi and nginx (www-data) can use it # @TODO: change nginx to run as the same use as uwsgi touch /app/uwsgi.sock +chown -R $PUID:$PGID /app chmod 777 /app/uwsgi.sock diff --git a/docker/init/99-init-dev.sh b/docker/init/99-init-dev.sh new file mode 100644 index 00000000..a1f13aef --- /dev/null +++ b/docker/init/99-init-dev.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +echo "πŸš€ Development Mode - Setting up Frontend..." + +# Install Node.js +if ! command -v node 2>&1 >/dev/null +then + echo "=== setting up nodejs ===" + curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh + bash /tmp/nodesource_setup.sh + apt-get update + apt-get install -y --no-install-recommends \ + nodejs +fi + +# Install frontend dependencies +cd /app/frontend && npm install diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index 72f4d6ad..b4884b62 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -4,6 +4,7 @@ exec-pre-app = python manage.py migrate --noinput attach-daemon = celery -A dispatcharr worker -l info attach-daemon = redis-server +attach-daemon = cd /app/frontend && npm run start # Core settings chdir = /app From 546704160621deefa129c8666ada2b8780278f3c Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 08:33:24 -0500 Subject: [PATCH 03/31] reverted latest changes, re-added back in logic for proper profile lock as well as determining available profiles - no mutli-stream channel support yet --- core/views.py | 112 +++++++++++++++++++++----------------------------- 1 file changed, 47 insertions(+), 65 deletions(-) diff --git a/core/views.py b/core/views.py index 002dda60..97551af3 100644 --- a/core/views.py +++ b/core/views.py @@ -32,11 +32,8 @@ def settings_view(request): def stream_view(request, stream_id): """ Streams the first available stream for the given channel. - It uses the channel’s assigned StreamProfile with a fallback to core default + It uses the channel’s assigned StreamProfile. A persistent Redis lock is used to prevent concurrent streaming on the same channel. - Priority: - - iterate through all streams - - iterate through each stream's m3u profile """ try: redis_host = getattr(settings, "REDIS_HOST", "localhost") @@ -51,72 +48,59 @@ def stream_view(request, stream_id): logger.error("No streams found for channel ID=%s", channel.id) return HttpResponseServerError("No stream found for this channel.") + # Get the first available stream. + stream = channel.streams.first() + logger.debug("Using stream: ID=%s, Name=%s", stream.id, stream.name) + + # Retrieve the M3U account associated with the stream. + m3u_account = stream.m3u_account + logger.debug("Using 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.custom_url or 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] + active_profile = None lock_key = None persistent_lock = None - - # iterate through channel's streams - for stream in channel.streams.all().order_by('channelstream__order'): - logger.debug(f"Checking stream: ID={stream.id}, Name={stream.name}") - - # Retrieve the M3U account associated with the stream. - m3u_account = stream.m3u_account - logger.debug(f"Using M3U account ID={m3u_account.id}, Name={m3u_account.name}") - - # Use the custom URL if available; otherwise, use the standard URL. - input_url = stream.custom_url or stream.url - logger.debug(f"Input URL: {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}') - # Acquire the persistent Redis lock, indexed by 0 through max_streams available in the profile - stream_index = 0 - while True: - stream_index += 1 - if stream_index > profile.max_streams: - # @TODO: we are bailing here if no profile was found, but we need to end up supporting looping through - # all available channel streams - logger.debug(f"Profile is using all available streams.") - break - - 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.") - continue - - break - - if persistent_lock.has_lock: - break - - if persistent_lock.has_lock == False: - logger.debug(f'Unable to get lock for profile {profile.id}:{profile.name}. Skipping...') + # -- 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 - break + logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available') + stream_index = 0 + while stream_index < profile.max_streams: + stream_index += 1 - if persistent_lock.has_lock == False: - logger.debug(f"Unable to find any available streams or stream profiles.") - return HttpResponseServerError("Resource busy, please try again later.") + 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}') - # *** DISABLE FAKE LOCKS: Ignore current_viewers/max_streams check *** - logger.debug(f"Using stream {stream.id}{stream.name}, M3U profile {profile.id}{profile.name}, stream index {stream_index}") - active_profile = M3UAccountProfile.objects.get(id=profile.id) + 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") + return HttpResponseServerError("No available profiles for the stream") + + 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}") @@ -148,7 +132,7 @@ def stream_view(request, stream_id): try: # Start the streaming process. - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192) + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) except Exception as e: persistent_lock.release() # Ensure the lock is released on error. logger.exception("Error starting stream for channel ID=%s", stream_id) @@ -167,7 +151,6 @@ def stream_view(request, stream_id): yield chunk finally: try: - proc.terminate() logger.debug("Streaming process terminated for stream ID=%s", s.id) except Exception as e: @@ -175,7 +158,6 @@ def stream_view(request, stream_id): persistent_lock.release() logger.debug("Persistent lock released for channel ID=%s", channel.id) - return StreamingHttpResponse( stream_generator(process, stream, persistent_lock), content_type="video/MP2T" From 412b799d7b47a2f3fc308b23a9b1bb9d34bbde02 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 11:08:04 -0500 Subject: [PATCH 04/31] new environment settings endpoint, added added in support for conditionally building video URL since we don't want dev env to proxy the stream through the react server --- core/api_urls.py | 3 +- core/api_views.py | 25 +++++ frontend/src/App.js | 50 ++------- frontend/src/api.js | 16 ++- frontend/src/components/Sidebar.js | 104 +++++++++++++++--- .../src/components/tables/ChannelsTable.js | 16 ++- frontend/src/components/tables/EPGsTable.js | 2 + .../src/components/tables/StreamsTable.js | 7 ++ frontend/src/pages/Guide.js | 13 ++- frontend/src/store/settings.js | 4 + 10 files changed, 174 insertions(+), 66 deletions(-) diff --git a/core/api_urls.py b/core/api_urls.py index 6e240927..724a3311 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet +from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment router = DefaultRouter() router.register(r'useragents', UserAgentViewSet, basename='useragent') @@ -10,5 +10,6 @@ router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile router.register(r'settings', CoreSettingsViewSet, basename='coresettings') urlpatterns = [ + path('settings/env/', environment, name='token_refresh'), path('', include(router.urls)), ] diff --git a/core/api_views.py b/core/api_views.py index 917afe12..842d650f 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -6,6 +6,10 @@ from django.shortcuts import get_object_or_404 from .models import UserAgent, StreamProfile, CoreSettings from .serializers import UserAgentSerializer, StreamProfileSerializer, CoreSettingsSerializer from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import api_view, permission_classes +from drf_yasg.utils import swagger_auto_schema +import requests +import os class UserAgentViewSet(viewsets.ModelViewSet): """ @@ -28,3 +32,24 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): """ queryset = CoreSettings.objects.all() serializer_class = CoreSettingsSerializer + +@swagger_auto_schema( + method='get', + operation_description="Endpoint for environment details", + responses={200: "Environment variables"} +) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def environment(request): + public_ip = None + try: + response = requests.get("https://api64.ipify.org?format=json") + public_ip = response.json().get("ip") + except requests.RequestException as e: + return f"Error: {e}" + + return Response({ + 'authenticated': True, + 'public_ip': public_ip, + 'env_mode': "dev" if os.getenv('DISPATCHARR_ENV', None) == "dev" else "prod", + }) diff --git a/frontend/src/App.js b/frontend/src/App.js index 9dc0fedd..a16c72d1 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -12,23 +12,13 @@ import Login from './pages/Login'; import Channels from './pages/Channels'; import M3U from './pages/M3U'; import { ThemeProvider } from '@mui/material/styles'; -import { - Box, - CssBaseline, - Drawer, - List, - ListItem, - ListItemButton, - ListItemText, - Divider, -} from '@mui/material'; +import { Box, CssBaseline } from '@mui/material'; import theme from './theme'; import EPG from './pages/EPG'; import Guide from './pages/Guide'; import Settings from './pages/Settings'; import StreamProfiles from './pages/StreamProfiles'; import useAuthStore from './store/auth'; -import logo from './images/logo.png'; import Alert from './components/Alert'; import FloatingVideo from './components/FloatingVideo'; import SuperuserForm from './components/forms/SuperuserForm'; @@ -90,39 +80,13 @@ const App = () => { - - - - - logo - {open && ( - - )} - - - - - - + miniDrawerWidth={miniDrawerWidth} + drawerWidth={drawerWidth} + toggleDrawer={toggleDrawer} + /> + , route: '/channels' }, @@ -29,24 +37,90 @@ const items = [ { text: 'Settings', icon: , route: '/settings' }, ]; -const Sidebar = ({ open }) => { +const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => { const location = useLocation(); + const { isAuthenticated } = useAuthStore(); + const { + environment: { public_ip }, + } = useSettingsStore(); return ( - - {items.map((item) => ( - - - {item.icon} - {open && } - - - ))} - + + + + + + logo + {open && ( + + )} + + + + + + + {items.map((item) => ( + + + {item.icon} + {open && } + + + ))} + + + + {isAuthenticated && ( + + + + + + + + + + + + + + )} + ); }; diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js index 2572085e..3fcfca7b 100644 --- a/frontend/src/components/tables/ChannelsTable.js +++ b/frontend/src/components/tables/ChannelsTable.js @@ -32,6 +32,7 @@ import { TableHelper } from '../../helpers'; import utils from '../../utils'; import logo from '../../images/logo.png'; import useVideoStore from '../../store/useVideoStore'; +import useSettingsStore from '../../store/settings'; const ChannelsTable = () => { const [channel, setChannel] = useState(null); @@ -42,9 +43,12 @@ const ChannelsTable = () => { const [textToCopy, setTextToCopy] = useState(''); const [snackbarMessage, setSnackbarMessage] = useState(''); const [snackbarOpen, setSnackbarOpen] = useState(false); + const { showVideo } = useVideoStore.getState(); // or useVideoStore() const { channels, isLoading: channelsLoading } = useChannelsStore(); - const { showVideo } = useVideoStore.getState(); // or useVideoStore() + const { + environment: { env_mode }, + } = useSettingsStore(); // Configure columns const columns = useMemo( @@ -100,11 +104,17 @@ const ChannelsTable = () => { }; const deleteChannel = async (id) => { + setIsLoading(true); await API.deleteChannel(id); + setIsLoading(false); }; function handleWatchStream(channelNumber) { - showVideo(`/output/stream/${channelNumber}/`); + let vidUrl = `/output/stream/${channelNumber}/`; + if (env_mode == 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + showVideo(vidUrl); } // (Optional) bulk delete, but your endpoint is @TODO @@ -131,7 +141,9 @@ const ChannelsTable = () => { const rowOrder = table.getRowModel().rows.map((row) => row.original.id); // Call our custom API endpoint + setIsLoading(true); const result = await API.assignChannelNumbers(rowOrder); + setIsLoading(false); // We might get { message: "Channels have been auto-assigned!" } setSnackbarMessage(result.message || 'Channels assigned'); diff --git a/frontend/src/components/tables/EPGsTable.js b/frontend/src/components/tables/EPGsTable.js index a23e89f9..aadc516f 100644 --- a/frontend/src/components/tables/EPGsTable.js +++ b/frontend/src/components/tables/EPGsTable.js @@ -75,7 +75,9 @@ const EPGsTable = () => { }; const deleteEPG = async (id) => { + setIsLoading(true); await API.deleteEPG(id); + setIsLoading(false); }; const refreshEPG = async (id) => { diff --git a/frontend/src/components/tables/StreamsTable.js b/frontend/src/components/tables/StreamsTable.js index a3591fcf..ca8e0c60 100644 --- a/frontend/src/components/tables/StreamsTable.js +++ b/frontend/src/components/tables/StreamsTable.js @@ -53,11 +53,13 @@ const StreamsTable = () => { // Fallback: Individual creation (optional) const createChannelFromStream = async (stream) => { + setIsLoading(true); await API.createChannelFromStream({ channel_name: stream.name, channel_number: null, stream_id: stream.id, }); + setIsLoading(false); }; // Bulk creation: create channels from selected streams in one API call @@ -67,6 +69,7 @@ const StreamsTable = () => { .getRowModel() .rows.filter((row) => row.getIsSelected()); + setIsLoading(true); await API.createChannelsFromStreams( selected.map((sel) => ({ stream_id: sel.original.id, @@ -82,14 +85,18 @@ const StreamsTable = () => { }; const deleteStream = async (id) => { + setIsLoading(true); await API.deleteStream(id); + setIsLoading(false); }; const deleteStreams = async () => { + setIsLoading(true); const selected = table .getRowModel() .rows.filter((row) => row.getIsSelected()); await API.deleteStreams(selected.map((stream) => stream.original.id)); + setIsLoading(false); }; const closeStreamForm = () => { diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js index e17b92fe..bfc49788 100644 --- a/frontend/src/pages/Guide.js +++ b/frontend/src/pages/Guide.js @@ -20,6 +20,7 @@ import useChannelsStore from '../store/channels'; import logo from '../images/logo.png'; import useVideoStore from '../store/useVideoStore'; // NEW import import useAlertStore from '../store/alerts'; +import useSettingsStore from '../store/settings'; /** Layout constants */ const CHANNEL_WIDTH = 120; // Width of the channel/logo column @@ -46,6 +47,9 @@ export default function TVChannelGuide({ startDate, endDate }) { const [selectedProgram, setSelectedProgram] = useState(null); const [loading, setLoading] = useState(true); const { showAlert } = useAlertStore(); + const { + environment: { env_mode }, + } = useSettingsStore(); const guideRef = useRef(null); @@ -172,9 +176,12 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } // Build a playable stream URL for that channel - const url = - window.location.origin + '/output/stream/' + matched.channel_number; - showVideo(url); + let vidUrl = `/output/stream/${matched.channel_number}/`; + if (env_mode == 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + + showVideo(vidUrl); // Optionally close the modal setSelectedProgram(null); diff --git a/frontend/src/store/settings.js b/frontend/src/store/settings.js index 9c13be2f..b35dbb7c 100644 --- a/frontend/src/store/settings.js +++ b/frontend/src/store/settings.js @@ -3,6 +3,7 @@ import api from '../api'; const useSettingsStore = create((set) => ({ settings: {}, + environment: {}, isLoading: false, error: null, @@ -10,12 +11,15 @@ const useSettingsStore = create((set) => ({ set({ isLoading: true, error: null }); try { const settings = await api.getSettings(); + const env = await api.getEnvironmentSettings(); + console.log(env); set({ settings: settings.reduce((acc, setting) => { acc[setting.key] = setting; return acc; }, {}), isLoading: false, + environment: env, }); } catch (error) { console.error('Failed to fetch settings:', error); From 31b1f1fe362a5f02cc0f6208a13a791024071815 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 14:15:25 -0500 Subject: [PATCH 05/31] use exec-before so these aren't executed once per worker, added daphne for websockets --- docker/uwsgi.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index b4884b62..2606fecf 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -1,10 +1,11 @@ [uwsgi] -exec-pre-app = python manage.py collectstatic --noinput -exec-pre-app = python manage.py migrate --noinput +exec-before = python manage.py collectstatic --noinput +exec-before = python manage.py migrate --noinput attach-daemon = celery -A dispatcharr worker -l info attach-daemon = redis-server attach-daemon = cd /app/frontend && npm run start +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application # Core settings chdir = /app From 75b828eb0761443c87c4a373712338ba0090fb88 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 14:19:08 -0500 Subject: [PATCH 06/31] new variables --- docker/entrypoint.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 32cf7ac0..0ab7daf8 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -45,6 +45,8 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then echo "export POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> /etc/profile.d/dispatcharr.sh echo "export POSTGRES_HOST=$POSTGRES_HOST" >> /etc/profile.d/dispatcharr.sh echo "export POSTGRES_PORT=$POSTGRES_PORT" >> /etc/profile.d/dispatcharr.sh + echo "export DISPATCHARR_ENV=$DISPATCHARR_ENV" >> /etc/profile.d/dispatcharr.sh + echo "export REACT_APP_ENV_MODE=$DISPATCHARR_ENV" >> /etc/profile.d/dispatcharr.sh fi chmod +x /etc/profile.d/dispatcharr.sh From 80fbb66dbdc0fe3db417f1a92d22d2a356a3e3cb Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 14:53:00 -0500 Subject: [PATCH 07/31] removed dev for now --- docker/uwsgi.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index 2606fecf..d563db47 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -4,8 +4,8 @@ exec-before = python manage.py migrate --noinput attach-daemon = celery -A dispatcharr worker -l info attach-daemon = redis-server -attach-daemon = cd /app/frontend && npm run start -attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application +; attach-daemon = cd /app/frontend && npm run start +; attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application # Core settings chdir = /app From 206a0814becdeb682f3010cfadba8808069b7ded Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 14:53:47 -0500 Subject: [PATCH 08/31] updated scripts, attempting to fix perms issue on socket --- docker/init/01-user-setup.sh | 4 +++- docker/init/03-init-dispatcharr.sh | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docker/init/01-user-setup.sh b/docker/init/01-user-setup.sh index 43878903..9b2755d1 100644 --- a/docker/init/01-user-setup.sh +++ b/docker/init/01-user-setup.sh @@ -6,7 +6,7 @@ export PGID=${PGID:-1000} # Create group if it doesn't exist if ! getent group "$PGID" >/dev/null 2>&1; then - groupadd -g "$PGID" mygroup + groupadd -g "$PGID" dispatch fi # Create user if it doesn't exist if ! getent passwd $PUID > /dev/null 2>&1; then @@ -17,3 +17,5 @@ else usermod -l $POSTGRES_USER -g $PGID "$existing_user" fi fi + +usermod -aG www-data $POSTGRES_USER diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 55f697bc..4aba05d6 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -4,4 +4,5 @@ # @TODO: change nginx to run as the same use as uwsgi touch /app/uwsgi.sock chown -R $PUID:$PGID /app +chown $PUID:www-data /app/uwsgi.sock chmod 777 /app/uwsgi.sock From 0c73549efbcbd2b633b1a1340791465ee0854710 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 15:04:50 -0500 Subject: [PATCH 09/31] new packages for websockets --- requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9303919f..7a00b5e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ Django==5.1.6 -gunicorn==23.0.0 psycopg2-binary==2.9.10 redis==4.5.5 celery @@ -23,3 +22,6 @@ torch==2.6.0+cpu # ML/NLP dependencies sentence-transformers==3.4.1 uwsgi +channels +channels-redis +daphne From 1c34fdba03c642594a875c29a691ca65740936e6 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 15:24:29 -0500 Subject: [PATCH 10/31] moved managelpy into entrypoint, hopefully fixed perms with socket --- docker/entrypoint.sh | 4 ++++ docker/uwsgi.ini | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0ab7daf8..680cd5e9 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -79,6 +79,10 @@ else pids+=("$nginx_pid") fi +cd /app +python manage.py migrate +python manage.py collectstatic + echo "πŸš€ Starting uwsgi..." su - $POSTGRES_USER -c "cd /app && uwsgi --ini /app/docker/uwsgi.ini &" uwsgi_pid=$(pgrep uwsgi | sort | head -n1) diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index d563db47..ccc22702 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -1,6 +1,6 @@ [uwsgi] -exec-before = python manage.py collectstatic --noinput -exec-before = python manage.py migrate --noinput +; exec-before = python manage.py collectstatic --noinput +; exec-before = python manage.py migrate --noinput attach-daemon = celery -A dispatcharr worker -l info attach-daemon = redis-server @@ -14,7 +14,7 @@ virtualenv = /dispatcharrpy master = true env = DJANGO_SETTINGS_MODULE=dispatcharr.settings socket = /app/uwsgi.sock -chmod-socket = 664 +; chmod-socket = 664 vacuum = true die-on-term = true From 85746495001ad7650ea756f8f546bcae574dc140 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 15:37:49 -0500 Subject: [PATCH 11/31] noinput needed --- docker/entrypoint.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 680cd5e9..231a5deb 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -80,8 +80,8 @@ else fi cd /app -python manage.py migrate -python manage.py collectstatic +python manage.py migrate --noinput +python manage.py collectstatic --noinput echo "πŸš€ Starting uwsgi..." su - $POSTGRES_USER -c "cd /app && uwsgi --ini /app/docker/uwsgi.ini &" From fe8426df2e92f03fffd6728002837922ae8080d5 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 15:54:35 -0500 Subject: [PATCH 12/31] don't want 777, but for testing --- docker/uwsgi.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index ccc22702..bd4cd574 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -14,7 +14,7 @@ virtualenv = /dispatcharrpy master = true env = DJANGO_SETTINGS_MODULE=dispatcharr.settings socket = /app/uwsgi.sock -; chmod-socket = 664 +chmod-socket = 777 vacuum = true die-on-term = true From 993ab0828f93c91e0aa0e3c24efc3e04f05df65c Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 17:03:53 -0500 Subject: [PATCH 13/31] frontend tweaks, better UX with loading, added in websockets --- frontend/src/App.js | 98 ++++++++++--------- frontend/src/WebSocket.js | 78 +++++++++++++++ frontend/src/components/Sidebar.js | 2 +- frontend/src/components/forms/LoginForm.js | 6 +- frontend/src/components/forms/M3U.js | 2 +- .../src/components/forms/SuperuserForm.js | 31 ++++-- .../src/components/tables/StreamsTable.js | 2 +- frontend/src/store/auth.js | 2 - frontend/src/store/settings.js | 2 - frontend/src/store/streams.js | 8 +- 10 files changed, 164 insertions(+), 67 deletions(-) create mode 100644 frontend/src/WebSocket.js diff --git a/frontend/src/App.js b/frontend/src/App.js index a16c72d1..def311ff 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -22,6 +22,7 @@ import useAuthStore from './store/auth'; import Alert from './components/Alert'; import FloatingVideo from './components/FloatingVideo'; import SuperuserForm from './components/forms/SuperuserForm'; +import { WebsocketProvider } from './WebSocket'; const drawerWidth = 240; const miniDrawerWidth = 60; @@ -79,60 +80,65 @@ const App = () => { return ( - - + + + - - - {isAuthenticated ? ( - <> - } /> - } /> - } /> - } /> - } /> - } /> - - ) : ( - } /> - )} - - } - /> - + + + {isAuthenticated ? ( + <> + } /> + } /> + } /> + } + /> + } /> + } /> + + ) : ( + } /> + )} + + } + /> + + - - - - + + + + ); }; diff --git a/frontend/src/WebSocket.js b/frontend/src/WebSocket.js new file mode 100644 index 00000000..a1fa1aaa --- /dev/null +++ b/frontend/src/WebSocket.js @@ -0,0 +1,78 @@ +import React, { + useState, + useEffect, + useRef, + createContext, + useContext, +} from 'react'; +import useStreamsStore from './store/streams'; +import useAlertStore from './store/alerts'; + +export const WebsocketContext = createContext(false, null, () => {}); + +export const WebsocketProvider = ({ children }) => { + const [isReady, setIsReady] = useState(false); + const [val, setVal] = useState(null); + + const { showAlert } = useAlertStore(); + + const ws = useRef(null); + + useEffect(() => { + let wsUrl = `ws://${window.location.host}/ws/`; + if (process.env.REACT_APP_ENV_MODE == 'dev') { + wsUrl = `ws://${window.location.hostname}:8001/ws/`; + } + + const socket = new WebSocket(wsUrl); + + socket.onopen = () => { + console.log('websocket connected'); + setIsReady(true); + }; + + // Reconnection logic + socket.onclose = () => { + setIsReady(false); + setTimeout(() => { + const reconnectWs = new WebSocket(wsUrl); + reconnectWs.onopen = () => setIsReady(true); + }, 3000); // Attempt to reconnect every 3 seconds + }; + + socket.onmessage = async (event) => { + event = JSON.parse(event.data); + switch (event.type) { + case 'm3u_refresh': + if (event.message?.success) { + useStreamsStore.getState().fetchStreams(); + showAlert(event.message.message, 'success'); + } + break; + + default: + console.error(`Unknown websocket event type: ${event.type}`); + break; + } + }; + + ws.current = socket; + + return () => { + socket.close(); + }; + }, []); + + const ret = [isReady, val, ws.current?.send.bind(ws.current)]; + + return ( + + {children} + + ); +}; + +export const useWebSocket = () => { + const socket = useContext(WebsocketContext); + return socket; +}; diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index abe9db7a..8b99d601 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -100,7 +100,7 @@ const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => { {isAuthenticated && ( - + diff --git a/frontend/src/components/forms/LoginForm.js b/frontend/src/components/forms/LoginForm.js index 2272d6f4..f7b4445e 100644 --- a/frontend/src/components/forms/LoginForm.js +++ b/frontend/src/components/forms/LoginForm.js @@ -66,7 +66,7 @@ const LoginForm = () => { justifyContent="center" direction="column" > - + { size="small" /> - + { size="small" /> - + diff --git a/frontend/src/components/tables/StreamsTable.js b/frontend/src/components/tables/StreamsTable.js index ca8e0c60..6f4fcd4d 100644 --- a/frontend/src/components/tables/StreamsTable.js +++ b/frontend/src/components/tables/StreamsTable.js @@ -141,7 +141,7 @@ const StreamsTable = () => { size="small" color="warning" onClick={() => editStream(row.original)} - disabled={row.original.m3u_account} + disabled={row.original.m3u_account ? true : false} sx={{ p: 0 }} > diff --git a/frontend/src/store/auth.js b/frontend/src/store/auth.js index 0b9f2f8a..8904e489 100644 --- a/frontend/src/store/auth.js +++ b/frontend/src/store/auth.js @@ -29,7 +29,6 @@ const useAuthStore = create((set, get) => ({ setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }), initData: async () => { - console.log('fetching data'); await Promise.all([ useChannelsStore.getState().fetchChannels(), useChannelsStore.getState().fetchChannelGroups(), @@ -43,7 +42,6 @@ const useAuthStore = create((set, get) => ({ }, getToken: async () => { - const expiration = localStorage.getItem('tokenExpiration'); const tokenExpiration = localStorage.getItem('tokenExpiration'); let accessToken = null; if (isTokenExpired(tokenExpiration)) { diff --git a/frontend/src/store/settings.js b/frontend/src/store/settings.js index b35dbb7c..5dffbed6 100644 --- a/frontend/src/store/settings.js +++ b/frontend/src/store/settings.js @@ -12,7 +12,6 @@ const useSettingsStore = create((set) => ({ try { const settings = await api.getSettings(); const env = await api.getEnvironmentSettings(); - console.log(env); set({ settings: settings.reduce((acc, setting) => { acc[setting.key] = setting; @@ -22,7 +21,6 @@ const useSettingsStore = create((set) => ({ environment: env, }); } catch (error) { - console.error('Failed to fetch settings:', error); set({ error: 'Failed to load settings.', isLoading: false }); } }, diff --git a/frontend/src/store/streams.js b/frontend/src/store/streams.js index 215a17c8..a594b8a7 100644 --- a/frontend/src/store/streams.js +++ b/frontend/src/store/streams.js @@ -1,5 +1,5 @@ -import { create } from "zustand"; -import api from "../api"; +import { create } from 'zustand'; +import api from '../api'; const useStreamsStore = create((set) => ({ streams: [], @@ -12,8 +12,8 @@ const useStreamsStore = create((set) => ({ const streams = await api.getStreams(); set({ streams: streams, isLoading: false }); } catch (error) { - console.error("Failed to fetch streams:", error); - set({ error: "Failed to load streams.", isLoading: false }); + console.error('Failed to fetch streams:', error); + set({ error: 'Failed to load streams.', isLoading: false }); } }, From 3ecb49375c9015a40470ab4115e31132e12ca147 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 17:04:43 -0500 Subject: [PATCH 14/31] Websockets, fixed channel name collision, added back in multi-stream per channel support --- .gitignore | 1 + apps/accounts/models.py | 2 +- apps/channels/apps.py | 3 +- apps/m3u/tasks.py | 12 ++++- core/views.py | 97 +++++++++++++++++++++++----------------- dispatcharr/asgi.py | 16 ++++--- dispatcharr/consumers.py | 18 ++++++++ dispatcharr/routing.py | 6 +++ dispatcharr/settings.py | 14 +++++- dispatcharr/urls.py | 5 ++- 10 files changed, 122 insertions(+), 52 deletions(-) create mode 100644 dispatcharr/consumers.py create mode 100644 dispatcharr/routing.py diff --git a/.gitignore b/.gitignore index 9a8b48aa..0ca57de6 100755 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ node_modules/ .history/ staticfiles/ +static/ diff --git a/apps/accounts/models.py b/apps/accounts/models.py index accd6ee7..5b24549f 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -9,7 +9,7 @@ class User(AbstractUser): """ avatar_config = models.JSONField(default=dict, blank=True, null=True) channel_groups = models.ManyToManyField( - 'channels.ChannelGroup', # Updated reference to renamed model + 'dispatcharr_channels.ChannelGroup', # Updated reference to renamed model blank=True, related_name="users" ) diff --git a/apps/channels/apps.py b/apps/channels/apps.py index bcca01ee..d6d29a80 100644 --- a/apps/channels/apps.py +++ b/apps/channels/apps.py @@ -4,7 +4,8 @@ class ChannelsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.channels' verbose_name = "Channel & Stream Management" - + label = 'dispatcharr_channels' + def ready(self): # Import signals so they get registered. import apps.channels.signals diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index d39a6511..30d8b11e 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -9,6 +9,8 @@ from django.conf import settings from django.core.cache import cache from .models import M3UAccount from apps.channels.models import Stream +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer logger = logging.getLogger(__name__) @@ -120,7 +122,7 @@ def refresh_single_m3u_account(account_id): # Extract tvg-id tvg_id_match = re.search(r'tvg-id="([^"]*)"', line) tvg_id = tvg_id_match.group(1) if tvg_id_match else "" - + fallback_name = line.split(",", 1)[-1].strip() if "," in line else "Default Stream" name = tvg_name_match.group(1) if tvg_name_match else fallback_name @@ -178,6 +180,14 @@ def refresh_single_m3u_account(account_id): logger.info(f"Completed parsing. Created {created_count} new Streams, updated {updated_count} existing Streams, excluded {excluded_count} Streams.") release_lock('refresh_single_m3u_account', account_id) + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "m3u_refresh", + "message": {"success": True, "message": "M3U refresh completed successfully"} + }, + ) return f"Account {account_id} => Created {created_count}, updated {updated_count}, excluded {excluded_count} Streams." def process_uploaded_m3u_file(file, account): diff --git a/core/views.py b/core/views.py index 97551af3..205e83c5 100644 --- a/core/views.py +++ b/core/views.py @@ -37,7 +37,7 @@ def stream_view(request, stream_id): """ try: redis_host = getattr(settings, "REDIS_HOST", "localhost") - redis_client = redis.Redis(host=settings.REDIS_HOST, port=6379, db=0) + redis_client = redis.Redis(host=settings.REDIS_HOST, port=6379, db=getattr(settings, "REDIS_DB", "0")) # Retrieve the channel by the provided stream_id. channel = Channel.objects.get(channel_number=stream_id) @@ -48,57 +48,70 @@ def stream_view(request, stream_id): logger.error("No streams found for channel ID=%s", channel.id) return HttpResponseServerError("No stream found for this channel.") - # Get the first available stream. - stream = channel.streams.first() - logger.debug("Using stream: ID=%s, Name=%s", stream.id, stream.name) - - # Retrieve the M3U account associated with the stream. - m3u_account = stream.m3u_account - logger.debug("Using 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.custom_url or 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] - + active_stream = None + m3u_account = None active_profile = None lock_key = None persistent_lock = None - # -- 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 - while stream_index < profile.max_streams: - stream_index += 1 + 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) - 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}') + # 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) - 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 + # Use the custom URL if available; otherwise, use the standard URL. + input_url = stream.custom_url or 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 - break + logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available') + stream_index = 0 + while stream_index < profile.max_streams: + stream_index += 1 - if persistent_lock is not None: - logger.debug(f'Successfully acquired lock: {lock_key}') - active_profile = M3UAccountProfile.objects.get(id=profile.id) - break + 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 active_profile is None or persistent_lock is None: - logger.exception("No available profiles for the stream") - return HttpResponseServerError("No available profiles for the stream") + 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. diff --git a/dispatcharr/asgi.py b/dispatcharr/asgi.py index fc4a377b..5e60f635 100644 --- a/dispatcharr/asgi.py +++ b/dispatcharr/asgi.py @@ -1,8 +1,14 @@ -""" -ASGI config for dispatcharr project. -""" import os from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +import dispatcharr.routing -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings') -application = get_asgi_application() +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dispatcharr.settings") + +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack( + URLRouter(dispatcharr.routing.websocket_urlpatterns) + ), +}) diff --git a/dispatcharr/consumers.py b/dispatcharr/consumers.py new file mode 100644 index 00000000..9c56605d --- /dev/null +++ b/dispatcharr/consumers.py @@ -0,0 +1,18 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer + +class MyWebSocketConsumer(AsyncWebsocketConsumer): + async def connect(self): + await self.accept() + self.room_name = "updates" + await self.channel_layer.group_add(self.room_name, self.channel_name) + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.room_name, self.channel_name) + + async def receive(self, text_data): + data = json.loads(text_data) + print("Received:", data) + + async def m3u_refresh(self, event): + await self.send(text_data=json.dumps(event)) diff --git a/dispatcharr/routing.py b/dispatcharr/routing.py new file mode 100644 index 00000000..7624e21d --- /dev/null +++ b/dispatcharr/routing.py @@ -0,0 +1,6 @@ +from django.urls import path +from dispatcharr.consumers import MyWebSocketConsumer + +websocket_urlpatterns = [ + path("ws/", MyWebSocketConsumer.as_asgi()), +] diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 261b49ee..36445331 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -6,6 +6,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = 'REPLACE_ME_WITH_A_REAL_SECRET' REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") +REDIS_DB = os.environ.get("REDIS_DB", "localhost") DEBUG = True ALLOWED_HOSTS = ["*"] @@ -13,7 +14,7 @@ ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ 'apps.api', 'apps.accounts', - 'apps.channels', + 'apps.channels.apps.ChannelsConfig', 'apps.dashboard', 'apps.epg', 'apps.hdhr', @@ -21,6 +22,8 @@ INSTALLED_APPS = [ 'apps.output', 'core', 'drf_yasg', + 'daphne', + 'channels', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -69,6 +72,15 @@ TEMPLATES = [ WSGI_APPLICATION = 'dispatcharr.wsgi.application' ASGI_APPLICATION = 'dispatcharr.asgi.application' +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [(REDIS_HOST, 6379, REDIS_DB)], # Ensure Redis is running + }, + }, +} + if os.getenv('DB_ENGINE', None) == 'sqlite': DATABASES = { 'default': { diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 3a3b3672..e9595cbf 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -6,6 +6,7 @@ from django.views.generic import TemplateView, RedirectView from rest_framework import permissions from drf_yasg.views import get_schema_view from drf_yasg import openapi +from .routing import websocket_urlpatterns # Define schema_view for Swagger schema_view = get_schema_view( @@ -24,7 +25,7 @@ schema_view = get_schema_view( urlpatterns = [ # API Routes path('api/', include(('apps.api.urls', 'api'), namespace='api')), - path('api', RedirectView.as_view(url='/api/', permanent=True)), + path('api', RedirectView.as_view(url='/api/', permanent=True)), # Admin path('admin', RedirectView.as_view(url='/admin/', permanent=True)), # This fixes the issue @@ -52,6 +53,8 @@ urlpatterns = [ ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +urlpatterns += websocket_urlpatterns + # Serve static files for development (React's JS, CSS, etc.) if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) From a9437e9214f276da25f24af57969cc5b474be582 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 17:18:04 -0500 Subject: [PATCH 15/31] new migrations --- apps/accounts/migrations/0001_initial.py | 6 +++--- apps/channels/migrations/0001_initial.py | 10 +++++----- apps/dashboard/migrations/0001_initial.py | 2 +- apps/epg/migrations/0001_initial.py | 2 +- apps/hdhr/migrations/0001_initial.py | 2 +- apps/m3u/migrations/0001_initial.py | 2 +- core/migrations/0001_initial.py | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py index bc92ebe6..1df20e37 100644 --- a/apps/accounts/migrations/0001_initial.py +++ b/apps/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 import django.contrib.auth.models import django.contrib.auth.validators @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('auth', '0012_alter_user_first_name_max_length'), - ('channels', '0001_initial'), + ('dispatcharr_channels', '0001_initial'), ] operations = [ @@ -31,7 +31,7 @@ class Migration(migrations.Migration): ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('avatar_config', models.JSONField(blank=True, default=dict, null=True)), - ('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='channels.channelgroup')), + ('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='dispatcharr_channels.channelgroup')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], diff --git a/apps/channels/migrations/0001_initial.py b/apps/channels/migrations/0001_initial.py index 8401450e..00ddbc76 100644 --- a/apps/channels/migrations/0001_initial.py +++ b/apps/channels/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 import django.db.models.deletion from django.db import migrations, models @@ -32,7 +32,7 @@ class Migration(migrations.Migration): ('tvg_id', models.CharField(blank=True, max_length=255, null=True)), ('tvg_name', models.CharField(blank=True, max_length=255, null=True)), ('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='core.streamprofile')), - ('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='channels.channelgroup')), + ('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='dispatcharr_channels.channelgroup')), ], ), migrations.CreateModel( @@ -62,8 +62,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order', models.PositiveIntegerField(default=0)), - ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='channels.channel')), - ('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='channels.stream')), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channel')), + ('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.stream')), ], options={ 'ordering': ['order'], @@ -72,6 +72,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='channel', name='streams', - field=models.ManyToManyField(blank=True, related_name='channels', through='channels.ChannelStream', to='channels.stream'), + field=models.ManyToManyField(blank=True, related_name='channels', through='dispatcharr_channels.ChannelStream', to='dispatcharr_channels.stream'), ), ] diff --git a/apps/dashboard/migrations/0001_initial.py b/apps/dashboard/migrations/0001_initial.py index 9c39a3b7..ba3311b1 100644 --- a/apps/dashboard/migrations/0001_initial.py +++ b/apps/dashboard/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 from django.db import migrations, models diff --git a/apps/epg/migrations/0001_initial.py b/apps/epg/migrations/0001_initial.py index 9454d514..7c77ba5e 100644 --- a/apps/epg/migrations/0001_initial.py +++ b/apps/epg/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 import django.db.models.deletion from django.db import migrations, models diff --git a/apps/hdhr/migrations/0001_initial.py b/apps/hdhr/migrations/0001_initial.py index 54ad7c8c..14b17ceb 100644 --- a/apps/hdhr/migrations/0001_initial.py +++ b/apps/hdhr/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 from django.db import migrations, models diff --git a/apps/m3u/migrations/0001_initial.py b/apps/m3u/migrations/0001_initial.py index eb92f063..7a20a713 100644 --- a/apps/m3u/migrations/0001_initial.py +++ b/apps/m3u/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 import django.db.models.deletion from django.db import migrations, models diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 79757ec4..b9290f90 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 from django.db import migrations, models From 84e73b9415fd388b09456a9f176325d2de54c6b2 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 19:00:13 -0500 Subject: [PATCH 16/31] dev mode of uwsgi --- docker/entrypoint.sh | 7 ++++++- docker/uwsgi.dev.ini | 42 ++++++++++++++++++++++++++++++++++++++++++ docker/uwsgi.ini | 3 +-- 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 docker/uwsgi.dev.ini diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 231a5deb..305f061f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -83,8 +83,13 @@ cd /app python manage.py migrate --noinput python manage.py collectstatic --noinput +uwsgi_file="/app/docker/uwsgi.ini" +if [ "$DISPATCHARR_ENV" = "dev" ]; then + uwsgi_file="/app/docker/uwsgi.dev.ini" +fi + echo "πŸš€ Starting uwsgi..." -su - $POSTGRES_USER -c "cd /app && uwsgi --ini /app/docker/uwsgi.ini &" +su - $POSTGRES_USER -c "cd /app && uwsgi --ini $uwsgi_file &" uwsgi_pid=$(pgrep uwsgi | sort | head -n1) echo "βœ… uwsgi started with PID $uwsgi_pid" pids+=("$uwsgi_pid") diff --git a/docker/uwsgi.dev.ini b/docker/uwsgi.dev.ini new file mode 100644 index 00000000..9bd0ab3f --- /dev/null +++ b/docker/uwsgi.dev.ini @@ -0,0 +1,42 @@ +[uwsgi] +; exec-before = python manage.py collectstatic --noinput +; exec-before = python manage.py migrate --noinput + +attach-daemon = celery -A dispatcharr worker -l info +attach-daemon = redis-server +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application +attach-daemon = cd /app/frontend && npm run start + +# Core settings +chdir = /app +module = dispatcharr.wsgi:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true + +# Worker management (Optimize for I/O bound tasks) +workers = 4 +threads = 2 +enable-threads = true + +# Optimize for streaming +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 # Increase buffer for large payloads +post-buffering = 4096 # Reduce buffering for real-time streaming +http-timeout = 600 # Prevent disconnects from long streams +lazy-apps = true # Improve memory efficiency + +# Async mode (use gevent for high concurrency) +gevent = 100 +async = 100 + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index bd4cd574..ace423af 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -4,8 +4,7 @@ attach-daemon = celery -A dispatcharr worker -l info attach-daemon = redis-server -; attach-daemon = cd /app/frontend && npm run start -; attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application # Core settings chdir = /app From a115710c8c3cc246356871c3f7a60c767b651916 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 21:18:34 -0500 Subject: [PATCH 17/31] updated with alpha-v1 tag --- docker/docker-compose.aio.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index afed565d..6f67bded 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -3,7 +3,7 @@ services: # build: # context: . # dockerfile: Dockerfile - image: dekzter/dispactharr + image: dispatcharr/dispatcharr:alpha-v1 container_name: dispatcharr ports: - 9191:9191 From 896d1a7cc6a6cba3d7f719a63d5adae1bb8995ad Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 21:19:37 -0500 Subject: [PATCH 18/31] updated with alpha-v1 t ag --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7f38d743..d183fada 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,6 @@ services: web: - image: dispatcharr/dispatcharr + image: dispatcharr/dispatcharr:alpha-v1 container_name: dispatcharr_web ports: - 9191:9191 From 5ced1d56538980216f3f40bf3ca8e59f72dbe9b3 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 5 Mar 2025 21:40:44 -0500 Subject: [PATCH 19/31] updated celery image --- docker/docker-compose.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d183fada..e6a06603 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,9 +16,7 @@ services: - CELERY_BROKER_URL=redis://redis:6379/0 celery: - build: - context: .. - dockerfile: docker/Dockerfile + image: dispatcharr/dispatcharr:alpha-v1 container_name: dispatcharr_celery depends_on: - db From 143ba85be6a6f59b788ee180a74e492fc429c1d8 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 6 Mar 2025 12:02:57 -0500 Subject: [PATCH 20/31] support wss for websockets --- frontend/src/WebSocket.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/WebSocket.js b/frontend/src/WebSocket.js index a1fa1aaa..b121e7d8 100644 --- a/frontend/src/WebSocket.js +++ b/frontend/src/WebSocket.js @@ -19,9 +19,15 @@ export const WebsocketProvider = ({ children }) => { const ws = useRef(null); useEffect(() => { - let wsUrl = `ws://${window.location.host}/ws/`; + let wsUrl = `${window.location.host}/ws/`; if (process.env.REACT_APP_ENV_MODE == 'dev') { - wsUrl = `ws://${window.location.hostname}:8001/ws/`; + wsUrl = `${window.location.hostname}:8001/ws/`; + } + + if (window.location.protocol.match(/https/)) { + wsUrl = `wss://${wsUrl}`; + } else { + wsUrl = `ws://${wsUrl}`; } const socket = new WebSocket(wsUrl); From 55d57f7e4ea1b963ef88e0a70f7baf638f54ad88 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 6 Mar 2025 12:03:24 -0500 Subject: [PATCH 21/31] fixed bad reference to redis db --- dispatcharr/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 36445331..0dd1f20d 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -6,7 +6,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = 'REPLACE_ME_WITH_A_REAL_SECRET' REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") -REDIS_DB = os.environ.get("REDIS_DB", "localhost") +REDIS_DB = os.environ.get("REDIS_DB", "0") DEBUG = True ALLOWED_HOSTS = ["*"] From b6cb6eb755e233f01c81d3ddbe1f1cf54a4101fd Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 6 Mar 2025 12:29:51 -0500 Subject: [PATCH 22/31] don't build feature branch anymore --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c33f24bf..ac90ef1d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -25,7 +25,7 @@ RUN apt-get update && \ virtualenv /dispatcharrpy && \ git clone https://github.com/Dispatcharr/Dispatcharr /app && \ cd /app && \ - git checkout --track origin/uwsgi && \ + rm -rf .git && \ cd /app && \ pip install --no-cache-dir -r requirements.txt && \ python manage.py collectstatic --noinput && \ From 8024c79dad057b544715e261d164ba48c97848cc Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 6 Mar 2025 12:49:34 -0500 Subject: [PATCH 23/31] added padding, fixed position of bottom section --- frontend/src/components/Sidebar.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index 8b99d601..161e6766 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -100,7 +100,7 @@ const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => { {isAuthenticated && ( - + @@ -117,6 +117,7 @@ const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => { label="Public IP" value={public_ip || ''} disabled + sx={{ p: 1 }} /> )} From 23ce4b983ee4b488ffa762e30b5ee04ed4f80e0b Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 6 Mar 2025 12:51:39 -0500 Subject: [PATCH 24/31] logout actually works now --- frontend/src/components/Sidebar.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index 161e6766..55cb2d43 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { List, ListItem, @@ -39,10 +39,16 @@ const items = [ const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => { const location = useLocation(); - const { isAuthenticated } = useAuthStore(); + const { isAuthenticated, logout } = useAuthStore(); const { environment: { public_ip }, } = useSettingsStore(); + const navigate = useNavigate(); + + const onLogout = () => { + logout(); + navigate('/login'); + }; return ( { - + From dfeb5a7c7bf520a27921aedfc7f71f07e1dcaecd Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 6 Mar 2025 12:52:08 -0500 Subject: [PATCH 25/31] pass int for redis db --- core/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/views.py b/core/views.py index 205e83c5..022f0fe8 100644 --- a/core/views.py +++ b/core/views.py @@ -37,7 +37,7 @@ def stream_view(request, stream_id): """ try: redis_host = getattr(settings, "REDIS_HOST", "localhost") - redis_client = redis.Redis(host=settings.REDIS_HOST, port=6379, db=getattr(settings, "REDIS_DB", "0")) + redis_client = redis.Redis(host=settings.REDIS_HOST, port=6379, db=int(getattr(settings, "REDIS_DB", "0"))) # Retrieve the channel by the provided stream_id. channel = Channel.objects.get(channel_number=stream_id) From 01f5b99f72d8781a4a8a0c66f71bb9397dec0017 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 6 Mar 2025 16:33:30 -0500 Subject: [PATCH 26/31] conditional manage perms since mac doesn't need it --- docker/init/03-init-dispatcharr.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 4aba05d6..951958a2 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -2,7 +2,12 @@ # Required so both uwsgi and nginx (www-data) can use it # @TODO: change nginx to run as the same use as uwsgi -touch /app/uwsgi.sock -chown -R $PUID:$PGID /app -chown $PUID:www-data /app/uwsgi.sock -chmod 777 /app/uwsgi.sock + +# NOTE: mac doesn't run as root, so only manage permissions +# if this script is running as root +if [ "$(id -u)" = "0" ]; then + touch /app/uwsgi.sock + chown -R $PUID:$PGID /app + chown $PUID:www-data /app/uwsgi.sock + chmod 777 /app/uwsgi.sock +fi From e305f1cba008d9b8a94a9dc24ab5f0f758094ea6 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 6 Mar 2025 18:43:14 -0600 Subject: [PATCH 27/31] Fixed HDHR Changed URLs so they would properly work as a HDHR device. --- apps/hdhr/api_views.py | 2 +- apps/hdhr/views.py | 2 +- dispatcharr/urls.py | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index dbc0d02d..844ee8fe 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -81,7 +81,7 @@ class LineupAPIView(APIView): { "GuideNumber": str(ch.channel_number), "GuideName": ch.channel_name, - "URL": request.build_absolute_uri(f"/player/stream/{ch.id}") + "URL": request.build_absolute_uri(f"/output/stream/{ch.id}") } for ch in channels ] diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index dbc0d02d..844ee8fe 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -81,7 +81,7 @@ class LineupAPIView(APIView): { "GuideNumber": str(ch.channel_number), "GuideName": ch.channel_name, - "URL": request.build_absolute_uri(f"/player/stream/{ch.id}") + "URL": request.build_absolute_uri(f"/output/stream/{ch.id}") } for ch in channels ] diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index e9595cbf..5424a197 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -7,6 +7,8 @@ from rest_framework import permissions from drf_yasg.views import get_schema_view from drf_yasg import openapi from .routing import websocket_urlpatterns +from apps.hdhr.api_views import HDHRDeviceViewSet, DiscoverAPIView, LineupAPIView, LineupStatusAPIView, HDHRDeviceXMLAPIView, hdhr_dashboard_view + # Define schema_view for Swagger schema_view = get_schema_view( @@ -39,6 +41,12 @@ urlpatterns = [ path('hdhr', RedirectView.as_view(url='/hdhr/', permanent=True)), # This fixes the issue path('hdhr/', include(('apps.hdhr.urls', 'hdhr'), namespace='hdhr')), + path('discover.json', DiscoverAPIView.as_view(), name='discover'), + path('lineup.json', LineupAPIView.as_view(), name='lineup'), + path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status'), + path('device.xml', HDHRDeviceXMLAPIView.as_view(), name='device_xml'), + + # Swagger UI path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), From 81a1156a018e1a2aa1aedb1ca08348393b9d30c4 Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 6 Mar 2025 19:56:58 -0500 Subject: [PATCH 28/31] better form validation --- frontend/src/components/forms/M3U.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/forms/M3U.js b/frontend/src/components/forms/M3U.js index d8120ad5..b99a36e7 100644 --- a/frontend/src/components/forms/M3U.js +++ b/frontend/src/components/forms/M3U.js @@ -40,13 +40,14 @@ const M3U = ({ playlist = null, isOpen, onClose }) => { initialValues: { name: '', server_url: '', - max_streams: 0, user_agent: '', is_active: true, }, validationSchema: Yup.object({ name: Yup.string().required('Name is required'), + server_url: Yup.string().required('Server URL is required'), user_agent: Yup.string().required('User-Agent is required'), + max_streams: Yup.string().required('Max streams is required'), }), onSubmit: async (values, { setSubmitting, resetForm }) => { if (playlist?.id) { From fb9a3ca65b65dd7169061cdb8ee9ecf92a8e50dd Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 6 Mar 2025 18:58:55 -0600 Subject: [PATCH 29/31] Added country flags Added Country codes next to public IP --- core/api_views.py | 39 +++++++++++++++++++++++++++--- frontend/src/components/Sidebar.js | 34 +++++++++++++++++++------- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/core/api_views.py b/core/api_views.py index 842d650f..eab5f44e 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -8,6 +8,7 @@ from .serializers import UserAgentSerializer, StreamProfileSerializer, CoreSetti from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import api_view, permission_classes from drf_yasg.utils import swagger_auto_schema +import socket import requests import os @@ -42,14 +43,44 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): @permission_classes([IsAuthenticated]) def environment(request): public_ip = None + local_ip = None + country_code = None + country_name = None + + # 1) Get the public IP try: - response = requests.get("https://api64.ipify.org?format=json") - public_ip = response.json().get("ip") + r = requests.get("https://api64.ipify.org?format=json", timeout=5) + r.raise_for_status() + public_ip = r.json().get("ip") except requests.RequestException as e: - return f"Error: {e}" + public_ip = f"Error: {e}" + + # 2) Get the local IP + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # connect to a β€œpublic” address so the OS can determine our local interface + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + except Exception as e: + local_ip = f"Error: {e}" + + # 3) If we got a valid public_ip, fetch geo info from ipapi.co + if public_ip and "Error" not in public_ip: + try: + geo = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5).json() + # ipapi returns fields like country_code, country_name, etc. + country_code = geo.get("country_code", "") # e.g. "US" + country_name = geo.get("country_name", "") # e.g. "United States" + except requests.RequestException as e: + country_code = None + country_name = None return Response({ 'authenticated': True, 'public_ip': public_ip, - 'env_mode': "dev" if os.getenv('DISPATCHARR_ENV', None) == "dev" else "prod", + 'local_ip': local_ip, + 'country_code': country_code, + 'country_name': country_name, + 'env_mode': "dev" if os.getenv('DISPATCHARR_ENV') == "dev" else "prod", }) diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index 55cb2d43..5489311d 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -41,7 +41,7 @@ const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => { const location = useLocation(); const { isAuthenticated, logout } = useAuthStore(); const { - environment: { public_ip }, + environment: { public_ip, country_code, country_name }, } = useSettingsStore(); const navigate = useNavigate(); @@ -117,14 +117,30 @@ const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => { - + {open && ( + + {/* Public IP + optional flag */} + + + {/* If we have a country code, show a small flag */} + {country_code && ( + {country_name + )} + + + )} )} From 1dcbf8875f97dbc33595740c1efa2d2796dd098e Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 6 Mar 2025 19:39:21 -0600 Subject: [PATCH 30/31] Updated Channel Serializer Fixed serialization problems that prevented editing. --- apps/channels/serializers.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index a0dcb6a6..c4af1ebb 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -44,6 +44,7 @@ class StreamSerializer(serializers.ModelSerializer): return fields + # # Channel Group # @@ -74,7 +75,8 @@ class ChannelSerializer(serializers.ModelSerializer): ) streams = serializers.ListField( - child=serializers.IntegerField(), write_only=True + child=serializers.IntegerField(), + write_only=True ) stream_ids = serializers.SerializerMethodField() @@ -111,17 +113,28 @@ class ChannelSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): print("Validated Data:", validated_data) - stream_ids = validated_data.get('streams', None) + stream_ids = validated_data.pop('streams', None) print(f'stream ids: {stream_ids}') - # Update basic fields - instance.name = validated_data.get('channel_name', instance.channel_name) + # Update the actual Channel fields + instance.channel_number = validated_data.get('channel_number', instance.channel_number) + instance.channel_name = validated_data.get('channel_name', instance.channel_name) + instance.logo_url = validated_data.get('logo_url', instance.logo_url) + instance.tvg_id = validated_data.get('tvg_id', instance.tvg_id) + instance.tvg_name = validated_data.get('tvg_name', instance.tvg_name) + + # If serializer allows changing channel_group or stream_profile: + if 'channel_group' in validated_data: + instance.channel_group = validated_data['channel_group'] + if 'stream_profile' in validated_data: + instance.stream_profile = validated_data['stream_profile'] + instance.save() + # Handle the many-to-many 'streams' if stream_ids is not None: # Clear existing relationships instance.channelstream_set.all().delete() - # Add new streams in order for index, stream_id in enumerate(stream_ids): ChannelStream.objects.create(channel=instance, stream_id=stream_id, order=index) From 725c21ed561dfc8c41243b4f47453ec5223f38d9 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 6 Mar 2025 20:40:18 -0600 Subject: [PATCH 31/31] Update epg tasks.py Added gzip support for unzipping EPG files --- apps/epg/tasks.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index d7963ec4..532b4de0 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -1,4 +1,5 @@ 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 @@ -29,7 +30,16 @@ def fetch_xmltv(source): response = requests.get(source.url, timeout=30) response.raise_for_status() logger.debug("XMLTV data fetched successfully.") - root = ET.fromstring(response.content) + + # 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 + + root = ET.fromstring(xml_data) logger.debug("Parsed XMLTV XML content.") # Group programmes by their tvg_id from the XMLTV file