Pre Alpha v4

Added login page
Added login/logout buttons
Added m3u settings
Added stream profiles to channels and streams
Added ability to add individual stream outside of m3u file
Added ability to add youtube live links as a stream source
Deleted migrations
Deleted pycache folders
Changed HDHR to get ip dynamically
This commit is contained in:
Dispatcharr 2025-02-22 20:43:55 -06:00
parent 6b857d3931
commit 9da7adda92
114 changed files with 702 additions and 825 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
.DS_Store
__pycache__/
**/__pycache__/
*.pyc

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

Some files were not shown because too many files have changed in this diff Show more