mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Pre Alpha 2
Added FFMPEG Removed "is_active" Added m3u output
This commit is contained in:
parent
7ae7dbe175
commit
3978e60ce9
18 changed files with 564 additions and 44 deletions
|
|
@ -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',)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -61,8 +61,5 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
'channel_group_id',
|
||||
'tvg_id',
|
||||
'tvg_name',
|
||||
'is_active',
|
||||
'is_looping',
|
||||
'shuffle_mode',
|
||||
'streams'
|
||||
]
|
||||
|
|
|
|||
0
apps/ffmpeg/__init__.py
Normal file
0
apps/ffmpeg/__init__.py
Normal file
6
apps/ffmpeg/apps.py
Normal file
6
apps/ffmpeg/apps.py
Normal file
|
|
@ -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"
|
||||
9
apps/ffmpeg/urls.py
Normal file
9
apps/ffmpeg/urls.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from django.urls import path
|
||||
from .views import stream_view, serve_hls_segment
|
||||
|
||||
app_name = 'ffmpeg'
|
||||
|
||||
urlpatterns = [
|
||||
path('<int:stream_id>/', stream_view, name='stream'),
|
||||
path('<int:stream_id>/<str:filename>/', serve_hls_segment, name='serve_hls_segment'),
|
||||
]
|
||||
74
apps/ffmpeg/views.py
Normal file
74
apps/ffmpeg/views.py
Normal file
|
|
@ -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"
|
||||
)
|
||||
19
apps/hdhr/urls.py
Normal file
19
apps/hdhr/urls.py
Normal file
|
|
@ -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
|
||||
132
apps/hdhr/views.py
Normal file
132
apps/hdhr/views.py
Normal file
|
|
@ -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"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<DeviceID>12345678</DeviceID>
|
||||
<FriendlyName>Dispatcharr HDHomeRun</FriendlyName>
|
||||
<ModelNumber>HDTC-2US</ModelNumber>
|
||||
<FirmwareName>hdhomerun3_atsc</FirmwareName>
|
||||
<FirmwareVersion>20200101</FirmwareVersion>
|
||||
<DeviceAuth>test_auth_token</DeviceAuth>
|
||||
<BaseURL>{base_url}</BaseURL>
|
||||
<LineupURL>{base_url}/lineup.json</LineupURL>
|
||||
</root>"""
|
||||
|
||||
return HttpResponse(xml_response, content_type="application/xml")
|
||||
0
apps/output/__init__.py
Normal file
0
apps/output/__init__.py
Normal file
6
apps/output/apps.py
Normal file
6
apps/output/apps.py
Normal file
|
|
@ -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"
|
||||
16
apps/output/tests.py
Normal file
16
apps/output/tests.py
Normal file
|
|
@ -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)
|
||||
9
apps/output/urls.py
Normal file
9
apps/output/urls.py
Normal file
|
|
@ -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')),
|
||||
]
|
||||
32
apps/output/views.py
Normal file
32
apps/output/views.py
Normal file
|
|
@ -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
|
||||
|
|
@ -16,6 +16,7 @@ INSTALLED_APPS = [
|
|||
'apps.epg',
|
||||
'apps.hdhr',
|
||||
'apps.m3u',
|
||||
'apps.output',
|
||||
'drf_yasg',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
<!-- Include DataTables CSS (adjust path if needed) -->
|
||||
<link rel="stylesheet" href="{% static 'css/datatables.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card my-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">EPG Sources</h3>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addEPGModal">Add EPG</button>
|
||||
<div>
|
||||
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#addEPGModal">Add EPG</button>
|
||||
<button class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#uploadEPGModal">Upload EPG File</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table id="epgTable" class="table table-striped">
|
||||
|
|
@ -20,51 +28,265 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for epg in epg_sources %}
|
||||
<tr>
|
||||
<td>{{ epg.name }}</td>
|
||||
<td>{{ epg.source_type }}</td>
|
||||
<td>{{ epg.url|default:epg.api_key }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-warning" onclick="editEPG({{ epg.id }})">Edit</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteEPG({{ epg.id }})">Delete</button>
|
||||
<button class="btn btn-sm btn-info" onclick="refreshEPG({{ epg.id }})">Refresh</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<!-- DataTables will populate the table body -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add EPG Modal -->
|
||||
<div class="modal fade" id="addEPGModal" tabindex="-1">
|
||||
<!-- Add EPG Source Modal -->
|
||||
<div class="modal fade" id="addEPGModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add EPG Source</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="epgForm">
|
||||
<form id="epgForm">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add EPG Source</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="epgName" required>
|
||||
<label for="epgName" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="epgName" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">EPG URL</label>
|
||||
<input type="url" class="form-control" id="epgURL">
|
||||
<label for="epgURL" class="form-label">EPG URL</label>
|
||||
<input type="url" class="form-control" id="epgURL" name="url">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Key</label>
|
||||
<input type="text" class="form-control" id="epgAPIKey">
|
||||
<label for="epgAPIKey" class="form-label">API Key</label>
|
||||
<input type="text" class="form-control" id="epgAPIKey" name="api_key">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="epgSourceType" class="form-label">Source Type</label>
|
||||
<select class="form-select" id="epgSourceType" name="source_type" required>
|
||||
<option value="xmltv">XMLTV</option>
|
||||
<option value="schedules_direct">Schedules Direct</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-success">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload EPG File Modal -->
|
||||
<div class="modal fade" id="uploadEPGModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="uploadEPGForm" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload EPG File</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="epgUploadName" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="epgUploadName" name="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="epgFile" class="form-label">EPG File</label>
|
||||
<input type="file" class="form-control" id="epgFile" name="epg_file" accept=".xml,.m3u,.txt" required>
|
||||
</div>
|
||||
<!-- Optionally include a source type if needed -->
|
||||
<div class="mb-3">
|
||||
<label for="epgUploadSourceType" class="form-label">Source Type</label>
|
||||
<select class="form-select" id="epgUploadSourceType" name="source_type" required>
|
||||
<option value="xmltv">XMLTV</option>
|
||||
<option value="schedules_direct">Schedules Direct</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-success">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css">
|
||||
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<script>
|
||||
// CSRF token helper from Django docs
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
|
||||
// Setup AJAX to include CSRF token
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^http:.*/.test(settings.url) && !/^https:.*/.test(settings.url)) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrftoken);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize DataTable and load EPG sources from API
|
||||
let epgTable;
|
||||
$(document).ready(function() {
|
||||
epgTable = $('#epgTable').DataTable({
|
||||
ajax: {
|
||||
url: '/api/epg/sources/', // adjust if needed
|
||||
dataSrc: ''
|
||||
},
|
||||
columns: [
|
||||
{ data: 'name' },
|
||||
{ data: 'source_type' },
|
||||
{
|
||||
data: null,
|
||||
render: function(data, type, row) {
|
||||
return row.url ? row.url : row.api_key;
|
||||
}
|
||||
},
|
||||
{
|
||||
data: null,
|
||||
render: function(data, type, row) {
|
||||
return `
|
||||
<button class="btn btn-sm btn-warning me-1" onclick="editEPG(${row.id})">Edit</button>
|
||||
<button class="btn btn-sm btn-danger me-1" onclick="deleteEPG(${row.id})">Delete</button>
|
||||
<button class="btn btn-sm btn-info" onclick="refreshEPG(${row.id})">Refresh</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Handle form submission for adding new EPG source
|
||||
$('#epgForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const formData = {
|
||||
name: $('#epgName').val(),
|
||||
url: $('#epgURL').val(),
|
||||
api_key: $('#epgAPIKey').val(),
|
||||
source_type: $('#epgSourceType').val()
|
||||
};
|
||||
$.ajax({
|
||||
url: '/api/epg/sources/',
|
||||
type: 'POST',
|
||||
data: JSON.stringify(formData),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
$('#addEPGModal').modal('hide');
|
||||
$('#epgForm')[0].reset();
|
||||
epgTable.ajax.reload();
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert('Error adding EPG Source: ' + xhr.responseText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle EPG file upload form submission using FormData
|
||||
$('#uploadEPGForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const uploadForm = document.getElementById('uploadEPGForm');
|
||||
const formData = new FormData(uploadForm);
|
||||
$.ajax({
|
||||
url: '/api/epg/upload/', // adjust the endpoint URL if necessary
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
success: function(response) {
|
||||
$('#uploadEPGModal').modal('hide');
|
||||
$('#uploadEPGForm')[0].reset();
|
||||
epgTable.ajax.reload();
|
||||
alert('EPG file uploaded successfully.');
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert('Error uploading EPG file: ' + xhr.responseText);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Edit function (example using prompt; consider a modal for better UX)
|
||||
function editEPG(id) {
|
||||
$.ajax({
|
||||
url: `/api/epg/sources/${id}/`,
|
||||
type: 'GET',
|
||||
success: function(data) {
|
||||
const newName = prompt("Edit EPG Name:", data.name);
|
||||
if (newName !== null) {
|
||||
data.name = newName;
|
||||
$.ajax({
|
||||
url: `/api/epg/sources/${id}/`,
|
||||
type: 'PUT',
|
||||
data: JSON.stringify(data),
|
||||
contentType: 'application/json',
|
||||
success: function(updatedData) {
|
||||
epgTable.ajax.reload();
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert('Error updating EPG Source: ' + xhr.responseText);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert('Error fetching EPG Source data: ' + xhr.responseText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete function
|
||||
function deleteEPG(id) {
|
||||
if (confirm("Are you sure you want to delete this EPG Source?")) {
|
||||
$.ajax({
|
||||
url: `/api/epg/sources/${id}/`,
|
||||
type: 'DELETE',
|
||||
success: function(response) {
|
||||
epgTable.ajax.reload();
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert('Error deleting EPG Source: ' + xhr.responseText);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh function – triggers global import endpoint (adjust if you have per-source refresh)
|
||||
function refreshEPG(id) {
|
||||
if (confirm("Trigger EPG refresh for this source?")) {
|
||||
$.ajax({
|
||||
url: '/api/epg/import/',
|
||||
type: 'POST',
|
||||
data: JSON.stringify({ id: id }),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
alert('EPG refresh initiated.');
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert('Error refreshing EPG: ' + xhr.responseText);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue