mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr
This commit is contained in:
commit
e8d7e80f8e
22 changed files with 699 additions and 824 deletions
|
|
@ -1,47 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2025-02-18 16:33
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('channels', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('avatar_config', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='channels.channelgroup')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
11
apps/accounts/urls.py
Normal file
11
apps/accounts/urls.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
app_name = 'accounts'
|
||||
|
||||
urlpatterns = [
|
||||
# Login view using Django's built-in authentication
|
||||
path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'),
|
||||
# Logout view using Django's built-in authentication
|
||||
path('logout/', auth_views.LogoutView.as_view(next_page='accounts:login'), name='logout'),
|
||||
]
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2025-02-18 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('m3u', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ChannelGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Stream',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='Default Stream', max_length=255)),
|
||||
('url', models.URLField()),
|
||||
('custom_url', models.URLField(blank=True, max_length=2000, null=True)),
|
||||
('logo_url', models.URLField(blank=True, max_length=2000, null=True)),
|
||||
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('local_file', models.FileField(blank=True, null=True, upload_to='uploads/')),
|
||||
('current_viewers', models.PositiveIntegerField(default=0)),
|
||||
('is_transcoded', models.BooleanField(default=False)),
|
||||
('ffmpeg_preset', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('group_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='streams', to='m3u.m3uaccount')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Stream',
|
||||
'verbose_name_plural': 'Streams',
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Channel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('channel_number', models.IntegerField()),
|
||||
('channel_name', models.CharField(max_length=255)),
|
||||
('logo_url', models.URLField(blank=True, max_length=2000, null=True)),
|
||||
('logo_file', models.ImageField(blank=True, null=True, upload_to='logos/')),
|
||||
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('tvg_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('is_looping', models.BooleanField(default=False, help_text='If True, loops local file(s).')),
|
||||
('shuffle_mode', models.BooleanField(default=False, help_text='If True, randomize streams for failover.')),
|
||||
('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='channels.channelgroup')),
|
||||
('streams', models.ManyToManyField(blank=True, related_name='channels', to='channels.stream')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -24,10 +24,15 @@ class Stream(models.Model):
|
|||
local_file = models.FileField(upload_to='uploads/', blank=True, null=True)
|
||||
current_viewers = models.PositiveIntegerField(default=0)
|
||||
is_transcoded = models.BooleanField(default=False)
|
||||
ffmpeg_preset = models.CharField(max_length=50, blank=True, null=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
group_name = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
stream_profile = models.ForeignKey(
|
||||
StreamProfile,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='streams'
|
||||
)
|
||||
class Meta:
|
||||
# If you use m3u_account, you might do unique_together = ('name','custom_url','m3u_account')
|
||||
verbose_name = "Stream"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Stream, Channel, ChannelGroup
|
||||
from core.models import StreamProfile
|
||||
|
||||
#
|
||||
# Stream
|
||||
#
|
||||
class StreamSerializer(serializers.ModelSerializer):
|
||||
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StreamProfile.objects.all(),
|
||||
source='stream_profile',
|
||||
allow_null=True,
|
||||
required=False
|
||||
)
|
||||
class Meta:
|
||||
model = Stream
|
||||
fields = [
|
||||
|
|
@ -18,9 +25,9 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
'local_file',
|
||||
'current_viewers',
|
||||
'is_transcoded',
|
||||
'ffmpeg_preset',
|
||||
'updated_at',
|
||||
'group_name',
|
||||
'stream_profile_id',
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -61,5 +68,6 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
'channel_group_id',
|
||||
'tvg_id',
|
||||
'tvg_name',
|
||||
'streams'
|
||||
'streams',
|
||||
'stream_profile_id',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -38,7 +38,4 @@ class StreamDashboardView(View):
|
|||
|
||||
@login_required
|
||||
def channels_dashboard_view(request):
|
||||
"""
|
||||
Example “dashboard” style view for Channels
|
||||
"""
|
||||
return render(request, 'channels/channels.html')
|
||||
return render(request, 'channels/channels.html')
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2025-02-18 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Settings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('server_name', models.CharField(default='Dispatcharr', max_length=255)),
|
||||
('time_zone', models.CharField(default='UTC', max_length=50)),
|
||||
('default_logo_url', models.URLField(blank=True, null=True)),
|
||||
('max_concurrent_streams', models.PositiveIntegerField(default=10)),
|
||||
('auto_backup_frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=50)),
|
||||
('enable_debug_logs', models.BooleanField(default=False)),
|
||||
('schedules_direct_username', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('schedules_direct_password', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('schedules_direct_update_frequency', models.CharField(choices=[('12h', 'Every 12 Hours'), ('daily', 'Daily')], default='daily', max_length=50)),
|
||||
('schedules_direct_api_key', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('transcoding_bitrate', models.PositiveIntegerField(default=2000)),
|
||||
('transcoding_audio_codec', models.CharField(choices=[('aac', 'AAC'), ('mp3', 'MP3')], default='aac', max_length=50)),
|
||||
('transcoding_resolution', models.CharField(choices=[('720p', '720p'), ('1080p', '1080p')], default='1080p', max_length=50)),
|
||||
('failover_behavior', models.CharField(choices=[('sequential', 'Sequential'), ('random', 'Random')], default='sequential', max_length=50)),
|
||||
('stream_health_check_frequency', models.PositiveIntegerField(default=5)),
|
||||
('email_notifications', models.BooleanField(default=False)),
|
||||
('webhook_url', models.URLField(blank=True, null=True)),
|
||||
('cpu_alert_threshold', models.PositiveIntegerField(default=90)),
|
||||
('memory_alert_threshold', models.PositiveIntegerField(default=90)),
|
||||
('hdhr_integration', models.BooleanField(default=True)),
|
||||
('custom_api_endpoints', models.JSONField(blank=True, null=True)),
|
||||
('backup_path', models.CharField(default='backups/', max_length=255)),
|
||||
('backup_frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=50)),
|
||||
('ffmpeg_path', models.CharField(default='/usr/bin/ffmpeg', max_length=255)),
|
||||
('custom_transcoding_flags', models.TextField(blank=True, null=True)),
|
||||
('celery_worker_concurrency', models.PositiveIntegerField(default=4)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2025-02-18 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('channels', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EPGSource',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('source_type', models.CharField(choices=[('xmltv', 'XMLTV URL'), ('schedules_direct', 'Schedules Direct API')], max_length=20)),
|
||||
('url', models.URLField(blank=True, null=True)),
|
||||
('api_key', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Program',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('start_time', models.DateTimeField()),
|
||||
('end_time', models.DateTimeField()),
|
||||
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='programs', to='channels.channel')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2025-02-18 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='HDHRDevice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('friendly_name', models.CharField(default='Dispatcharr HDHomeRun', max_length=100)),
|
||||
('device_id', models.CharField(max_length=32, unique=True)),
|
||||
('tuner_count', models.PositiveIntegerField(default=3)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,23 +1,29 @@
|
|||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from django.conf import settings
|
||||
|
||||
# SSDP Multicast Address and Port
|
||||
SSDP_MULTICAST = "239.255.255.250"
|
||||
SSDP_PORT = 1900
|
||||
|
||||
# Server Information
|
||||
DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaServer:1"
|
||||
SERVER_IP = "10.0.0.107" # Replace with your server's IP address
|
||||
SERVER_PORT = 8000
|
||||
|
||||
def ssdp_response(addr):
|
||||
"""Send an SSDP response to a specific address."""
|
||||
def get_host_ip():
|
||||
try:
|
||||
# This relies on "host.docker.internal" being mapped to the host’s gateway IP.
|
||||
return socket.gethostbyname("host.docker.internal")
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
def ssdp_response(addr, host_ip):
|
||||
response = (
|
||||
f"HTTP/1.1 200 OK\r\n"
|
||||
f"CACHE-CONTROL: max-age=1800\r\n"
|
||||
f"EXT:\r\n"
|
||||
f"LOCATION: http://{SERVER_IP}:{SERVER_PORT}/hdhr/device.xml\r\n"
|
||||
f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n"
|
||||
f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n"
|
||||
f"ST: {DEVICE_TYPE}\r\n"
|
||||
f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n"
|
||||
|
|
@ -27,25 +33,22 @@ def ssdp_response(addr):
|
|||
sock.sendto(response.encode("utf-8"), addr)
|
||||
sock.close()
|
||||
|
||||
def ssdp_listener():
|
||||
"""Listen for SSDP M-SEARCH requests and respond."""
|
||||
def ssdp_listener(host_ip):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((SSDP_MULTICAST, SSDP_PORT))
|
||||
|
||||
while True:
|
||||
data, addr = sock.recvfrom(1024)
|
||||
if b"M-SEARCH" in data and DEVICE_TYPE.encode("utf-8") in data:
|
||||
print(f"Received M-SEARCH from {addr}")
|
||||
ssdp_response(addr)
|
||||
ssdp_response(addr, host_ip)
|
||||
|
||||
def ssdp_broadcaster():
|
||||
"""Broadcast SSDP NOTIFY messages periodically."""
|
||||
def ssdp_broadcaster(host_ip):
|
||||
notify = (
|
||||
f"NOTIFY * HTTP/1.1\r\n"
|
||||
f"HOST: {SSDP_MULTICAST}:{SSDP_PORT}\r\n"
|
||||
f"CACHE-CONTROL: max-age=1800\r\n"
|
||||
f"LOCATION: http://{SERVER_IP}:{SERVER_PORT}/hdhr/device.xml\r\n"
|
||||
f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n"
|
||||
f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n"
|
||||
f"NT: {DEVICE_TYPE}\r\n"
|
||||
f"NTS: ssdp:alive\r\n"
|
||||
|
|
@ -54,19 +57,12 @@ def ssdp_broadcaster():
|
|||
)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
|
||||
while True:
|
||||
sock.sendto(notify.encode("utf-8"), (SSDP_MULTICAST, SSDP_PORT))
|
||||
time.sleep(30)
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
def start_ssdp():
|
||||
"""Start SSDP services."""
|
||||
global SERVER_IP
|
||||
# Dynamically get the IP address of the server
|
||||
SERVER_IP = settings.SERVER_IP or "127.0.0.1" # Default to localhost if not set
|
||||
threading.Thread(target=ssdp_listener, daemon=True).start()
|
||||
threading.Thread(target=ssdp_broadcaster, daemon=True).start()
|
||||
print(f"SSDP services started on {SERVER_IP}.")
|
||||
|
||||
host_ip = get_host_ip()
|
||||
threading.Thread(target=ssdp_listener, args=(host_ip,), daemon=True).start()
|
||||
threading.Thread(target=ssdp_broadcaster, args=(host_ip,), daemon=True).start()
|
||||
print(f"SSDP services started on {host_ip}.")
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
# Generated by Django 4.2.2 on 2025-02-18 16:34
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='M3UAccount',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Unique name for this M3U account', max_length=255, unique=True)),
|
||||
('server_url', models.URLField(blank=True, help_text='The base URL of the M3U server (optional if a file is uploaded)', null=True)),
|
||||
('uploaded_file', models.FileField(blank=True, null=True, upload_to='m3u_uploads/')),
|
||||
('max_streams', models.PositiveIntegerField(default=0, help_text='Maximum number of concurrent streams (0 for unlimited)')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Set to false to deactivate this M3U account')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='Time when this account was created')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='Time when this account was last updated')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServerGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Unique name for this server group.', max_length=100, unique=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='M3UFilter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('filter_type', models.CharField(choices=[('group', 'Group Title'), ('name', 'Stream Name')], default='group', help_text='Filter based on either group title or stream name.', max_length=50)),
|
||||
('regex_pattern', models.CharField(help_text='A regex pattern to match streams or groups.', max_length=200)),
|
||||
('exclude', models.BooleanField(default=True, help_text='If True, matching items are excluded; if False, only matches are included.')),
|
||||
('m3u_account', models.ForeignKey(help_text='The M3U account this filter is applied to.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='m3u.m3uaccount')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='server_group',
|
||||
field=models.ForeignKey(blank=True, help_text='The server group this M3U account belongs to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='m3u.servergroup'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# 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',
|
||||
),
|
||||
]
|
||||
0
core/migrations/__init__.py
Normal file
0
core/migrations/__init__.py
Normal file
|
|
@ -28,7 +28,7 @@ urlpatterns = [
|
|||
path('api/', include(('apps.api.urls', 'api'), namespace='api')),
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
#path('accounts/', include(('apps.accounts.urls', 'accounts'), namespace='accounts')),
|
||||
path('accounts/', include(('apps.accounts.urls', 'accounts'), namespace='accounts')),
|
||||
#path('streams/', include(('apps.streams.urls', 'streams'), namespace='streams')),
|
||||
#path('hdhr/', include(('apps.hdhr.urls', 'hdhr'), namespace='hdhr')),
|
||||
path('m3u/', include(('apps.m3u.urls', 'm3u'), namespace='m3u')),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ services:
|
|||
- redis
|
||||
volumes:
|
||||
- ../:/app
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- POSTGRES_HOST=db
|
||||
- POSTGRES_DB=dispatcharr
|
||||
|
|
@ -29,6 +31,8 @@ services:
|
|||
- redis
|
||||
volumes:
|
||||
- ../:/app
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- POSTGRES_HOST=localhost
|
||||
- POSTGRES_DB=dispatcharr
|
||||
|
|
@ -58,16 +62,5 @@ services:
|
|||
image: redis:latest
|
||||
container_name: dispatcharr_redis
|
||||
|
||||
|
||||
# You can add an Nginx or Traefik service here for SSL
|
||||
# nginx:
|
||||
# image: nginx:alpine
|
||||
# container_name: dispatcharr_nginx
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# depends_on:
|
||||
# - web
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
|
|||
|
|
@ -39,10 +39,20 @@
|
|||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if user.is_authenticated %}
|
||||
<!-- If user is logged in, show Logout -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/accounts/login/">Login</a>
|
||||
<form id="logoutForm" method="post" action="{% url 'accounts:logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
{% else %}
|
||||
<!-- If user is not logged in, show Login link -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'accounts:login' %}">Login</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<!-- 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"
|
||||
|
|
|
|||
|
|
@ -22,24 +22,22 @@
|
|||
</button>
|
||||
|
||||
<!-- Example placeholders for HDHR/M3U/EPG links -->
|
||||
<button class="btn btn-info btn-sm me-1">
|
||||
HDHR URL
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm me-1">
|
||||
M3U URL
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm">
|
||||
EPG
|
||||
<button class="btn btn-info btn-sm me-1">HDHR URL</button>
|
||||
<button
|
||||
class="btn btn-secondary btn-sm me-1"
|
||||
id="copyM3UUrlBtn"
|
||||
data-url="{{ request.scheme }}://{{ request.get_host }}{% url 'output:generate_m3u' %}"
|
||||
>
|
||||
M3U URL
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm">EPG</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<table id="channelsTable" class="table table-hover table-sm w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px;">
|
||||
<input type="checkbox" id="selectAllChannels">
|
||||
</th>
|
||||
<th style="width:30px;"><input type="checkbox" id="selectAllChannels"></th>
|
||||
<th>#</th>
|
||||
<th>Logo</th>
|
||||
<th>Name</th>
|
||||
|
|
@ -60,6 +58,10 @@
|
|||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">Streams</h3>
|
||||
<div>
|
||||
<!-- New Add Stream button -->
|
||||
<button id="addStreamBtn" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#addStreamModal">
|
||||
<i class="fa-solid fa-plus"></i> Add Stream
|
||||
</button>
|
||||
<button id="createChannelsFromStreamsBtn" class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-plus"></i> Create Channels
|
||||
</button>
|
||||
|
|
@ -69,9 +71,7 @@
|
|||
<table id="streamsTable" class="table table-hover table-sm w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px;">
|
||||
<input type="checkbox" id="selectAllStreams">
|
||||
</th>
|
||||
<th style="width:30px;"><input type="checkbox" id="selectAllStreams"></th>
|
||||
<th>Stream Name</th>
|
||||
<th>Group</th>
|
||||
<th>Actions</th>
|
||||
|
|
@ -103,6 +103,8 @@
|
|||
{% include "channels/modals/edit_logo.html" %}
|
||||
{% include "channels/modals/delete_channel.html" %}
|
||||
{% include "channels/modals/delete_stream.html" %}
|
||||
{% include "channels/modals/add_stream.html" %}
|
||||
{% include "channels/modals/edit_stream.html" %}
|
||||
{% include "channels/modals/add_m3u.html" %}
|
||||
{% include "channels/modals/edit_m3u.html" %}
|
||||
{% include "channels/modals/add_group.html" %}
|
||||
|
|
@ -122,6 +124,22 @@
|
|||
|
||||
<!-- ============== MAIN SCRIPT ============== -->
|
||||
<script>
|
||||
|
||||
document.getElementById('copyM3UUrlBtn').addEventListener('click', function() {
|
||||
const m3uUrl = this.getAttribute('data-url');
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(m3uUrl).then(() => {
|
||||
// Optionally, show a confirmation message (e.g., with SweetAlert or a simple alert)
|
||||
alert('Copied to clipboard: ' + m3uUrl);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
} else {
|
||||
// Fallback if Clipboard API is not supported
|
||||
alert('Clipboard API not supported.');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
|
@ -135,82 +153,49 @@ $(document).ready(function () {
|
|||
// 1) Channels DataTable
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
const channelsDataTable = $("#channelsTable").DataTable({
|
||||
ajax: {
|
||||
url: "/api/channels/channels/",
|
||||
dataSrc: ""
|
||||
},
|
||||
ajax: { url: "/api/channels/channels/", dataSrc: "" },
|
||||
columns: [
|
||||
{
|
||||
data: "id",
|
||||
render: (data) => `<input type="checkbox" class="channel-checkbox" data-channel-id="${data}">`,
|
||||
orderable: false,
|
||||
searchable: false
|
||||
},
|
||||
{ data: "id", render: data => `<input type="checkbox" class="channel-checkbox" data-channel-id="${data}">`, orderable: false, searchable: false },
|
||||
{ data: "channel_number" },
|
||||
{
|
||||
data: "logo_url",
|
||||
render: (logoUrl, type, row) => {
|
||||
const safeLogo = logoUrl || "/static/default-logo.png";
|
||||
return `
|
||||
<img src="${safeLogo}"
|
||||
alt="logo"
|
||||
style="width:40px; height:40px; object-fit:contain; cursor:pointer;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editLogoModal"
|
||||
data-channelid="${row.id}"
|
||||
data-channelname="${row.channel_name}"
|
||||
data-logourl="${safeLogo}">
|
||||
<img src="${safeLogo}" alt="logo" style="width:40px; height:40px; object-fit:contain; cursor:pointer;"
|
||||
data-bs-toggle="modal" data-bs-target="#editLogoModal"
|
||||
data-channelid="${row.id}" data-channelname="${row.channel_name}" data-logourl="${safeLogo}">
|
||||
`;
|
||||
},
|
||||
orderable: false,
|
||||
searchable: false
|
||||
orderable: false, searchable: false
|
||||
},
|
||||
{ data: "channel_name" },
|
||||
{
|
||||
data: "tvg_name",
|
||||
render: (tvgName) => tvgName || "[n/a]"
|
||||
},
|
||||
{
|
||||
data: "channel_group",
|
||||
render: (group) => group?.name || ""
|
||||
},
|
||||
{ data: "tvg_name", render: tvgName => tvgName || "[n/a]" },
|
||||
{ data: "channel_group", render: group => group?.name || "" },
|
||||
{
|
||||
data: "id",
|
||||
render: function (data, type, row) {
|
||||
return `
|
||||
<button class="btn btn-info btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editChannelModal"
|
||||
data-channelid="${data}">
|
||||
<i class="fa-solid fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteChannelModal"
|
||||
data-channelid="${data}"
|
||||
data-channelname="${row.channel_name}">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
`;
|
||||
},
|
||||
orderable: false,
|
||||
searchable: false
|
||||
render: (data, type, row) => `
|
||||
<button class="btn btn-info btn-sm" data-bs-toggle="modal" data-bs-target="#editChannelModal" data-channelid="${data}">
|
||||
<i class="fa-solid fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteChannelModal"
|
||||
data-channelid="${data}" data-channelname="${row.channel_name}">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
`,
|
||||
orderable: false, searchable: false
|
||||
}
|
||||
],
|
||||
responsive: true,
|
||||
pageLength: 10,
|
||||
order: [[1, "asc"]]
|
||||
responsive: true, pageLength: 10, order: [[1, "asc"]]
|
||||
});
|
||||
|
||||
// Helper to find next available channel_number by scanning loaded channels
|
||||
// Helper: next available channel number
|
||||
function getNextChannelNumber() {
|
||||
const allChannels = channelsDataTable.rows().data().toArray();
|
||||
let maxNum = 0;
|
||||
allChannels.forEach(ch => {
|
||||
const chNum = parseInt(ch.channel_number, 10);
|
||||
if (!isNaN(chNum) && chNum > maxNum) {
|
||||
maxNum = chNum;
|
||||
}
|
||||
if (!isNaN(chNum) && chNum > maxNum) maxNum = chNum;
|
||||
});
|
||||
return maxNum + 1;
|
||||
}
|
||||
|
|
@ -219,204 +204,115 @@ $(document).ready(function () {
|
|||
// 2) Streams DataTable
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
const streamsDataTable = $("#streamsTable").DataTable({
|
||||
ajax: {
|
||||
url: "/api/channels/streams/",
|
||||
dataSrc: ""
|
||||
},
|
||||
ajax: { url: "/api/channels/streams/", dataSrc: "" },
|
||||
columns: [
|
||||
{
|
||||
data: "id",
|
||||
render: (data) => `<input type="checkbox" class="stream-checkbox" data-stream-id="${data}">`,
|
||||
orderable: false,
|
||||
searchable: false
|
||||
},
|
||||
{
|
||||
data: "name",
|
||||
render: (name) => name || "Unnamed Stream"
|
||||
},
|
||||
{
|
||||
data: "group_name",
|
||||
render: (val) => val || ""
|
||||
},
|
||||
{ data: "id", render: data => `<input type="checkbox" class="stream-checkbox" data-stream-id="${data}">`, orderable: false, searchable: false },
|
||||
{ data: "name", render: name => name || "Unnamed Stream" },
|
||||
{ data: "group_name", render: val => val || "" },
|
||||
{
|
||||
data: "id",
|
||||
render: (data, type, row) => {
|
||||
const name = row.name || "Stream";
|
||||
return `
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<!-- If you have an “editStreamModal”, keep it. Otherwise remove. -->
|
||||
<button class="btn btn-primary btn-sm edit-stream-btn mx-1"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editStreamModal"
|
||||
data-stream-id="${data}"
|
||||
data-stream-name="${name}">
|
||||
<button class="btn btn-primary btn-sm edit-stream-btn mx-1" data-bs-toggle="modal"
|
||||
data-bs-target="#editStreamModal" data-stream-id="${data}" data-stream-name="${name}">
|
||||
<i class="fa-solid fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm delete-stream-btn mx-1"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#deleteStreamModal"
|
||||
data-stream-id="${data}"
|
||||
data-stream-name="${name}">
|
||||
<button class="btn btn-danger btn-sm delete-stream-btn mx-1" data-bs-toggle="modal"
|
||||
data-bs-target="#deleteStreamModal" data-stream-id="${data}" data-stream-name="${name}">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
<button class="btn btn-success btn-sm create-channel-from-stream-btn mx-1"
|
||||
data-stream-id="${data}"
|
||||
<button class="btn btn-success btn-sm create-channel-from-stream-btn mx-1" data-stream-id="${data}"
|
||||
data-stream-name="${name}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
orderable: false,
|
||||
searchable: false
|
||||
orderable: false, searchable: false
|
||||
}
|
||||
],
|
||||
responsive: true,
|
||||
pageLength: 10
|
||||
responsive: true, pageLength: 10
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 3) Clicking the “+” in Streams => open “Add Channel” & auto‐fill channel name
|
||||
// 3) Add Channel Modal initialization (existing functionality)
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
let newAvailableStreamsTable = null;
|
||||
let newActiveStreamsTable = null;
|
||||
|
||||
// We'll do the actual logic inside a small function so we can reuse it:
|
||||
let newAvailableStreamsTable = null, newActiveStreamsTable = null;
|
||||
function initAddChannelModal() {
|
||||
// (A) Set next available channel number
|
||||
$("#newChannelNumberField").val(getNextChannelNumber());
|
||||
|
||||
// (B) If not initialized, create the "Available" side as a DataTable
|
||||
if (!newAvailableStreamsTable) {
|
||||
newAvailableStreamsTable = $("#newAvailableStreamsTable").DataTable({
|
||||
ajax: {
|
||||
url: "/api/channels/streams/?unassigned=1", // or your real unassigned filter
|
||||
dataSrc: ""
|
||||
},
|
||||
ajax: { url: "/api/channels/streams/?unassigned=1", dataSrc: "" },
|
||||
columns: [
|
||||
{ data: "name" },
|
||||
{
|
||||
data: "m3u_name",
|
||||
render: (val) => val || ""
|
||||
},
|
||||
{
|
||||
data: "id",
|
||||
render: (id, type, row) => `
|
||||
<button class="btn btn-primary btn-sm addToActiveBtn">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
`
|
||||
}
|
||||
{ data: "m3u_name", render: val => val || "" },
|
||||
{ data: "id", render: (id, type, row) => `<button class="btn btn-primary btn-sm addToActiveBtn"><i class="fa-solid fa-plus"></i></button>` }
|
||||
],
|
||||
destroy: true,
|
||||
searching: true,
|
||||
paging: true,
|
||||
pageLength: 5,
|
||||
responsive: true
|
||||
destroy: true, searching: true, paging: true, pageLength: 5, responsive: true
|
||||
});
|
||||
} else {
|
||||
// re-load it
|
||||
newAvailableStreamsTable.ajax.url("/api/channels/streams/?unassigned=1").load();
|
||||
}
|
||||
|
||||
// (C) Same for "Active Streams" side
|
||||
if (!newActiveStreamsTable) {
|
||||
newActiveStreamsTable = $("#newActiveStreamsTable").DataTable({
|
||||
columns: [
|
||||
{ data: "name" },
|
||||
{
|
||||
data: "m3u_name",
|
||||
render: (val) => val || ""
|
||||
},
|
||||
{
|
||||
data: "id",
|
||||
render: (id, type, row) => `
|
||||
<button class="btn btn-danger btn-sm removeFromActiveBtn">
|
||||
<i class="fa-solid fa-minus"></i>
|
||||
</button>
|
||||
`
|
||||
}
|
||||
{ data: "m3u_name", render: val => val || "" },
|
||||
{ data: "id", render: (id, type, row) => `<button class="btn btn-danger btn-sm removeFromActiveBtn"><i class="fa-solid fa-minus"></i></button>` }
|
||||
],
|
||||
destroy: true,
|
||||
searching: true,
|
||||
paging: true,
|
||||
pageLength: 5,
|
||||
responsive: true
|
||||
destroy: true, searching: true, paging: true, pageLength: 5, responsive: true
|
||||
});
|
||||
} else {
|
||||
// Clear it out so we start fresh
|
||||
newActiveStreamsTable.clear().draw();
|
||||
}
|
||||
}
|
||||
|
||||
// When user manually opens "Add Channel" (top button)
|
||||
$("#addChannelModal").on("show.bs.modal", function () {
|
||||
// Clear form fields
|
||||
$("#newChannelNameField").val("");
|
||||
$("#removeNewLogoButton").click(); // if you want to reset the logo preview
|
||||
|
||||
initAddChannelModal(); // sets channelNumber, loads DataTables
|
||||
$("#removeNewLogoButton").click();
|
||||
initAddChannelModal();
|
||||
});
|
||||
|
||||
// If user clicks “+” in Streams => open the same Add Channel modal, but also set name
|
||||
$("#streamsTable").on("click", ".create-channel-from-stream-btn", function () {
|
||||
const rowData = streamsDataTable.row($(this).closest("tr")).data();
|
||||
if (!rowData) return;
|
||||
|
||||
// Open the modal
|
||||
const addModalEl = document.getElementById("addChannelModal");
|
||||
const addModal = new bootstrap.Modal(addModalEl);
|
||||
addModal.show();
|
||||
|
||||
// Wait until modal is shown to finish logic
|
||||
new bootstrap.Modal(addModalEl).show();
|
||||
setTimeout(() => {
|
||||
// We know "initAddChannelModal" was called above, so channelNumber is set
|
||||
// Now set the name from the stream:
|
||||
$("#newChannelNameField").val(rowData.name || "");
|
||||
|
||||
// Move that stream from "Available" => "Active" if it’s found
|
||||
const availableData = newAvailableStreamsTable.rows().data().toArray();
|
||||
const match = availableData.find(s => s.id === rowData.id);
|
||||
if (match) {
|
||||
// remove from "available"
|
||||
const idx = newAvailableStreamsTable.row((_, d) => d.id === rowData.id);
|
||||
idx.remove().draw();
|
||||
// add to "active"
|
||||
newActiveStreamsTable.row.add(rowData).draw();
|
||||
}
|
||||
}, 400); // small delay to ensure DataTables have re‐initialized
|
||||
}, 400);
|
||||
});
|
||||
|
||||
// Move from “Available” => “Active”
|
||||
$("#newAvailableStreamsTable").on("click", ".addToActiveBtn", function () {
|
||||
const rowData = newAvailableStreamsTable.row($(this).closest("tr")).data();
|
||||
newAvailableStreamsTable.row($(this).closest("tr")).remove().draw();
|
||||
newActiveStreamsTable.row.add(rowData).draw();
|
||||
});
|
||||
// Move from “Active” => “Available”
|
||||
$("#newActiveStreamsTable").on("click", ".removeFromActiveBtn", function () {
|
||||
const rowData = newActiveStreamsTable.row($(this).closest("tr")).data();
|
||||
newActiveStreamsTable.row($(this).closest("tr")).remove().draw();
|
||||
newAvailableStreamsTable.row.add(rowData).draw();
|
||||
});
|
||||
|
||||
// Submit => POST /api/channels/channels/ with “streams[]” appended
|
||||
$("#addChannelForm").submit(function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
|
||||
// gather “active” streams
|
||||
const activeRows = newActiveStreamsTable.rows().data().toArray();
|
||||
activeRows.forEach((s) => {
|
||||
formData.append("streams", s.id);
|
||||
});
|
||||
|
||||
activeRows.forEach(s => formData.append("streams", s.id));
|
||||
$.ajax({
|
||||
url: "/api/channels/channels/",
|
||||
type: "POST",
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
processData: false, contentType: false,
|
||||
success: function (createdChannel, status, xhr) {
|
||||
if (xhr.status === 201 || xhr.status === 200) {
|
||||
channelsDataTable.ajax.reload(null, false);
|
||||
|
|
@ -427,18 +323,15 @@ $(document).ready(function () {
|
|||
Swal.fire("Error", "Server did not return success code.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Server Error", "Error creating channel.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Server Error", "Error creating channel.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 4) Bulk ops: “Delete Selected Channels”, “Auto Assign”, “Bulk Create Channels from Streams”
|
||||
// 4) Bulk Operations (Delete, Auto-assign, etc.)
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
$("#selectAllChannels").on("change", function () {
|
||||
const checked = $(this).is(":checked");
|
||||
$(".channel-checkbox").prop("checked", checked);
|
||||
$(".channel-checkbox").prop("checked", $(this).is(":checked"));
|
||||
});
|
||||
|
||||
$("#deleteSelectedChannelsBtn").click(function () {
|
||||
|
|
@ -471,9 +364,7 @@ $(document).ready(function () {
|
|||
Swal.fire("Error", "Server did not return 204.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Server Error", "Error sending bulk-delete request.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Server Error", "Error sending bulk-delete request.", "error"); }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -481,9 +372,7 @@ $(document).ready(function () {
|
|||
|
||||
$("#autoAssignBtn").click(function () {
|
||||
const channelIDs = [];
|
||||
channelsDataTable.rows().every(function () {
|
||||
channelIDs.push(this.data().id);
|
||||
});
|
||||
channelsDataTable.rows().every(function () { channelIDs.push(this.data().id); });
|
||||
$.ajax({
|
||||
url: "/api/channels/assign/",
|
||||
method: "POST",
|
||||
|
|
@ -497,15 +386,12 @@ $(document).ready(function () {
|
|||
Swal.fire("Auto Assign Failed", "No success response from server.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Auto Assign Error", "An error occurred auto‐assigning channels.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Auto Assign Error", "An error occurred auto‐assigning channels.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
$("#selectAllStreams").on("change", function () {
|
||||
const checked = $(this).is(":checked");
|
||||
$(".stream-checkbox").prop("checked", checked);
|
||||
$(".stream-checkbox").prop("checked", $(this).is(":checked"));
|
||||
});
|
||||
|
||||
$("#createChannelsFromStreamsBtn").click(function () {
|
||||
|
|
@ -530,159 +416,101 @@ $(document).ready(function () {
|
|||
Swal.fire("Error", "Could not create channels.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Request Failed", "Error creating channels from streams.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Request Failed", "Error creating channels from streams.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 5) Edit Channel => load channel info + active/available streams
|
||||
// 5) Edit Channel (existing functionality with updated Stream Profile)
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
let editActiveStreamsTable = null;
|
||||
let editAvailableStreamsTable = null;
|
||||
|
||||
let editActiveStreamsTable = null, editAvailableStreamsTable = null;
|
||||
$("#editChannelModal").on("show.bs.modal", function (event) {
|
||||
const button = $(event.relatedTarget);
|
||||
const channelID = button.data("channelid");
|
||||
|
||||
// 1) “Active Streams” side => only streams assigned to this channel
|
||||
if (!editActiveStreamsTable) {
|
||||
editActiveStreamsTable = $("#editActiveStreamsTable").DataTable({
|
||||
ajax: {
|
||||
url: `/api/channels/streams/?assigned=${channelID}`,
|
||||
dataSrc: ""
|
||||
},
|
||||
ajax: { url: `/api/channels/streams/?assigned=${channelID}`, dataSrc: "" },
|
||||
columns: [
|
||||
{ data: "name" },
|
||||
{
|
||||
data: "m3u_name",
|
||||
render: (val) => val || ""
|
||||
},
|
||||
{
|
||||
data: "id",
|
||||
render: (id, type, row) => `
|
||||
<button class="btn btn-danger btn-sm editRemoveFromActiveBtn">
|
||||
<i class="fa-solid fa-minus"></i>
|
||||
</button>
|
||||
`
|
||||
}
|
||||
{ data: "m3u_name", render: val => val || "" },
|
||||
{ data: "id", render: (id, type, row) => `<button class="btn btn-danger btn-sm editRemoveFromActiveBtn"><i class="fa-solid fa-minus"></i></button>` }
|
||||
],
|
||||
destroy: true,
|
||||
searching: true,
|
||||
paging: true,
|
||||
pageLength: 5,
|
||||
responsive: true
|
||||
destroy: true, searching: true, paging: true, pageLength: 5, responsive: true
|
||||
});
|
||||
} else {
|
||||
editActiveStreamsTable.clear().draw();
|
||||
editActiveStreamsTable.ajax.url(`/api/channels/streams/?assigned=${channelID}`).load();
|
||||
}
|
||||
|
||||
// 2) “Available Streams” => not assigned
|
||||
if (!editAvailableStreamsTable) {
|
||||
editAvailableStreamsTable = $("#editAvailableStreamsTable").DataTable({
|
||||
ajax: {
|
||||
url: "/api/channels/streams/?unassigned=1",
|
||||
dataSrc: ""
|
||||
},
|
||||
ajax: { url: "/api/channels/streams/?unassigned=1", dataSrc: "" },
|
||||
columns: [
|
||||
{ data: "name" },
|
||||
{
|
||||
data: "m3u_name",
|
||||
render: (val) => val || ""
|
||||
},
|
||||
{
|
||||
data: "id",
|
||||
render: (id, type, row) => `
|
||||
<button class="btn btn-primary btn-sm editAddToActiveBtn">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
`
|
||||
}
|
||||
{ data: "m3u_name", render: val => val || "" },
|
||||
{ data: "id", render: (id, type, row) => `<button class="btn btn-primary btn-sm editAddToActiveBtn"><i class="fa-solid fa-plus"></i></button>` }
|
||||
],
|
||||
destroy: true,
|
||||
searching: true,
|
||||
paging: true,
|
||||
pageLength: 5,
|
||||
responsive: true
|
||||
destroy: true, searching: true, paging: true, pageLength: 5, responsive: true
|
||||
});
|
||||
} else {
|
||||
editAvailableStreamsTable.clear().draw();
|
||||
editAvailableStreamsTable.ajax.url("/api/channels/streams/?unassigned=1").load();
|
||||
}
|
||||
|
||||
// 3) Fetch the channel’s details to fill name/number/logo/group
|
||||
$.getJSON(`/api/channels/channels/${channelID}/`, function (channel) {
|
||||
$("#editChannelIdField").val(channelID);
|
||||
$("#editChannelNameField").val(channel.channel_name || "");
|
||||
$("#editChannelNumberField").val(channel.channel_number || 0);
|
||||
$("#editLogoPreview").attr("src", channel.logo_url || "/static/default-logo.png");
|
||||
if (channel.channel_group && channel.channel_group.id) {
|
||||
$("#editGroupField").val(channel.channel_group.id);
|
||||
} else {
|
||||
$("#editGroupField").val("");
|
||||
$("#editGroupField").val(channel.channel_group?.id || "");
|
||||
// Set the Stream Profile dropdown value
|
||||
if (channel.stream_profile_id) {
|
||||
// Delay setting the value to allow the dropdown to be populated
|
||||
setTimeout(() => {
|
||||
$("#editChannelProfileField").val(channel.stream_profile_id);
|
||||
}, 300);
|
||||
}
|
||||
}).fail(function () {
|
||||
Swal.fire("Error", "Could not load channel data from server.", "error");
|
||||
});
|
||||
}).fail(function () { Swal.fire("Error", "Could not load channel data from server.", "error"); });
|
||||
});
|
||||
|
||||
// Move from Available => Active
|
||||
$("#editAvailableStreamsTable").on("click", ".editAddToActiveBtn", function () {
|
||||
const rowData = editAvailableStreamsTable.row($(this).closest("tr")).data();
|
||||
editAvailableStreamsTable.row($(this).closest("tr")).remove().draw();
|
||||
editActiveStreamsTable.row.add(rowData).draw();
|
||||
});
|
||||
// Move from Active => Available
|
||||
$("#editActiveStreamsTable").on("click", ".editRemoveFromActiveBtn", function () {
|
||||
const rowData = editActiveStreamsTable.row($(this).closest("tr")).data();
|
||||
editActiveStreamsTable.row($(this).closest("tr")).remove().draw();
|
||||
editAvailableStreamsTable.row.add(rowData).draw();
|
||||
});
|
||||
|
||||
$("#editChannelForm").submit(function (e) {
|
||||
e.preventDefault();
|
||||
const channelID = $("#editChannelIdField").val();
|
||||
const formData = new FormData(this);
|
||||
|
||||
// gather active streams
|
||||
const activeRows = editActiveStreamsTable.rows().data().toArray();
|
||||
activeRows.forEach((s) => {
|
||||
formData.append("streams", s.id);
|
||||
});
|
||||
|
||||
activeRows.forEach(s => formData.append("streams", s.id));
|
||||
$.ajax({
|
||||
url: `/api/channels/channels/${channelID}/`,
|
||||
type: "PUT", // or PATCH if your API requires
|
||||
type: "PUT",
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
processData: false, contentType: false,
|
||||
success: function (resp, status, xhr) {
|
||||
if (xhr.status === 200) {
|
||||
Swal.fire("Channel Updated", "Channel saved successfully.", "success");
|
||||
const modalEl = document.getElementById("editChannelModal");
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
bootstrap.Modal.getInstance(document.getElementById("editChannelModal")).hide();
|
||||
channelsDataTable.ajax.reload(null, false);
|
||||
} else {
|
||||
Swal.fire("Error", "Could not update channel.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Server Error", "Error updating channel.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Server Error", "Error updating channel.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 6) Delete Channel / Stream modals
|
||||
// 6) Delete Channel / Stream
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
$("#deleteChannelModal").on("show.bs.modal", function (event) {
|
||||
const button = $(event.relatedTarget);
|
||||
const channelID = button.data("channelid");
|
||||
const channelName = button.data("channelname");
|
||||
$("#deleteChannelIdHidden").val(channelID);
|
||||
$("#channelName").text(channelName);
|
||||
$("#deleteChannelIdHidden").val(button.data("channelid"));
|
||||
$("#channelName").text(button.data("channelname"));
|
||||
});
|
||||
$("#deleteChannelForm").submit(function (e) {
|
||||
e.preventDefault();
|
||||
|
|
@ -694,24 +522,19 @@ $(document).ready(function () {
|
|||
if (xhr.status === 204) {
|
||||
channelsDataTable.ajax.reload(null, false);
|
||||
Swal.fire("Channel Deleted", "The channel was deleted.", "success");
|
||||
const modalEl = document.getElementById("deleteChannelModal");
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
bootstrap.Modal.getInstance(document.getElementById("deleteChannelModal")).hide();
|
||||
} else {
|
||||
Swal.fire("Error", "Server did not return 204.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Server Error", "Error deleting channel.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Server Error", "Error deleting channel.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
$("#deleteStreamModal").on("show.bs.modal", function (event) {
|
||||
const button = $(event.relatedTarget);
|
||||
const streamID = button.data("stream-id");
|
||||
const streamName = button.data("stream-name");
|
||||
$("#deleteStreamIdHidden").val(streamID);
|
||||
$("#streamName").text(streamName);
|
||||
$("#deleteStreamIdHidden").val(button.data("stream-id"));
|
||||
$("#streamName").text(button.data("stream-name"));
|
||||
});
|
||||
$("#deleteStreamForm").submit(function (e) {
|
||||
e.preventDefault();
|
||||
|
|
@ -723,15 +546,12 @@ $(document).ready(function () {
|
|||
if (xhr.status === 204) {
|
||||
streamsDataTable.ajax.reload(null, false);
|
||||
Swal.fire("Stream Deleted", "The stream was deleted.", "success");
|
||||
const modalEl = document.getElementById("deleteStreamModal");
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
bootstrap.Modal.getInstance(document.getElementById("deleteStreamModal")).hide();
|
||||
} else {
|
||||
Swal.fire("Error", "Server did not return 204.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Server Error", "Error deleting stream.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Server Error", "Error deleting stream.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -748,20 +568,15 @@ $(document).ready(function () {
|
|||
contentType: "application/json",
|
||||
success: function (createdGroup, status, xhr) {
|
||||
if (xhr.status === 201) {
|
||||
// Optionally add it to group dropdowns
|
||||
$("#newGroupField").append(new Option(createdGroup.name, createdGroup.id));
|
||||
$("#editGroupField").append(new Option(createdGroup.name, createdGroup.id));
|
||||
$("#newGroupField, #editGroupField").append(new Option(createdGroup.name, createdGroup.id));
|
||||
$("#newGroupNameField").val("");
|
||||
const modalEl = document.getElementById("addGroupModal");
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
bootstrap.Modal.getInstance(document.getElementById("addGroupModal")).hide();
|
||||
Swal.fire("Group Added", `New group "${createdGroup.name}" created.`, "success");
|
||||
} else {
|
||||
Swal.fire("Error", "Server did not return 201.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Server Error", "Error adding group.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Server Error", "Error adding group.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -770,13 +585,9 @@ $(document).ready(function () {
|
|||
////////////////////////////////////////////////////////////////////////////
|
||||
$("#editLogoModal").on("show.bs.modal", function (event) {
|
||||
const button = $(event.relatedTarget);
|
||||
const channelID = button.data("channelid");
|
||||
const channelName = button.data("channelname");
|
||||
const logoURL = button.data("logourl");
|
||||
|
||||
$("#channel_id_field").val(channelID);
|
||||
$("#logo_url").val(logoURL || "");
|
||||
$("#editLogoModalLabel").text(`Edit Logo for ${channelName}`);
|
||||
$("#channel_id_field").val(button.data("channelid"));
|
||||
$("#logo_url").val(button.data("logourl") || "");
|
||||
$("#editLogoModalLabel").text(`Edit Logo for ${button.data("channelname")}`);
|
||||
});
|
||||
$("#editLogoForm").submit(function (e) {
|
||||
e.preventDefault();
|
||||
|
|
@ -786,21 +597,17 @@ $(document).ready(function () {
|
|||
url: `/api/channels/channels/${channelID}/logo`,
|
||||
type: "POST",
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
processData: false, contentType: false,
|
||||
success: function (resp, status, xhr) {
|
||||
if (xhr.status === 200) {
|
||||
channelsDataTable.ajax.reload(null, false);
|
||||
Swal.fire("Logo Updated", "Channel logo updated successfully.", "success");
|
||||
const modalEl = document.getElementById("editLogoModal");
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
bootstrap.Modal.getInstance(document.getElementById("editLogoModal")).hide();
|
||||
} else {
|
||||
Swal.fire("Error", "Server didn't return success for updating logo.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Server Error", "Error updating channel logo.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Server Error", "Error updating channel logo.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -815,41 +622,125 @@ $(document).ready(function () {
|
|||
success: function (data, status, xhr) {
|
||||
if (xhr.status === 202) {
|
||||
Swal.fire("Refresh Started", "M3U refresh has been initiated.", "success");
|
||||
const modalEl = document.getElementById("refreshModal");
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
bootstrap.Modal.getInstance(document.getElementById("refreshModal")).hide();
|
||||
} else {
|
||||
Swal.fire("Error", "Server did not return 202.", "error");
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
Swal.fire("Error", "Failed to refresh M3U.", "error");
|
||||
}
|
||||
error: function () { Swal.fire("Error", "Failed to refresh M3U.", "error"); }
|
||||
});
|
||||
});
|
||||
$("#backupForm").submit(function (e) {
|
||||
e.preventDefault();
|
||||
$.post("/api/channels/backup/", {}, function (resp) {
|
||||
Swal.fire("Backup Created", "Backup has been created successfully.", "success");
|
||||
const modalEl = document.getElementById("backupModal");
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
}).fail(function () {
|
||||
Swal.fire("Server Error", "Error creating backup.", "error");
|
||||
});
|
||||
bootstrap.Modal.getInstance(document.getElementById("backupModal")).hide();
|
||||
}).fail(function () { Swal.fire("Server Error", "Error creating backup.", "error"); });
|
||||
});
|
||||
$("#restoreForm").submit(function (e) {
|
||||
e.preventDefault();
|
||||
$.post("/api/channels/restore/", {}, function (resp) {
|
||||
Swal.fire("Restored", "Restore complete.", "success");
|
||||
const modalEl = document.getElementById("restoreModal");
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
bootstrap.Modal.getInstance(document.getElementById("restoreModal")).hide();
|
||||
channelsDataTable.ajax.reload();
|
||||
streamsDataTable.ajax.reload();
|
||||
// If you have an M3U table, reload it as well.
|
||||
}).fail(function () {
|
||||
Swal.fire("Server Error", "Error restoring backup.", "error");
|
||||
}).fail(function () { Swal.fire("Server Error", "Error restoring backup.", "error"); });
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// NEW: Add/Edit Stream Modals
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// When the Edit Stream modal is shown, prefill with stream data.
|
||||
$("#editStreamModal").on("show.bs.modal", function(event) {
|
||||
const button = $(event.relatedTarget);
|
||||
const streamId = button.data("stream-id");
|
||||
$.getJSON(`/api/channels/streams/${streamId}/`, function(stream) {
|
||||
$("#editStreamIdField").val(stream.id);
|
||||
$("#editStreamNameField").val(stream.name);
|
||||
$("#editStreamGroupField").val(stream.group_name || "");
|
||||
$("#editStreamUrlField").val(stream.url);
|
||||
$("#editStreamProfileField").val(stream.stream_profile || "");
|
||||
});
|
||||
});
|
||||
|
||||
// Handle Add Stream form submission.
|
||||
$("#addStreamForm").submit(function(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
$.ajax({
|
||||
url: "/api/channels/streams/",
|
||||
type: "POST",
|
||||
data: formData,
|
||||
processData: false, contentType: false,
|
||||
success: function(newStream, status, xhr) {
|
||||
if (xhr.status === 201 || xhr.status === 200) {
|
||||
streamsDataTable.ajax.reload(null, false);
|
||||
bootstrap.Modal.getInstance(document.getElementById("addStreamModal")).hide();
|
||||
Swal.fire("Stream Created", "New stream was added.", "success");
|
||||
} else {
|
||||
Swal.fire("Error", "Server did not return success code.", "error");
|
||||
}
|
||||
},
|
||||
error: function() { Swal.fire("Server Error", "Error creating stream.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
// Handle Edit Stream form submission.
|
||||
$("#editStreamForm").submit(function(e) {
|
||||
e.preventDefault();
|
||||
const streamId = $("#editStreamIdField").val();
|
||||
const formData = new FormData(this);
|
||||
$.ajax({
|
||||
url: `/api/channels/streams/${streamId}/`,
|
||||
type: "PUT",
|
||||
data: formData,
|
||||
processData: false, contentType: false,
|
||||
success: function(updatedStream, status, xhr) {
|
||||
if (xhr.status === 200) {
|
||||
streamsDataTable.ajax.reload(null, false);
|
||||
bootstrap.Modal.getInstance(document.getElementById("editStreamModal")).hide();
|
||||
Swal.fire("Stream Updated", "Stream updated successfully.", "success");
|
||||
} else {
|
||||
Swal.fire("Error", "Could not update stream.", "error");
|
||||
}
|
||||
},
|
||||
error: function() { Swal.fire("Server Error", "Error updating stream.", "error"); }
|
||||
});
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// NEW: Load Stream Profiles from API and populate dropdowns
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
function loadStreamProfiles(selectElementId, selectedValue = null) {
|
||||
fetch("/api/core/streamprofiles/")
|
||||
.then(response => response.json())
|
||||
.then(profiles => {
|
||||
const selectEl = document.getElementById(selectElementId);
|
||||
if (!selectEl) return;
|
||||
selectEl.innerHTML = '<option value="">Select stream profile (optional)</option>';
|
||||
profiles.forEach(profile => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = profile.id;
|
||||
opt.textContent = profile.profile_name;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
if (selectedValue) {
|
||||
selectEl.value = selectedValue;
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Error loading stream profiles:", error));
|
||||
}
|
||||
|
||||
// When Add Stream modal is shown, load profiles
|
||||
$("#addStreamModal").on("show.bs.modal", function () {
|
||||
loadStreamProfiles("newStreamProfileField");
|
||||
});
|
||||
|
||||
// When Edit Channel modal is shown, load profiles (already called in its show handler)
|
||||
$("#editChannelModal").on("show.bs.modal", function () {
|
||||
loadStreamProfiles("editChannelProfileField");
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
41
templates/channels/modals/add_stream.html
Normal file
41
templates/channels/modals/add_stream.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<div class="modal fade" id="addStreamModal" tabindex="-1" aria-labelledby="addStreamModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="addStreamForm" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header bg-dark text-white">
|
||||
<h5 class="modal-title" id="addStreamModalLabel">Add New Stream</h5>
|
||||
<button type="button" class="btn-close text-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Stream Details -->
|
||||
<div class="mb-3">
|
||||
<label for="newStreamNameField" class="form-label">Stream Name</label>
|
||||
<input type="text" class="form-control" id="newStreamNameField" name="stream_name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newStreamGroupField" class="form-label">Stream Group</label>
|
||||
<input type="text" class="form-control" id="newStreamGroupField" name="stream_group" placeholder="Enter stream group (optional)">
|
||||
</div>
|
||||
<!-- The Stream Profile dropdown we’ll fill via JS -->
|
||||
<div class="mb-3">
|
||||
<label for="newStreamProfileField" class="form-label">Stream Profile</label>
|
||||
<select class="form-select" id="newStreamProfileField" name="stream_profile_id">
|
||||
<!-- Will be populated by fetch("/api/core/streamprofiles/") in JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="newStreamUrlField" class="form-label">Stream URL</label>
|
||||
<input type="url" class="form-control" id="newStreamUrlField" name="url" required>
|
||||
</div>
|
||||
<!-- etc... -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Create Stream</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4,42 +4,105 @@
|
|||
<div class="modal-content">
|
||||
<form id="editChannelForm" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<!-- Hidden field for the Channel ID -->
|
||||
<input type="hidden" id="editChannelIdField" name="channel_id" value="">
|
||||
|
||||
<div class="modal-header bg-dark text-white">
|
||||
<h5 class="modal-title" id="editChannelModalLabel">Edit Channel</h5>
|
||||
<button type="button" class="btn-close text-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- Channel Details -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<!-- Channel Name -->
|
||||
<div class="mb-3">
|
||||
<label for="editChannelNameField" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="editChannelNameField" name="channel_name" required>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="editChannelNameField"
|
||||
name="channel_name"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Channel Group -->
|
||||
<div class="mb-3 d-flex align-items-center">
|
||||
<label for="editGroupField" class="form-label me-2">Channel Group</label>
|
||||
<select class="form-select" id="editGroupField" name="group" style="flex-grow: 1;">
|
||||
<!-- Dynamically populated groups -->
|
||||
<select
|
||||
class="form-select"
|
||||
id="editGroupField"
|
||||
name="group"
|
||||
style="flex-grow: 1;"
|
||||
>
|
||||
<!-- Dynamically populated groups via JS or server-side -->
|
||||
</select>
|
||||
<button type="button" class="btn btn-primary ms-2" data-bs-toggle="modal" data-bs-target="#addGroupModal">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary ms-2"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#addGroupModal"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Channel Number -->
|
||||
<div class="mb-3">
|
||||
<label for="editChannelNumberField" class="form-label">Channel #</label>
|
||||
<input type="number" class="form-control" id="editChannelNumberField" name="channel_number" min="1" required>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
id="editChannelNumberField"
|
||||
name="channel_number"
|
||||
min="1"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- NEW: Stream Profile -->
|
||||
<div class="mb-3">
|
||||
<label for="editChannelProfileField" class="form-label">Stream Profile</label>
|
||||
<select
|
||||
class="form-select"
|
||||
id="editChannelProfileField"
|
||||
name="stream_profile_id"
|
||||
>
|
||||
<!-- Options loaded from /api/core/streamprofiles/ in your JS -->
|
||||
<option value="">Select stream profile (optional)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo Upload Section -->
|
||||
<div class="col-md-6">
|
||||
<div class="text-center mb-3">
|
||||
<label class="form-label">Logo</label>
|
||||
<div class="mb-2">
|
||||
<img id="editLogoPreview" src="/static/default-logo.png" class="img-thumbnail" alt="Channel Logo" style="width: 100px; height: 100px;">
|
||||
<img
|
||||
id="editLogoPreview"
|
||||
src="/static/default-logo.png"
|
||||
class="img-thumbnail"
|
||||
alt="Channel Logo"
|
||||
style="width: 100px; height: 100px;"
|
||||
>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="file" class="form-control" id="editLogoFileField" name="logo_file">
|
||||
<button class="btn btn-secondary" type="button" id="removeEditLogoButton">Remove</button>
|
||||
<input
|
||||
type="file"
|
||||
class="form-control"
|
||||
id="editLogoFileField"
|
||||
name="logo_file"
|
||||
>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
id="removeEditLogoButton"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,42 +112,60 @@
|
|||
|
||||
<!-- Stream Management for the channel -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-uppercase">Active Streams</h6>
|
||||
<table id="editActiveStreamsTable" class="table table-sm table-bordered w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>M3U</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Dynamically populated by DataTables -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-uppercase">Available Streams</h6>
|
||||
<table id="editAvailableStreamsTable" class="table table-sm table-bordered w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>M3U</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Dynamically populated by DataTables -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-uppercase">Active Streams</h6>
|
||||
<table
|
||||
id="editActiveStreamsTable"
|
||||
class="table table-sm table-bordered w-100"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>M3U</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Dynamically populated by DataTables or JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-uppercase">Available Streams</h6>
|
||||
<table
|
||||
id="editAvailableStreamsTable"
|
||||
class="table table-sm table-bordered w-100"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>M3U</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Dynamically populated by DataTables or JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /.modal-body -->
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Save Changes</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
data-bs-dismiss="modal"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-success"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
42
templates/channels/modals/edit_stream.html
Normal file
42
templates/channels/modals/edit_stream.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<div class="modal fade" id="editStreamModal" tabindex="-1" aria-labelledby="editStreamModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="editStreamForm" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" id="editStreamIdField" name="stream_id">
|
||||
<div class="modal-header bg-dark text-white">
|
||||
<h5 class="modal-title" id="editStreamModalLabel">Edit Stream</h5>
|
||||
<button type="button" class="btn-close text-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Stream Details -->
|
||||
<div class="mb-3">
|
||||
<label for="editStreamNameField" class="form-label">Stream Name</label>
|
||||
<input type="text" class="form-control" id="editStreamNameField" name="stream_name" placeholder="Enter stream name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editStreamGroupField" class="form-label">Stream Group</label>
|
||||
<input type="text" class="form-control" id="editStreamGroupField" name="stream_group" placeholder="Enter stream group (optional)">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editStreamProfileField" class="form-label">Stream Profile</label>
|
||||
<select class="form-select" id="editStreamProfileField" name="stream_profile">
|
||||
<option value="">Select stream profile (optional)</option>
|
||||
{% for profile in stream_profiles %}
|
||||
<option value="{{ profile.id }}">{{ profile.profile_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editStreamUrlField" class="form-label">Stream URL</label>
|
||||
<input type="url" class="form-control" id="editStreamUrlField" name="stream_url" placeholder="Enter stream URL" required>
|
||||
</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-success">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Login - Dispatcharr{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center mt-5">
|
||||
|
|
@ -9,8 +10,10 @@
|
|||
<h3>Dispatcharr Login</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="loginForm" method="post" action="/accounts/login/">
|
||||
<form id="loginForm" method="post" action="{% url 'accounts:login' %}">
|
||||
{% csrf_token %}
|
||||
<!-- Pass along the next parameter -->
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" id="username" required>
|
||||
|
|
@ -29,29 +32,32 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
loginForm.addEventListener('submit', function(e){
|
||||
e.preventDefault();
|
||||
fetch(loginForm.action, {
|
||||
method: 'POST',
|
||||
body: new FormData(loginForm)
|
||||
}).then(response => {
|
||||
if(response.ok){
|
||||
window.location.href = "{% url 'core:dashboard' %}";
|
||||
} else {
|
||||
response.json().then(data => {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Login Failed',
|
||||
text: data.error || 'Invalid credentials.'
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
loginForm.addEventListener('submit', function(e){
|
||||
e.preventDefault();
|
||||
fetch(loginForm.action, {
|
||||
method: 'POST',
|
||||
body: new FormData(loginForm)
|
||||
}).then(response => {
|
||||
if(response.ok){
|
||||
// Use the hidden next value if available, otherwise default to the dashboard URL.
|
||||
const nextUrl = loginForm.querySelector('input[name="next"]').value || "{% url 'dashboard:dashboard' %}";
|
||||
window.location.href = nextUrl;
|
||||
} else {
|
||||
response.json().then(data => {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Login Failed',
|
||||
text: data.error || 'Invalid credentials.'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,20 @@
|
|||
{% block page_header %}M3U Management{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- M3U Accounts Card -->
|
||||
<!-- ================== M3U ACCOUNTS ================== -->
|
||||
<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>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -25,7 +24,8 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User Agent Management Card -->
|
||||
|
||||
<!-- ================== USER AGENTS ================== -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="card-title">User Agents</h3>
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>User Agent</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
|
|
@ -50,16 +51,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit M3U Account Modal -->
|
||||
<div class="modal fade" id="addM3UModal" tabindex="-1">
|
||||
<!-- ================== MODAL: ADD/EDIT M3U ACCOUNT ================== -->
|
||||
<div class="modal fade" id="addM3UModal" tabindex="-1" aria-labelledby="m3uModalLabel" aria-hidden="true">
|
||||
<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>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></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">
|
||||
|
|
@ -79,7 +79,14 @@
|
|||
<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 -->
|
||||
<!-- New: Active Dropdown -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Active</label>
|
||||
<select class="form-select" id="m3uActiveSelect" name="is_active" required>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">User Agent</label>
|
||||
<select class="form-select" id="m3uUserAgentSelect" name="user_agent" required>
|
||||
|
|
@ -93,7 +100,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Agent Add/Edit Modal -->
|
||||
<!-- ================== MODAL: ADD/EDIT USER AGENT ================== -->
|
||||
<div class="modal fade" id="userAgentModal" tabindex="-1" aria-labelledby="userAgentModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
|
|
@ -105,14 +112,22 @@
|
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- User Agent Name -->
|
||||
<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>
|
||||
<label for="userAgentNameField" class="form-label">User Agent Name</label>
|
||||
<input type="text" class="form-control" id="userAgentNameField" name="user_agent_name" required>
|
||||
</div>
|
||||
<!-- User Agent String -->
|
||||
<div class="mb-3">
|
||||
<label for="userAgentStringField" class="form-label">User Agent String</label>
|
||||
<input type="text" class="form-control" id="userAgentStringField" name="user_agent" required>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="mb-3">
|
||||
<label for="userAgentDescription" class="form-label">Description</label>
|
||||
<input type="text" class="form-control" id="userAgentDescription" name="description">
|
||||
</div>
|
||||
<!-- Active Dropdown -->
|
||||
<div class="mb-3">
|
||||
<label for="userAgentActive" class="form-label">Active</label>
|
||||
<select class="form-select" id="userAgentActive" name="is_active" required>
|
||||
|
|
@ -130,7 +145,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Agent Delete Modal -->
|
||||
<!-- ================== MODAL: DELETE USER AGENT ================== -->
|
||||
<div class="modal fade" id="deleteUserAgentModal" tabindex="-1" aria-labelledby="deleteUserAgentModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
|
|
@ -155,12 +170,12 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block extra_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>
|
||||
// CSRF helper function
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
|
|
@ -179,53 +194,69 @@ const csrftoken = getCookie('csrftoken');
|
|||
$.ajaxSetup({
|
||||
headers: { "X-CSRFToken": csrftoken }
|
||||
});
|
||||
|
||||
$(document).ready(function() {
|
||||
// Initialize the M3U DataTable
|
||||
var m3uTable = $('#m3uTable').DataTable({
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 1) Initialize M3U Accounts DataTable
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
const 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>';
|
||||
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>';
|
||||
return `<a href="${data.uploaded_file}" download>Download File</a>`;
|
||||
} else {
|
||||
return 'No URL or file';
|
||||
return "No URL or file";
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
data: "max_streams",
|
||||
render: function(data) {
|
||||
return data ? data : "N/A";
|
||||
}
|
||||
},
|
||||
{
|
||||
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="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>';
|
||||
render: function(data) {
|
||||
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
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 2) Function: Load User Agent Options (for M3U form)
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
function loadUserAgentOptions(selectedId) {
|
||||
fetch("/api/core/useragents/")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
let options = '<option value="">Select a User Agent</option>';
|
||||
let options = `<option value="">Select a User Agent</option>`;
|
||||
data.forEach(function(ua) {
|
||||
options += `<option value="${ua.id}">${ua.user_agent}</option>`;
|
||||
const displayText = ua.user_agent_name
|
||||
? `${ua.user_agent_name} | (${ua.user_agent})`
|
||||
: ua.user_agent;
|
||||
options += `<option value="${ua.id}">${displayText}</option>`;
|
||||
});
|
||||
$('#m3uUserAgentSelect').html(options);
|
||||
if (selectedId) {
|
||||
|
|
@ -236,25 +267,30 @@ $(document).ready(function() {
|
|||
console.error("Error loading user agents:", err);
|
||||
});
|
||||
}
|
||||
|
||||
// When the "Add M3U" button is clicked, reset the form and load user agent options
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 3) Add M3U: Open Modal
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
$('#addM3UButton').click(function() {
|
||||
$('#m3uForm')[0].reset();
|
||||
$('#m3uId').val('');
|
||||
$('#m3uModalLabel').text("Add M3U Account");
|
||||
loadUserAgentOptions();
|
||||
loadUserAgentOptions(); // No selected ID for new
|
||||
new bootstrap.Modal(document.getElementById("addM3UModal")).show();
|
||||
});
|
||||
|
||||
// Edit M3U Account
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 4) Edit M3U: Fetch and populate form
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
window.editM3U = function(id) {
|
||||
fetch("/api/m3u/accounts/" + 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);
|
||||
$('#m3uActiveSelect').val(data.is_active ? "true" : "false");
|
||||
loadUserAgentOptions(data.user_agent);
|
||||
$('#m3uModalLabel').text("Edit M3U Account");
|
||||
new bootstrap.Modal(document.getElementById("addM3UModal")).show();
|
||||
|
|
@ -264,20 +300,30 @@ $(document).ready(function() {
|
|||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
// M3U Form Submission (handles both create and update)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 5) M3U Form Submission (Create/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/";
|
||||
const m3uId = $('#m3uId').val();
|
||||
const formData = new FormData(this);
|
||||
const method = m3uId ? "PUT" : "POST";
|
||||
const url = m3uId
|
||||
? `/api/m3u/accounts/${m3uId}/`
|
||||
: "/api/m3u/accounts/";
|
||||
|
||||
// Include CSRF header in the fetch request
|
||||
fetch(url, {
|
||||
method: method,
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
}).then(response => {
|
||||
if(response.ok) {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
"X-CSRFToken": getCookie('csrftoken')
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById("addM3UModal")).hide();
|
||||
Swal.fire("Success", "M3U Account saved successfully!", "success");
|
||||
m3uTable.ajax.reload();
|
||||
|
|
@ -285,41 +331,52 @@ $(document).ready(function() {
|
|||
} else {
|
||||
throw new Error("Failed to save M3U account.");
|
||||
}
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch(error => {
|
||||
Swal.fire("Error", error.message, "error");
|
||||
console.error("Error:", error);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete M3U Account
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 6) Delete M3U Account (using fetch with CSRF header)
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
window.deleteM3U = function(id) {
|
||||
Swal.fire({
|
||||
title: "Are you sure?",
|
||||
text: "You won't be able to revert this!",
|
||||
text: "This action cannot be undone!",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#d33",
|
||||
confirmButtonText: "Yes, delete it!"
|
||||
}).then((result) => {
|
||||
if(result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: `/api/m3u/accounts/${id}/`,
|
||||
if (result.isConfirmed) {
|
||||
fetch(`/api/m3u/accounts/${id}/`, {
|
||||
method: "DELETE",
|
||||
success: function() {
|
||||
headers: { "X-CSRFToken": getCookie('csrftoken') },
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
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");
|
||||
} else {
|
||||
throw new Error("Failed to delete M3U account.");
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
Swal.fire("Error", error.message, "error");
|
||||
console.error("Error:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Refresh M3U Account
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 7) Refresh M3U Account
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
window.refreshM3U = function(id) {
|
||||
$.ajax({
|
||||
url: `/m3u/${id}/refresh/`,
|
||||
|
|
@ -335,55 +392,71 @@ $(document).ready(function() {
|
|||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize User Agent DataTable
|
||||
var userAgentTable = $('#userAgentTable').DataTable({
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 8) Initialize User Agent DataTable
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
const userAgentTable = $('#userAgentTable').DataTable({
|
||||
ajax: {
|
||||
url: "/api/core/useragents/",
|
||||
dataSrc: ""
|
||||
},
|
||||
columns: [
|
||||
{ data: "id" },
|
||||
{ data: "user_agent_name" },
|
||||
{ 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>';
|
||||
render: function(data) {
|
||||
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
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 9) 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
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 10) User Agent Form Submission (Create/Update)
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
$('#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);
|
||||
const id = $('#userAgentId').val();
|
||||
const method = id ? "PUT" : "POST";
|
||||
const url = id
|
||||
? `/api/core/useragents/${id}/`
|
||||
: "/api/core/useragents/";
|
||||
const formData = new FormData(this);
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
}).then(response => {
|
||||
if(response.ok){
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
"X-CSRFToken": getCookie('csrftoken')
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById("userAgentModal")).hide();
|
||||
Swal.fire("Success", "User Agent saved successfully!", "success");
|
||||
userAgentTable.ajax.reload();
|
||||
|
|
@ -391,41 +464,50 @@ $(document).ready(function() {
|
|||
} else {
|
||||
throw new Error("Failed to save User Agent.");
|
||||
}
|
||||
}).catch(error => {
|
||||
})
|
||||
.catch(error => {
|
||||
Swal.fire("Error", error.message, "error");
|
||||
console.error("Error:", error);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete User Agent Form Submission
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 11) Delete User Agent (with CSRF header)
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
$('#deleteUserAgentForm').submit(function(e){
|
||||
e.preventDefault();
|
||||
var id = $('#deleteUserAgentId').val();
|
||||
fetch("/api/core/useragents/" + id + "/", {
|
||||
const id = $('#deleteUserAgentId').val();
|
||||
fetch(`/api/core/useragents/${id}/`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-CSRFToken": getCookie('csrftoken') },
|
||||
credentials: 'same-origin'
|
||||
}).then(response => {
|
||||
if(response.ok){
|
||||
})
|
||||
.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 => {
|
||||
})
|
||||
.catch(error => {
|
||||
Swal.fire("Error", error.message, "error");
|
||||
console.error("Error:", error);
|
||||
});
|
||||
});
|
||||
|
||||
// Edit User Agent function
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 12) Edit User Agent
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
window.editUserAgent = function(id) {
|
||||
fetch("/api/core/useragents/" + 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);
|
||||
$('#userAgentNameField').val(data.user_agent_name || "");
|
||||
$('#userAgentStringField').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();
|
||||
|
|
@ -435,8 +517,10 @@ $(document).ready(function() {
|
|||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
// Delete User Agent function (opens delete modal)
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// 13) Delete User Agent: Open Modal
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
window.deleteUserAgent = function(id) {
|
||||
$('#deleteUserAgentId').val(id);
|
||||
new bootstrap.Modal(document.getElementById("deleteUserAgentModal")).show();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue