mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Pre-Alpha v4
Added settings page Added EPG functionality
This commit is contained in:
parent
3a15cf6b7f
commit
d89bf35c0d
17 changed files with 1081 additions and 212 deletions
|
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
6
core/urls.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.urls import path
|
||||||
|
from .views import settings_view
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', settings_view, name='settings'),
|
||||||
|
]
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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')),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
34
templates/epg/tvguide.html
Executable 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 %}
|
||||||
|
|
@ -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 & 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"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue