forked from Mirrors/Dispatcharr
Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into Proxy
This commit is contained in:
commit
fe83e8a03e
49 changed files with 813 additions and 401 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,5 +6,6 @@ __pycache__/
|
|||
node_modules/
|
||||
.history/
|
||||
staticfiles/
|
||||
static/
|
||||
docker/DockerfileAIO
|
||||
docker/Dockerfile DEV
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ 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 socket
|
||||
import requests
|
||||
import os
|
||||
|
||||
class UserAgentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
|
|
@ -28,3 +33,54 @@ 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
|
||||
local_ip = None
|
||||
country_code = None
|
||||
country_name = None
|
||||
|
||||
# 1) Get the public IP
|
||||
try:
|
||||
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:
|
||||
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,
|
||||
'local_ip': local_ip,
|
||||
'country_code': country_code,
|
||||
'country_name': country_name,
|
||||
'env_mode': "dev" if os.getenv('DISPATCHARR_ENV') == "dev" else "prod",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -32,15 +32,12 @@ 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")
|
||||
redis_client = redis.Redis(host=settings.REDIS_HOST, port=6379, 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)
|
||||
|
|
@ -51,27 +48,32 @@ 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.")
|
||||
|
||||
active_stream = None
|
||||
m3u_account = None
|
||||
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}")
|
||||
streams = channel.streams.all().order_by('channelstream__order')
|
||||
logger.debug(f'Found {len(streams)} streams for channel {channel.channel_number}')
|
||||
for stream in streams:
|
||||
# Get the first available stream.
|
||||
logger.debug("Checking stream: ID=%s, Name=%s", stream.id, stream.name)
|
||||
|
||||
# Retrieve the M3U account associated with the stream.
|
||||
m3u_account = stream.m3u_account
|
||||
logger.debug(f"Using M3U account ID={m3u_account.id}, Name={m3u_account.name}")
|
||||
logger.debug("Stream M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name)
|
||||
|
||||
# Use the custom URL if available; otherwise, use the standard URL.
|
||||
input_url = stream.custom_url or stream.url
|
||||
logger.debug(f"Input URL: {input_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}...')
|
||||
|
|
@ -79,44 +81,39 @@ def stream_view(request, stream_id):
|
|||
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
|
||||
logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available')
|
||||
stream_index = 0
|
||||
while True:
|
||||
while stream_index < profile.max_streams:
|
||||
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.")
|
||||
persistent_lock = None
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
if persistent_lock.has_lock:
|
||||
if persistent_lock is not None:
|
||||
logger.debug(f'Successfully acquired lock: {lock_key}')
|
||||
active_profile = M3UAccountProfile.objects.get(id=profile.id)
|
||||
break
|
||||
|
||||
if persistent_lock.has_lock == False:
|
||||
logger.debug(f'Unable to get lock for profile {profile.id}:{profile.name}. Skipping...')
|
||||
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 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.")
|
||||
|
||||
# *** 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 active_profile:
|
||||
logger.exception("No available streams for this channel")
|
||||
return HttpResponseServerError("No available streams for this channel")
|
||||
|
||||
logger.debug(f"Using M3U profile ID={active_profile.id} (ignoring viewer count limits)")
|
||||
# Prepare the pattern replacement.
|
||||
logger.debug("Executing the following pattern replacement:")
|
||||
logger.debug(f" search: {active_profile.search_pattern}")
|
||||
|
|
@ -148,7 +145,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 +164,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 +171,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"
|
||||
|
|
|
|||
|
|
@ -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
18
dispatcharr/consumers.py
Normal 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
6
dispatcharr/routing.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.urls import path
|
||||
from dispatcharr.consumers import MyWebSocketConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/", MyWebSocketConsumer.as_asgi()),
|
||||
]
|
||||
|
|
@ -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", "0")
|
||||
|
||||
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',
|
||||
|
|
@ -22,6 +23,8 @@ INSTALLED_APPS = [
|
|||
'apps.proxy.apps.ProxyConfig',
|
||||
'core',
|
||||
'drf_yasg',
|
||||
'daphne',
|
||||
'channels',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
|
|
@ -70,6 +73,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': {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ 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
|
||||
from apps.hdhr.api_views import HDHRDeviceViewSet, DiscoverAPIView, LineupAPIView, LineupStatusAPIView, HDHRDeviceXMLAPIView, hdhr_dashboard_view
|
||||
|
||||
|
||||
# Define schema_view for Swagger
|
||||
schema_view = get_schema_view(
|
||||
|
|
@ -24,7 +27,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)),
|
||||
|
|
@ -42,6 +45,13 @@ urlpatterns = [
|
|||
path('proxy/', include(('apps.proxy.urls', 'proxy'), namespace='proxy')),
|
||||
path('proxy', RedirectView.as_view(url='/proxy/', permanent=True)),
|
||||
|
||||
# HDHR API
|
||||
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'),
|
||||
|
||||
|
|
@ -57,6 +67,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)
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
rm -rf .git && \
|
||||
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 \
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ services:
|
|||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
image: dekzter/dispactharr
|
||||
image: dispatcharr/dispatcharr:alpha-v1
|
||||
container_name: dispatcharr
|
||||
ports:
|
||||
- 9191:9191
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
21
docker/init/01-user-setup.sh
Normal file
21
docker/init/01-user-setup.sh
Normal 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
|
||||
55
docker/init/02-postgres.sh
Normal file
55
docker/init/02-postgres.sh
Normal 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
|
||||
13
docker/init/03-init-dispatcharr.sh
Normal file
13
docker/init/03-init-dispatcharr.sh
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Required so both uwsgi and nginx (www-data) can use it
|
||||
# @TODO: change nginx to run as the same use as uwsgi
|
||||
|
||||
# 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
|
||||
17
docker/init/99-init-dev.sh
Normal file
17
docker/init/99-init-dev.sh
Normal 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
|
||||
|
|
@ -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
42
docker/uwsgi.dev.ini
Normal 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
41
docker/uwsgi.ini
Normal 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
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
84
frontend/src/WebSocket.js
Normal file
84
frontend/src/WebSocket.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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 = `${window.location.host}/ws/`;
|
||||
if (process.env.REACT_APP_ENV_MODE == 'dev') {
|
||||
wsUrl = `${window.location.hostname}:8001/ws/`;
|
||||
}
|
||||
|
||||
if (window.location.protocol.match(/https/)) {
|
||||
wsUrl = `wss://${wsUrl}`;
|
||||
} else {
|
||||
wsUrl = `ws://${wsUrl}`;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
@ -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}/`, {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
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,113 @@ const items = [
|
|||
{ text: 'Settings', icon: <SettingsIcon />, route: '/settings' },
|
||||
];
|
||||
|
||||
const Sidebar = ({ open }) => {
|
||||
const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, logout } = useAuthStore();
|
||||
const {
|
||||
environment: { public_ip, country_code, country_name },
|
||||
} = useSettingsStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
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={{ borderTop: '1px solid #ccc' }}>
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={onLogout}>
|
||||
<ListItemIcon>
|
||||
<LogoutIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Logout" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
{open && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
||||
{/* Public IP + optional flag */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Public IP"
|
||||
value={public_ip || ''}
|
||||
disabled
|
||||
variant="outlined"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
{/* If we have a country code, show a small flag */}
|
||||
{country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${country_code.toLowerCase()}.png`}
|
||||
alt={country_name || country_code}
|
||||
title={country_name || country_code}
|
||||
style={{ border: '1px solid #ccc', borderRadius: 2 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -183,7 +184,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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -75,7 +75,9 @@ const EPGsTable = () => {
|
|||
};
|
||||
|
||||
const deleteEPG = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deleteEPG(id);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const refreshEPG = async (id) => {
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue