forked from Mirrors/Dispatcharr
Alpha v3
Added user agents Added Stream Profiles Added new API calls
This commit is contained in:
parent
952a23fe3b
commit
1fb7a0c9eb
36 changed files with 1001 additions and 641 deletions
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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'),
|
||||
]
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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',)
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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']
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
69
core/admin.py
Normal 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
14
core/api_urls.py
Normal 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
54
core/api_views.py
Normal 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
6
core/apps.py
Normal 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
94
core/models.py
Normal 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
19
core/serializers.py
Normal 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
3
core/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
82
core/views.py
Normal file
82
core/views.py
Normal 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 channel’s 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
8
dispatcharr/admin.py
Normal 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()
|
||||
|
|
@ -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/'
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
25
templates/admin/app_cards.html
Executable 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>
|
||||
|
|
@ -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>© {{ 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
66
templates/admin/base_site.html
Executable 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
4
templates/admin/index.html
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
{% block content %}
|
||||
{% include "admin/app_cards.html" %}
|
||||
{% endblock %}
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue