Merge pull request #16 from Dispatcharr/uwsgi

UWSGI and Websockets
This commit is contained in:
dekzter 2025-03-06 06:11:33 -05:00 committed by GitHub
commit 473f511665
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 733 additions and 401 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ __pycache__/
node_modules/
.history/
staticfiles/
static/

View file

@ -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')),
],

View file

@ -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"
)

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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)),
]

View file

@ -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",
})

View file

@ -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

View file

@ -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.

View file

@ -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)
),
})

18
dispatcharr/consumers.py Normal file
View file

@ -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))

6
dispatcharr/routing.py Normal file
View file

@ -0,0 +1,6 @@
from django.urls import path
from dispatcharr.consumers import MyWebSocketConsumer
websocket_urlpatterns = [
path("ws/", MyWebSocketConsumer.as_asgi()),
]

View file

@ -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': {

View file

@ -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)

View file

@ -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 && \
@ -21,6 +25,8 @@ RUN apt-get update && \
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 && \
python manage.py collectstatic --noinput && \
cd /app/frontend && \
@ -45,6 +51,7 @@ RUN apt-get update && \
ffmpeg \
gnupg2 \
gpg \
libpcre3 \
libpq-dev \
lsb-release \
nginx \

View file

@ -3,7 +3,7 @@ services:
# build:
# context: .
# dockerfile: Dockerfile
image: dekzter/dispactharr
image: dispatcharr/dispatcharr:alpha-v1
container_name: dispatcharr
ports:
- 9191:9191

View file

@ -1,6 +1,6 @@
services:
web:
image: dispatcharr/dispatcharr
image: dispatcharr/dispatcharr:alpha-v1
container_name: dispatcharr_web
ports:
- 9191:9191
@ -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

View file

@ -27,84 +27,50 @@ 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
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
# If running in development mode, install and start frontend
chmod +x /etc/profile.d/dispatcharr.sh
# Run init scripts
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..."
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")
if [ "$DISPATCHARR_ENV" = "dev" ]; then
echo "🚀 Development Mode - Setting up Frontend..."
# 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
# Install frontend dependencies
cd /app/frontend && npm install
cd /app
# 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
. /app/docker/init/99-init-dev.sh
else
echo "🚀 Starting nginx..."
nginx
@ -113,141 +79,21 @@ else
pids+=("$nginx_pid")
fi
# 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'..."
cd /app
python manage.py migrate --noinput
python manage.py collectstatic --noinput
# 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}" <<EOF
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$POSTGRES_USER') THEN
CREATE ROLE $POSTGRES_USER WITH LOGIN PASSWORD '$POSTGRES_PASSWORD';
END IF;
END
\$\$;
EOF
echo_with_timestamp "Setting PostgreSQL user privileges..."
su postgres -c "$PG_BINDIR/psql -p ${POSTGRES_PORT} -c \"ALTER DATABASE ${POSTGRES_DB} OWNER TO $POSTGRES_USER;\""
su postgres -c "$PG_BINDIR/psql -p ${POSTGRES_PORT} -c \"GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO $POSTGRES_USER;\""
# Finished setting up PosgresSQL database
echo_with_timestamp "PostgreSQL database setup complete."
fi
# Test PostgreSQL connection and exit if unavailable
echo_with_timestamp "Testing database connection..."
if ! pg_isready -h $POSTGRES_HOST -p $POSTGRES_PORT -U $POSTGRES_USER -d $POSTGRES_DB; then
echo_with_timestamp "ERROR: PostgreSQL is not ready. Exiting..."
exit 1
else
echo_with_timestamp "PostgreSQL is ready to accept connections."
fi
# Verify database accessibility
echo_with_timestamp "Verifying database accessibility..."
if ! su - $POSTGRES_USER -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB} -c 'SELECT 1;'" >/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
uwsgi_file="/app/docker/uwsgi.ini"
if [ "$DISPATCHARR_ENV" = "dev" ]; then
uwsgi_file="/app/docker/uwsgi.dev.ini"
fi
# Run Django commands
cd /app
echo_with_timestamp "Running Django commands..."
python manage.py migrate --noinput || true
python manage.py collectstatic --noinput || true
echo "🚀 Starting uwsgi..."
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")
# 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..."

View file

@ -0,0 +1,21 @@
#!/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" dispatch
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
usermod -aG www-data $POSTGRES_USER

View file

@ -0,0 +1,55 @@
#!/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}" <<EOF
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$POSTGRES_USER') THEN
CREATE ROLE $POSTGRES_USER WITH LOGIN PASSWORD '$POSTGRES_PASSWORD';
END IF;
END
\$\$;
EOF
echo_with_timestamp "Setting PostgreSQL user privileges..."
su postgres -c "/usr/lib/postgresql/14/bin/psql -p ${POSTGRES_PORT} -c \"ALTER DATABASE ${POSTGRES_DB} OWNER TO $POSTGRES_USER;\""
su postgres -c "/usr/lib/postgresql/14/bin/psql -p ${POSTGRES_PORT} -c \"GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO $POSTGRES_USER;\""
# Finished setting up PosgresSQL database
echo_with_timestamp "PostgreSQL database setup complete."
fi
kill $postgres_pid
while kill -0 $postgres_pid; do
sleep 1
done
fi

View file

@ -0,0 +1,8 @@
#!/bin/bash
# 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

View file

@ -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

View file

@ -1,15 +1,34 @@
server {
listen 9191;
server_name yourdomain.com;
# Serve Django via uWSGI
location / {
include uwsgi_params;
uwsgi_pass unix:/app/uwsgi.sock;
}
location /static/ {
root /app; # Base directory for static files
}
location / {
# admin disabled when not in dev mode
location /admin {
return 301 /login;
}
# Serve FFmpeg streams efficiently
location /stream/ {
proxy_pass http://127.0.0.1:5656;
proxy_buffering off;
proxy_set_header Connection keep-alive;
}
# WebSockets for real-time communication
location /ws/ {
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

42
docker/uwsgi.dev.ini Normal file
View file

@ -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

41
docker/uwsgi.ini Normal file
View file

@ -0,0 +1,41 @@
[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
# 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

View file

@ -12,26 +12,17 @@ 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';
import { WebsocketProvider } from './WebSocket';
const drawerWidth = 240;
const miniDrawerWidth = 60;
@ -89,86 +80,65 @@ const App = () => {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
<Drawer
variant="permanent"
open={open}
sx={{
width: open ? drawerWidth : miniDrawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: open ? drawerWidth : miniDrawerWidth,
transition: 'width 0.3s',
overflowX: 'hidden',
},
}}
>
<List sx={{ backgroundColor: '#495057', color: 'white' }}>
<ListItem disablePadding>
<ListItemButton
onClick={toggleDrawer}
size="small"
sx={{
pt: 0,
pb: 0,
}}
>
<img src={logo} width="33x" alt="logo" />
{open && (
<ListItemText primary="Dispatcharr" sx={{ paddingLeft: 3 }} />
)}
</ListItemButton>
</ListItem>
</List>
<Divider />
<Sidebar open />
</Drawer>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
backgroundColor: '#495057',
height: '100%',
}}
>
<WebsocketProvider>
<Router>
<Sidebar
open={open}
miniDrawerWidth={miniDrawerWidth}
drawerWidth={drawerWidth}
toggleDrawer={toggleDrawer}
/>
<Box
sx={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
backgroundColor: '#495057',
height: '100%',
}}
>
<Routes>
{isAuthenticated ? (
<>
<Route path="/channels" element={<Channels />} />
<Route path="/m3u" element={<M3U />} />
<Route path="/epg" element={<EPG />} />
<Route path="/stream-profiles" element={<StreamProfiles />} />
<Route path="/guide" element={<Guide />} />
<Route path="/settings" element={<Settings />} />
</>
) : (
<Route path="/login" element={<Login />} />
)}
<Route
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
replace
/>
}
/>
</Routes>
<Box
sx={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Routes>
{isAuthenticated ? (
<>
<Route path="/channels" element={<Channels />} />
<Route path="/m3u" element={<M3U />} />
<Route path="/epg" element={<EPG />} />
<Route
path="/stream-profiles"
element={<StreamProfiles />}
/>
<Route path="/guide" element={<Guide />} />
<Route path="/settings" element={<Settings />} />
</>
) : (
<Route path="/login" element={<Login />} />
)}
<Route
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
replace
/>
}
/>
</Routes>
</Box>
</Box>
</Box>
</Router>
<Alert />
<FloatingVideo />
</Router>
<Alert />
<FloatingVideo />
</WebsocketProvider>
</ThemeProvider>
);
};

78
frontend/src/WebSocket.js Normal file
View file

@ -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 (
<WebsocketContext.Provider value={ret}>
{children}
</WebsocketContext.Provider>
);
};
export const useWebSocket = () => {
const socket = useContext(WebsocketContext);
return socket;
};

View file

@ -16,7 +16,7 @@ export default class API {
* A static method so we can do: await API.getAuthToken()
*/
static async getAuthToken() {
return await useAuthStore.getState().getToken();
return await useAuthStore.getState().getToken();
}
static async login(username, password) {
@ -500,7 +500,7 @@ export default class API {
return retval;
}
// Notice there's a duplicated "refreshPlaylist" method above;
// Notice there's a duplicated "refreshPlaylist" method above;
// you might want to rename or remove one if it's not needed.
static async addEPG(values) {
@ -706,6 +706,18 @@ export default class API {
return retval;
}
static async getEnvironmentSettings() {
const response = await fetch(`${host}/api/core/settings/env/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
const retval = await response.json();
return retval;
}
static async updateSetting(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/core/settings/${id}/`, {

View file

@ -6,6 +6,10 @@ import {
ListItemButton,
ListItemText,
ListItemIcon,
Box,
Divider,
Drawer,
TextField,
} from '@mui/material';
import {
Tv as TvIcon,
@ -14,7 +18,11 @@ import {
LiveTv as LiveTvIcon,
PlaylistPlay as PlaylistPlayIcon,
Settings as SettingsIcon,
Logout as LogoutIcon,
} from '@mui/icons-material';
import logo from '../images/logo.png';
import useAuthStore from '../store/auth';
import useSettingsStore from '../store/settings';
const items = [
{ text: 'Channels', icon: <TvIcon />, route: '/channels' },
@ -29,24 +37,90 @@ const items = [
{ text: 'Settings', icon: <SettingsIcon />, route: '/settings' },
];
const Sidebar = ({ open }) => {
const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => {
const location = useLocation();
const { isAuthenticated } = useAuthStore();
const {
environment: { public_ip },
} = useSettingsStore();
return (
<List>
{items.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
component={Link}
to={item.route}
selected={location.pathname == item.route}
>
<ListItemIcon>{item.icon}</ListItemIcon>
{open && <ListItemText primary={item.text} />}
</ListItemButton>
</ListItem>
))}
</List>
<Drawer
variant="permanent"
open={open}
sx={{
width: open ? drawerWidth : miniDrawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: open ? drawerWidth : miniDrawerWidth,
transition: 'width 0.3s',
overflowX: 'hidden',
'& .MuiDrawer-paper': {
display: 'flex',
flexDirection: 'column',
height: '100vh',
},
},
}}
>
<Box sx={{ flexGrow: 1 }}>
<List sx={{ backgroundColor: '#495057', color: 'white' }}>
<ListItem disablePadding>
<ListItemButton
onClick={toggleDrawer}
size="small"
sx={{
pt: 0,
pb: 0,
}}
>
<img src={logo} width="33x" alt="logo" />
{open && (
<ListItemText primary="Dispatcharr" sx={{ paddingLeft: 3 }} />
)}
</ListItemButton>
</ListItem>
</List>
<Divider />
<List>
{items.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
component={Link}
to={item.route}
selected={location.pathname == item.route}
>
<ListItemIcon>{item.icon}</ListItemIcon>
{open && <ListItemText primary={item.text} />}
</ListItemButton>
</ListItem>
))}
</List>
</Box>
{isAuthenticated && (
<Box sx={{ flexGrow: 1, borderTop: '1px solid #ccc' }}>
<List>
<ListItem disablePadding>
<ListItemButton>
<ListItemIcon>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary="Logout" />
</ListItemButton>
</ListItem>
</List>
<TextField
size="small"
fullWidth
label="Public IP"
value={public_ip || ''}
disabled
/>
</Box>
)}
</Drawer>
);
};

View file

@ -66,7 +66,7 @@ const LoginForm = () => {
justifyContent="center"
direction="column"
>
<Grid2 item xs={12}>
<Grid2 xs={12}>
<TextField
label="Username"
variant="standard"
@ -78,7 +78,7 @@ const LoginForm = () => {
size="small"
/>
</Grid2>
<Grid2 item xs={12}>
<Grid2 xs={12}>
<TextField
label="Password"
variant="standard"
@ -91,7 +91,7 @@ const LoginForm = () => {
size="small"
/>
</Grid2>
<Grid2 item xs={12}>
<Grid2 xs={12}>
<Button
type="submit"
variant="contained"

View file

@ -183,7 +183,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
helperText={formik.touched.user_agent && formik.errors.user_agent}
// helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
{userAgents.map((option, index) => (

View file

@ -1,7 +1,14 @@
// frontend/src/components/forms/SuperuserForm.js
import React, { useState } from 'react';
import axios from 'axios';
import { Box, Paper, Typography, Grid as Grid2, TextField, Button } from '@mui/material';
import {
Box,
Paper,
Typography,
Grid2,
TextField,
Button,
} from '@mui/material';
function SuperuserForm({ onSuccess }) {
const [formData, setFormData] = useState({
@ -58,8 +65,13 @@ function SuperuserForm({ onSuccess }) {
</Typography>
)}
<form onSubmit={handleSubmit}>
<Grid2 container spacing={2} justifyContent="center" direction="column">
<Grid2 item xs={12}>
<Grid2
container
spacing={2}
justifyContent="center"
direction="column"
>
<Grid2 xs={12}>
<TextField
label="Username"
variant="standard"
@ -71,7 +83,7 @@ function SuperuserForm({ onSuccess }) {
size="small"
/>
</Grid2>
<Grid2 item xs={12}>
<Grid2 xs={12}>
<TextField
label="Password"
variant="standard"
@ -84,7 +96,7 @@ function SuperuserForm({ onSuccess }) {
size="small"
/>
</Grid2>
<Grid2 item xs={12}>
<Grid2 xs={12}>
<TextField
label="Email (optional)"
variant="standard"
@ -96,8 +108,13 @@ function SuperuserForm({ onSuccess }) {
size="small"
/>
</Grid2>
<Grid2 item xs={12}>
<Button type="submit" variant="contained" color="primary" fullWidth>
<Grid2 xs={12}>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
>
Create Superuser
</Button>
</Grid2>

View file

@ -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');

View file

@ -75,7 +75,9 @@ const EPGsTable = () => {
};
const deleteEPG = async (id) => {
setIsLoading(true);
await API.deleteEPG(id);
setIsLoading(false);
};
const refreshEPG = async (id) => {

View file

@ -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 = () => {
@ -134,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 }}
>
<EditIcon fontSize="small" />

View file

@ -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);

View file

@ -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)) {

View file

@ -3,6 +3,7 @@ import api from '../api';
const useSettingsStore = create((set) => ({
settings: {},
environment: {},
isLoading: false,
error: null,
@ -10,15 +11,16 @@ const useSettingsStore = create((set) => ({
set({ isLoading: true, error: null });
try {
const settings = await api.getSettings();
const env = await api.getEnvironmentSettings();
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);
set({ error: 'Failed to load settings.', isLoading: false });
}
},

View file

@ -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 });
}
},

View file

@ -1,5 +1,4 @@
Django==5.1.6
gunicorn==23.0.0
psycopg2-binary==2.9.10
redis==4.5.5
celery
@ -21,4 +20,8 @@ rapidfuzz==3.12.1
torch==2.6.0+cpu
# ML/NLP dependencies
sentence-transformers==3.4.1
sentence-transformers==3.4.1
uwsgi
channels
channels-redis
daphne