Added user agents
Added Stream Profiles
Added new API calls
This commit is contained in:
Dispatcharr 2025-02-21 15:31:59 -06:00
parent 952a23fe3b
commit 1fb7a0c9eb
36 changed files with 1001 additions and 641 deletions

View file

@ -21,11 +21,11 @@ schema_view = get_schema_view(
urlpatterns = [
path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')),
#path('backup/', include(('apps.backup.api_urls', 'backup'), namespace='backup')),
path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')),
path('epg/', include(('apps.epg.api_urls', 'epg'), namespace='epg')),
path('hdhr/', include(('apps.hdhr.api_urls', 'hdhr'), namespace='hdhr')),
path('m3u/', include(('apps.m3u.api_urls', 'm3u'), namespace='m3u')),
path('core/', include(('core.api_urls', 'core'), namespace='core')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),

View file

@ -1,5 +1,6 @@
from django.db import models
from django.core.exceptions import ValidationError
from core.models import StreamProfile
# If you have an M3UAccount model in apps.m3u, you can still import it:
from apps.m3u.models import M3UAccount
@ -71,6 +72,15 @@ class Channel(models.Model):
tvg_name = models.CharField(max_length=255, blank=True, null=True)
objects = ChannelManager()
stream_profile = models.ForeignKey(
StreamProfile,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='channels'
)
def clean(self):
# Enforce unique channel_number within a given group
existing = Channel.objects.filter(

View file

@ -1,6 +0,0 @@
from django.apps import AppConfig
class FfmpegConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.ffmpeg'
verbose_name = "FFmpeg Streaming"

View file

@ -1,9 +0,0 @@
from django.urls import path
from .views import stream_view, serve_hls_segment
app_name = 'ffmpeg'
urlpatterns = [
path('<int:stream_id>/', stream_view, name='stream'),
path('<int:stream_id>/<str:filename>/', serve_hls_segment, name='serve_hls_segment'),
]

View file

@ -1,75 +0,0 @@
import os
import redis
import subprocess
from django.conf import settings
from django.http import (
StreamingHttpResponse,
HttpResponseServerError,
FileResponse,
Http404,
)
from django.db.models import F
from apps.channels.models import Channel, Stream
# Configure Redis
redis_host = os.environ.get("REDIS_HOST", "redis")
redis_port = int(os.environ.get("REDIS_PORT", 6379))
redis_db = int(os.environ.get("REDIS_DB", 0))
redis_client = redis.Redis(host=redis_host, port=redis_port, db=redis_db)
def serve_hls_segment(request, stream_id, filename):
# Remove any trailing slashes from the filename. / caused problems.
filename = filename.rstrip('/')
# Construct the file path (e.g., /tmp/hls_4/segment_001.ts)
file_path = os.path.join('/tmp', f'hls_{stream_id}', filename)
if os.path.exists(file_path):
return FileResponse(open(file_path, 'rb'), content_type='video/MP2T')
else:
raise Http404("Segment not found")
def stream_view(request, stream_id):
try:
channel = Channel.objects.get(id=stream_id)
if not channel.streams.exists():
return HttpResponseServerError("No stream found for this channel.")
# Pick the first available stream and get its actual model instance.
stream = channel.streams.first()
# Use the custom URL if available; otherwise, the regular URL.
input_url = stream.custom_url or stream.url
# Increment the viewer count atomically.
Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') + 1)
ffmpeg_cmd = [
"ffmpeg",
"-i", input_url,
"-c:v", "copy",
"-c:a", "copy",
"-f", "mpegts",
"-" # output to stdout
]
process = subprocess.Popen(
ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
except Exception as e:
return HttpResponseServerError(f"Error starting stream: {e}")
def stream_generator(process, stream):
try:
while True:
chunk = process.stdout.read(8192)
if not chunk:
break
yield chunk
finally:
# Decrement the viewer count when the stream finishes or the connection closes.
Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') - 1)
return StreamingHttpResponse(
stream_generator(process, stream),
content_type="video/MP2T"
)

View file

@ -1,6 +1,6 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import M3UAccount, M3UFilter, ServerGroup
from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent
class M3UFilterInline(admin.TabularInline):
model = M3UFilter
@ -10,12 +10,21 @@ class M3UFilterInline(admin.TabularInline):
@admin.register(M3UAccount)
class M3UAccountAdmin(admin.ModelAdmin):
list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'uploaded_file_link', 'created_at', 'updated_at')
list_filter = ('is_active',)
list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'user_agent_display', 'uploaded_file_link', 'created_at', 'updated_at')
list_filter = ('is_active', 'server_group')
search_fields = ('name', 'server_url', 'server_group__name')
inlines = [M3UFilterInline]
actions = ['activate_accounts', 'deactivate_accounts']
# Handle both ForeignKey and ManyToManyField cases for UserAgent
def user_agent_display(self, obj):
if hasattr(obj, 'user_agent'): # ForeignKey case
return obj.user_agent.user_agent if obj.user_agent else "None"
elif hasattr(obj, 'user_agents'): # ManyToManyField case
return ", ".join([ua.user_agent for ua in obj.user_agents.all()]) or "None"
return "None"
user_agent_display.short_description = "User Agent(s)"
def uploaded_file_link(self, obj):
if obj.uploaded_file:
return format_html("<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url)
@ -30,6 +39,10 @@ class M3UAccountAdmin(admin.ModelAdmin):
def deactivate_accounts(self, request, queryset):
queryset.update(is_active=False)
# Add ManyToManyField for Django Admin (if applicable)
if hasattr(M3UAccount, 'user_agents'):
filter_horizontal = ('user_agents',) # Only for ManyToManyField
@admin.register(M3UFilter)
class M3UFilterAdmin(admin.ModelAdmin):
list_display = ('m3u_account', 'filter_type', 'regex_pattern', 'exclude')
@ -41,3 +54,4 @@ class M3UFilterAdmin(admin.ModelAdmin):
class ServerGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)

View file

@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet
app_name = 'm3u'

View file

@ -7,36 +7,38 @@ from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.core.cache import cache
# Import all models, including UserAgent.
from .models import M3UAccount, M3UFilter, ServerGroup
from .serializers import M3UAccountSerializer, M3UFilterSerializer, ServerGroupSerializer
from core.models import UserAgent
from core.serializers import UserAgentSerializer
# Import all serializers, including the UserAgentSerializer.
from .serializers import (
M3UAccountSerializer,
M3UFilterSerializer,
ServerGroupSerializer,
)
from .tasks import refresh_single_m3u_account, refresh_m3u_accounts
# 🔹 1) M3U Account API (CRUD)
class M3UAccountViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U accounts"""
queryset = M3UAccount.objects.all()
serializer_class = M3UAccountSerializer
permission_classes = [IsAuthenticated]
# 🔹 2) M3U Filter API (CRUD)
class M3UFilterViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U filters"""
queryset = M3UFilter.objects.all()
serializer_class = M3UFilterSerializer
permission_classes = [IsAuthenticated]
# 🔹 3) Server Group API (CRUD)
class ServerGroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Server Groups"""
queryset = ServerGroup.objects.all()
serializer_class = ServerGroupSerializer
permission_classes = [IsAuthenticated]
# 🔹 4) Refresh All M3U Accounts
class RefreshM3UAPIView(APIView):
"""Triggers refresh for all active M3U accounts"""
@ -48,8 +50,6 @@ class RefreshM3UAPIView(APIView):
refresh_m3u_accounts.delay()
return Response({'success': True, 'message': 'M3U refresh initiated.'}, status=status.HTTP_202_ACCEPTED)
# 🔹 5) Refresh Single M3U Account
class RefreshSingleM3UAPIView(APIView):
"""Triggers refresh for a single M3U account"""
@ -59,4 +59,12 @@ class RefreshSingleM3UAPIView(APIView):
)
def post(self, request, account_id, format=None):
refresh_single_m3u_account.delay(account_id)
return Response({'success': True, 'message': f'M3U account {account_id} refresh initiated.'}, status=status.HTTP_202_ACCEPTED)
return Response({'success': True, 'message': f'M3U account {account_id} refresh initiated.'},
status=status.HTTP_202_ACCEPTED)
class UserAgentViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for User Agents"""
queryset = UserAgent.objects.all()
serializer_class = UserAgentSerializer
permission_classes = [IsAuthenticated]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-02-21 14:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('m3u', '0003_m3uaccount_user_agent'),
]
operations = [
migrations.AlterField(
model_name='m3uaccount',
name='user_agent',
field=models.ForeignKey(blank=True, help_text='The User-Agent associated with this M3U account.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='core.useragent'),
),
migrations.DeleteModel(
name='UserAgent',
),
]

View file

@ -1,6 +1,6 @@
from rest_framework import serializers
from .models import M3UAccount, M3UFilter, ServerGroup
from core.models import UserAgent
class M3UFilterSerializer(serializers.ModelSerializer):
"""Serializer for M3U Filters"""
@ -13,11 +13,18 @@ class M3UFilterSerializer(serializers.ModelSerializer):
class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = M3UFilterSerializer(many=True, read_only=True)
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
required=True
)
class Meta:
model = M3UAccount
fields = ['id', 'name', 'server_url', 'uploaded_file', 'server_group',
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters']
fields = [
'id', 'name', 'server_url', 'uploaded_file', 'server_group',
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent'
]
class ServerGroupSerializer(serializers.ModelSerializer):
@ -26,3 +33,5 @@ class ServerGroupSerializer(serializers.ModelSerializer):
class Meta:
model = ServerGroup
fields = ['id', 'name']

View file

@ -4,8 +4,7 @@ import re
import requests
import os
from celery.app.control import Inspect
from celery import shared_task
from celery import current_app
from celery import shared_task, current_app
from django.conf import settings
from django.core.cache import cache
from .models import M3UAccount
@ -15,15 +14,13 @@ logger = logging.getLogger(__name__)
LOCK_EXPIRE = 120 # Lock expires after 120 seconds
def _get_group_title(extinf_line: str) -> str:
"""Extract group title from EXTINF line."""
match = re.search(r'group-title="([^"]*)"', extinf_line)
return match.group(1) if match else "Default Group"
def _matches_filters(stream_name: str, group_name: str, filters) -> bool:
logger.info(f"Testing filter")
logger.info("Testing filter")
for f in filters:
pattern = f.regex_pattern
target = group_name if f.filter_type == 'group' else stream_name
@ -33,7 +30,6 @@ def _matches_filters(stream_name: str, group_name: str, filters) -> bool:
return f.exclude
return False
def acquire_lock(task_name, account_id):
"""Acquire a lock to prevent concurrent task execution."""
lock_id = f"task_lock_{task_name}_{account_id}"
@ -42,13 +38,11 @@ def acquire_lock(task_name, account_id):
logger.warning(f"Lock for {task_name} and account_id={account_id} already acquired. Task will not proceed.")
return lock_acquired
def release_lock(task_name, account_id):
"""Release the lock after task execution."""
lock_id = f"task_lock_{task_name}_{account_id}"
cache.delete(lock_id)
@shared_task
def refresh_m3u_accounts():
"""Queue background parse for all active M3UAccounts."""
@ -62,10 +56,8 @@ def refresh_m3u_accounts():
logger.info(msg)
return msg
@shared_task
def refresh_single_m3u_account(account_id):
"""Parse and refresh a single M3U account."""
logger.info(f"Task {refresh_single_m3u_account.request.id}: Starting refresh for account_id={account_id}")
if not acquire_lock('refresh_single_m3u_account', account_id):
@ -88,7 +80,13 @@ def refresh_single_m3u_account(account_id):
try:
lines = []
if account.server_url:
headers = {"User-Agent": "Mozilla/5.0"}
if not account.user_agent:
err_msg = f"User-Agent not provided for account id {account_id}."
logger.error(err_msg)
release_lock('refresh_single_m3u_account', account_id)
return err_msg
headers = {"User-Agent": account.user_agent.user_agent}
response = requests.get(account.server_url, timeout=60, headers=headers)
response.raise_for_status()
lines = response.text.splitlines()
@ -99,6 +97,7 @@ def refresh_single_m3u_account(account_id):
else:
err_msg = f"No server_url or uploaded_file provided for account_id={account_id}."
logger.error(err_msg)
release_lock('refresh_single_m3u_account', account_id)
return err_msg
except Exception as e:
err_msg = f"Failed fetching M3U: {e}"
@ -168,7 +167,6 @@ def refresh_single_m3u_account(account_id):
release_lock('refresh_single_m3u_account', account_id)
return f"Account {account_id} => Created {created_count}, updated {updated_count}, excluded {excluded_count} Streams."
def process_uploaded_m3u_file(file, account):
"""Save and parse an uploaded M3U file."""
upload_dir = os.path.join(settings.MEDIA_ROOT, 'm3u_uploads')
@ -184,7 +182,6 @@ def process_uploaded_m3u_file(file, account):
except Exception as e:
logger.error(f"Error parsing uploaded M3U file {file_path}: {e}")
def parse_m3u_file(file_path, account):
"""Parse a local M3U file and create or update Streams."""
skip_exts = ('.mkv', '.mp4', '.ts', '.m4v', '.wav', '.avi', '.flv', '.m4p', '.mpg',

View file

@ -1,9 +1,11 @@
from django.urls import path, include
from .views import generate_m3u
from core.views import stream_view
app_name = 'output'
urlpatterns = [
path('m3u/', generate_m3u, name='generate_m3u'),
path('stream/', include(('apps.ffmpeg.urls', 'ffmpeg'), namespace='ffmpeg')),
path('stream/<int:stream_id>/', stream_view, name='stream'),
]

View file

@ -1,32 +1,30 @@
# apps/output/views.py
from django.http import HttpResponse
from django.urls import reverse
from apps.channels.models import Channel
def generate_m3u(request):
"""
Dynamically generate an M3U file from channels with extended metadata,
and have the stream URL point to the ffmpeg relay.
Dynamically generate an M3U file from channels.
The stream URL now points to the new stream_view that uses StreamProfile.
"""
m3u_content = "#EXTM3U\n"
channels = Channel.objects.order_by('channel_number')
for channel in channels:
group_title = channel.channel_group.name if channel.channel_group else "Default"
tvg_id = channel.tvg_id or ""
tvg_name = channel.tvg_name or channel.channel_name
tvg_logo = channel.logo_url or "" # Adjust if you have a fallback
tvg_logo = channel.logo_url or ""
channel_number = channel.channel_number
extinf_line = (
f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" '
f'tvg-chno="{channel_number}" group-title="{group_title}",{channel.channel_name}\n'
)
# Use the new stream view from outputs app
stream_url = request.build_absolute_uri(reverse('output:stream', args=[channel.id]))
m3u_content += extinf_line + stream_url + "\n"
ffmpeg_url = request.build_absolute_uri(reverse('ffmpeg:stream', args=[channel.id]))
m3u_content += extinf_line + ffmpeg_url + "\n"
# Return the generated content with the appropriate MIME type.
response = HttpResponse(m3u_content, content_type="application/x-mpegURL")
response['Content-Disposition'] = 'attachment; filename="channels.m3u"'
return response

69
core/admin.py Normal file
View file

@ -0,0 +1,69 @@
# core/admin.py
from django.contrib import admin
from .models import UserAgent, StreamProfile, CoreSettings
@admin.register(UserAgent)
class UserAgentAdmin(admin.ModelAdmin):
list_display = (
"user_agent_name",
"user_agent",
"description",
"is_active",
"created_at",
"updated_at",
)
search_fields = ("user_agent_name", "user_agent", "description")
list_filter = ("is_active",)
readonly_fields = ("created_at", "updated_at")
@admin.register(StreamProfile)
class StreamProfileAdmin(admin.ModelAdmin):
list_display = (
"profile_name",
"command",
"is_active",
"user_agent",
)
search_fields = ("profile_name", "command", "user_agent")
list_filter = ("is_active",)
@admin.register(CoreSettings)
class CoreSettingsAdmin(admin.ModelAdmin):
"""
Because CoreSettings is typically a single 'singleton' row,
you can either allow multiple or restrict it. For now, we
just list and allow editing of any instance.
"""
list_display = (
"default_user_agent",
"default_stream_profile",
"stream_command_timeout",
"enable_stream_logging",
"useragent_cache_timeout",
"streamprofile_cache_timeout",
"streamlink_path",
"vlc_path",
)
fieldsets = (
(None, {
"fields": (
"default_user_agent",
"default_stream_profile",
"stream_command_timeout",
"enable_stream_logging",
)
}),
("Caching", {
"fields": (
"useragent_cache_timeout",
"streamprofile_cache_timeout",
)
}),
("Paths", {
"fields": (
"streamlink_path",
"vlc_path",
)
}),
)

14
core/api_urls.py Normal file
View file

@ -0,0 +1,14 @@
# core/api_urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet
router = DefaultRouter()
router.register(r'useragents', UserAgentViewSet, basename='useragent')
router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile')
router.register(r'settings', CoreSettingsViewSet, basename='coresettings')
urlpatterns = [
path('', include(router.urls)),
]

54
core/api_views.py Normal file
View file

@ -0,0 +1,54 @@
# core/api_views.py
from rest_framework import viewsets, status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from .models import UserAgent, StreamProfile, CoreSettings
from .serializers import UserAgentSerializer, StreamProfileSerializer, CoreSettingsSerializer
from rest_framework.permissions import IsAuthenticated
class UserAgentViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows user agents to be viewed, created, edited, or deleted.
"""
queryset = UserAgent.objects.all()
serializer_class = UserAgentSerializer
class StreamProfileViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows stream profiles to be viewed, created, edited, or deleted.
"""
queryset = StreamProfile.objects.all()
serializer_class = StreamProfileSerializer
class CoreSettingsViewSet(viewsets.ModelViewSet):
"""
API endpoint for editing core settings.
This is treated as a singleton: only one instance should exist.
"""
queryset = CoreSettings.objects.all()
serializer_class = CoreSettingsSerializer
permission_classes = [IsAuthenticated]
def create(self, request, *args, **kwargs):
if CoreSettings.objects.exists():
return Response(
{"detail": "Core settings already exist. Use PUT to update."},
status=status.HTTP_400_BAD_REQUEST
)
return super().create(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
# Always return the singleton instance (creating it if needed)
settings_instance, created = CoreSettings.objects.get_or_create(pk=1)
serializer = self.get_serializer(settings_instance)
return Response([serializer.data]) # Return as a list for DRF router compatibility
def retrieve(self, request, *args, **kwargs):
# Retrieve the singleton instance
settings_instance = get_object_or_404(CoreSettings, pk=1)
serializer = self.get_serializer(settings_instance)
return Response(serializer.data)

6
core/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'

94
core/models.py Normal file
View file

@ -0,0 +1,94 @@
# core/models.py
from django.db import models
class UserAgent(models.Model):
user_agent_name = models.CharField(
max_length=512,
unique=True,
help_text="The User-Agent name."
)
user_agent = models.CharField(
max_length=512,
unique=True,
help_text="The complete User-Agent string sent by the client."
)
description = models.CharField(
max_length=255,
blank=True,
help_text="An optional description of the client or device type."
)
is_active = models.BooleanField(
default=True,
help_text="Whether this user agent is currently allowed/recognized."
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.user_agent_name
class StreamProfile(models.Model):
profile_name = models.CharField(max_length=255, help_text="Name of the stream profile")
command = models.CharField(
max_length=255,
help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')"
)
parameters = models.TextField(
help_text="Command-line parameters. Use {userAgent} and {streamUrl} as placeholders."
)
is_active = models.BooleanField(default=True, help_text="Whether this profile is active")
user_agent = models.CharField(
max_length=512,
blank=True,
null=True,
help_text="Optional user agent to use. If not set, you can fall back to a default."
)
def __str__(self):
return self.profile_name
class CoreSettings(models.Model):
default_user_agent = models.CharField(
max_length=512,
default="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/112.0.0.0 Safari/537.36",
help_text="The default User-Agent string to use if none is provided."
)
default_stream_profile = models.CharField(
max_length=255,
default="default_profile",
help_text="Name or identifier for the default stream profile."
)
stream_command_timeout = models.PositiveIntegerField(
default=300,
help_text="Timeout in seconds for running stream commands."
)
enable_stream_logging = models.BooleanField(
default=True,
help_text="Toggle verbose logging for stream commands."
)
useragent_cache_timeout = models.PositiveIntegerField(
default=300,
help_text="Cache timeout in seconds for user agent data."
)
streamprofile_cache_timeout = models.PositiveIntegerField(
default=300,
help_text="Cache timeout in seconds for stream profile data."
)
streamlink_path = models.CharField(
max_length=255,
default="/usr/bin/streamlink",
help_text="Override path for the streamlink command."
)
vlc_path = models.CharField(
max_length=255,
default="/usr/bin/vlc",
help_text="Override path for the VLC command."
)
def __str__(self):
return "Core Settings"
class Meta:
verbose_name = "Core Setting"
verbose_name_plural = "Core Settings"

19
core/serializers.py Normal file
View file

@ -0,0 +1,19 @@
# core/serializers.py
from rest_framework import serializers
from .models import UserAgent, StreamProfile, CoreSettings
class UserAgentSerializer(serializers.ModelSerializer):
class Meta:
model = UserAgent
fields = ['id', 'user_agent_name', 'user_agent', 'description', 'is_active', 'created_at', 'updated_at']
class StreamProfileSerializer(serializers.ModelSerializer):
class Meta:
model = StreamProfile
fields = ['id', 'profile_name', 'command', 'parameters', 'is_active', 'user_agent']
class CoreSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = CoreSettings
fields = '__all__'

3
core/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

82
core/views.py Normal file
View file

@ -0,0 +1,82 @@
import os
import sys
import subprocess
import logging
from django.conf import settings
from django.http import StreamingHttpResponse, HttpResponseServerError
from django.db.models import F
from apps.channels.models import Channel, Stream
from core.models import StreamProfile
# Configure logging to output to the console.
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logger = logging.getLogger(__name__)
def stream_view(request, stream_id):
"""
Streams the first available stream for the given channel.
It uses the channels assigned StreamProfile.
"""
try:
# Retrieve the channel by the provided stream_id.
channel = Channel.objects.get(id=stream_id)
logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.channel_name)
# Ensure the channel has at least one stream.
if not channel.streams.exists():
logger.error("No streams found for channel ID=%s", channel.id)
return HttpResponseServerError("No stream found for this channel.")
# Get the first available stream.
stream = channel.streams.first()
logger.debug("Using stream: ID=%s, Name=%s", stream.id, stream.name)
# Use the custom URL if available; otherwise, use the standard URL.
input_url = stream.custom_url or stream.url
logger.debug("Input URL: %s", input_url)
# Get the stream profile set on the channel.
# (Ensure your Channel model has a 'stream_profile' field.)
profile = channel.stream_profile
if not profile:
logger.error("No stream profile set for channel ID=%s", channel.id)
return HttpResponseServerError("No stream profile set for this channel.")
logger.debug("Stream profile used: %s", profile.profile_name)
# Determine the user agent to use.
user_agent = profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
logger.debug("User agent: %s", user_agent)
# Substitute placeholders in the parameters template.
parameters = profile.parameters.format(userAgent=user_agent, streamUrl=input_url)
logger.debug("Formatted parameters: %s", parameters)
# Build the final command.
cmd = [profile.command] + parameters.split()
logger.debug("Executing command: %s", cmd)
# Increment the viewer count.
Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') + 1)
logger.debug("Viewer count incremented for stream ID=%s", stream.id)
# Start the streaming process.
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except Exception as e:
logger.exception("Error starting stream for channel ID=%s", stream_id)
return HttpResponseServerError(f"Error starting stream: {e}")
def stream_generator(proc, s):
try:
while True:
chunk = proc.stdout.read(8192)
if not chunk:
break
yield chunk
finally:
# Decrement the viewer count once streaming ends.
Stream.objects.filter(id=s.id).update(current_viewers=F('current_viewers') - 1)
logger.debug("Viewer count decremented for stream ID=%s", s.id)
return StreamingHttpResponse(stream_generator(process, stream), content_type="video/MP2T")

8
dispatcharr/admin.py Normal file
View file

@ -0,0 +1,8 @@
"""
ASGI config for dispatcharr project.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
application = get_asgi_application()

View file

@ -17,6 +17,7 @@ INSTALLED_APPS = [
'apps.hdhr',
'apps.m3u',
'apps.output',
'core',
'drf_yasg',
'django.contrib.admin',
'django.contrib.auth',
@ -35,7 +36,6 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django_otp.middleware.OTPMiddleware', # Correct OTP Middleware
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
@ -98,20 +98,12 @@ STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = '/m3u_uploads/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'm3u_uploads')
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'accounts.User'
# Celery
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = CELERY_BROKER_URL
# django-two-factor-auth (example)
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/'
MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = '/media/'

View file

@ -38,7 +38,6 @@ urlpatterns = [
#path('backup/', include(('apps.backup.urls', 'backup'), namespace='backup')),
path('dashboard/', include(('apps.dashboard.urls', 'dashboard'), namespace='dashboard')),
path('output/', include('apps.output.urls', namespace='output')),
path('stream/', include(('apps.ffmpeg.urls', 'ffmpeg'), namespace='ffmpeg')),
# Swagger UI:

View file

@ -1,8 +1,10 @@
FROM python:3.10-slim
# Install required packages
# Install required packages including ffmpeg, streamlink, and vlc
RUN apt-get update && apt-get install -y \
ffmpeg \
streamlink \
vlc \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
@ -26,8 +28,8 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
RUN python manage.py collectstatic --noinput || true
RUN python manage.py migrate --noinput || true
# Expose the port
EXPOSE 8000
# Expose port 9191 (this is the port the app will listen on inside the container)
EXPOSE 9191
# Command to run the application
CMD ["gunicorn", "--workers=4", "--worker-class=gevent", "--timeout=300", "--bind", "0.0.0.0:8000", "dispatcharr.wsgi:application"]
# Command to run the application binding to port 9191
CMD ["gunicorn", "--workers=4", "--worker-class=gevent", "--timeout=300", "--bind", "0.0.0.0:9191", "dispatcharr.wsgi:application"]

View file

@ -5,7 +5,7 @@ services:
dockerfile: docker/Dockerfile
container_name: dispatcharr_web
ports:
- "9191:8000"
- "9191:9191"
depends_on:
- db
- redis
@ -30,7 +30,7 @@ services:
volumes:
- ../:/app
environment:
- POSTGRES_HOST=db
- POSTGRES_HOST=localhost
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret

View file

@ -1,16 +0,0 @@
Django==4.2.2
gunicorn==20.1.0
psycopg2-binary==2.9.6
redis==4.5.5
# Optional for tasks:
celery==5.2.7
# Optional for DRF:
djangorestframework==3.14.0
# For 2FA:
django-two-factor-auth==1.14.0
django-otp==1.2.0
phonenumbers==8.13.13
requests==2.31.0
django-adminlte3
psutil==5.9.7
pillow

View file

@ -1,17 +1,14 @@
Django==4.2.2
gunicorn==20.1.0
psycopg2-binary==2.9.6
Django==5.1.6
gunicorn==23.0.0
psycopg2-binary==2.9.10
redis==4.5.5
# Optional for tasks:
celery==5.2.7
# Optional for DRF:
djangorestframework==3.14.0
# For 2FA:
django-two-factor-auth==1.14.0
django-otp==1.2.0
phonenumbers==8.13.13
requests==2.31.0
django-adminlte3
psutil==5.9.7
djangorestframework==3.15.2
requests==2.32.3
psutil==7.0.0
pillow
drf-yasg>=1.20.0
streamlink
python-vlc
yt-dlp
gevent==24.11.1

25
templates/admin/app_cards.html Executable file
View file

@ -0,0 +1,25 @@
<div class="container mt-4">
<div class="row">
{% for app in app_list %}
<div class="col-md-6 col-lg-4 mb-3"> <!-- Responsive grid layout -->
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">{{ app.name }}</h5>
</div>
<div class="card-body bg-dark">
<ul class="list-unstyled">
{% for model in app.models %}
<li class="mb-2">
<a href="{{ model.admin_url }}" class="d-flex justify-content-between align-items-center text-white text-decoration-none">
{{ model.name }}
<span class="badge bg-secondary">Manage</span>
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endfor %}
</div>
</div>

View file

@ -1,202 +0,0 @@
{% load static %}
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<title>{% block title %}Dispatcharr{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Fonts -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/source-sans-3@5.0.12/index.css" crossorigin="anonymous" />
<!-- Third Party Plugins -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/overlayscrollbars@2.10.1/styles/overlayscrollbars.min.css" crossorigin="anonymous" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" crossorigin="anonymous" />
<!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- AdminLTE CSS -->
<link rel="stylesheet" href="{% static 'admin-lte/dist/css/adminlte.css' %}" />
<!-- ApexCharts and jsVectorMap CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/apexcharts@3.37.1/dist/apexcharts.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsvectormap@1.5.3/dist/css/jsvectormap.min.css" crossorigin="anonymous" />
{% block extra_css %}{% endblock %}
</head>
<body class="layout-fixed sidebar-expand-lg bg-body-tertiary">
<div class="app-wrapper">
<!-- Header / Navbar -->
<nav class="app-header navbar navbar-expand bg-body">
<div class="container-fluid">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-lte-toggle="sidebar" href="#" role="button">
<i class="bi bi-list"></i>
</a>
</li>
<li class="nav-item d-none d-md-block">
<a href="{% url 'dashboard:dashboard' %}" class="nav-link">Home</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="/accounts/login/">Login</a>
</li>
<!-- Theme Switcher Dropdown -->
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="themeToggleBtn" type="button" aria-expanded="false"
data-bs-toggle="dropdown" data-bs-display="static">
<span class="theme-icon-active"><i class="bi bi-sun-fill my-1"></i></span>
<span class="d-lg-none ms-2" id="theme-toggle-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="themeToggleBtn" style="--bs-dropdown-min-width: 8rem;">
<li>
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="light">
<i class="bi bi-sun-fill me-2"></i> Light
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
<i class="bi bi-moon-fill me-2"></i> Dark
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="auto">
<i class="bi bi-circle-half me-2"></i> Auto
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
</ul>
</li>
</ul>
</div>
</nav>
<!-- Sidebar -->
<aside class="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
<div class="sidebar-brand">
<a href="{% url 'dashboard:dashboard' %}" class="brand-link">
<img src="{% static 'admin-lte/dist/assets/img/logo.png' %}" alt="Dispatcharr Logo" class="brand-image opacity-75 shadow" />
<span class="brand-text fw-light">Dispatcharr</span>
</a>
</div>
<div class="sidebar-wrapper">
<nav class="mt-2">
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" role="menu" data-accordion="false">
<li class="nav-item">
<a href="{% url 'dashboard:dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-speedometer"></i>
<p>Dashboard</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'channels:channels_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-tv"></i>
<p>Channels</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'm3u:m3u_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-file-earmark-text"></i>
<p>M3U</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'epg:epg_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-calendar3"></i>
<p>EPG</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'dashboard:settings' %}" class="nav-link">
<i class="nav-icon bi bi-gear"></i>
<p>Settings</p>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<!-- Main Content -->
<main class="app-main">
<div class="app-content">
<div class="container-fluid">
<!-- Content Wrapper -->
<div class="content-wrapper">
<!-- Page Header -->
<section class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1>{% block admin_title %}Admin{% endblock %}</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item active">{% block breadcrumb %}Admin{% endblock %}</li>
</ol>
</div>
</div>
</div>
</section>
<!-- Main Content -->
<section class="content">
<div class="container-fluid">
{% block content %}{% endblock %}
</div>
</section>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="float-end d-none d-sm-inline">Anything you want</div>
<strong>&copy; {{ current_year|default:"2025" }} Dispatcharr.</strong> All rights reserved.
</footer>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/overlayscrollbars@2.10.1/browser/overlayscrollbars.browser.es6.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"></script>
<script src="{% static 'admin-lte/dist/js/adminlte.js' %}"></script>
{% block extra_js %}{% endblock %}
<!-- AdminLTE 4 Theme Toggle -->
<script>
(() => {
"use strict";
const storedTheme = localStorage.getItem("theme");
const getPreferredTheme = () => storedTheme || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const setTheme = (theme) => {
document.documentElement.setAttribute("data-bs-theme", theme);
};
setTheme(getPreferredTheme());
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-bs-theme-value]").forEach(button => {
button.addEventListener("click", () => {
const theme = button.getAttribute("data-bs-theme-value");
localStorage.setItem("theme", theme);
setTheme(theme);
});
});
});
})();
</script>
</body>
</html>

66
templates/admin/base_site.html Executable file
View file

@ -0,0 +1,66 @@
{% extends "base.html" %}
{% load i18n static %}
{% block title %}Admin Dashboard | Dispatcharr{% endblock %}
{% block extrastyle %}
{{ block.super }}
<!-- DataTables CSS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.5/css/dataTables.bootstrap5.min.css">
{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
{% for app in app_list %}
<div class="col-md-6 col-lg-4 mb-3">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="card-title mb-0">{{ app.name }}</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
{% for model in app.models %}
<li>
<a href="{{ model.admin_url }}" class="d-flex justify-content-between align-items-center text-decoration-none">
{{ model.name }}
<span class="badge bg-secondary">Manage</span>
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<!-- jQuery (Required for DataTables) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- DataTables JS -->
<script src="https://cdn.datatables.net/1.13.5/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.5/js/dataTables.bootstrap5.min.js"></script>
<script>
$(document).ready(function () {
// Apply DataTables to all Django admin tables
$("table").addClass("table table-striped table-dark"); // Bootstrap styling
$("table").DataTable({
"paging": true,
"searching": true,
"ordering": true,
"info": true,
"lengthChange": true,
"pageLength": 10, // Default page size
"language": {
"search": "Search:",
"lengthMenu": "Show _MENU_ entries"
}
});
});
</script>
{% endblock %}

4
templates/admin/index.html Executable file
View file

@ -0,0 +1,4 @@
{% extends "admin/base_site.html" %}
{% block content %}
{% include "admin/app_cards.html" %}
{% endblock %}

View file

@ -33,6 +33,28 @@
</table>
</div>
</div>
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">User Agents</h3>
<button id="addUserAgentBtn" class="btn btn-primary">
<i class="bi bi-plus"></i> Add User Agent
</button>
</div>
<div class="card-body">
<table id="userAgentTable" class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>User Agent</th>
<th>Description</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<!-- Add EPG Source Modal -->

View file

@ -1,77 +0,0 @@
{% extends "base.html" %}
{% block title %}Settings - Dispatcharr{% endblock %}
{% block page_header %}Settings{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{% url 'dashboard' %}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Settings</li>
{% endblock %}
{% block content %}
<form id="settingsForm">
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Schedule Direct Settings</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="schedulesDirectUsername" class="form-label">Schedules Direct Username</label>
<input type="text" class="form-control" id="schedulesDirectUsername" name="schedules_direct_username" required>
</div>
<div class="mb-3">
<label for="schedulesDirectPassword" class="form-label">Schedules Direct Password</label>
<input type="password" class="form-control" id="schedulesDirectPassword" name="schedules_direct_password" required>
</div>
<div class="mb-3">
<label for="schedulesDirectAPIKey" class="form-label">Schedules Direct API Key</label>
<input type="text" class="form-control" id="schedulesDirectAPIKey" name="schedules_direct_api_key">
</div>
<div class="mb-3">
<label for="schedulesDirectUpdateFrequency" class="form-label">Update Frequency</label>
<select class="form-select" id="schedulesDirectUpdateFrequency" name="schedules_direct_update_frequency">
<option value="daily">Daily</option>
<option value="12h">Every 12 Hours</option>
</select>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">FFmpeg Settings</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="ffmpegPath" class="form-label">FFmpeg Path</label>
<input type="text" class="form-control" id="ffmpegPath" name="ffmpeg_path" required>
</div>
<div class="mb-3">
<label for="customTranscodingFlags" class="form-label">Custom Transcoding Flags</label>
<textarea class="form-control" id="customTranscodingFlags" name="custom_transcoding_flags"></textarea>
</div>
</div>
</div>
<div class="card">
<div class="card-footer">
<button type="submit" class="btn btn-success">Save Settings</button>
</div>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function(){
document.getElementById("settingsForm").addEventListener("submit", function(e){
e.preventDefault();
fetch("{% url 'api:settings-update' %}", {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: new URLSearchParams(new FormData(this))
}).then(response => {
if(response.ok){
Swal.fire("Success", "Settings updated!", "success");
} else {
Swal.fire("Error", "Failed to update settings.", "error");
}
});
});
});
</script>
{% endblock %}

View file

@ -2,217 +2,445 @@
{% block title %}M3U Management - Dispatcharr{% endblock %}
{% block page_header %}M3U Management{% endblock %}
{% block content %}
<div class="container">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">M3U Accounts</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addM3UModal">Add M3U</button>
</div>
<div class="card-body">
<!-- The table body will be populated via AJAX -->
<table id="m3uTable" class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>URL/File</th>
<th>Max Streams</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- M3U Accounts Card -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">M3U Accounts</h3>
<!-- Changed button id to addM3UButton and removed data-bs-toggle attribute -->
<button id="addM3UButton" class="btn btn-primary">Add M3U</button>
</div>
<div class="card-body">
<!-- The table body will be populated via AJAX -->
<table id="m3uTable" class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>URL/File</th>
<th>Max Streams</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- User Agent Management Card -->
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">User Agents</h3>
<button id="addUserAgentBtn" class="btn btn-primary">
<i class="bi bi-plus"></i> Add User Agent
</button>
</div>
<div class="card-body">
<table id="userAgentTable" class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>User Agent</th>
<th>Description</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<!-- Add M3U Modal -->
<!-- Add/Edit M3U Account Modal -->
<div class="modal fade" id="addM3UModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add M3U Account</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Note: The form does not submit normally; JavaScript handles submission -->
<form id="m3uForm" enctype="multipart/form-data" action="/api/m3u/accounts/">
{% csrf_token %}
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" id="m3uName" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">M3U URL</label>
<input type="url" class="form-control" id="m3uURL" name="server_url">
</div>
<div class="mb-3">
<label class="form-label">Upload File</label>
<input type="file" class="form-control" id="m3uFile" name="uploaded_file">
</div>
<div class="mb-3">
<label class="form-label">Max Streams</label>
<input type="number" class="form-control" id="m3uMaxStreams" name="max_streams" value="0">
</div>
<button type="submit" class="btn btn-success">Save</button>
</form>
</div>
</div>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="m3uModalLabel">Add M3U Account</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- M3U Account Form -->
<form id="m3uForm" enctype="multipart/form-data" action="/api/m3u/accounts/">
{% csrf_token %}
<input type="hidden" id="m3uId" name="id">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" id="m3uName" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">M3U URL</label>
<input type="url" class="form-control" id="m3uURL" name="server_url">
</div>
<div class="mb-3">
<label class="form-label">Upload File</label>
<input type="file" class="form-control" id="m3uFile" name="uploaded_file">
</div>
<div class="mb-3">
<label class="form-label">Max Streams</label>
<input type="number" class="form-control" id="m3uMaxStreams" name="max_streams" value="0">
</div>
<!-- New mandatory User Agent dropdown field -->
<div class="mb-3">
<label class="form-label">User Agent</label>
<select class="form-select" id="m3uUserAgentSelect" name="user_agent" required>
<option value="">Select a User Agent</option>
</select>
</div>
<button type="submit" class="btn btn-success">Save</button>
</form>
</div>
</div>
</div>
</div>
<!-- User Agent Add/Edit Modal -->
<div class="modal fade" id="userAgentModal" tabindex="-1" aria-labelledby="userAgentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="userAgentForm">
{% csrf_token %}
<input type="hidden" id="userAgentId" name="id">
<div class="modal-header">
<h5 class="modal-title" id="userAgentModalLabel">Add User Agent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="userAgentString" class="form-label">User Agent</label>
<input type="text" class="form-control" id="userAgentString" 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" required>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save User Agent</button>
</div>
</form>
</div>
</div>
</div>
<!-- User Agent Delete Modal -->
<div class="modal fade" id="deleteUserAgentModal" tabindex="-1" aria-labelledby="deleteUserAgentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="deleteUserAgentForm">
{% csrf_token %}
<input type="hidden" id="deleteUserAgentId" name="id">
<div class="modal-header">
<h5 class="modal-title" id="deleteUserAgentModalLabel">Delete User Agent</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this User Agent?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Delete</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- DataTables CSS/JS -->
<!-- DataTables and SweetAlert2 CSS/JS -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css">
<script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(';');
for (let i=0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
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;
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
$.ajaxSetup({
headers: { "X-CSRFToken": csrftoken }
headers: { "X-CSRFToken": csrftoken }
});
$(document).ready(function () {
// Initialize the DataTable with an AJAX source.
var m3uTable = $('#m3uTable').DataTable({
ajax: {
url: "/api/m3u/accounts/",
dataSrc: ""
},
columns: [
{ data: "name" },
{
data: null,
render: function(data) {
if (data.server_url) {
return '<a href="' + data.server_url + '" target="_blank">M3U URL</a>';
} else if (data.uploaded_file) {
return '<a href="' + data.uploaded_file + '" download>Download File</a>';
} else {
return 'No URL or file';
}
}
},
{
data: "max_streams",
render: function(data) {
return data ? data : "N/A";
}
},
{
data: "id",
orderable: false,
render: function(data, type, row) {
return '<button class="btn btn-sm btn-warning" onclick="editM3U('+data+')">Edit</button> ' +
'<button class="btn btn-sm btn-danger" onclick="deleteM3U('+data+')">Delete</button> ' +
'<button class="btn btn-sm btn-info" onclick="refreshM3U('+data+')">Refresh</button>';
}
}
]
});
// Handle form submission to add a new M3U account via AJAX.
$('#m3uForm').submit(function(e){
e.preventDefault(); // Prevent normal submission
var form = this;
var formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(response => {
if(!response.ok) {
throw new Error("Failed to save M3U account.");
}
return response.json();
})
.then(data => {
Swal.fire("Success", "M3U account saved successfully!", "success");
// Reload the DataTable data without reloading the whole page.
m3uTable.ajax.reload();
// Hide the modal (using Bootstrap 5)
var addModal = bootstrap.Modal.getInstance(document.getElementById("addM3UModal"));
if(addModal) addModal.hide();
form.reset();
})
.catch(error => {
Swal.fire("Error", error.message, "error");
console.error("Error:", error);
$(document).ready(function() {
// Initialize the M3U DataTable
var m3uTable = $('#m3uTable').DataTable({
ajax: {
url: "/api/m3u/accounts/",
dataSrc: ""
},
columns: [
{ data: "name" },
{
data: null,
render: function(data) {
if (data.server_url) {
return '<a href="' + data.server_url + '" target="_blank">M3U URL</a>';
} else if (data.uploaded_file) {
return '<a href="' + data.uploaded_file + '" download>Download File</a>';
} else {
return 'No URL or file';
}
}
},
{
data: "max_streams",
render: function(data) {
return data ? data : "N/A";
}
},
{
data: "id",
orderable: false,
render: function(data, type, row) {
return '<button class="btn btn-sm btn-warning" onclick="editM3U('+data+')">Edit</button> ' +
'<button class="btn btn-sm btn-danger" onclick="deleteM3U('+data+')">Delete</button> ' +
'<button class="btn btn-sm btn-info" onclick="refreshM3U('+data+')">Refresh</button>';
}
}
]
});
// Function to load User Agent options into the M3U form dropdown
function loadUserAgentOptions(selectedId) {
fetch("/api/core/useragents/")
.then(res => res.json())
.then(data => {
let options = '<option value="">Select a User Agent</option>';
data.forEach(function(ua) {
options += `<option value="${ua.id}">${ua.user_agent}</option>`;
});
$('#m3uUserAgentSelect').html(options);
if (selectedId) {
$('#m3uUserAgentSelect').val(selectedId);
}
})
.catch(err => {
console.error("Error loading user agents:", err);
});
}
// When the "Add M3U" button is clicked, reset the form and load user agent options
$('#addM3UButton').click(function() {
$('#m3uForm')[0].reset();
$('#m3uId').val('');
$('#m3uModalLabel').text("Add M3U Account");
loadUserAgentOptions();
new bootstrap.Modal(document.getElementById("addM3UModal")).show();
});
// Edit M3U Account
window.editM3U = function(id) {
fetch("/api/m3u/accounts/" + id + "/")
.then(res => res.json())
.then(data => {
$('#m3uId').val(data.id);
$('#m3uName').val(data.name);
$('#m3uURL').val(data.server_url || "");
$('#m3uMaxStreams').val(data.max_streams);
loadUserAgentOptions(data.user_agent);
$('#m3uModalLabel').text("Edit M3U Account");
new bootstrap.Modal(document.getElementById("addM3UModal")).show();
})
.catch(err => {
Swal.fire("Error", "Failed to load M3U account details.", "error");
console.error(err);
});
};
// M3U Form Submission (handles both create and update)
$('#m3uForm').submit(function(e) {
e.preventDefault();
var m3uId = $('#m3uId').val();
var formData = new FormData(this);
var method = m3uId ? "PUT" : "POST";
var url = m3uId ? "/api/m3u/accounts/" + m3uId + "/" : "/api/m3u/accounts/";
fetch(url, {
method: method,
body: formData,
credentials: 'same-origin'
}).then(response => {
if(response.ok) {
bootstrap.Modal.getInstance(document.getElementById("addM3UModal")).hide();
Swal.fire("Success", "M3U Account saved successfully!", "success");
m3uTable.ajax.reload();
return response.json();
} else {
throw new Error("Failed to save M3U account.");
}
}).catch(error => {
Swal.fire("Error", error.message, "error");
console.error("Error:", error);
});
});
function deleteM3U(id) {
});
// Delete M3U Account
window.deleteM3U = function(id) {
Swal.fire({
title: "Are you sure?",
text: "You won't be able to revert this!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
confirmButtonText: "Yes, delete it!"
title: "Are you sure?",
text: "You won't be able to revert this!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
confirmButtonText: "Yes, delete it!"
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: `/api/m3u/accounts/${id}/`, // Updated URL
method: "DELETE", // Use DELETE method
success: function () {
Swal.fire("Deleted!", "The M3U account has been deleted.", "success")
.then(() => {
$('#m3uTable').DataTable().ajax.reload();
});
},
error: function () {
Swal.fire("Error", "Failed to delete the M3U account.", "error");
}
});
}
if(result.isConfirmed) {
$.ajax({
url: `/api/m3u/accounts/${id}/`,
method: "DELETE",
success: function() {
Swal.fire("Deleted!", "The M3U account has been deleted.", "success")
.then(() => {
m3uTable.ajax.reload();
});
},
error: function() {
Swal.fire("Error", "Failed to delete the M3U account.", "error");
}
});
}
});
}
function refreshM3U(id) {
};
// Refresh M3U Account
window.refreshM3U = function(id) {
$.ajax({
url: `/m3u/${id}/refresh/`,
method: "POST",
success: function () {
Swal.fire("Refreshed!", "The M3U has been refreshed.", "success")
.then(() => {
$('#m3uTable').DataTable().ajax.reload();
});
},
error: function () {
Swal.fire("Error", "Failed to refresh the M3U.", "error");
}
url: `/m3u/${id}/refresh/`,
method: "POST",
success: function() {
Swal.fire("Refreshed!", "The M3U has been refreshed.", "success")
.then(() => {
m3uTable.ajax.reload();
});
},
error: function() {
Swal.fire("Error", "Failed to refresh the M3U.", "error");
}
});
}
function editM3U(id) {
// Implement the edit functionality here.
Swal.fire("Info", "Edit functionality not implemented yet.", "info");
}
};
// Initialize User Agent DataTable
var userAgentTable = $('#userAgentTable').DataTable({
ajax: {
url: "/api/core/useragents/",
dataSrc: ""
},
columns: [
{ data: "id" },
{ data: "user_agent" },
{ data: "description" },
{
data: "is_active",
render: function(data) {
return data ? "Yes" : "No";
}
},
{
data: "id",
orderable: false,
render: function(data, type, row) {
return '<button class="btn btn-sm btn-warning" onclick="editUserAgent('+data+')">Edit</button> ' +
'<button class="btn btn-sm btn-danger" onclick="deleteUserAgent('+data+')">Delete</button>';
}
}
]
});
// Open Add User Agent modal
$('#addUserAgentBtn').click(function () {
$('#userAgentForm')[0].reset();
$('#userAgentId').val('');
$('#userAgentModalLabel').text("Add User Agent");
new bootstrap.Modal(document.getElementById("userAgentModal")).show();
});
// User Agent Form Submission
$('#userAgentForm').submit(function(e){
e.preventDefault();
var id = $('#userAgentId').val();
var method = id ? "PUT" : "POST";
var url = id ? "/api/core/useragents/" + id + "/" : "/api/core/useragents/";
var formData = new FormData(this);
fetch(url, {
method: method,
body: formData,
credentials: 'same-origin'
}).then(response => {
if(response.ok){
bootstrap.Modal.getInstance(document.getElementById("userAgentModal")).hide();
Swal.fire("Success", "User Agent saved successfully!", "success");
userAgentTable.ajax.reload();
return response.json();
} else {
throw new Error("Failed to save User Agent.");
}
}).catch(error => {
Swal.fire("Error", error.message, "error");
console.error("Error:", error);
});
});
// Delete User Agent Form Submission
$('#deleteUserAgentForm').submit(function(e){
e.preventDefault();
var id = $('#deleteUserAgentId').val();
fetch("/api/core/useragents/" + id + "/", {
method: "DELETE",
credentials: 'same-origin'
}).then(response => {
if(response.ok){
bootstrap.Modal.getInstance(document.getElementById("deleteUserAgentModal")).hide();
Swal.fire("Deleted!", "User Agent deleted.", "success");
userAgentTable.ajax.reload();
} else {
throw new Error("Failed to delete User Agent.");
}
}).catch(error => {
Swal.fire("Error", error.message, "error");
console.error("Error:", error);
});
});
// Edit User Agent function
window.editUserAgent = function(id) {
fetch("/api/core/useragents/" + id + "/")
.then(res => res.json())
.then(data => {
$('#userAgentId').val(data.id);
$('#userAgentString').val(data.user_agent);
$('#userAgentDescription').val(data.description);
$('#userAgentActive').val(data.is_active ? "true" : "false");
$('#userAgentModalLabel').text("Edit User Agent");
new bootstrap.Modal(document.getElementById("userAgentModal")).show();
})
.catch(err => {
Swal.fire("Error", "Failed to load User Agent details.", "error");
console.error(err);
});
};
// Delete User Agent function (opens delete modal)
window.deleteUserAgent = function(id) {
$('#deleteUserAgentId').val(id);
new bootstrap.Modal(document.getElementById("deleteUserAgentModal")).show();
};
});
</script>
{% endblock %}