diff --git a/apps/channels/admin.py b/apps/channels/admin.py index 182f506e..8a3d38d6 100644 --- a/apps/channels/admin.py +++ b/apps/channels/admin.py @@ -14,10 +14,9 @@ class StreamAdmin(admin.ModelAdmin): @admin.register(Channel) class ChannelAdmin(admin.ModelAdmin): list_display = ( - 'channel_number', 'channel_name', 'channel_group', - 'is_active', 'is_looping', 'shuffle_mode', 'tvg_name' + 'channel_number', 'channel_name', 'channel_group', 'tvg_name' ) - list_filter = ('channel_group', 'is_active', 'is_looping', 'shuffle_mode') + list_filter = ('channel_group',) search_fields = ('channel_name', 'channel_group__name', 'tvg_name') ordering = ('channel_number',) diff --git a/apps/channels/models.py b/apps/channels/models.py index 64197bce..a5b55c74 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -39,7 +39,7 @@ class Stream(models.Model): class ChannelManager(models.Manager): def active(self): - return self.filter(is_active=True) + return self.all() class Channel(models.Model): @@ -69,10 +69,6 @@ class Channel(models.Model): ) tvg_id = models.CharField(max_length=255, blank=True, null=True) tvg_name = models.CharField(max_length=255, blank=True, null=True) - is_active = models.BooleanField(default=True) - is_looping = models.BooleanField(default=False, help_text="If True, loops local file(s).") - shuffle_mode = models.BooleanField(default=False, help_text="If True, randomize streams for failover.") - objects = ChannelManager() def clean(self): diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index b1e1aafc..62e00082 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -61,8 +61,5 @@ class ChannelSerializer(serializers.ModelSerializer): 'channel_group_id', 'tvg_id', 'tvg_name', - 'is_active', - 'is_looping', - 'shuffle_mode', 'streams' ] diff --git a/apps/ffmpeg/__init__.py b/apps/ffmpeg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/ffmpeg/apps.py b/apps/ffmpeg/apps.py new file mode 100644 index 00000000..c6386dcc --- /dev/null +++ b/apps/ffmpeg/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class FfmpegConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.ffmpeg' + verbose_name = "FFmpeg Streaming" diff --git a/apps/ffmpeg/urls.py b/apps/ffmpeg/urls.py new file mode 100644 index 00000000..ec8ce8f6 --- /dev/null +++ b/apps/ffmpeg/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from .views import stream_view, serve_hls_segment + +app_name = 'ffmpeg' + +urlpatterns = [ + path('/', stream_view, name='stream'), + path('//', serve_hls_segment, name='serve_hls_segment'), +] diff --git a/apps/ffmpeg/views.py b/apps/ffmpeg/views.py new file mode 100644 index 00000000..5bbfb2b9 --- /dev/null +++ b/apps/ffmpeg/views.py @@ -0,0 +1,74 @@ +import os +import redis +import subprocess + +from django.conf import settings +from django.http import ( + StreamingHttpResponse, + HttpResponseServerError, + FileResponse, + Http404, +) +from django.db.models import F +from apps.channels.models import Channel, Stream + +# Configure Redis +redis_host = os.environ.get("REDIS_HOST", "redis") +redis_port = int(os.environ.get("REDIS_PORT", 6379)) +redis_client = redis.Redis(host=redis_host, port=redis_port, db=0) + +def serve_hls_segment(request, stream_id, filename): + # Remove any trailing slashes from the filename. / caused problems. + filename = filename.rstrip('/') + + # Construct the file path (e.g., /tmp/hls_4/segment_001.ts) + file_path = os.path.join('/tmp', f'hls_{stream_id}', filename) + + if os.path.exists(file_path): + return FileResponse(open(file_path, 'rb'), content_type='video/MP2T') + else: + raise Http404("Segment not found") + + +def stream_view(request, stream_id): + try: + channel = Channel.objects.get(id=stream_id) + if not channel.streams.exists(): + return HttpResponseServerError("No stream found for this channel.") + # Pick the first available stream and get its actual model instance. + stream = channel.streams.first() + # Use the custom URL if available; otherwise, the regular URL. + input_url = stream.custom_url or stream.url + + # Increment the viewer count atomically. + Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') + 1) + + ffmpeg_cmd = [ + "ffmpeg", + "-i", input_url, + "-c:v", "copy", + "-c:a", "copy", + "-f", "mpegts", + "-" # output to stdout + ] + process = subprocess.Popen( + ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + except Exception as e: + return HttpResponseServerError(f"Error starting stream: {e}") + + def stream_generator(process, stream): + try: + while True: + chunk = process.stdout.read(8192) + if not chunk: + break + yield chunk + finally: + # Decrement the viewer count when the stream finishes or the connection closes. + Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') - 1) + + return StreamingHttpResponse( + stream_generator(process, stream), + content_type="video/MP2T" + ) diff --git a/apps/hdhr/urls.py b/apps/hdhr/urls.py new file mode 100644 index 00000000..2a1a06e0 --- /dev/null +++ b/apps/hdhr/urls.py @@ -0,0 +1,19 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import HDHRDeviceViewSet, DiscoverAPIView, LineupAPIView, LineupStatusAPIView, HDHRDeviceXMLAPIView, hdhr_dashboard_view + +app_name = 'hdhr' + +router = DefaultRouter() +router.register(r'devices', HDHRDeviceViewSet, basename='hdhr-device') + +urlpatterns = [ + path('dashboard/', hdhr_dashboard_view, name='hdhr_dashboard'), + path('', hdhr_dashboard_view, name='hdhr_dashboard'), + 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'), +] + +urlpatterns += router.urls diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py new file mode 100644 index 00000000..a1ec835d --- /dev/null +++ b/apps/hdhr/views.py @@ -0,0 +1,132 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from django.http import JsonResponse, HttpResponseForbidden, HttpResponse +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from django.shortcuts import get_object_or_404 +from apps.channels.models import Channel +from .models import HDHRDevice +from .serializers import HDHRDeviceSerializer +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +from django.views import View +from django.utils.decorators import method_decorator +from django.contrib.auth.decorators import login_required +from django.views.decorators.csrf import csrf_exempt + +@login_required +def hdhr_dashboard_view(request): + """Render the HDHR management page.""" + hdhr_devices = HDHRDevice.objects.all() + return render(request, "hdhr/hdhr.html", {"hdhr_devices": hdhr_devices}) + +# ๐Ÿ”น 1) HDHomeRun Device API +class HDHRDeviceViewSet(viewsets.ModelViewSet): + """Handles CRUD operations for HDHomeRun devices""" + queryset = HDHRDevice.objects.all() + serializer_class = HDHRDeviceSerializer + permission_classes = [IsAuthenticated] + + +# ๐Ÿ”น 2) Discover API +class DiscoverAPIView(APIView): + """Returns device discovery information""" + + @swagger_auto_schema( + operation_description="Retrieve HDHomeRun device discovery information", + responses={200: openapi.Response("HDHR Discovery JSON")} + ) + def get(self, request): + base_url = request.build_absolute_uri('/hdhr/').rstrip('/') + device = HDHRDevice.objects.first() + + if not device: + data = { + "FriendlyName": "Dispatcharr HDHomeRun", + "ModelNumber": "HDTC-2US", + "FirmwareName": "hdhomerun3_atsc", + "FirmwareVersion": "20200101", + "DeviceID": "12345678", + "DeviceAuth": "test_auth_token", + "BaseURL": base_url, + "LineupURL": f"{base_url}/lineup.json", + } + else: + data = { + "FriendlyName": device.friendly_name, + "ModelNumber": "HDTC-2US", + "FirmwareName": "hdhomerun3_atsc", + "FirmwareVersion": "20200101", + "DeviceID": device.device_id, + "DeviceAuth": "test_auth_token", + "BaseURL": base_url, + "LineupURL": f"{base_url}/lineup.json", + } + return JsonResponse(data) + + +# ๐Ÿ”น 3) Lineup API +class LineupAPIView(APIView): + """Returns available channel lineup""" + + @swagger_auto_schema( + operation_description="Retrieve the available channel lineup", + responses={200: openapi.Response("Channel Lineup JSON")} + ) + def get(self, request): + channels = Channel.objects.filter(is_active=True).order_by('channel_number') + lineup = [ + { + "GuideNumber": str(ch.channel_number), + "GuideName": ch.channel_name, + "URL": request.build_absolute_uri(f"/player/stream/{ch.id}") + } + for ch in channels + ] + return JsonResponse(lineup, safe=False) + + +# ๐Ÿ”น 4) Lineup Status API +class LineupStatusAPIView(APIView): + """Returns the current status of the HDHR lineup""" + + @swagger_auto_schema( + operation_description="Retrieve the HDHomeRun lineup status", + responses={200: openapi.Response("Lineup Status JSON")} + ) + def get(self, request): + data = { + "ScanInProgress": 0, + "ScanPossible": 0, + "Source": "Cable", + "SourceList": ["Cable"] + } + return JsonResponse(data) + + +# ๐Ÿ”น 5) Device XML API +class HDHRDeviceXMLAPIView(APIView): + """Returns HDHomeRun device configuration in XML""" + + @swagger_auto_schema( + operation_description="Retrieve the HDHomeRun device XML configuration", + responses={200: openapi.Response("HDHR Device XML")} + ) + def get(self, request): + base_url = request.build_absolute_uri('/hdhr/').rstrip('/') + + xml_response = f""" + + 12345678 + Dispatcharr HDHomeRun + HDTC-2US + hdhomerun3_atsc + 20200101 + test_auth_token + {base_url} + {base_url}/lineup.json + """ + + return HttpResponse(xml_response, content_type="application/xml") diff --git a/apps/output/__init__.py b/apps/output/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/output/apps.py b/apps/output/apps.py new file mode 100644 index 00000000..cdd1b460 --- /dev/null +++ b/apps/output/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class OutputConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.output' + verbose_name = "Output" diff --git a/apps/output/tests.py b/apps/output/tests.py new file mode 100644 index 00000000..e1e857ee --- /dev/null +++ b/apps/output/tests.py @@ -0,0 +1,16 @@ +from django.test import TestCase, Client +from django.urls import reverse + +class OutputM3UTest(TestCase): + def setUp(self): + self.client = Client() + + def test_generate_m3u_response(self): + """ + Test that the M3U endpoint returns a valid M3U file. + """ + url = reverse('output:generate_m3u') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + content = response.content.decode() + self.assertIn("#EXTM3U", content) diff --git a/apps/output/urls.py b/apps/output/urls.py new file mode 100644 index 00000000..9c496622 --- /dev/null +++ b/apps/output/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include +from .views import generate_m3u + +app_name = 'output' + +urlpatterns = [ + path('m3u/', generate_m3u, name='generate_m3u'), + path('stream/', include(('apps.ffmpeg.urls', 'ffmpeg'), namespace='ffmpeg')), +] diff --git a/apps/output/views.py b/apps/output/views.py new file mode 100644 index 00000000..d3199ccc --- /dev/null +++ b/apps/output/views.py @@ -0,0 +1,32 @@ +from django.http import HttpResponse +from django.urls import reverse +from apps.channels.models import Channel + +def generate_m3u(request): + """ + Dynamically generate an M3U file from channels with extended metadata, + and have the stream URL point to the ffmpeg relay. + """ + m3u_content = "#EXTM3U\n" + + channels = Channel.objects.order_by('channel_number') + + for channel in channels: + group_title = channel.channel_group.name if channel.channel_group else "Default" + tvg_id = channel.tvg_id or "" + tvg_name = channel.tvg_name or channel.channel_name + tvg_logo = channel.logo_url or "" # Adjust if you have a fallback + channel_number = channel.channel_number + + extinf_line = ( + f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" ' + f'tvg-chno="{channel_number}" group-title="{group_title}",{channel.channel_name}\n' + ) + + ffmpeg_url = request.build_absolute_uri(reverse('ffmpeg:stream', args=[channel.id])) + m3u_content += extinf_line + ffmpeg_url + "\n" + + # Return the generated content with the appropriate MIME type. + response = HttpResponse(m3u_content, content_type="application/x-mpegURL") + response['Content-Disposition'] = 'attachment; filename="channels.m3u"' + return response diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 247ed7b5..eb55fbc8 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -16,6 +16,7 @@ INSTALLED_APPS = [ 'apps.epg', 'apps.hdhr', 'apps.m3u', + 'apps.output', 'drf_yasg', 'django.contrib.admin', 'django.contrib.auth', diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 95e15582..ebc63e2f 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -37,6 +37,8 @@ urlpatterns = [ #path('settings/', include(('apps.settings.urls', 'settings'), namespace='settings')), #path('backup/', include(('apps.backup.urls', 'backup'), namespace='backup')), path('dashboard/', include(('apps.dashboard.urls', 'dashboard'), namespace='dashboard')), + path('output/', include('apps.output.urls', namespace='output')), + path('stream/', include(('apps.ffmpeg.urls', 'ffmpeg'), namespace='ffmpeg')), # Swagger UI: diff --git a/templates/channels/channels.html b/templates/channels/channels.html index a64c5fb7..4ae653d5 100644 --- a/templates/channels/channels.html +++ b/templates/channels/channels.html @@ -408,7 +408,7 @@ $(document).ready(function () { // gather โ€œactiveโ€ streams const activeRows = newActiveStreamsTable.rows().data().toArray(); activeRows.forEach((s) => { - formData.append("streams[]", s.id); + formData.append("streams", s.id); }); $.ajax({ @@ -649,7 +649,7 @@ $(document).ready(function () { // gather active streams const activeRows = editActiveStreamsTable.rows().data().toArray(); activeRows.forEach((s) => { - formData.append("streams[]", s.id); + formData.append("streams", s.id); }); $.ajax({ diff --git a/templates/epg/epg.html b/templates/epg/epg.html index 12dd7576..639cf078 100755 --- a/templates/epg/epg.html +++ b/templates/epg/epg.html @@ -1,13 +1,21 @@ {% extends "base.html" %} +{% load static %} {% block title %}EPG Management - Dispatcharr{% endblock %} {% block page_header %}EPG Management{% endblock %} -{% block content %} +{% block extra_head %} + + +{% endblock %} +{% block content %}
-
+

EPG Sources

- +
+ + +
@@ -20,51 +28,265 @@ - {% for epg in epg_sources %} - - - - - - - {% endfor %} +
{{ epg.name }}{{ epg.source_type }}{{ epg.url|default:epg.api_key }} - - - -
- -