Pre Alpha 2

Added FFMPEG
Removed "is_active"
Added m3u output
This commit is contained in:
Dispatcharr 2025-02-19 16:55:23 -06:00
parent 7ae7dbe175
commit 3978e60ce9
18 changed files with 564 additions and 44 deletions

View file

@ -14,10 +14,9 @@ class StreamAdmin(admin.ModelAdmin):
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
list_display = (
'channel_number', 'channel_name', 'channel_group',
'is_active', 'is_looping', 'shuffle_mode', 'tvg_name'
'channel_number', 'channel_name', 'channel_group', 'tvg_name'
)
list_filter = ('channel_group', 'is_active', 'is_looping', 'shuffle_mode')
list_filter = ('channel_group',)
search_fields = ('channel_name', 'channel_group__name', 'tvg_name')
ordering = ('channel_number',)

View file

@ -39,7 +39,7 @@ class Stream(models.Model):
class ChannelManager(models.Manager):
def active(self):
return self.filter(is_active=True)
return self.all()
class Channel(models.Model):
@ -69,10 +69,6 @@ class Channel(models.Model):
)
tvg_id = models.CharField(max_length=255, blank=True, null=True)
tvg_name = models.CharField(max_length=255, blank=True, 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.")
objects = ChannelManager()
def clean(self):

View file

@ -61,8 +61,5 @@ class ChannelSerializer(serializers.ModelSerializer):
'channel_group_id',
'tvg_id',
'tvg_name',
'is_active',
'is_looping',
'shuffle_mode',
'streams'
]

0
apps/ffmpeg/__init__.py Normal file
View file

6
apps/ffmpeg/apps.py Normal file
View file

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

9
apps/ffmpeg/urls.py Normal file
View file

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

74
apps/ffmpeg/views.py Normal file
View file

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

19
apps/hdhr/urls.py Normal file
View file

@ -0,0 +1,19 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import HDHRDeviceViewSet, DiscoverAPIView, LineupAPIView, LineupStatusAPIView, HDHRDeviceXMLAPIView, hdhr_dashboard_view
app_name = 'hdhr'
router = DefaultRouter()
router.register(r'devices', HDHRDeviceViewSet, basename='hdhr-device')
urlpatterns = [
path('dashboard/', hdhr_dashboard_view, name='hdhr_dashboard'),
path('', hdhr_dashboard_view, name='hdhr_dashboard'),
path('discover.json', DiscoverAPIView.as_view(), name='discover'),
path('lineup.json', LineupAPIView.as_view(), name='lineup'),
path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status'),
path('device.xml', HDHRDeviceXMLAPIView.as_view(), name='device_xml'),
]
urlpatterns += router.urls

132
apps/hdhr/views.py Normal file
View file

@ -0,0 +1,132 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel
from .models import HDHRDevice
from .serializers import HDHRDeviceSerializer
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.views import View
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
@login_required
def hdhr_dashboard_view(request):
"""Render the HDHR management page."""
hdhr_devices = HDHRDevice.objects.all()
return render(request, "hdhr/hdhr.html", {"hdhr_devices": hdhr_devices})
# 🔹 1) HDHomeRun Device API
class HDHRDeviceViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for HDHomeRun devices"""
queryset = HDHRDevice.objects.all()
serializer_class = HDHRDeviceSerializer
permission_classes = [IsAuthenticated]
# 🔹 2) Discover API
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
@swagger_auto_schema(
operation_description="Retrieve HDHomeRun device discovery information",
responses={200: openapi.Response("HDHR Discovery JSON")}
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
device = HDHRDevice.objects.first()
if not device:
data = {
"FriendlyName": "Dispatcharr HDHomeRun",
"ModelNumber": "HDTC-2US",
"FirmwareName": "hdhomerun3_atsc",
"FirmwareVersion": "20200101",
"DeviceID": "12345678",
"DeviceAuth": "test_auth_token",
"BaseURL": base_url,
"LineupURL": f"{base_url}/lineup.json",
}
else:
data = {
"FriendlyName": device.friendly_name,
"ModelNumber": "HDTC-2US",
"FirmwareName": "hdhomerun3_atsc",
"FirmwareVersion": "20200101",
"DeviceID": device.device_id,
"DeviceAuth": "test_auth_token",
"BaseURL": base_url,
"LineupURL": f"{base_url}/lineup.json",
}
return JsonResponse(data)
# 🔹 3) Lineup API
class LineupAPIView(APIView):
"""Returns available channel lineup"""
@swagger_auto_schema(
operation_description="Retrieve the available channel lineup",
responses={200: openapi.Response("Channel Lineup JSON")}
)
def get(self, request):
channels = Channel.objects.filter(is_active=True).order_by('channel_number')
lineup = [
{
"GuideNumber": str(ch.channel_number),
"GuideName": ch.channel_name,
"URL": request.build_absolute_uri(f"/player/stream/{ch.id}")
}
for ch in channels
]
return JsonResponse(lineup, safe=False)
# 🔹 4) Lineup Status API
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun lineup status",
responses={200: openapi.Response("Lineup Status JSON")}
)
def get(self, request):
data = {
"ScanInProgress": 0,
"ScanPossible": 0,
"Source": "Cable",
"SourceList": ["Cable"]
}
return JsonResponse(data)
# 🔹 5) Device XML API
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun device XML configuration",
responses={200: openapi.Response("HDHR Device XML")}
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<root>
<DeviceID>12345678</DeviceID>
<FriendlyName>Dispatcharr HDHomeRun</FriendlyName>
<ModelNumber>HDTC-2US</ModelNumber>
<FirmwareName>hdhomerun3_atsc</FirmwareName>
<FirmwareVersion>20200101</FirmwareVersion>
<DeviceAuth>test_auth_token</DeviceAuth>
<BaseURL>{base_url}</BaseURL>
<LineupURL>{base_url}/lineup.json</LineupURL>
</root>"""
return HttpResponse(xml_response, content_type="application/xml")

0
apps/output/__init__.py Normal file
View file

6
apps/output/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class OutputConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.output'
verbose_name = "Output"

16
apps/output/tests.py Normal file
View file

@ -0,0 +1,16 @@
from django.test import TestCase, Client
from django.urls import reverse
class OutputM3UTest(TestCase):
def setUp(self):
self.client = Client()
def test_generate_m3u_response(self):
"""
Test that the M3U endpoint returns a valid M3U file.
"""
url = reverse('output:generate_m3u')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
self.assertIn("#EXTM3U", content)

9
apps/output/urls.py Normal file
View file

@ -0,0 +1,9 @@
from django.urls import path, include
from .views import generate_m3u
app_name = 'output'
urlpatterns = [
path('m3u/', generate_m3u, name='generate_m3u'),
path('stream/', include(('apps.ffmpeg.urls', 'ffmpeg'), namespace='ffmpeg')),
]

32
apps/output/views.py Normal file
View file

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

View file

@ -16,6 +16,7 @@ INSTALLED_APPS = [
'apps.epg',
'apps.hdhr',
'apps.m3u',
'apps.output',
'drf_yasg',
'django.contrib.admin',
'django.contrib.auth',

View file

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

View file

@ -408,7 +408,7 @@ $(document).ready(function () {
// gather “active” streams
const activeRows = newActiveStreamsTable.rows().data().toArray();
activeRows.forEach((s) => {
formData.append("streams[]", s.id);
formData.append("streams", s.id);
});
$.ajax({
@ -649,7 +649,7 @@ $(document).ready(function () {
// gather active streams
const activeRows = editActiveStreamsTable.rows().data().toArray();
activeRows.forEach((s) => {
formData.append("streams[]", s.id);
formData.append("streams", s.id);
});
$.ajax({

View file

@ -1,13 +1,21 @@
{% extends "base.html" %}
{% load static %}
{% block title %}EPG Management - Dispatcharr{% endblock %}
{% block page_header %}EPG Management{% endblock %}
{% block content %}
{% block extra_head %}
<!-- Include DataTables CSS (adjust path if needed) -->
<link rel="stylesheet" href="{% static 'css/datatables.min.css' %}">
{% endblock %}
{% block content %}
<div class="container">
<div class="card">
<div class="card my-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">EPG Sources</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addEPGModal">Add EPG</button>
<div>
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#addEPGModal">Add EPG</button>
<button class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#uploadEPGModal">Upload EPG File</button>
</div>
</div>
<div class="card-body">
<table id="epgTable" class="table table-striped">
@ -20,51 +28,265 @@
</tr>
</thead>
<tbody>
{% for epg in epg_sources %}
<tr>
<td>{{ epg.name }}</td>
<td>{{ epg.source_type }}</td>
<td>{{ epg.url|default:epg.api_key }}</td>
<td>
<button class="btn btn-sm btn-warning" onclick="editEPG({{ epg.id }})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteEPG({{ epg.id }})">Delete</button>
<button class="btn btn-sm btn-info" onclick="refreshEPG({{ epg.id }})">Refresh</button>
</td>
</tr>
{% endfor %}
<!-- DataTables will populate the table body -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Add EPG Modal -->
<div class="modal fade" id="addEPGModal" tabindex="-1">
<!-- Add EPG Source Modal -->
<div class="modal fade" id="addEPGModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add EPG Source</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="epgForm">
<form id="epgForm">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Add EPG Source</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" id="epgName" required>
<label for="epgName" class="form-label">Name</label>
<input type="text" class="form-control" id="epgName" name="name" required>
</div>
<div class="mb-3">
<label class="form-label">EPG URL</label>
<input type="url" class="form-control" id="epgURL">
<label for="epgURL" class="form-label">EPG URL</label>
<input type="url" class="form-control" id="epgURL" name="url">
</div>
<div class="mb-3">
<label class="form-label">API Key</label>
<input type="text" class="form-control" id="epgAPIKey">
<label for="epgAPIKey" class="form-label">API Key</label>
<input type="text" class="form-control" id="epgAPIKey" name="api_key">
</div>
<div class="mb-3">
<label for="epgSourceType" class="form-label">Source Type</label>
<select class="form-select" id="epgSourceType" name="source_type" required>
<option value="xmltv">XMLTV</option>
<option value="schedules_direct">Schedules Direct</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-success">Save</button>
</form>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Upload EPG File Modal -->
<div class="modal fade" id="uploadEPGModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="uploadEPGForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Upload EPG File</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="epgUploadName" class="form-label">Name</label>
<input type="text" class="form-control" id="epgUploadName" name="name" required>
</div>
<div class="mb-3">
<label for="epgFile" class="form-label">EPG File</label>
<input type="file" class="form-control" id="epgFile" name="epg_file" accept=".xml,.m3u,.txt" required>
</div>
<!-- Optionally include a source type if needed -->
<div class="mb-3">
<label for="epgUploadSourceType" class="form-label">Source Type</label>
<select class="form-select" id="epgUploadSourceType" name="source_type" required>
<option value="xmltv">XMLTV</option>
<option value="schedules_direct">Schedules Direct</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-success">Upload</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_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 token helper from Django docs
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
// Setup AJAX to include CSRF token
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^http:.*/.test(settings.url) && !/^https:.*/.test(settings.url)) {
xhr.setRequestHeader("X-CSRFToken", csrftoken);
}
}
});
// Initialize DataTable and load EPG sources from API
let epgTable;
$(document).ready(function() {
epgTable = $('#epgTable').DataTable({
ajax: {
url: '/api/epg/sources/', // adjust if needed
dataSrc: ''
},
columns: [
{ data: 'name' },
{ data: 'source_type' },
{
data: null,
render: function(data, type, row) {
return row.url ? row.url : row.api_key;
}
},
{
data: null,
render: function(data, type, row) {
return `
<button class="btn btn-sm btn-warning me-1" onclick="editEPG(${row.id})">Edit</button>
<button class="btn btn-sm btn-danger me-1" onclick="deleteEPG(${row.id})">Delete</button>
<button class="btn btn-sm btn-info" onclick="refreshEPG(${row.id})">Refresh</button>
`;
}
}
]
});
// Handle form submission for adding new EPG source
$('#epgForm').on('submit', function(e) {
e.preventDefault();
const formData = {
name: $('#epgName').val(),
url: $('#epgURL').val(),
api_key: $('#epgAPIKey').val(),
source_type: $('#epgSourceType').val()
};
$.ajax({
url: '/api/epg/sources/',
type: 'POST',
data: JSON.stringify(formData),
contentType: 'application/json',
success: function(response) {
$('#addEPGModal').modal('hide');
$('#epgForm')[0].reset();
epgTable.ajax.reload();
},
error: function(xhr) {
alert('Error adding EPG Source: ' + xhr.responseText);
}
});
});
// Handle EPG file upload form submission using FormData
$('#uploadEPGForm').on('submit', function(e) {
e.preventDefault();
const uploadForm = document.getElementById('uploadEPGForm');
const formData = new FormData(uploadForm);
$.ajax({
url: '/api/epg/upload/', // adjust the endpoint URL if necessary
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(response) {
$('#uploadEPGModal').modal('hide');
$('#uploadEPGForm')[0].reset();
epgTable.ajax.reload();
alert('EPG file uploaded successfully.');
},
error: function(xhr) {
alert('Error uploading EPG file: ' + xhr.responseText);
}
});
});
});
// Edit function (example using prompt; consider a modal for better UX)
function editEPG(id) {
$.ajax({
url: `/api/epg/sources/${id}/`,
type: 'GET',
success: function(data) {
const newName = prompt("Edit EPG Name:", data.name);
if (newName !== null) {
data.name = newName;
$.ajax({
url: `/api/epg/sources/${id}/`,
type: 'PUT',
data: JSON.stringify(data),
contentType: 'application/json',
success: function(updatedData) {
epgTable.ajax.reload();
},
error: function(xhr) {
alert('Error updating EPG Source: ' + xhr.responseText);
}
});
}
},
error: function(xhr) {
alert('Error fetching EPG Source data: ' + xhr.responseText);
}
});
}
// Delete function
function deleteEPG(id) {
if (confirm("Are you sure you want to delete this EPG Source?")) {
$.ajax({
url: `/api/epg/sources/${id}/`,
type: 'DELETE',
success: function(response) {
epgTable.ajax.reload();
},
error: function(xhr) {
alert('Error deleting EPG Source: ' + xhr.responseText);
}
});
}
}
// Refresh function triggers global import endpoint (adjust if you have per-source refresh)
function refreshEPG(id) {
if (confirm("Trigger EPG refresh for this source?")) {
$.ajax({
url: '/api/epg/import/',
type: 'POST',
data: JSON.stringify({ id: id }),
contentType: 'application/json',
success: function(response) {
alert('EPG refresh initiated.');
},
error: function(xhr) {
alert('Error refreshing EPG: ' + xhr.responseText);
}
});
}
}
</script>
{% endblock %}