Pre-Alpha v4

Added settings page
Added EPG functionality
This commit is contained in:
Dispatcharr 2025-02-24 15:04:03 -06:00
parent 3a15cf6b7f
commit d89bf35c0d
17 changed files with 1081 additions and 212 deletions

View file

@ -27,9 +27,6 @@ class ChannelForm(forms.ModelForm):
'channel_number', 'channel_number',
'channel_name', 'channel_name',
'channel_group', 'channel_group',
'is_active',
'is_looping',
'shuffle_mode',
] ]
@ -47,6 +44,5 @@ class StreamForm(forms.ModelForm):
'tvg_id', 'tvg_id',
'local_file', 'local_file',
'is_transcoded', 'is_transcoded',
'ffmpeg_preset',
'group_name', 'group_name',
] ]

View file

@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import EPGSource, Program from .models import EPGSource, ProgramData
@admin.register(EPGSource) @admin.register(EPGSource)
class EPGSourceAdmin(admin.ModelAdmin): class EPGSourceAdmin(admin.ModelAdmin):
@ -7,13 +7,13 @@ class EPGSourceAdmin(admin.ModelAdmin):
list_filter = ['source_type', 'is_active'] list_filter = ['source_type', 'is_active']
search_fields = ['name'] search_fields = ['name']
@admin.register(Program) @admin.register(ProgramData)
class ProgramAdmin(admin.ModelAdmin): class ProgramAdmin(admin.ModelAdmin):
list_display = ['title', 'get_channel_tvg_id', 'start_time', 'end_time'] list_display = ['title', 'get_channel_tvg_id', 'start_time', 'end_time']
list_filter = ['channel'] list_filter = ['epg__channel'] # updated here
search_fields = ['title', 'channel__channel_name'] search_fields = ['title', 'epg__channel__channel_name'] # updated here
def get_channel_tvg_id(self, obj): def get_channel_tvg_id(self, obj):
return obj.channel.tvg_id if obj.channel else '' return obj.epg.channel.tvg_id if obj.epg and obj.epg.channel else ''
get_channel_tvg_id.short_description = 'Channel TVG ID' get_channel_tvg_id.short_description = 'Channel TVG ID'
get_channel_tvg_id.admin_order_field = 'channel__tvg_id' get_channel_tvg_id.admin_order_field = 'epg__channel__tvg_id'

View file

@ -1,4 +1,5 @@
from rest_framework import generics, status, viewsets import logging
from rest_framework import viewsets, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -6,46 +7,64 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi from drf_yasg import openapi
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from .models import Program, EPGSource from .models import EPGSource, ProgramData # Updated: use ProgramData instead of Program
from .serializers import ProgramSerializer, EPGSourceSerializer from .serializers import ProgramDataSerializer, EPGSourceSerializer # Updated serializer
from .tasks import refresh_epg_data from .tasks import refresh_epg_data
logger = logging.getLogger(__name__)
# 🔹 1) EPG Source API (CRUD) # ─────────────────────────────
# 1) EPG Source API (CRUD)
# ─────────────────────────────
class EPGSourceViewSet(viewsets.ModelViewSet): class EPGSourceViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for EPG sources""" """Handles CRUD operations for EPG sources"""
queryset = EPGSource.objects.all() queryset = EPGSource.objects.all()
serializer_class = EPGSourceSerializer serializer_class = EPGSourceSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG sources.")
return super().list(request, *args, **kwargs)
# 🔹 2) Program API (CRUD) # ─────────────────────────────
# 2) Program API (CRUD)
# ─────────────────────────────
class ProgramViewSet(viewsets.ModelViewSet): class ProgramViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for EPG programs""" """Handles CRUD operations for EPG programs"""
queryset = Program.objects.all() queryset = ProgramData.objects.all() # Updated to ProgramData
serializer_class = ProgramSerializer serializer_class = ProgramDataSerializer # Updated serializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG programs.")
return super().list(request, *args, **kwargs)
# 🔹 3) EPG Grid View: Shows programs airing within the next 12 hours # ─────────────────────────────
# 3) EPG Grid View
# ─────────────────────────────
class EPGGridAPIView(APIView): class EPGGridAPIView(APIView):
"""Returns all programs airing in the next 12 hours""" """Returns all programs airing in the next 12 hours"""
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Retrieve upcoming EPG programs within the next 12 hours", operation_description="Retrieve upcoming EPG programs within the next 12 hours",
responses={200: ProgramSerializer(many=True)} responses={200: ProgramDataSerializer(many=True)}
) )
def get(self, request, format=None): def get(self, request, format=None):
now = timezone.now() now = timezone.now()
twelve_hours_later = now + timedelta(hours=12) twelve_hours_later = now + timedelta(hours=12)
programs = Program.objects.select_related('channel').filter( logger.debug(f"EPGGridAPIView: Querying programs between {now} and {twelve_hours_later}.")
# Use select_related to prefetch EPGData and Channel data
programs = ProgramData.objects.select_related('epg__channel').filter(
start_time__gte=now, start_time__lte=twelve_hours_later start_time__gte=now, start_time__lte=twelve_hours_later
) )
serializer = ProgramSerializer(programs, many=True) count = programs.count()
logger.debug(f"EPGGridAPIView: Found {count} program(s).")
serializer = ProgramDataSerializer(programs, many=True)
return Response({'data': serializer.data}, status=status.HTTP_200_OK) return Response({'data': serializer.data}, status=status.HTTP_200_OK)
# ─────────────────────────────
# 🔹 4) EPG Import View: Triggers an import of EPG data # 4) EPG Import View
# ─────────────────────────────
class EPGImportAPIView(APIView): class EPGImportAPIView(APIView):
"""Triggers an EPG data refresh""" """Triggers an EPG data refresh"""
@ -54,5 +73,7 @@ class EPGImportAPIView(APIView):
responses={202: "EPG data import initiated"} responses={202: "EPG data import initiated"}
) )
def post(self, request, format=None): def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
refresh_epg_data.delay() # Trigger Celery task refresh_epg_data.delay() # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
return Response({'success': True, 'message': 'EPG data import initiated.'}, status=status.HTTP_202_ACCEPTED) return Response({'success': True, 'message': 'EPG data import initiated.'}, status=status.HTTP_202_ACCEPTED)

View file

@ -2,7 +2,6 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from apps.channels.models import Channel from apps.channels.models import Channel
class EPGSource(models.Model): class EPGSource(models.Model):
SOURCE_TYPE_CHOICES = [ SOURCE_TYPE_CHOICES = [
('xmltv', 'XMLTV URL'), ('xmltv', 'XMLTV URL'),
@ -17,12 +16,25 @@ class EPGSource(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
class Program(models.Model):
channel = models.ForeignKey('channels.Channel', on_delete=models.CASCADE, related_name="programs") class EPGData(models.Model):
title = models.CharField(max_length=255) """
description = models.TextField(blank=True, null=True) Stores EPG data for a specific channel.
"""
channel = models.ForeignKey(Channel, on_delete=models.CASCADE, related_name="epg_data")
channel_name = models.CharField(max_length=255)
def __str__(self):
return f"EPG Data for {self.channel_name}"
class ProgramData(models.Model):
epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs")
start_time = models.DateTimeField() start_time = models.DateTimeField()
end_time = models.DateTimeField() end_time = models.DateTimeField()
title = models.CharField(max_length=255)
sub_title = models.CharField(max_length=255, blank=True, null=True)
description = models.TextField(blank=True, null=True)
def __str__(self): def __str__(self):
return f"{self.title} ({self.start_time} - {self.end_time})" return f"{self.title} ({self.start_time} - {self.end_time})"

View file

@ -1,5 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Program, EPGSource from .models import EPGSource, EPGData, ProgramData
from apps.channels.models import Channel from apps.channels.models import Channel
class EPGSourceSerializer(serializers.ModelSerializer): class EPGSourceSerializer(serializers.ModelSerializer):
@ -7,13 +7,18 @@ class EPGSourceSerializer(serializers.ModelSerializer):
model = EPGSource model = EPGSource
fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active'] fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active']
class ProgramDataSerializer(serializers.ModelSerializer):
class Meta:
model = ProgramData
fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description']
class ProgramSerializer(serializers.ModelSerializer): class EPGDataSerializer(serializers.ModelSerializer):
programs = ProgramDataSerializer(many=True, read_only=True)
channel = serializers.SerializerMethodField() channel = serializers.SerializerMethodField()
def get_channel(self, obj): def get_channel(self, obj):
return {"id": obj.channel.id, "name": obj.channel.name} if obj.channel else None return {"id": obj.channel.id, "name": obj.channel.channel_name} if obj.channel else None
class Meta: class Meta:
model = Program model = EPGData
fields = ['id', 'channel', 'title', 'description', 'start_time', 'end_time'] fields = ['id', 'channel', 'channel_name', 'programs']

View file

@ -1,119 +1,180 @@
import logging
from celery import shared_task from celery import shared_task
from .models import EPGSource, Program from .models import EPGSource, EPGData, ProgramData
from apps.channels.models import Channel from apps.channels.models import Channel
from django.utils import timezone from django.utils import timezone
import requests import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone as dt_timezone
from django.db import transaction from django.db import transaction
logger = logging.getLogger(__name__)
@shared_task @shared_task
def refresh_epg_data(): def refresh_epg_data():
logger.info("Starting refresh_epg_data task.")
active_sources = EPGSource.objects.filter(is_active=True) active_sources = EPGSource.objects.filter(is_active=True)
logger.debug(f"Found {active_sources.count()} active EPGSource(s).")
for source in active_sources: for source in active_sources:
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
if source.source_type == 'xmltv': if source.source_type == 'xmltv':
fetch_xmltv(source) fetch_xmltv(source)
elif source.source_type == 'schedules_direct': elif source.source_type == 'schedules_direct':
fetch_schedules_direct(source) fetch_schedules_direct(source)
logger.info("Finished refresh_epg_data task.")
return "EPG data refreshed." return "EPG data refreshed."
def fetch_xmltv(source): def fetch_xmltv(source):
logger.info(f"Fetching XMLTV data from source: {source.name}")
try: try:
response = requests.get(source.url, timeout=30) response = requests.get(source.url, timeout=30)
response.raise_for_status() response.raise_for_status()
logger.debug("XMLTV data fetched successfully.")
root = ET.fromstring(response.content) root = ET.fromstring(response.content)
logger.debug("Parsed XMLTV XML content.")
with transaction.atomic(): # Group programmes by channel tvg_id
for programme in root.findall('programme'): programmes_by_channel = {}
start_time = parse_xmltv_time(programme.get('start')) for programme in root.findall('programme'):
stop_time = parse_xmltv_time(programme.get('stop')) start_time = parse_xmltv_time(programme.get('start'))
channel_tvg_id = programme.get('channel') stop_time = parse_xmltv_time(programme.get('stop'))
channel_tvg_id = programme.get('channel')
title = programme.findtext('title', default='No Title')
desc = programme.findtext('desc', default='')
title = programme.findtext('title', default='No Title') programmes_by_channel.setdefault(channel_tvg_id, []).append({
desc = programme.findtext('desc', default='') 'start_time': start_time,
'end_time': stop_time,
'title': title,
'description': desc,
})
# Process each channel group
for tvg_id, programmes in programmes_by_channel.items():
try:
channel = Channel.objects.get(tvg_id=tvg_id)
logger.debug(f"Found Channel: {channel}")
except Channel.DoesNotExist:
logger.warning(f"No channel found for tvg_id '{tvg_id}'. Skipping programmes.")
continue
# Find or create the channel # Get or create the EPGData record for the channel
try: epg_data, created = EPGData.objects.get_or_create(
channel = Channel.objects.get(tvg_id=channel_tvg_id) channel=channel,
except Channel.DoesNotExist: defaults={'channel_name': channel.channel_name}
# Optionally, skip programs for unknown channels )
continue if not created and epg_data.channel_name != channel.channel_name:
epg_data.channel_name = channel.channel_name
# Create or update the program epg_data.save(update_fields=['channel_name'])
Program.objects.update_or_create(
channel=channel, logger.info(f"Processing {len(programmes)} programme(s) for channel '{channel.channel_name}'.")
title=title, # For each programme, update or create a ProgramData record
start_time=start_time, with transaction.atomic():
end_time=stop_time, for prog in programmes:
defaults={'description': desc} obj, created = ProgramData.objects.update_or_create(
) epg=epg_data,
start_time=prog['start_time'],
title=prog['title'],
defaults={
'end_time': prog['end_time'],
'description': prog['description'],
'sub_title': ''
}
)
if created:
logger.info(f"Created ProgramData '{prog['title']}' for channel '{channel.channel_name}'.")
else:
logger.info(f"Updated ProgramData '{prog['title']}' for channel '{channel.channel_name}'.")
except Exception as e: except Exception as e:
# Log the error appropriately logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True)
print(f"Error fetching XMLTV from {source.name}: {e}")
def fetch_schedules_direct(source): def fetch_schedules_direct(source):
logger.info(f"Fetching Schedules Direct data from source: {source.name}")
try: try:
# need to add a setting for api url. # NOTE: You need to provide the correct api_url for Schedules Direct.
api_url = '' api_url = ''
headers = { headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': f'Bearer {source.api_key}', 'Authorization': f'Bearer {source.api_key}',
} }
logger.debug(f"Requesting subscriptions from Schedules Direct using URL: {api_url}")
# Fetch subscriptions (channels)
response = requests.get(api_url, headers=headers, timeout=30) response = requests.get(api_url, headers=headers, timeout=30)
response.raise_for_status() response.raise_for_status()
subscriptions = response.json() subscriptions = response.json()
logger.debug(f"Fetched subscriptions: {subscriptions}")
# Fetch schedules for each subscription
for sub in subscriptions: for sub in subscriptions:
channel_tvg_id = sub.get('stationID') channel_tvg_id = sub.get('stationID')
# Fetch schedules logger.debug(f"Processing subscription for tvg_id: {channel_tvg_id}")
# Need to add schedules direct url
schedules_url = f"/schedules/{channel_tvg_id}" schedules_url = f"/schedules/{channel_tvg_id}"
logger.debug(f"Requesting schedules from URL: {schedules_url}")
sched_response = requests.get(schedules_url, headers=headers, timeout=30) sched_response = requests.get(schedules_url, headers=headers, timeout=30)
sched_response.raise_for_status() sched_response.raise_for_status()
schedules = sched_response.json() schedules = sched_response.json()
logger.debug(f"Fetched schedules: {schedules}")
with transaction.atomic(): try:
try: channel = Channel.objects.get(tvg_id=channel_tvg_id)
channel = Channel.objects.get(tvg_id=channel_tvg_id) logger.debug(f"Found Channel: {channel}")
except Channel.DoesNotExist: except Channel.DoesNotExist:
# skip programs for unknown channels logger.warning(f"No channel found for tvg_id '{channel_tvg_id}'. Skipping subscription.")
continue continue
for sched in schedules.get('schedules', []): # Get or create the EPGData record for the channel
title = sched.get('title', 'No Title') epg_data, created = EPGData.objects.get_or_create(
desc = sched.get('description', '') channel=channel,
start_time = parse_schedules_direct_time(sched.get('startTime')) defaults={'channel_name': channel.channel_name}
end_time = parse_schedules_direct_time(sched.get('endTime')) )
if not created and epg_data.channel_name != channel.channel_name:
Program.objects.update_or_create( epg_data.channel_name = channel.channel_name
channel=channel, epg_data.save(update_fields=['channel_name'])
title=title,
start_time=start_time,
end_time=end_time,
defaults={'description': desc}
)
for sched in schedules.get('schedules', []):
title = sched.get('title', 'No Title')
desc = sched.get('description', '')
start_time = parse_schedules_direct_time(sched.get('startTime'))
end_time = parse_schedules_direct_time(sched.get('endTime'))
obj, created = ProgramData.objects.update_or_create(
epg=epg_data,
start_time=start_time,
title=title,
defaults={
'end_time': end_time,
'description': desc,
'sub_title': ''
}
)
if created:
logger.info(f"Created ProgramData '{title}' for channel '{channel.channel_name}'.")
else:
logger.info(f"Updated ProgramData '{title}' for channel '{channel.channel_name}'.")
except Exception as e: except Exception as e:
# Log the error appropriately logger.error(f"Error fetching Schedules Direct data from {source.name}: {e}", exc_info=True)
print(f"Error fetching Schedules Direct data from {source.name}: {e}")
def parse_xmltv_time(time_str): def parse_xmltv_time(time_str):
# XMLTV time format: '20250130120000 +0000' try:
dt = datetime.strptime(time_str[:14], '%Y%m%d%H%M%S') dt_obj = datetime.strptime(time_str[:14], '%Y%m%d%H%M%S')
tz_sign = time_str[15] tz_sign = time_str[15]
tz_hours = int(time_str[16:18]) tz_hours = int(time_str[16:18])
tz_minutes = int(time_str[18:20]) tz_minutes = int(time_str[18:20])
if tz_sign == '+': if tz_sign == '+':
dt = dt - timedelta(hours=tz_hours, minutes=tz_minutes) dt_obj = dt_obj - timedelta(hours=tz_hours, minutes=tz_minutes)
elif tz_sign == '-': elif tz_sign == '-':
dt = dt + timedelta(hours=tz_hours, minutes=tz_minutes) dt_obj = dt_obj + timedelta(hours=tz_hours, minutes=tz_minutes)
return timezone.make_aware(dt, timezone=timezone.utc) # Make the datetime aware with UTC using the imported dt_timezone
aware_dt = timezone.make_aware(dt_obj, timezone=dt_timezone.utc)
logger.debug(f"Parsed XMLTV time '{time_str}' to {aware_dt}")
return aware_dt
except Exception as e:
logger.error(f"Error parsing XMLTV time '{time_str}': {e}", exc_info=True)
raise
def parse_schedules_direct_time(time_str): def parse_schedules_direct_time(time_str):
# Schedules Direct time format: ISO 8601, e.g., '2025-01-30T12:00:00Z' try:
dt = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ') dt_obj = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')
return timezone.make_aware(dt, timezone=timezone.utc) aware_dt = timezone.make_aware(dt_obj, timezone=dt_timezone.utc)
logger.debug(f"Parsed Schedules Direct time '{time_str}' to {aware_dt}")
return aware_dt
except Exception as e:
logger.error(f"Error parsing Schedules Direct time '{time_str}': {e}", exc_info=True)
raise

View file

@ -1,8 +1,9 @@
from django.urls import path from django.urls import path
from .views import EPGDashboardView from .views import EPGDashboardView, epg_view
app_name = 'epg_dashboard' app_name = 'epg_dashboard'
urlpatterns = [ urlpatterns = [
path('dashboard/', EPGDashboardView.as_view(), name='epg_dashboard'), path('dashboard/', EPGDashboardView.as_view(), name='epg_dashboard'),
path('guide/', epg_view, name='epg_guide'),
] ]

View file

@ -2,8 +2,50 @@ from django.views import View
from django.shortcuts import render from django.shortcuts import render
from django.http import JsonResponse from django.http import JsonResponse
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from .models import EPGSource from .models import EPGSource, ProgramData # Updated: import ProgramData instead of Program
from .serializers import EPGSourceSerializer from .serializers import EPGSourceSerializer
from django.utils import timezone
from datetime import timedelta
def epg_view(request):
"""
Renders the TV guide using programmes from the next 12 hours,
grouped by channel (via EPGData).
"""
now = timezone.now()
end_time = now + timedelta(hours=12)
print(f"[EPG VIEW] Now: {now} | End Time: {end_time}")
# Query ProgramData within the time range
programmes = ProgramData.objects.filter(
start_time__gte=now,
start_time__lte=end_time
).order_by('start_time')
print(f"[EPG VIEW] Found {programmes.count()} programme(s) between now and end_time.")
# Group programmes by channel (retrieved via the EPGData parent)
channels = {}
for prog in programmes:
# Assume that the EPGData instance (prog.epg) has a link to a Channel.
channel = prog.epg.channel if prog.epg and prog.epg.channel else None
if not channel:
continue
channels.setdefault(channel, []).append(prog)
if not channels:
print("[EPG VIEW] No channels with programmes found.")
else:
for channel, progs in channels.items():
print(f"[EPG VIEW] Channel: {channel} has {len(progs)} programme(s).")
context = {
'channels': channels,
'now': now,
'end_time': end_time,
}
return render(request, 'epg/tvguide.html', context)
class EPGDashboardView(View): class EPGDashboardView(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):

View file

@ -1,5 +1,5 @@
from django.urls import path, include from django.urls import path, include
from .views import generate_m3u from .views import generate_m3u, generate_epg
from core.views import stream_view from core.views import stream_view
@ -7,5 +7,6 @@ app_name = 'output'
urlpatterns = [ urlpatterns = [
path('m3u/', generate_m3u, name='generate_m3u'), path('m3u/', generate_m3u, name='generate_m3u'),
path('epg/', generate_epg, name='generate_epg'),
path('stream/<int:stream_id>/', stream_view, name='stream'), path('stream/<int:stream_id>/', stream_view, name='stream'),
] ]

View file

@ -2,6 +2,10 @@
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from apps.channels.models import Channel from apps.channels.models import Channel
from datetime import timedelta
from apps.epg.models import ProgramData
from apps.channels.models import Channel
from django.utils import timezone
def generate_m3u(request): def generate_m3u(request):
""" """
@ -28,3 +32,53 @@ def generate_m3u(request):
response = HttpResponse(m3u_content, content_type="application/x-mpegURL") response = HttpResponse(m3u_content, content_type="application/x-mpegURL")
response['Content-Disposition'] = 'attachment; filename="channels.m3u"' response['Content-Disposition'] = 'attachment; filename="channels.m3u"'
return response return response
def generate_epg(request):
"""
Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models.
Only channels that have EPG programmes (via EPGData) are included.
"""
now = timezone.now()
end_time = now + timedelta(hours=24)
# Query ProgramData objects in the next 24 hours
programs = ProgramData.objects.select_related('epg__channel').filter(
start_time__gte=now, start_time__lte=end_time
).order_by('start_time')
# Group programmes by their channel (via EPGData)
channels_programs = {}
for prog in programs:
if prog.epg and prog.epg.channel:
channel = prog.epg.channel
channels_programs.setdefault(channel, []).append(prog)
xml_lines = []
xml_lines.append('<?xml version="1.0" encoding="UTF-8"?>')
xml_lines.append('<tv generator-info-name="Dispatcharr" generator-info-url="https://example.com">')
# Output channel definitions for channels that have programmes
for channel in channels_programs.keys():
xml_lines.append(f' <channel id="{channel.id}">')
xml_lines.append(f' <display-name>{channel.channel_name}</display-name>')
if channel.logo_url:
xml_lines.append(f' <icon src="{channel.logo_url}" />')
xml_lines.append(' </channel>')
# Output programme entries
for channel, progs in channels_programs.items():
for prog in progs:
start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
xml_lines.append(f' <programme start="{start_str}" stop="{stop_str}" channel="{channel.id}">')
xml_lines.append(f' <title>{prog.title}</title>')
xml_lines.append(f' <desc>{prog.description}</desc>')
xml_lines.append(' </programme>')
xml_lines.append('</tv>')
xml_content = "\n".join(xml_lines)
response = HttpResponse(xml_content, content_type="application/xml")
response['Content-Disposition'] = 'attachment; filename="epg.xml"'
return response

6
core/urls.py Normal file
View file

@ -0,0 +1,6 @@
from django.urls import path
from .views import settings_view
urlpatterns = [
path('', settings_view, name='settings'),
]

View file

@ -6,6 +6,7 @@ import logging
from django.conf import settings from django.conf import settings
from django.http import StreamingHttpResponse, HttpResponseServerError from django.http import StreamingHttpResponse, HttpResponseServerError
from django.db.models import F from django.db.models import F
from django.shortcuts import render
from apps.channels.models import Channel, Stream from apps.channels.models import Channel, Stream
from core.models import StreamProfile from core.models import StreamProfile
@ -14,6 +15,13 @@ from core.models import StreamProfile
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def settings_view(request):
"""
Renders the settings page.
"""
return render(request, 'settings.html')
def stream_view(request, stream_id): def stream_view(request, stream_id):
""" """
Streams the first available stream for the given channel. Streams the first available stream for the given channel.

View file

@ -34,7 +34,7 @@ urlpatterns = [
path('m3u/', include(('apps.m3u.urls', 'm3u'), namespace='m3u')), path('m3u/', include(('apps.m3u.urls', 'm3u'), namespace='m3u')),
path('epg/', include(('apps.epg.urls', 'epg'), namespace='epg')), path('epg/', include(('apps.epg.urls', 'epg'), namespace='epg')),
path('channels/', include(('apps.channels.urls', 'channels'), namespace='channels')), path('channels/', include(('apps.channels.urls', 'channels'), namespace='channels')),
#path('settings/', include(('apps.settings.urls', 'settings'), namespace='settings')), path('settings/', include(('core.urls', 'settings'), namespace='settings')),
#path('backup/', include(('apps.backup.urls', 'backup'), namespace='backup')), #path('backup/', include(('apps.backup.urls', 'backup'), namespace='backup')),
path('dashboard/', include(('apps.dashboard.urls', 'dashboard'), namespace='dashboard')), path('dashboard/', include(('apps.dashboard.urls', 'dashboard'), namespace='dashboard')),
path('output/', include('apps.output.urls', namespace='output')), path('output/', include('apps.output.urls', namespace='output')),

View file

@ -34,7 +34,7 @@ services:
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: environment:
- POSTGRES_HOST=localhost - POSTGRES_HOST=db
- POSTGRES_DB=dispatcharr - POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch - POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret - POSTGRES_PASSWORD=secret

View file

@ -11,7 +11,7 @@
<!-- Stream Details --> <!-- Stream Details -->
<div class="mb-3"> <div class="mb-3">
<label for="newStreamNameField" class="form-label">Stream Name</label> <label for="newStreamNameField" class="form-label">Stream Name</label>
<input type="text" class="form-control" id="newStreamNameField" name="stream_name" required> <input type="text" class="form-control" id="newStreamNameField" name="name" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="newStreamGroupField" class="form-label">Stream Group</label> <label for="newStreamGroupField" class="form-label">Stream Group</label>

34
templates/epg/tvguide.html Executable file
View file

@ -0,0 +1,34 @@
{# templates/output/epg_tvguide.html #}
{% extends "base.html" %}
{% block title %}TV Guide{% endblock %}
{% block content %}
<h1>TV Guide</h1>
<p>Showing programs from {{ now|date:"H:i" }} to {{ end_time|date:"H:i" }}</p>
{% for channel, programs in channels.items %}
<h2>{{ channel.channel_name }}</h2>
<table class="table table-bordered">
<thead>
<tr>
<th>Start Time</th>
<th>End Time</th>
<th>Program Title</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for program in programs %}
<tr>
<td>{{ program.start_time|date:"H:i" }}</td>
<td>{{ program.end_time|date:"H:i" }}</td>
<td>{{ program.title }}</td>
<td>{{ program.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% empty %}
<p>No programs scheduled in this time frame.</p>
{% endfor %}
{% endblock %}

View file

@ -1,68 +1,334 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}M3U Management - Dispatcharr{% endblock %} {% block title %}Settings - Dispatcharr{% endblock %}
{% block page_header %}M3U Management{% endblock %} {% block page_header %}Settings{% endblock %}
{% block breadcrumb %} {% block breadcrumb %}
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li> <li class="breadcrumb-item"><a href="/core/dashboard/">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">M3U Management</li> <li class="breadcrumb-item active" aria-current="page">Settings</li>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">M3U Accounts</h3> <h3 class="card-title">Settings</h3>
<button id="addM3UBtn" class="btn btn-primary float-end">
<i class="bi bi-plus"></i> Add M3U Account
</button>
</div> </div>
<div class="card-body"> <div class="card-body">
<table id="m3uTable" class="table table-striped"> <!-- Nav Tabs -->
<thead> <ul class="nav nav-tabs" id="settingsTab" role="tablist">
<tr> <li class="nav-item">
<th>ID</th> <a class="nav-link active" id="stream-profiles-tab" data-bs-toggle="tab" href="#stream-profiles"
<th>Name</th> role="tab" aria-controls="stream-profiles" aria-selected="true">
<th>Server URL</th> Stream Profiles
<th>Uploaded File</th> </a>
<th>Active</th> </li>
<th>Actions</th> <li class="nav-item">
</tr> <a class="nav-link" id="user-agents-tab" data-bs-toggle="tab" href="#user-agents" role="tab"
</thead> aria-controls="user-agents" aria-selected="false">
<tbody></tbody> User Agents
</table> </a>
</div> </li>
</div> <li class="nav-item">
<a class="nav-link" id="logo-caching-tab" data-bs-toggle="tab" href="#logo-caching" role="tab"
aria-controls="logo-caching" aria-selected="false">
Logo Caching
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="backup-restore-tab" data-bs-toggle="tab" href="#backup-restore" role="tab"
aria-controls="backup-restore" aria-selected="false">
Backup/Restore
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="hdhr-tab" data-bs-toggle="tab" href="#hdhr" role="tab"
aria-controls="hdhr" aria-selected="false">
HDHR Emulation
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="user-management-tab" data-bs-toggle="tab" href="#user-management"
role="tab" aria-controls="user-management" aria-selected="false">
Users &amp; Groups
</a>
</li>
</ul>
<!-- M3U Modal --> <!-- Tab Content -->
<div class="modal fade" id="m3uModal" tabindex="-1" aria-labelledby="m3uModalLabel" aria-hidden="true"> <div class="tab-content mt-3" id="settingsTabContent">
<!-- STREAM PROFILES -->
<div class="tab-pane fade show active" id="stream-profiles" role="tabpanel"
aria-labelledby="stream-profiles-tab">
<button id="addStreamProfileBtn" class="btn btn-primary mb-2">
<i class="bi bi-plus"></i> Add Stream Profile
</button>
<div class="table-responsive">
<table id="streamProfilesTable" class="table table-striped nowrap">
<thead>
<tr>
<th>ID</th>
<th>Profile Name</th>
<th>Command</th>
<th>Parameters</th>
<th>Active</th>
<th>User Agent</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- USER AGENTS -->
<div class="tab-pane fade" id="user-agents" role="tabpanel" aria-labelledby="user-agents-tab">
<button id="addUserAgentBtn" class="btn btn-primary mb-2">
<i class="bi bi-plus"></i> Add User Agent
</button>
<div class="table-responsive">
<table id="userAgentsTable" class="table table-striped nowrap">
<thead>
<tr>
<th>ID</th>
<th>User Agent Name</th>
<th>User Agent</th>
<th>Description</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- LOGO CACHING -->
<div class="tab-pane fade" id="logo-caching" role="tabpanel" aria-labelledby="logo-caching-tab">
<form id="logoCachingForm" class="mt-3">
<div class="form-check form-switch mb-3">
<input type="checkbox" class="form-check-input" id="cacheLogosToggle" name="cache_logos">
<label class="form-check-label" for="cacheLogosToggle">
Cache Channel Logos Locally
</label>
</div>
<button type="submit" class="btn btn-primary">Save Logo Caching Settings</button>
</form>
</div>
<!-- BACKUP / RESTORE -->
<div class="tab-pane fade" id="backup-restore" role="tabpanel" aria-labelledby="backup-restore-tab">
<div class="mt-3">
<button id="createBackupBtn" class="btn btn-success mb-2">
<i class="bi bi-download"></i> Create Backup
</button>
<button id="restoreBackupBtn" class="btn btn-warning mb-2">
<i class="bi bi-upload"></i> Restore Backup
</button>
</div>
</div>
<!-- HDHR EMULATION -->
<div class="tab-pane fade" id="hdhr" role="tabpanel" aria-labelledby="hdhr-tab">
<form id="hdhrEmulationForm" class="mt-3">
<div class="form-check form-switch mb-3">
<input type="checkbox" class="form-check-input" id="hdhrEmulationToggle" name="hdhr_emulation">
<label class="form-check-label" for="hdhrEmulationToggle">Enable HDHR Emulation</label>
</div>
<button type="submit" class="btn btn-primary">Save HDHR Settings</button>
</form>
</div>
<!-- USERS & GROUPS -->
<div class="tab-pane fade" id="user-management" role="tabpanel" aria-labelledby="user-management-tab">
<div class="row mt-3">
<!-- USERS -->
<div class="col-md-6">
<h5>Users</h5>
<button id="addUserBtn" class="btn btn-primary mb-2">
<i class="bi bi-plus"></i> Add User
</button>
<div class="table-responsive">
<table id="usersTable" class="table table-striped nowrap">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Groups</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- GROUPS -->
<div class="col-md-6">
<h5>Groups</h5>
<button id="addGroupBtn" class="btn btn-primary mb-2">
<i class="bi bi-plus"></i> Add Group
</button>
<div class="table-responsive">
<table id="groupsTable" class="table table-striped nowrap">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div><!-- end row -->
</div><!-- end user-management tab content -->
</div><!-- end tab-content -->
</div><!-- end card-body -->
</div><!-- end card -->
<!-- ============== MODALS ============== -->
<!-- STREAM PROFILE MODAL -->
<div class="modal fade" id="streamProfileModal" tabindex="-1" aria-labelledby="streamProfileModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<form id="m3uForm" enctype="multipart/form-data"> <form id="streamProfileForm" enctype="multipart/form-data">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="m3uModalLabel">M3U Account</h5> <h5 class="modal-title" id="streamProfileModalLabel">Stream Profile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="m3uId" name="id"> <input type="hidden" id="streamProfileId" name="id">
<div class="mb-3"> <div class="mb-3">
<label for="m3uName" class="form-label">Name</label> <label for="profileName" class="form-label">Profile Name</label>
<input type="text" class="form-control" id="m3uName" name="name" required> <input type="text" class="form-control" id="profileName" name="profile_name" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="serverUrl" class="form-label">Server URL</label> <label for="command" class="form-label">Command</label>
<input type="url" class="form-control" id="serverUrl" name="server_url"> <input type="text" class="form-control" id="command" name="command" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="uploadedFile" class="form-label">Uploaded File</label> <label for="parameters" class="form-label">Parameters</label>
<input type="file" class="form-control" id="uploadedFile" name="uploaded_file"> <textarea class="form-control" id="parameters" name="parameters" required></textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="isActive" class="form-label">Active</label> <label for="profileActive" class="form-label">Active</label>
<select class="form-select" id="isActive" name="is_active"> <select class="form-select" id="profileActive" name="is_active">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="mb-3">
<label for="profileUserAgent" class="form-label">User Agent (optional)</label>
<input type="text" class="form-control" id="profileUserAgent" name="user_agent">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save Profile</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</form>
</div>
</div>
<!-- USER AGENT MODAL -->
<div class="modal fade" id="userAgentModal" tabindex="-1" aria-labelledby="userAgentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<form id="userAgentForm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userAgentModalLabel">Add User Agent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="userAgentId" name="id">
<div class="mb-3">
<label for="userAgentNameField" class="form-label">User Agent Name</label>
<input type="text" class="form-control" id="userAgentNameField" name="user_agent_name" required>
</div>
<div class="mb-3">
<label for="userAgentStringField" class="form-label">User Agent String</label>
<input type="text" class="form-control" id="userAgentStringField" name="user_agent" required>
</div>
<div class="mb-3">
<label for="userAgentDescription" class="form-label">Description</label>
<input type="text" class="form-control" id="userAgentDescription" name="description">
</div>
<div class="mb-3">
<label for="userAgentActive" class="form-label">Active</label>
<select class="form-select" id="userAgentActive" name="is_active">
<option value="true">Yes</option> <option value="true">Yes</option>
<option value="false">No</option> <option value="false">No</option>
</select> </select>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="submit" class="btn btn-primary">Save Account</button> <button type="submit" class="btn btn-primary">Save User Agent</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</form>
</div>
</div>
<!-- USER MODAL -->
<div class="modal fade" id="userModal" tabindex="-1" aria-labelledby="userModalLabel" aria-hidden="true">
<div class="modal-dialog">
<form id="userForm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userModalLabel">Add User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="userId" name="id">
<div class="mb-3">
<label for="usernameField" class="form-label">Username</label>
<input type="text" class="form-control" id="usernameField" name="username" required>
</div>
<div class="mb-3">
<label for="emailField" class="form-label">Email</label>
<input type="email" class="form-control" id="emailField" name="email">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save User</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</form>
</div>
</div>
<!-- GROUP MODAL -->
<div class="modal fade" id="groupModal" tabindex="-1" aria-labelledby="groupModalLabel" aria-hidden="true">
<div class="modal-dialog">
<form id="groupForm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="groupModalLabel">Add Group</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="groupId" name="id">
<div class="mb-3">
<label for="groupNameField" class="form-label">Group Name</label>
<input type="text" class="form-control" id="groupNameField" name="name" required>
</div>
<!-- If you want to pick permissions:
<div class="mb-3">
<label class="form-label">Permissions</label>
<select class="form-select" id="groupPermissionsField" name="permissions" multiple>
...
</select>
</div>
-->
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save Group</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div> </div>
</div> </div>
@ -70,86 +336,448 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.3.0/css/responsive.dataTables.min.css">
<!-- DataTables JS / Dependencies -->
<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.datatables.net/responsive/2.3.0/js/dataTables.responsive.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function(){ /* CSRF Helper */
var m3uTable = new DataTable("#m3uTable", { function getCookie(name) {
ajax: "{% url 'api:m3u-account-list' %}", 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");
document.addEventListener("DOMContentLoaded", function() {
/* STREAM PROFILES TABLE */
const streamProfilesTable = new DataTable("#streamProfilesTable", {
ajax: { url: "/api/core/streamprofiles/", dataSrc: "" },
responsive: true,
columns: [ columns: [
{ data: "id" }, { data: "id" },
{ data: "name" }, { data: "profile_name" },
{ data: "server_url" }, { data: "command" },
{ data: "uploaded_file" }, { data: "parameters" },
{ data: "is_active", render: function(data){ return data ? "Yes" : "No"; } }, {
{ data: null, render: function(data){ data: "is_active",
return '<button class="btn btn-sm btn-primary edit-m3u" data-id="'+data.id+'">Edit</button> ' + render: data => (data ? "Yes" : "No")
'<button class="btn btn-sm btn-danger delete-m3u" data-id="'+data.id+'">Delete</button>'; },
} { data: "user_agent" },
{
data: null,
render: row => `
<button class="btn btn-sm btn-primary edit-stream-profile" data-id="${row.id}">Edit</button>
<button class="btn btn-sm btn-danger delete-stream-profile" data-id="${row.id}">Delete</button>
`
} }
] ]
}); });
document.getElementById("addM3UBtn").addEventListener("click", function(){ /* ADD STREAM PROFILE */
document.getElementById("m3uForm").reset(); document.getElementById("addStreamProfileBtn").addEventListener("click", () => {
document.getElementById("m3uId").value = ''; document.getElementById("streamProfileForm").reset();
document.getElementById("m3uModalLabel").textContent = "Add M3U Account"; document.getElementById("streamProfileId").value = "";
new bootstrap.Modal(document.getElementById("m3uModal")).show(); document.getElementById("streamProfileModalLabel").textContent = "Add Stream Profile";
new bootstrap.Modal(document.getElementById("streamProfileModal")).show();
}); });
document.querySelector("#m3uTable").addEventListener("click", function(e){ /* SAVE STREAM PROFILE */
if(e.target.classList.contains("edit-m3u")){ document.getElementById("streamProfileForm").addEventListener("submit", function(e) {
var m3uId = e.target.getAttribute("data-id");
fetch("/api/m3u/accounts/" + m3uId + "/")
.then(res => res.json())
.then(data => {
document.getElementById("m3uId").value = data.id;
document.getElementById("m3uName").value = data.name;
document.getElementById("serverUrl").value = data.server_url;
document.getElementById("isActive").value = data.is_active ? "true" : "false";
document.getElementById("m3uModalLabel").textContent = "Edit M3U Account";
new bootstrap.Modal(document.getElementById("m3uModal")).show();
});
}
if(e.target.classList.contains("delete-m3u")){
var m3uId = e.target.getAttribute("data-id");
Swal.fire({
title: 'Are you sure?',
text: "This will delete the M3U Account permanently.",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, delete it!'
}).then(result => {
if(result.isConfirmed){
fetch("/api/m3u/accounts/" + m3uId + "/", { method: "DELETE" })
.then(response => {
if(response.ok){
Swal.fire("Deleted!", "M3U Account deleted.", "success");
m3uTable.ajax.reload();
} else {
Swal.fire("Error", "Failed to delete M3U Account.", "error");
}
});
}
});
}
});
document.getElementById("m3uForm").addEventListener("submit", function(e){
e.preventDefault(); e.preventDefault();
var m3uId = document.getElementById("m3uId").value; const idVal = document.getElementById("streamProfileId").value;
var formData = new FormData(this); const url = idVal ? `/api/core/streamprofiles/${idVal}/` : "/api/core/streamprofiles/";
var method = m3uId ? "PUT" : "POST"; const method = idVal ? "PUT" : "POST";
var url = m3uId ? "/api/m3u/accounts/" + m3uId + "/" : "/api/m3u/accounts/";
const formData = new FormData(this);
fetch(url, { fetch(url, {
method: method, method: method,
body: formData body: formData,
}).then(response => { headers: { "X-CSRFToken": csrftoken }
if(response.ok){ })
bootstrap.Modal.getInstance(document.getElementById("m3uModal")).hide(); .then(resp => {
Swal.fire("Success", "M3U Account saved!", "success"); if(!resp.ok) throw new Error("Failed to save Stream Profile");
m3uTable.ajax.reload(); return resp.json();
} else { })
Swal.fire("Error", "Failed to save M3U Account.", "error"); .then(() => {
Swal.fire("Success", "Stream Profile saved.", "success");
streamProfilesTable.ajax.reload();
bootstrap.Modal.getInstance(document.getElementById("streamProfileModal")).hide();
})
.catch(err => {
Swal.fire("Error", err.message, "error");
});
});
/* EDIT / DELETE STREAM PROFILE */
$("#streamProfilesTable").on("click", ".edit-stream-profile", function(){
const rowData = streamProfilesTable.row($(this).closest("tr")).data();
document.getElementById("streamProfileModalLabel").textContent = "Edit Stream Profile";
document.getElementById("streamProfileId").value = rowData.id;
document.getElementById("profileName").value = rowData.profile_name;
document.getElementById("command").value = rowData.command;
document.getElementById("parameters").value = rowData.parameters;
document.getElementById("profileActive").value = rowData.is_active ? "true" : "false";
document.getElementById("profileUserAgent").value = rowData.user_agent || "";
new bootstrap.Modal(document.getElementById("streamProfileModal")).show();
});
$("#streamProfilesTable").on("click", ".delete-stream-profile", function(){
const rowData = streamProfilesTable.row($(this).closest("tr")).data();
Swal.fire({
title: "Delete this Stream Profile?",
text: `Are you sure you want to delete "${rowData.profile_name}"?`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete it!"
}).then(result => {
if(result.isConfirmed) {
fetch(`/api/core/streamprofiles/${rowData.id}/`, {
method: "DELETE",
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(!resp.ok) throw new Error("Failed to delete profile");
Swal.fire("Deleted!", "Stream Profile was removed.", "success");
streamProfilesTable.ajax.reload();
})
.catch(err => Swal.fire("Error", err.message, "error"));
}
});
});
/* USER AGENTS TABLE */
const userAgentsTable = new DataTable("#userAgentsTable", {
ajax: { url: "/api/core/useragents/", dataSrc: "" },
responsive: true,
columns: [
{ data: "id" },
{ data: "user_agent_name" },
{ data: "user_agent" },
{ data: "description" },
{
data: "is_active",
render: data => (data ? "Yes" : "No")
},
{
data: null,
render: row => `
<button class="btn btn-sm btn-primary edit-user-agent" data-id="${row.id}">Edit</button>
<button class="btn btn-sm btn-danger delete-user-agent" data-id="${row.id}">Delete</button>
`
}
]
});
/* ADD USER AGENT */
document.getElementById("addUserAgentBtn").addEventListener("click", () => {
document.getElementById("userAgentForm").reset();
document.getElementById("userAgentModalLabel").textContent = "Add User Agent";
document.getElementById("userAgentId").value = "";
new bootstrap.Modal(document.getElementById("userAgentModal")).show();
});
/* SAVE USER AGENT */
document.getElementById("userAgentForm").addEventListener("submit", function(e){
e.preventDefault();
const idVal = document.getElementById("userAgentId").value;
const url = idVal ? `/api/core/useragents/${idVal}/` : "/api/core/useragents/";
const method = idVal ? "PUT" : "POST";
const formData = new FormData(this);
fetch(url, {
method,
body: formData,
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(!resp.ok) throw new Error("Failed to save user agent");
return resp.json();
})
.then(() => {
Swal.fire("Success", "User Agent saved.", "success");
userAgentsTable.ajax.reload();
bootstrap.Modal.getInstance(document.getElementById("userAgentModal")).hide();
})
.catch(err => Swal.fire("Error", err.message, "error"));
});
/* EDIT / DELETE USER AGENT */
$("#userAgentsTable").on("click", ".edit-user-agent", function(){
const rowData = userAgentsTable.row($(this).closest("tr")).data();
document.getElementById("userAgentModalLabel").textContent = "Edit User Agent";
document.getElementById("userAgentId").value = rowData.id;
document.getElementById("userAgentNameField").value = rowData.user_agent_name || "";
document.getElementById("userAgentStringField").value = rowData.user_agent || "";
document.getElementById("userAgentDescription").value = rowData.description || "";
document.getElementById("userAgentActive").value = rowData.is_active ? "true" : "false";
new bootstrap.Modal(document.getElementById("userAgentModal")).show();
});
$("#userAgentsTable").on("click", ".delete-user-agent", function(){
const rowData = userAgentsTable.row($(this).closest("tr")).data();
Swal.fire({
title: "Delete this User Agent?",
text: `Are you sure you want to delete "${rowData.user_agent_name}"?`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete it!"
}).then(result => {
if(result.isConfirmed) {
fetch(`/api/core/useragents/${rowData.id}/`, {
method: "DELETE",
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(!resp.ok) throw new Error("Failed to delete user agent");
Swal.fire("Deleted!", "User Agent was removed.", "success");
userAgentsTable.ajax.reload();
})
.catch(err => Swal.fire("Error", err.message, "error"));
}
});
});
/* LOGO CACHING SETTINGS */
document.getElementById("logoCachingForm").addEventListener("submit", function(e){
e.preventDefault();
const formData = new FormData(this);
fetch("/api/core/settings/1/", {
method: "PUT",
body: formData,
headers: { "X-CSRFToken": csrftoken }
})
.then(response => {
if(response.ok) Swal.fire("Success", "Logo caching settings saved.", "success");
else Swal.fire("Error", "Failed to save logo caching settings.", "error");
});
});
/* BACKUP / RESTORE */
document.getElementById("createBackupBtn").addEventListener("click", function(){
fetch("/api/channels/backup/", {
method: "POST",
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(resp.ok) Swal.fire("Success", "Backup created.", "success");
else Swal.fire("Error", "Backup failed.", "error");
});
});
document.getElementById("restoreBackupBtn").addEventListener("click", function(){
fetch("/api/channels/restore/", {
method: "POST",
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(resp.ok) Swal.fire("Success", "Restore complete.", "success");
else Swal.fire("Error", "Restore failed.", "error");
});
});
/* HDHR Emulation */
document.getElementById("hdhrEmulationForm").addEventListener("submit", function(e){
e.preventDefault();
const formData = new FormData(this);
fetch("/api/core/settings/1/", {
method: "PUT",
body: formData,
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(resp.ok) Swal.fire("Success", "HDHR settings saved.", "success");
else Swal.fire("Error", "Failed to save HDHR settings.", "error");
});
});
/* USERS TABLE */
const usersTable = new DataTable("#usersTable", {
ajax: { url: "/api/accounts/users", dataSrc: "" },
responsive: true,
columns: [
{ data: "id" },
{ data: "username" },
{ data: "email" },
{ data: "groups" },
{
data: null,
render: row => `
<button class="btn btn-sm btn-primary edit-user" data-id="${row.id}">Edit</button>
<button class="btn btn-sm btn-danger delete-user" data-id="${row.id}">Delete</button>
`
}
]
});
/* ADD USER */
document.getElementById("addUserBtn").addEventListener("click", () => {
document.getElementById("userForm").reset();
document.getElementById("userModalLabel").textContent = "Add User";
document.getElementById("userId").value = "";
new bootstrap.Modal(document.getElementById("userModal")).show();
});
/* SAVE USER */
document.getElementById("userForm").addEventListener("submit", function(e){
e.preventDefault();
const idVal = document.getElementById("userId").value;
const url = idVal ? `/api/accounts/users/${idVal}/` : "/api/accounts/users/";
const method = idVal ? "PUT" : "POST";
const formData = new FormData(this);
fetch(url, {
method,
body: formData,
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(!resp.ok) throw new Error("Failed to save user");
return resp.json();
})
.then(() => {
Swal.fire("Success", "User saved.", "success");
usersTable.ajax.reload();
bootstrap.Modal.getInstance(document.getElementById("userModal")).hide();
})
.catch(err => Swal.fire("Error", err.message, "error"));
});
/* EDIT / DELETE USER */
$("#usersTable").on("click", ".edit-user", function(){
const rowData = usersTable.row($(this).closest("tr")).data();
document.getElementById("userModalLabel").textContent = "Edit User";
document.getElementById("userId").value = rowData.id;
document.getElementById("usernameField").value = rowData.username || "";
document.getElementById("emailField").value = rowData.email || "";
new bootstrap.Modal(document.getElementById("userModal")).show();
});
$("#usersTable").on("click", ".delete-user", function(){
const rowData = usersTable.row($(this).closest("tr")).data();
Swal.fire({
title: "Delete this user?",
text: `Are you sure you want to delete "${rowData.username}"?`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete!"
}).then(res => {
if(res.isConfirmed){
fetch(`/api/accounts/users/${rowData.id}/`, {
method: "DELETE",
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(!resp.ok) throw new Error("Failed to delete user");
Swal.fire("Deleted!", "User was removed.", "success");
usersTable.ajax.reload();
})
.catch(err => Swal.fire("Error", err.message, "error"));
}
});
});
/* GROUPS TABLE */
const groupsTable = new DataTable("#groupsTable", {
ajax: { url: "/api/accounts/groups/", dataSrc: "" },
responsive: true,
columns: [
{ data: "id" },
{ data: "name" },
{
data: "permissions",
render: perms => {
if(!perms || !perms.length) return "";
return perms.join(", ");
}
},
{
data: null,
render: row => `
<button class="btn btn-sm btn-primary edit-group" data-id="${row.id}">Edit</button>
<button class="btn btn-sm btn-danger delete-group" data-id="${row.id}">Delete</button>
`
}
]
});
/* ADD GROUP */
document.getElementById("addGroupBtn").addEventListener("click", () => {
document.getElementById("groupForm").reset();
document.getElementById("groupModalLabel").textContent = "Add Group";
document.getElementById("groupId").value = "";
new bootstrap.Modal(document.getElementById("groupModal")).show();
});
/* SAVE GROUP */
document.getElementById("groupForm").addEventListener("submit", function(e){
e.preventDefault();
const idVal = document.getElementById("groupId").value;
const url = idVal ? `/api/accounts/groups/${idVal}/` : "/api/accounts/groups/";
const method = idVal ? "PUT" : "POST";
const formData = new FormData(this);
fetch(url, {
method,
body: formData,
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(!resp.ok) throw new Error("Failed to save group");
return resp.json();
})
.then(() => {
Swal.fire("Success", "Group saved.", "success");
groupsTable.ajax.reload();
bootstrap.Modal.getInstance(document.getElementById("groupModal")).hide();
})
.catch(err => Swal.fire("Error", err.message, "error"));
});
/* EDIT / DELETE GROUP */
$("#groupsTable").on("click", ".edit-group", function(){
const rowData = groupsTable.row($(this).closest("tr")).data();
document.getElementById("groupModalLabel").textContent = "Edit Group";
document.getElementById("groupId").value = rowData.id;
document.getElementById("groupNameField").value = rowData.name || "";
new bootstrap.Modal(document.getElementById("groupModal")).show();
});
$("#groupsTable").on("click", ".delete-group", function(){
const rowData = groupsTable.row($(this).closest("tr")).data();
Swal.fire({
title: "Delete this group?",
text: `Are you sure you want to delete "${rowData.name}"?`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete!"
}).then(res => {
if(res.isConfirmed){
fetch(`/api/accounts/groups/${rowData.id}/`, {
method: "DELETE",
headers: { "X-CSRFToken": csrftoken }
})
.then(resp => {
if(!resp.ok) throw new Error("Failed to delete group");
Swal.fire("Deleted!", "Group was removed.", "success");
groupsTable.ajax.reload();
})
.catch(err => Swal.fire("Error", err.message, "error"));
} }
}); });
}); });