diff --git a/.gitignore b/.gitignore index 79c33bcf..81528e32 100755 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ __pycache__/ node_modules/ .history/ staticfiles/ +static/ docker/DockerfileAIO docker/Dockerfile DEV diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py index bc92ebe6..1df20e37 100644 --- a/apps/accounts/migrations/0001_initial.py +++ b/apps/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 import django.contrib.auth.models import django.contrib.auth.validators @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('auth', '0012_alter_user_first_name_max_length'), - ('channels', '0001_initial'), + ('dispatcharr_channels', '0001_initial'), ] operations = [ @@ -31,7 +31,7 @@ class Migration(migrations.Migration): ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('avatar_config', models.JSONField(blank=True, default=dict, null=True)), - ('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='channels.channelgroup')), + ('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='dispatcharr_channels.channelgroup')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], diff --git a/apps/accounts/models.py b/apps/accounts/models.py index accd6ee7..5b24549f 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -9,7 +9,7 @@ class User(AbstractUser): """ avatar_config = models.JSONField(default=dict, blank=True, null=True) channel_groups = models.ManyToManyField( - 'channels.ChannelGroup', # Updated reference to renamed model + 'dispatcharr_channels.ChannelGroup', # Updated reference to renamed model blank=True, related_name="users" ) diff --git a/apps/channels/apps.py b/apps/channels/apps.py index bcca01ee..d6d29a80 100644 --- a/apps/channels/apps.py +++ b/apps/channels/apps.py @@ -4,7 +4,8 @@ class ChannelsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.channels' verbose_name = "Channel & Stream Management" - + label = 'dispatcharr_channels' + def ready(self): # Import signals so they get registered. import apps.channels.signals diff --git a/apps/channels/migrations/0001_initial.py b/apps/channels/migrations/0001_initial.py index 8401450e..00ddbc76 100644 --- a/apps/channels/migrations/0001_initial.py +++ b/apps/channels/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 import django.db.models.deletion from django.db import migrations, models @@ -32,7 +32,7 @@ class Migration(migrations.Migration): ('tvg_id', models.CharField(blank=True, max_length=255, null=True)), ('tvg_name', models.CharField(blank=True, max_length=255, null=True)), ('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='core.streamprofile')), - ('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='channels.channelgroup')), + ('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='dispatcharr_channels.channelgroup')), ], ), migrations.CreateModel( @@ -62,8 +62,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order', models.PositiveIntegerField(default=0)), - ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='channels.channel')), - ('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='channels.stream')), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channel')), + ('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.stream')), ], options={ 'ordering': ['order'], @@ -72,6 +72,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='channel', name='streams', - field=models.ManyToManyField(blank=True, related_name='channels', through='channels.ChannelStream', to='channels.stream'), + field=models.ManyToManyField(blank=True, related_name='channels', through='dispatcharr_channels.ChannelStream', to='dispatcharr_channels.stream'), ), ] diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index a0dcb6a6..c4af1ebb 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -44,6 +44,7 @@ class StreamSerializer(serializers.ModelSerializer): return fields + # # Channel Group # @@ -74,7 +75,8 @@ class ChannelSerializer(serializers.ModelSerializer): ) streams = serializers.ListField( - child=serializers.IntegerField(), write_only=True + child=serializers.IntegerField(), + write_only=True ) stream_ids = serializers.SerializerMethodField() @@ -111,17 +113,28 @@ class ChannelSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): print("Validated Data:", validated_data) - stream_ids = validated_data.get('streams', None) + stream_ids = validated_data.pop('streams', None) print(f'stream ids: {stream_ids}') - # Update basic fields - instance.name = validated_data.get('channel_name', instance.channel_name) + # Update the actual Channel fields + instance.channel_number = validated_data.get('channel_number', instance.channel_number) + instance.channel_name = validated_data.get('channel_name', instance.channel_name) + instance.logo_url = validated_data.get('logo_url', instance.logo_url) + instance.tvg_id = validated_data.get('tvg_id', instance.tvg_id) + instance.tvg_name = validated_data.get('tvg_name', instance.tvg_name) + + # If serializer allows changing channel_group or stream_profile: + if 'channel_group' in validated_data: + instance.channel_group = validated_data['channel_group'] + if 'stream_profile' in validated_data: + instance.stream_profile = validated_data['stream_profile'] + instance.save() + # Handle the many-to-many 'streams' if stream_ids is not None: # Clear existing relationships instance.channelstream_set.all().delete() - # Add new streams in order for index, stream_id in enumerate(stream_ids): ChannelStream.objects.create(channel=instance, stream_id=stream_id, order=index) diff --git a/apps/dashboard/migrations/0001_initial.py b/apps/dashboard/migrations/0001_initial.py index 9c39a3b7..ba3311b1 100644 --- a/apps/dashboard/migrations/0001_initial.py +++ b/apps/dashboard/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 from django.db import migrations, models diff --git a/apps/epg/migrations/0001_initial.py b/apps/epg/migrations/0001_initial.py index 9454d514..7c77ba5e 100644 --- a/apps/epg/migrations/0001_initial.py +++ b/apps/epg/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 import django.db.models.deletion from django.db import migrations, models diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index d7963ec4..532b4de0 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -1,4 +1,5 @@ import logging +import gzip # <-- New import for gzip support from celery import shared_task from .models import EPGSource, EPGData, ProgramData from django.utils import timezone @@ -29,7 +30,16 @@ def fetch_xmltv(source): response = requests.get(source.url, timeout=30) response.raise_for_status() logger.debug("XMLTV data fetched successfully.") - root = ET.fromstring(response.content) + + # If the URL ends with '.gz', decompress the response content + if source.url.lower().endswith('.gz'): + logger.debug("Detected .gz file. Decompressing...") + decompressed_bytes = gzip.decompress(response.content) + xml_data = decompressed_bytes.decode('utf-8') + else: + xml_data = response.text + + root = ET.fromstring(xml_data) logger.debug("Parsed XMLTV XML content.") # Group programmes by their tvg_id from the XMLTV file diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index dbc0d02d..844ee8fe 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -81,7 +81,7 @@ class LineupAPIView(APIView): { "GuideNumber": str(ch.channel_number), "GuideName": ch.channel_name, - "URL": request.build_absolute_uri(f"/player/stream/{ch.id}") + "URL": request.build_absolute_uri(f"/output/stream/{ch.id}") } for ch in channels ] diff --git a/apps/hdhr/migrations/0001_initial.py b/apps/hdhr/migrations/0001_initial.py index 54ad7c8c..14b17ceb 100644 --- a/apps/hdhr/migrations/0001_initial.py +++ b/apps/hdhr/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 from django.db import migrations, models diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index dbc0d02d..844ee8fe 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -81,7 +81,7 @@ class LineupAPIView(APIView): { "GuideNumber": str(ch.channel_number), "GuideName": ch.channel_name, - "URL": request.build_absolute_uri(f"/player/stream/{ch.id}") + "URL": request.build_absolute_uri(f"/output/stream/{ch.id}") } for ch in channels ] diff --git a/apps/m3u/migrations/0001_initial.py b/apps/m3u/migrations/0001_initial.py index eb92f063..7a20a713 100644 --- a/apps/m3u/migrations/0001_initial.py +++ b/apps/m3u/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 import django.db.models.deletion from django.db import migrations, models diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index d39a6511..30d8b11e 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -9,6 +9,8 @@ from django.conf import settings from django.core.cache import cache from .models import M3UAccount from apps.channels.models import Stream +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer logger = logging.getLogger(__name__) @@ -120,7 +122,7 @@ def refresh_single_m3u_account(account_id): # Extract tvg-id tvg_id_match = re.search(r'tvg-id="([^"]*)"', line) tvg_id = tvg_id_match.group(1) if tvg_id_match else "" - + fallback_name = line.split(",", 1)[-1].strip() if "," in line else "Default Stream" name = tvg_name_match.group(1) if tvg_name_match else fallback_name @@ -178,6 +180,14 @@ def refresh_single_m3u_account(account_id): logger.info(f"Completed parsing. Created {created_count} new Streams, updated {updated_count} existing Streams, excluded {excluded_count} Streams.") release_lock('refresh_single_m3u_account', account_id) + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "m3u_refresh", + "message": {"success": True, "message": "M3U refresh completed successfully"} + }, + ) return f"Account {account_id} => Created {created_count}, updated {updated_count}, excluded {excluded_count} Streams." def process_uploaded_m3u_file(file, account): diff --git a/core/api_urls.py b/core/api_urls.py index 6e240927..724a3311 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet +from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment router = DefaultRouter() router.register(r'useragents', UserAgentViewSet, basename='useragent') @@ -10,5 +10,6 @@ router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile router.register(r'settings', CoreSettingsViewSet, basename='coresettings') urlpatterns = [ + path('settings/env/', environment, name='token_refresh'), path('', include(router.urls)), ] diff --git a/core/api_views.py b/core/api_views.py index 917afe12..eab5f44e 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -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", + }) diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 79757ec4..b9290f90 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-03-02 13:52 +# Generated by Django 5.1.6 on 2025-03-05 22:07 from django.db import migrations, models diff --git a/core/views.py b/core/views.py index 002dda60..022f0fe8 100644 --- a/core/views.py +++ b/core/views.py @@ -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" diff --git a/dispatcharr/asgi.py b/dispatcharr/asgi.py index fc4a377b..5e60f635 100644 --- a/dispatcharr/asgi.py +++ b/dispatcharr/asgi.py @@ -1,8 +1,14 @@ -""" -ASGI config for dispatcharr project. -""" import os from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +import dispatcharr.routing -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings') -application = get_asgi_application() +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dispatcharr.settings") + +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack( + URLRouter(dispatcharr.routing.websocket_urlpatterns) + ), +}) diff --git a/dispatcharr/consumers.py b/dispatcharr/consumers.py new file mode 100644 index 00000000..9c56605d --- /dev/null +++ b/dispatcharr/consumers.py @@ -0,0 +1,18 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer + +class MyWebSocketConsumer(AsyncWebsocketConsumer): + async def connect(self): + await self.accept() + self.room_name = "updates" + await self.channel_layer.group_add(self.room_name, self.channel_name) + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.room_name, self.channel_name) + + async def receive(self, text_data): + data = json.loads(text_data) + print("Received:", data) + + async def m3u_refresh(self, event): + await self.send(text_data=json.dumps(event)) diff --git a/dispatcharr/routing.py b/dispatcharr/routing.py new file mode 100644 index 00000000..7624e21d --- /dev/null +++ b/dispatcharr/routing.py @@ -0,0 +1,6 @@ +from django.urls import path +from dispatcharr.consumers import MyWebSocketConsumer + +websocket_urlpatterns = [ + path("ws/", MyWebSocketConsumer.as_asgi()), +] diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index b86cc356..5de5a496 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -6,6 +6,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = 'REPLACE_ME_WITH_A_REAL_SECRET' REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") +REDIS_DB = os.environ.get("REDIS_DB", "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': { diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index e8039194..72361783 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -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) diff --git a/docker/Dockerfile b/docker/Dockerfile index 1a011943..ac90ef1d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,9 +7,13 @@ ENV PATH="/dispatcharrpy/bin:$PATH" \ RUN apt-get update && \ apt-get install -y --no-install-recommends \ + build-essential \ curl \ gcc \ git \ + libpcre3 \ + libpcre3-dev \ + python3-dev \ wget && \ echo "=== setting up nodejs ===" && \ curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh && \ @@ -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 \ diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index afed565d..6f67bded 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -3,7 +3,7 @@ services: # build: # context: . # dockerfile: Dockerfile - image: dekzter/dispactharr + image: dispatcharr/dispatcharr:alpha-v1 container_name: dispatcharr ports: - 9191:9191 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7f38d743..e6a06603 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,6 @@ services: web: - image: dispatcharr/dispatcharr + image: dispatcharr/dispatcharr:alpha-v1 container_name: dispatcharr_web ports: - 9191:9191 @@ -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 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 35b30142..305f061f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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}" </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..." diff --git a/docker/init/01-user-setup.sh b/docker/init/01-user-setup.sh new file mode 100644 index 00000000..9b2755d1 --- /dev/null +++ b/docker/init/01-user-setup.sh @@ -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 diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh new file mode 100644 index 00000000..ddaa15b7 --- /dev/null +++ b/docker/init/02-postgres.sh @@ -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}" <&1 >/dev/null +then + echo "=== setting up nodejs ===" + curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh + bash /tmp/nodesource_setup.sh + apt-get update + apt-get install -y --no-install-recommends \ + nodejs +fi + +# Install frontend dependencies +cd /app/frontend && npm install diff --git a/docker/nginx.conf b/docker/nginx.conf index 966ebd63..988393ac 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -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; } } diff --git a/docker/uwsgi.dev.ini b/docker/uwsgi.dev.ini new file mode 100644 index 00000000..9bd0ab3f --- /dev/null +++ b/docker/uwsgi.dev.ini @@ -0,0 +1,42 @@ +[uwsgi] +; exec-before = python manage.py collectstatic --noinput +; exec-before = python manage.py migrate --noinput + +attach-daemon = celery -A dispatcharr worker -l info +attach-daemon = redis-server +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application +attach-daemon = cd /app/frontend && npm run start + +# Core settings +chdir = /app +module = dispatcharr.wsgi:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true + +# Worker management (Optimize for I/O bound tasks) +workers = 4 +threads = 2 +enable-threads = true + +# Optimize for streaming +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 # Increase buffer for large payloads +post-buffering = 4096 # Reduce buffering for real-time streaming +http-timeout = 600 # Prevent disconnects from long streams +lazy-apps = true # Improve memory efficiency + +# Async mode (use gevent for high concurrency) +gevent = 100 +async = 100 + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini new file mode 100644 index 00000000..ace423af --- /dev/null +++ b/docker/uwsgi.ini @@ -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 diff --git a/frontend/src/App.js b/frontend/src/App.js index 9dc0fedd..def311ff 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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 ( - - - - - - logo - {open && ( - - )} - - - - - - - + + + + - - {isAuthenticated ? ( - <> - } /> - } /> - } /> - } /> - } /> - } /> - - ) : ( - } /> - )} - - } - /> - + + + {isAuthenticated ? ( + <> + } /> + } /> + } /> + } + /> + } /> + } /> + + ) : ( + } /> + )} + + } + /> + + - - - - + + + + ); }; diff --git a/frontend/src/WebSocket.js b/frontend/src/WebSocket.js new file mode 100644 index 00000000..b121e7d8 --- /dev/null +++ b/frontend/src/WebSocket.js @@ -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 ( + + {children} + + ); +}; + +export const useWebSocket = () => { + const socket = useContext(WebsocketContext); + return socket; +}; diff --git a/frontend/src/api.js b/frontend/src/api.js index 0ed976f0..0d813bcf 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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}/`, { diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index ca84d536..5489311d 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -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: , route: '/channels' }, @@ -29,24 +37,113 @@ const items = [ { text: 'Settings', icon: , 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 ( - - {items.map((item) => ( - - - {item.icon} - {open && } - - - ))} - + + + + + + logo + {open && ( + + )} + + + + + + + {items.map((item) => ( + + + {item.icon} + {open && } + + + ))} + + + + {isAuthenticated && ( + + + + + + + + + + + + {open && ( + + {/* Public IP + optional flag */} + + + {/* If we have a country code, show a small flag */} + {country_code && ( + {country_name + )} + + + )} + + )} + ); }; diff --git a/frontend/src/components/forms/LoginForm.js b/frontend/src/components/forms/LoginForm.js index 2272d6f4..f7b4445e 100644 --- a/frontend/src/components/forms/LoginForm.js +++ b/frontend/src/components/forms/LoginForm.js @@ -66,7 +66,7 @@ const LoginForm = () => { justifyContent="center" direction="column" > - + { size="small" /> - + { size="small" /> - + diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js index 2572085e..3fcfca7b 100644 --- a/frontend/src/components/tables/ChannelsTable.js +++ b/frontend/src/components/tables/ChannelsTable.js @@ -32,6 +32,7 @@ import { TableHelper } from '../../helpers'; import utils from '../../utils'; import logo from '../../images/logo.png'; import useVideoStore from '../../store/useVideoStore'; +import useSettingsStore from '../../store/settings'; const ChannelsTable = () => { const [channel, setChannel] = useState(null); @@ -42,9 +43,12 @@ const ChannelsTable = () => { const [textToCopy, setTextToCopy] = useState(''); const [snackbarMessage, setSnackbarMessage] = useState(''); const [snackbarOpen, setSnackbarOpen] = useState(false); + const { showVideo } = useVideoStore.getState(); // or useVideoStore() const { channels, isLoading: channelsLoading } = useChannelsStore(); - const { showVideo } = useVideoStore.getState(); // or useVideoStore() + const { + environment: { env_mode }, + } = useSettingsStore(); // Configure columns const columns = useMemo( @@ -100,11 +104,17 @@ const ChannelsTable = () => { }; const deleteChannel = async (id) => { + setIsLoading(true); await API.deleteChannel(id); + setIsLoading(false); }; function handleWatchStream(channelNumber) { - showVideo(`/output/stream/${channelNumber}/`); + let vidUrl = `/output/stream/${channelNumber}/`; + if (env_mode == 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + showVideo(vidUrl); } // (Optional) bulk delete, but your endpoint is @TODO @@ -131,7 +141,9 @@ const ChannelsTable = () => { const rowOrder = table.getRowModel().rows.map((row) => row.original.id); // Call our custom API endpoint + setIsLoading(true); const result = await API.assignChannelNumbers(rowOrder); + setIsLoading(false); // We might get { message: "Channels have been auto-assigned!" } setSnackbarMessage(result.message || 'Channels assigned'); diff --git a/frontend/src/components/tables/EPGsTable.js b/frontend/src/components/tables/EPGsTable.js index a23e89f9..aadc516f 100644 --- a/frontend/src/components/tables/EPGsTable.js +++ b/frontend/src/components/tables/EPGsTable.js @@ -75,7 +75,9 @@ const EPGsTable = () => { }; const deleteEPG = async (id) => { + setIsLoading(true); await API.deleteEPG(id); + setIsLoading(false); }; const refreshEPG = async (id) => { diff --git a/frontend/src/components/tables/StreamsTable.js b/frontend/src/components/tables/StreamsTable.js index a3591fcf..6f4fcd4d 100644 --- a/frontend/src/components/tables/StreamsTable.js +++ b/frontend/src/components/tables/StreamsTable.js @@ -53,11 +53,13 @@ const StreamsTable = () => { // Fallback: Individual creation (optional) const createChannelFromStream = async (stream) => { + setIsLoading(true); await API.createChannelFromStream({ channel_name: stream.name, channel_number: null, stream_id: stream.id, }); + setIsLoading(false); }; // Bulk creation: create channels from selected streams in one API call @@ -67,6 +69,7 @@ const StreamsTable = () => { .getRowModel() .rows.filter((row) => row.getIsSelected()); + setIsLoading(true); await API.createChannelsFromStreams( selected.map((sel) => ({ stream_id: sel.original.id, @@ -82,14 +85,18 @@ const StreamsTable = () => { }; const deleteStream = async (id) => { + setIsLoading(true); await API.deleteStream(id); + setIsLoading(false); }; const deleteStreams = async () => { + setIsLoading(true); const selected = table .getRowModel() .rows.filter((row) => row.getIsSelected()); await API.deleteStreams(selected.map((stream) => stream.original.id)); + setIsLoading(false); }; const closeStreamForm = () => { @@ -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 }} > diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js index e17b92fe..bfc49788 100644 --- a/frontend/src/pages/Guide.js +++ b/frontend/src/pages/Guide.js @@ -20,6 +20,7 @@ import useChannelsStore from '../store/channels'; import logo from '../images/logo.png'; import useVideoStore from '../store/useVideoStore'; // NEW import import useAlertStore from '../store/alerts'; +import useSettingsStore from '../store/settings'; /** Layout constants */ const CHANNEL_WIDTH = 120; // Width of the channel/logo column @@ -46,6 +47,9 @@ export default function TVChannelGuide({ startDate, endDate }) { const [selectedProgram, setSelectedProgram] = useState(null); const [loading, setLoading] = useState(true); const { showAlert } = useAlertStore(); + const { + environment: { env_mode }, + } = useSettingsStore(); const guideRef = useRef(null); @@ -172,9 +176,12 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } // Build a playable stream URL for that channel - const url = - window.location.origin + '/output/stream/' + matched.channel_number; - showVideo(url); + let vidUrl = `/output/stream/${matched.channel_number}/`; + if (env_mode == 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + + showVideo(vidUrl); // Optionally close the modal setSelectedProgram(null); diff --git a/frontend/src/store/auth.js b/frontend/src/store/auth.js index 0b9f2f8a..8904e489 100644 --- a/frontend/src/store/auth.js +++ b/frontend/src/store/auth.js @@ -29,7 +29,6 @@ const useAuthStore = create((set, get) => ({ setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }), initData: async () => { - console.log('fetching data'); await Promise.all([ useChannelsStore.getState().fetchChannels(), useChannelsStore.getState().fetchChannelGroups(), @@ -43,7 +42,6 @@ const useAuthStore = create((set, get) => ({ }, getToken: async () => { - const expiration = localStorage.getItem('tokenExpiration'); const tokenExpiration = localStorage.getItem('tokenExpiration'); let accessToken = null; if (isTokenExpired(tokenExpiration)) { diff --git a/frontend/src/store/settings.js b/frontend/src/store/settings.js index 9c13be2f..5dffbed6 100644 --- a/frontend/src/store/settings.js +++ b/frontend/src/store/settings.js @@ -3,6 +3,7 @@ import api from '../api'; const useSettingsStore = create((set) => ({ settings: {}, + environment: {}, isLoading: false, error: null, @@ -10,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 }); } }, diff --git a/frontend/src/store/streams.js b/frontend/src/store/streams.js index 215a17c8..a594b8a7 100644 --- a/frontend/src/store/streams.js +++ b/frontend/src/store/streams.js @@ -1,5 +1,5 @@ -import { create } from "zustand"; -import api from "../api"; +import { create } from 'zustand'; +import api from '../api'; const useStreamsStore = create((set) => ({ streams: [], @@ -12,8 +12,8 @@ const useStreamsStore = create((set) => ({ const streams = await api.getStreams(); set({ streams: streams, isLoading: false }); } catch (error) { - console.error("Failed to fetch streams:", error); - set({ error: "Failed to load streams.", isLoading: false }); + console.error('Failed to fetch streams:', error); + set({ error: 'Failed to load streams.', isLoading: false }); } }, diff --git a/requirements.txt b/requirements.txt index 6cb290c0..7a00b5e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ Django==5.1.6 -gunicorn==23.0.0 psycopg2-binary==2.9.10 redis==4.5.5 celery @@ -21,4 +20,8 @@ rapidfuzz==3.12.1 torch==2.6.0+cpu # ML/NLP dependencies -sentence-transformers==3.4.1 \ No newline at end of file +sentence-transformers==3.4.1 +uwsgi +channels +channels-redis +daphne