This commit is contained in:
SergeantPanda 2025-02-22 21:03:38 -06:00
commit e8d7e80f8e
22 changed files with 699 additions and 824 deletions

View file

@ -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
View 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'),
]

View file

@ -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')),
],
),
]

View file

@ -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"

View file

@ -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',
]

View file

@ -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')

View file

@ -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)),
],
),
]

View file

@ -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')),
],
),
]

View file

@ -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)),
],
),
]

View file

@ -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 hosts 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}.")

View file

@ -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'),
),
]

View file

@ -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',
),
]

View file

View 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')),

View file

@ -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:

View file

@ -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"

View file

@ -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” & autofill 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 its 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 reinitialized
}, 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 autoassigning channels.", "error");
}
error: function () { Swal.fire("Auto Assign Error", "An error occurred autoassigning 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 channels 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 %}

View 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 well 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>

View file

@ -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>

View 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>

View file

@ -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 %}

View file

@ -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();