Dispatcharr/FileTree.txt
2025-02-18 11:14:09 -06:00

6348 lines
240 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

├── ./
│ ├── requirements.txt
│ └── manage.py
│ ├── docker/
│ │ ├── requirements.txt
│ │ ├── Dockerfile
│ │ └── docker-compose.yml
│ ├── dispatcharr/
│ │ ├── asgi.py
│ │ ├── utils.py
│ │ ├── celery.py
│ │ ├── settings.py
│ │ ├── urls.py
│ │ └── wsgi.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── ffmpeg.html
│ │ ├── login.html
│ │ └── settings.html
│ │ ├── m3u/
│ │ │ └── m3u.html
│ │ ├── admin/
│ │ │ └── base.htmlold.html
│ │ ├── dashboard/
│ │ │ └── dashboard.html
│ │ ├── epg/
│ │ │ └── epg.html
│ │ ├── hdhr/
│ │ │ └── hdhr.html
│ │ ├── channels/
│ │ │ └── channels.html
│ │ │ ├── modals/
│ │ │ │ ├── create_channel_from_stream.html
│ │ │ │ ├── delete_channel.html
│ │ │ │ ├── edit_channel.html
│ │ │ │ ├── add_channel.html
│ │ │ │ ├── refresh.html
│ │ │ │ ├── edit_m3u.html
│ │ │ │ ├── delete_m3u.html
│ │ │ │ ├── add_m3u.html
│ │ │ │ ├── edit_logo.html
│ │ │ │ ├── backup.html
│ │ │ │ ├── delete_stream.html
│ │ │ │ ├── restore.html
│ │ │ │ └── add_group.html
│ ├── apps/
│ │ ├── m3u/
│ │ │ ├── signals.py
│ │ │ ├── tasks.py
│ │ │ ├── models.py
│ │ │ ├── serializers.py
│ │ │ ├── api_urls.py
│ │ │ ├── apps.py
│ │ │ ├── forms.py
│ │ │ ├── api_views.py
│ │ │ ├── admin.py
│ │ │ ├── utils.py
│ │ │ ├── urls.py
│ │ │ └── views.py
│ │ ├── dashboard/
│ │ │ ├── models.py
│ │ │ ├── api_urls.py
│ │ │ ├── apps.py
│ │ │ ├── admin.py
│ │ │ ├── tests.py
│ │ │ ├── urls.py
│ │ │ └── views.py
│ │ ├── accounts/
│ │ │ ├── signals.py
│ │ │ ├── models.py
│ │ │ ├── serializers.py
│ │ │ ├── api_urls.py
│ │ │ ├── apps.py
│ │ │ ├── forms.py
│ │ │ ├── api_views.py
│ │ │ └── admin.py
│ │ ├── epg/
│ │ │ ├── tasks.py
│ │ │ ├── models.py
│ │ │ ├── serializers.py
│ │ │ ├── api_urls.py
│ │ │ ├── apps.py
│ │ │ ├── api_views.py
│ │ │ ├── admin.py
│ │ │ ├── urls.py
│ │ │ └── views.py
│ │ ├── api/
│ │ │ └── urls.py
│ │ ├── hdhr/
│ │ │ ├── models.py
│ │ │ ├── serializers.py
│ │ │ ├── api_urls.py
│ │ │ ├── apps.py
│ │ │ ├── api_views.py
│ │ │ ├── admin.py
│ │ │ └── ssdp.py
│ │ ├── outputs/
│ │ ├── channels/
│ │ │ ├── models.py
│ │ │ ├── serializers.py
│ │ │ ├── api_urls.py
│ │ │ ├── apps.py
│ │ │ ├── forms.py
│ │ │ ├── api_views.py
│ │ │ ├── admin.py
│ │ │ ├── utils.py
│ │ │ ├── urls.py
│ │ │ └── views.py
│ │ │ ├── management/
│ │ │ │ ├── commands/
│ │ │ │ │ └── remove_duplicates.py
┌───────────────────────────────────────────────┐
│ Directory: │
│ File: requirements.txt │
└───────────────────────────────────────────────┘
Django==4.2.2
gunicorn==20.1.0
psycopg2-binary==2.9.6
redis==4.5.5
# Optional for tasks:
celery==5.2.7
# Optional for DRF:
djangorestframework==3.14.0
# For 2FA:
django-two-factor-auth==1.14.0
django-otp==1.2.0
phonenumbers==8.13.13
requests==2.31.0
django-adminlte3
psutil==5.9.7
pillow
drf-yasg>=1.20.0
========= END OF FILE =========
File: requirements.txt
===============================
┌───────────────────────────────────────────────┐
│ Directory: │
│ File: manage.py │
└───────────────────────────────────────────────┘
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Make sure it's installed and "
"available on your PYTHONPATH environment variable."
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
========= END OF FILE =========
File: manage.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: docker │
│ File: requirements.txt │
└───────────────────────────────────────────────┘
Django==4.2.2
gunicorn==20.1.0
psycopg2-binary==2.9.6
redis==4.5.5
# Optional for tasks:
celery==5.2.7
# Optional for DRF:
djangorestframework==3.14.0
# For 2FA:
django-two-factor-auth==1.14.0
django-otp==1.2.0
phonenumbers==8.13.13
requests==2.31.0
django-adminlte3
psutil==5.9.7
pillow
========= END OF FILE =========
File: requirements.txt
===============================
┌───────────────────────────────────────────────┐
│ Directory: docker │
│ File: Dockerfile │
└───────────────────────────────────────────────┘
FROM python:3.10-slim
# Install required packages
RUN apt-get update && apt-get install -y \
ffmpeg \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Install Python dependencies
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install gevent # Install gevent for async workers with Gunicorn
# Copy application files
COPY . /app/
# Set environment variables
ENV DJANGO_SETTINGS_MODULE=dispatcharr.settings
ENV PYTHONUNBUFFERED=1
# Run Django commands
RUN python manage.py collectstatic --noinput || true
RUN python manage.py migrate --noinput || true
# Expose the port
EXPOSE 8000
# Command to run the application
CMD ["gunicorn", "--workers=4", "--worker-class=gevent", "--timeout=300", "--bind", "0.0.0.0:8000", "dispatcharr.wsgi:application"]
========= END OF FILE =========
File: Dockerfile
===============================
┌───────────────────────────────────────────────┐
│ Directory: docker │
│ File: docker-compose.yml │
└───────────────────────────────────────────────┘
services:
web:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: dispatcharr_web
ports:
- "9191:8000"
depends_on:
- db
- redis
volumes:
- ../:/app
environment:
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
- REDIS_HOST=redis
- CELERY_BROKER_URL=redis://redis:6379/0
celery:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: dispatcharr_celery
depends_on:
- db
- redis
volumes:
- ../:/app
environment:
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
- REDIS_HOST=redis
- CELERY_BROKER_URL=redis://redis:6379/0
command: >
bash -c "
cd /app &&
celery -A dispatcharr worker -l info
"
db:
image: postgres:14
container_name: dispatcharr_db
environment:
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
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:
========= END OF FILE =========
File: docker-compose.yml
===============================
┌───────────────────────────────────────────────┐
│ Directory: dispatcharr │
│ File: asgi.py │
└───────────────────────────────────────────────┘
"""
ASGI config for dispatcharr project.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
application = get_asgi_application()
========= END OF FILE =========
File: asgi.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: dispatcharr │
│ File: utils.py │
└───────────────────────────────────────────────┘
# dispatcharr/utils.py
from django.http import JsonResponse
from django.core.exceptions import ValidationError
def json_error_response(message, status=400):
"""Return a standardized error JSON response."""
return JsonResponse({'success': False, 'error': message}, status=status)
def json_success_response(data=None, status=200):
"""Return a standardized success JSON response."""
response = {'success': True}
if data is not None:
response.update(data)
return JsonResponse(response, status=status)
def validate_logo_file(file):
"""Validate uploaded logo file size and MIME type."""
valid_mime_types = ['image/jpeg', 'image/png', 'image/gif']
if file.content_type not in valid_mime_types:
raise ValidationError('Unsupported file type. Allowed types: JPEG, PNG, GIF.')
if file.size > 2 * 1024 * 1024:
raise ValidationError('File too large. Max 2MB.')
========= END OF FILE =========
File: utils.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: dispatcharr │
│ File: celery.py │
└───────────────────────────────────────────────┘
# dispatcharr/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
app = Celery("dispatcharr")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
========= END OF FILE =========
File: celery.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: dispatcharr │
│ File: settings.py │
└───────────────────────────────────────────────┘
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'REPLACE_ME_WITH_A_REAL_SECRET'
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
'apps.api',
'apps.accounts',
'apps.channels',
'apps.dashboard',
'apps.epg',
'apps.hdhr',
'apps.m3u',
'drf_yasg',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django_otp.middleware.OTPMiddleware', # Correct OTP Middleware
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'dispatcharr.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'dispatcharr.wsgi.application'
ASGI_APPLICATION = 'dispatcharr.asgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('POSTGRES_DB', 'dispatcharr'),
'USER': os.environ.get('POSTGRES_USER', 'dispatch'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'secret'),
'HOST': 'db',
'PORT': 5432,
}
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
]
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
}
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = '/m3u_uploads/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'm3u_uploads')
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'accounts.User'
# Celery
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_RESULT_BACKEND = CELERY_BROKER_URL
# django-two-factor-auth (example)
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/'
MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = '/media/'
SERVER_IP = "10.0.0.107"
========= END OF FILE =========
File: settings.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: dispatcharr │
│ File: urls.py │
└───────────────────────────────────────────────┘
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import RedirectView
from rest_framework import permissions
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
# Define schema_view for Swagger
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
default_version='v1',
description="API documentation for Dispatcharr",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="contact@dispatcharr.local"),
license=openapi.License(name="Unlicense"),
),
public=True,
permission_classes=(permissions.AllowAny,),
)
urlpatterns = [
path('', RedirectView.as_view(pattern_name='dashboard:dashboard'), name='home'),
path('api/', include(('apps.api.urls', 'api'), namespace='api')),
path('admin/', admin.site.urls),
#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')),
path('epg/', include(('apps.epg.urls', 'epg'), namespace='epg')),
path('channels/', include(('apps.channels.urls', 'channels'), namespace='channels')),
#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')),
# Swagger UI:
path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
# ReDoc UI:
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
# Optionally, you can also serve the raw JSON:
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(
settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT
)
========= END OF FILE =========
File: urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: dispatcharr │
│ File: wsgi.py │
└───────────────────────────────────────────────┘
"""
WSGI config for dispatcharr project.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
application = get_wsgi_application()
========= END OF FILE =========
File: wsgi.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates │
│ File: base.html │
└───────────────────────────────────────────────┘
{% load static %}
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<title>{% block title %}Dispatcharr{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Fonts -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/source-sans-3@5.0.12/index.css" crossorigin="anonymous" />
<!-- Third Party Plugins -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/overlayscrollbars@2.10.1/styles/overlayscrollbars.min.css" crossorigin="anonymous" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" crossorigin="anonymous" />
<!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- AdminLTE CSS -->
<link rel="stylesheet" href="{% static 'admin-lte/dist/css/adminlte.css' %}" />
<!-- ApexCharts and jsVectorMap CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/apexcharts@3.37.1/dist/apexcharts.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsvectormap@1.5.3/dist/css/jsvectormap.min.css" crossorigin="anonymous" />
{% block extra_css %}{% endblock %}
</head>
<body class="layout-fixed sidebar-expand-lg bg-body-tertiary">
<div class="app-wrapper">
<!-- Header / Navbar -->
<nav class="app-header navbar navbar-expand bg-body">
<div class="container-fluid">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-lte-toggle="sidebar" href="#" role="button">
<i class="bi bi-list"></i>
</a>
</li>
<li class="nav-item d-none d-md-block">
<a href="{% url 'core:dashboard' %}" class="nav-link">Home</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="/accounts/login/">Login</a>
</li>
<!-- Theme Switcher Dropdown -->
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="themeToggleBtn" type="button" aria-expanded="false"
data-bs-toggle="dropdown" data-bs-display="static">
<span class="theme-icon-active"><i class="bi bi-sun-fill my-1"></i></span>
<span class="d-lg-none ms-2" id="theme-toggle-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="themeToggleBtn" style="--bs-dropdown-min-width: 8rem;">
<li>
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="light">
<i class="bi bi-sun-fill me-2"></i> Light
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
<i class="bi bi-moon-fill me-2"></i> Dark
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="auto">
<i class="bi bi-circle-half me-2"></i> Auto
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
</ul>
</li>
</ul>
</div>
</nav>
<!-- Sidebar -->
<aside class="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
<div class="sidebar-brand">
<a href="{% url 'dashboard:dashboard' %}" class="brand-link">
<img src="{% static 'admin-lte/dist/assets/img/logo.png' %}" alt="Dispatcharr Logo" class="brand-image opacity-75 shadow" />
<span class="brand-text fw-light">Dispatcharr</span>
</a>
</div>
<div class="sidebar-wrapper">
<nav class="mt-2">
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" role="menu" data-accordion="false">
<li class="nav-item">
<a href="{% url 'dashboard:dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-speedometer"></i>
<p>Dashboard</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'channels:streams_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-tv"></i>
<p>Channels</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'm3u:m3u_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-file-earmark-text"></i>
<p>M3U</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'epg:epg_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-calendar3"></i>
<p>EPG</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'core:settings' %}" class="nav-link">
<i class="nav-icon bi bi-gear"></i>
<p>Settings</p>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<!-- Main Content -->
<main class="app-main">
<div class="app-content">
<div class="container-fluid">
{% block content %}{% endblock %}
</div>
</div>
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="float-end d-none d-sm-inline">Anything you want</div>
<strong>&copy; {{ current_year|default:"2025" }} Dispatcharr.</strong> All rights reserved.
</footer>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/overlayscrollbars@2.10.1/browser/overlayscrollbars.browser.es6.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"></script>
<script src="{% static 'admin-lte/dist/js/adminlte.js' %}"></script>
{% block extra_js %}{% endblock %}
<!-- AdminLTE 4 Theme Toggle -->
<script>
(() => {
"use strict";
const storedTheme = localStorage.getItem("theme");
const getPreferredTheme = () => storedTheme || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const setTheme = (theme) => {
document.documentElement.setAttribute("data-bs-theme", theme);
};
setTheme(getPreferredTheme());
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-bs-theme-value]").forEach(button => {
button.addEventListener("click", () => {
const theme = button.getAttribute("data-bs-theme-value");
localStorage.setItem("theme", theme);
setTheme(theme);
});
});
});
})();
</script>
</body>
</html>
========= END OF FILE =========
File: base.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates │
│ File: ffmpeg.html │
└───────────────────────────────────────────────┘
{% extends "base.html" %}
{% block title %}Settings - Dispatcharr{% endblock %}
{% block page_header %}Settings{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{% url 'dashboard' %}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Settings</li>
{% endblock %}
{% block content %}
<form id="settingsForm">
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">Schedule Direct Settings</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="schedulesDirectUsername" class="form-label">Schedules Direct Username</label>
<input type="text" class="form-control" id="schedulesDirectUsername" name="schedules_direct_username" required>
</div>
<div class="mb-3">
<label for="schedulesDirectPassword" class="form-label">Schedules Direct Password</label>
<input type="password" class="form-control" id="schedulesDirectPassword" name="schedules_direct_password" required>
</div>
<div class="mb-3">
<label for="schedulesDirectAPIKey" class="form-label">Schedules Direct API Key</label>
<input type="text" class="form-control" id="schedulesDirectAPIKey" name="schedules_direct_api_key">
</div>
<div class="mb-3">
<label for="schedulesDirectUpdateFrequency" class="form-label">Update Frequency</label>
<select class="form-select" id="schedulesDirectUpdateFrequency" name="schedules_direct_update_frequency">
<option value="daily">Daily</option>
<option value="12h">Every 12 Hours</option>
</select>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h3 class="card-title">FFmpeg Settings</h3>
</div>
<div class="card-body">
<div class="mb-3">
<label for="ffmpegPath" class="form-label">FFmpeg Path</label>
<input type="text" class="form-control" id="ffmpegPath" name="ffmpeg_path" required>
</div>
<div class="mb-3">
<label for="customTranscodingFlags" class="form-label">Custom Transcoding Flags</label>
<textarea class="form-control" id="customTranscodingFlags" name="custom_transcoding_flags"></textarea>
</div>
</div>
</div>
<div class="card">
<div class="card-footer">
<button type="submit" class="btn btn-success">Save Settings</button>
</div>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function(){
document.getElementById("settingsForm").addEventListener("submit", function(e){
e.preventDefault();
fetch("{% url 'api:settings-update' %}", {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded"},
body: new URLSearchParams(new FormData(this))
}).then(response => {
if(response.ok){
Swal.fire("Success", "Settings updated!", "success");
} else {
Swal.fire("Error", "Failed to update settings.", "error");
}
});
});
});
</script>
{% endblock %}
========= END OF FILE =========
File: ffmpeg.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates │
│ File: login.html │
└───────────────────────────────────────────────┘
{% extends "base.html" %}
{% block title %}Login - Dispatcharr{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card">
<div class="card-header text-center">
<h3>Dispatcharr Login</h3>
</div>
<div class="card-body">
<form id="loginForm" method="post" action="/accounts/login/">
{% csrf_token %}
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" name="username" class="form-control" id="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" name="password" class="form-control" id="password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Sign In</button>
</div>
</form>
</div>
</div>
</div>
</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.'
});
});
}
});
});
});
</script>
{% endblock %}
========= END OF FILE =========
File: login.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates │
│ File: settings.html │
└───────────────────────────────────────────────┘
{% extends "base.html" %}
{% block title %}M3U Management - Dispatcharr{% endblock %}
{% block page_header %}M3U Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">M3U Management</li>
{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h3 class="card-title">M3U Accounts</h3>
<button id="addM3UBtn" class="btn btn-primary float-end">
<i class="bi bi-plus"></i> Add M3U Account
</button>
</div>
<div class="card-body">
<table id="m3uTable" class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Server URL</th>
<th>Uploaded File</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- M3U Modal -->
<div class="modal fade" id="m3uModal" tabindex="-1" aria-labelledby="m3uModalLabel" aria-hidden="true">
<div class="modal-dialog">
<form id="m3uForm" enctype="multipart/form-data">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="m3uModalLabel">M3U Account</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="m3uId" name="id">
<div class="mb-3">
<label for="m3uName" class="form-label">Name</label>
<input type="text" class="form-control" id="m3uName" name="name" required>
</div>
<div class="mb-3">
<label for="serverUrl" class="form-label">Server URL</label>
<input type="url" class="form-control" id="serverUrl" name="server_url">
</div>
<div class="mb-3">
<label for="uploadedFile" class="form-label">Uploaded File</label>
<input type="file" class="form-control" id="uploadedFile" name="uploaded_file">
</div>
<div class="mb-3">
<label for="isActive" class="form-label">Active</label>
<select class="form-select" id="isActive" name="is_active">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save Account</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function(){
var m3uTable = new DataTable("#m3uTable", {
ajax: "{% url 'api:m3u-account-list' %}",
columns: [
{ data: "id" },
{ data: "name" },
{ data: "server_url" },
{ data: "uploaded_file" },
{ data: "is_active", render: function(data){ return data ? "Yes" : "No"; } },
{ data: null, render: function(data){
return '<button class="btn btn-sm btn-primary edit-m3u" data-id="'+data.id+'">Edit</button> ' +
'<button class="btn btn-sm btn-danger delete-m3u" data-id="'+data.id+'">Delete</button>';
}
}
]
});
document.getElementById("addM3UBtn").addEventListener("click", function(){
document.getElementById("m3uForm").reset();
document.getElementById("m3uId").value = '';
document.getElementById("m3uModalLabel").textContent = "Add M3U Account";
new bootstrap.Modal(document.getElementById("m3uModal")).show();
});
document.querySelector("#m3uTable").addEventListener("click", function(e){
if(e.target.classList.contains("edit-m3u")){
var m3uId = e.target.getAttribute("data-id");
fetch("/api/m3u/accounts/" + m3uId + "/")
.then(res => res.json())
.then(data => {
document.getElementById("m3uId").value = data.id;
document.getElementById("m3uName").value = data.name;
document.getElementById("serverUrl").value = data.server_url;
document.getElementById("isActive").value = data.is_active ? "true" : "false";
document.getElementById("m3uModalLabel").textContent = "Edit M3U Account";
new bootstrap.Modal(document.getElementById("m3uModal")).show();
});
}
if(e.target.classList.contains("delete-m3u")){
var m3uId = e.target.getAttribute("data-id");
Swal.fire({
title: 'Are you sure?',
text: "This will delete the M3U Account permanently.",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Yes, delete it!'
}).then(result => {
if(result.isConfirmed){
fetch("/api/m3u/accounts/" + m3uId + "/", { method: "DELETE" })
.then(response => {
if(response.ok){
Swal.fire("Deleted!", "M3U Account deleted.", "success");
m3uTable.ajax.reload();
} else {
Swal.fire("Error", "Failed to delete M3U Account.", "error");
}
});
}
});
}
});
document.getElementById("m3uForm").addEventListener("submit", function(e){
e.preventDefault();
var m3uId = document.getElementById("m3uId").value;
var formData = new FormData(this);
var method = m3uId ? "PUT" : "POST";
var url = m3uId ? "/api/m3u/accounts/" + m3uId + "/" : "/api/m3u/accounts/";
fetch(url, {
method: method,
body: formData
}).then(response => {
if(response.ok){
bootstrap.Modal.getInstance(document.getElementById("m3uModal")).hide();
Swal.fire("Success", "M3U Account saved!", "success");
m3uTable.ajax.reload();
} else {
Swal.fire("Error", "Failed to save M3U Account.", "error");
}
});
});
});
</script>
{% endblock %}
========= END OF FILE =========
File: settings.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/m3u │
│ File: m3u.html │
└───────────────────────────────────────────────┘
{% extends "base.html" %}
{% block title %}M3U Management - Dispatcharr{% endblock %}
{% block page_header %}M3U Management{% endblock %}
{% block content %}
<div class="container">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">M3U Accounts</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addM3UModal">Add M3U</button>
</div>
<div class="card-body">
<table id="m3uTable" class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>URL/File</th>
<th>Max Streams</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for m3u in m3u_accounts %}
<tr>
<td>{{ m3u.name }}</td>
<td>
{% if m3u.server_url %}
<a href="{{ m3u.server_url }}" target="_blank">M3U URL</a>
{% elif m3u.uploaded_file and m3u.uploaded_file.url %}
<a href="{{ m3u.uploaded_file.url }}" download>Download File</a>
{% else %}
No URL or file
{% endif %}
</td>
<td>{{ m3u.max_streams|default:"N/A" }}</td>
<td>
<button class="btn btn-sm btn-warning" onclick="editM3U({{ m3u.id }})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteM3U({{ m3u.id }})">Delete</button>
<button class="btn btn-sm btn-info" onclick="refreshM3U({{ m3u.id }})">Refresh</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Add M3U Modal -->
<div class="modal fade" id="addM3UModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add M3U Account</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="m3uForm">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" id="m3uName" required>
</div>
<div class="mb-3">
<label class="form-label">M3U URL</label>
<input type="url" class="form-control" id="m3uURL">
</div>
<div class="mb-3">
<label class="form-label">Upload File</label>
<input type="file" class="form-control" id="m3uFile">
</div>
<div class="mb-3">
<label class="form-label">Max Streams</label>
<input type="number" class="form-control" id="m3uMaxStreams" value="0">
</div>
<button type="submit" class="btn btn-success">Save</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(document).ready(function () {
$('#m3uTable').DataTable();
});
function deleteM3U(id) {
Swal.fire({
title: "Are you sure?",
text: "You won't be able to revert this!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
confirmButtonText: "Yes, delete it!"
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: `/m3u/${id}/delete/`,
method: "POST",
success: function () {
Swal.fire("Deleted!", "The M3U account has been deleted.", "success")
.then(() => location.reload());
},
error: function () {
Swal.fire("Error", "Failed to delete the M3U account.", "error");
}
});
}
});
}
function refreshM3U(id) {
$.ajax({
url: `/m3u/${id}/refresh/`,
method: "POST",
success: function () {
Swal.fire("Refreshed!", "The M3U has been refreshed.", "success")
.then(() => location.reload());
},
error: function () {
Swal.fire("Error", "Failed to refresh the M3U.", "error");
}
});
}
</script>
{% endblock %}
========= END OF FILE =========
File: m3u.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/admin │
│ File: base.htmlold.html │
└───────────────────────────────────────────────┘
{% load static %}
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<title>{% block title %}Dispatcharr{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Fonts -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/source-sans-3@5.0.12/index.css" crossorigin="anonymous" />
<!-- Third Party Plugins -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/overlayscrollbars@2.10.1/styles/overlayscrollbars.min.css" crossorigin="anonymous" />
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" crossorigin="anonymous" />
<!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- AdminLTE CSS -->
<link rel="stylesheet" href="{% static 'admin-lte/dist/css/adminlte.css' %}" />
<!-- ApexCharts and jsVectorMap CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/apexcharts@3.37.1/dist/apexcharts.css" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsvectormap@1.5.3/dist/css/jsvectormap.min.css" crossorigin="anonymous" />
{% block extra_css %}{% endblock %}
</head>
<body class="layout-fixed sidebar-expand-lg bg-body-tertiary">
<div class="app-wrapper">
<!-- Header / Navbar -->
<nav class="app-header navbar navbar-expand bg-body">
<div class="container-fluid">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-lte-toggle="sidebar" href="#" role="button">
<i class="bi bi-list"></i>
</a>
</li>
<li class="nav-item d-none d-md-block">
<a href="{% url 'dashboard:dashboard' %}" class="nav-link">Home</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="/accounts/login/">Login</a>
</li>
<!-- Theme Switcher Dropdown -->
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="themeToggleBtn" type="button" aria-expanded="false"
data-bs-toggle="dropdown" data-bs-display="static">
<span class="theme-icon-active"><i class="bi bi-sun-fill my-1"></i></span>
<span class="d-lg-none ms-2" id="theme-toggle-text">Toggle theme</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="themeToggleBtn" style="--bs-dropdown-min-width: 8rem;">
<li>
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="light">
<i class="bi bi-sun-fill me-2"></i> Light
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
<i class="bi bi-moon-fill me-2"></i> Dark
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="auto">
<i class="bi bi-circle-half me-2"></i> Auto
<i class="bi bi-check-lg ms-auto d-none"></i>
</button>
</li>
</ul>
</li>
</ul>
</div>
</nav>
<!-- Sidebar -->
<aside class="app-sidebar bg-body-secondary shadow" data-bs-theme="dark">
<div class="sidebar-brand">
<a href="{% url 'dashboard:dashboard' %}" class="brand-link">
<img src="{% static 'admin-lte/dist/assets/img/logo.png' %}" alt="Dispatcharr Logo" class="brand-image opacity-75 shadow" />
<span class="brand-text fw-light">Dispatcharr</span>
</a>
</div>
<div class="sidebar-wrapper">
<nav class="mt-2">
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" role="menu" data-accordion="false">
<li class="nav-item">
<a href="{% url 'dashboard:dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-speedometer"></i>
<p>Dashboard</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'channels:channels_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-tv"></i>
<p>Channels</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'm3u:m3u_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-file-earmark-text"></i>
<p>M3U</p>
</a>
</li>
<li class="nav-item">
<a href="{% url 'epg:epg_dashboard' %}" class="nav-link">
<i class="nav-icon bi bi-calendar3"></i>
<p>EPG</p>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link">
<i class="nav-icon bi bi-gear"></i>
<p>Settings</p>
</a>
</li>
</ul>
</nav>
</div>
</aside>
<!-- Main Content -->
<main class="app-main">
<div class="app-content">
<div class="container-fluid">
<!-- Content Wrapper -->
<div class="content-wrapper">
<!-- Page Header -->
<section class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1>{% block admin_title %}Admin{% endblock %}</h1>
</div>
<div class="col-sm-6">
<ol class="breadcrumb float-sm-right">
<li class="breadcrumb-item"><a href="/">Home</a></li>
<li class="breadcrumb-item active">{% block breadcrumb %}Admin{% endblock %}</li>
</ol>
</div>
</div>
</div>
</section>
<!-- Main Content -->
<section class="content">
<div class="container-fluid">
{% block content %}{% endblock %}
</div>
</section>
</div>
</div>
</div>
</main>
<!-- Footer -->
<footer class="app-footer">
<div class="float-end d-none d-sm-inline">Anything you want</div>
<strong>&copy; {{ current_year|default:"2025" }} Dispatcharr.</strong> All rights reserved.
</footer>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/overlayscrollbars@2.10.1/browser/overlayscrollbars.browser.es6.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"></script>
<script src="{% static 'admin-lte/dist/js/adminlte.js' %}"></script>
{% block extra_js %}{% endblock %}
<!-- AdminLTE 4 Theme Toggle -->
<script>
(() => {
"use strict";
const storedTheme = localStorage.getItem("theme");
const getPreferredTheme = () => storedTheme || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const setTheme = (theme) => {
document.documentElement.setAttribute("data-bs-theme", theme);
};
setTheme(getPreferredTheme());
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-bs-theme-value]").forEach(button => {
button.addEventListener("click", () => {
const theme = button.getAttribute("data-bs-theme-value");
localStorage.setItem("theme", theme);
setTheme(theme);
});
});
});
})();
</script>
</body>
</html>
========= END OF FILE =========
File: base.htmlold.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/dashboard │
│ File: dashboard.html │
└───────────────────────────────────────────────┘
{% extends "base.html" %}
{% block title %}Dashboard - Dispatcharr{% endblock %}
{% block page_header %}Dashboard{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{% url 'core:dashboard' %}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Dashboard</li>
{% endblock %}
{% block content %}
<!-- Grid Layout -->
<div class="row">
<!-- CPU Usage Chart -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">CPU & RAM Usage</h3>
</div>
<div class="card-body">
<div id="cpuRamChart"></div>
</div>
</div>
</div>
<!-- Network Traffic & Streams Chart -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Network Traffic & Current Streams</h3>
</div>
<div class="card-body">
<div id="networkStreamsChart"></div>
</div>
</div>
</div>
</div>
<!-- Stream Details -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Active Streams</h3>
</div>
<div class="card-body">
<table id="streamDetailsTable" class="table table-striped">
<thead>
<tr>
<th>Stream Name</th>
<th>Viewers</th>
<th>M3U Account</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr id="noStreamsRow">
<td colspan="4" class="text-center text-muted">No active streams.</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script>
document.addEventListener('DOMContentLoaded', function(){
let cpuRamChart, networkStreamsChart;
function getThemeColors() {
const isDarkMode = document.documentElement.getAttribute("data-bs-theme") === "dark";
return {
textColor: isDarkMode ? "#ffffff" : "#333333",
backgroundColor: isDarkMode ? "#1e1e1e" : "#ffffff",
gridColor: isDarkMode ? "#444" : "#ddd",
};
}
function createCharts() {
const themeColors = getThemeColors();
cpuRamChart = new ApexCharts(document.querySelector("#cpuRamChart"), {
chart: {
type: "line",
height: 250,
foreColor: themeColors.textColor,
background: themeColors.backgroundColor,
},
stroke: { width: 2 },
series: [
{ name: "CPU Usage (%)", data: [] },
{ name: "RAM Usage (GB)", data: [] }
],
xaxis: {
type: "datetime",
labels: { style: { colors: themeColors.textColor } },
},
yaxis: {
labels: { style: { colors: themeColors.textColor } },
},
grid: { borderColor: themeColors.gridColor }
});
cpuRamChart.render();
networkStreamsChart = new ApexCharts(document.querySelector("#networkStreamsChart"), {
chart: {
type: "line",
height: 250,
foreColor: themeColors.textColor,
background: themeColors.backgroundColor,
},
stroke: { width: 2 },
series: [
{ name: "Network Traffic (MB)", data: [] },
{ name: "Current Streams", data: [] }
],
xaxis: {
type: "datetime",
labels: { style: { colors: themeColors.textColor } },
},
yaxis: {
labels: { style: { colors: themeColors.textColor } },
},
grid: { borderColor: themeColors.gridColor }
});
networkStreamsChart.render();
}
function fetchDashboardData(){
fetch("{% url 'core:dashboard_data' %}")
.then(res => res.json())
.then(data => {
const now = new Date().getTime();
// Update CPU & RAM Chart
cpuRamChart.updateSeries([
{ name: "CPU Usage (%)", data: [...cpuRamChart.w.config.series[0].data, [now, data.cpu_usage]] },
{ name: "RAM Usage (GB)", data: [...cpuRamChart.w.config.series[1].data, [now, data.ram_usage]] }
]);
// Update Network & Streams Chart
networkStreamsChart.updateSeries([
{ name: "Network Traffic (MB)", data: [...networkStreamsChart.w.config.series[0].data, [now, data.network_traffic]] },
{ name: "Current Streams", data: [...networkStreamsChart.w.config.series[1].data, [now, data.current_streams]] }
]);
// Update Stream Table
const tbody = document.querySelector("#streamDetailsTable tbody");
tbody.innerHTML = "";
if (data.active_streams.length > 0) {
data.active_streams.forEach(stream => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${stream}</td>
<td>--</td>
<td>--</td>
<td><i class="bi bi-info-circle"></i></td>`;
tbody.appendChild(row);
});
} else {
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted">No active streams.</td></tr>`;
}
});
}
createCharts();
fetchDashboardData();
setInterval(fetchDashboardData, 10000);
// **Update Chart Theme Instead of Destroying Data**
document.querySelectorAll("[data-bs-theme-value]").forEach(button => {
button.addEventListener("click", function () {
setTimeout(() => {
const themeColors = getThemeColors();
// Update CPU & RAM Chart Colors
cpuRamChart.updateOptions({
chart: { foreColor: themeColors.textColor, background: themeColors.backgroundColor },
xaxis: { labels: { style: { colors: themeColors.textColor } } },
yaxis: { labels: { style: { colors: themeColors.textColor } } },
grid: { borderColor: themeColors.gridColor }
});
// Update Network & Streams Chart Colors
networkStreamsChart.updateOptions({
chart: { foreColor: themeColors.textColor, background: themeColors.backgroundColor },
xaxis: { labels: { style: { colors: themeColors.textColor } } },
yaxis: { labels: { style: { colors: themeColors.textColor } } },
grid: { borderColor: themeColors.gridColor }
});
}, 200);
});
});
});
</script>
{% endblock %}
========= END OF FILE =========
File: dashboard.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/epg │
│ File: epg.html │
└───────────────────────────────────────────────┘
{% extends "base.html" %}
{% block title %}EPG Management - Dispatcharr{% endblock %}
{% block page_header %}EPG Management{% endblock %}
{% block content %}
<div class="container">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">EPG Sources</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addEPGModal">Add EPG</button>
</div>
<div class="card-body">
<table id="epgTable" class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Source Type</th>
<th>URL/API Key</th>
<th>Actions</th>
</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 %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Add EPG Modal -->
<div class="modal fade" id="addEPGModal" tabindex="-1">
<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">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" id="epgName" required>
</div>
<div class="mb-3">
<label class="form-label">EPG URL</label>
<input type="url" class="form-control" id="epgURL">
</div>
<div class="mb-3">
<label class="form-label">API Key</label>
<input type="text" class="form-control" id="epgAPIKey">
</div>
<button type="submit" class="btn btn-success">Save</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
========= END OF FILE =========
File: epg.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/hdhr │
│ File: hdhr.html │
└───────────────────────────────────────────────┘
{% extends "base.html" %}
{% block title %}HDHomeRun Management - Dispatcharr{% endblock %}
{% block page_header %}HDHomeRun Management{% endblock %}
{% block content %}
<div class="container">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">HDHomeRun Devices</h3>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addHDHRModal">Add HDHR Device</button>
</div>
<div class="card-body">
<table id="hdhrTable" class="table table-striped">
<thead>
<tr>
<th>Device Name</th>
<th>Device ID</th>
<th>Tuners</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for device in hdhr_devices %}
<tr>
<td>{{ device.friendly_name }}</td>
<td>{{ device.device_id }}</td>
<td>{{ device.tuner_count }}</td>
<td>
<button class="btn btn-sm btn-warning" onclick="editHDHR({{ device.id }})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteHDHR({{ device.id }})">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Add HDHR Modal -->
<div class="modal fade" id="addHDHRModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add HDHomeRun Device</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="hdhrForm">
<div class="mb-3">
<label class="form-label">Device Name</label>
<input type="text" class="form-control" id="hdhrName" required>
</div>
<div class="mb-3">
<label class="form-label">Device ID</label>
<input type="text" class="form-control" id="hdhrDeviceId" required>
</div>
<div class="mb-3">
<label class="form-label">Tuner Count</label>
<input type="number" class="form-control" id="hdhrTunerCount" value="3">
</div>
<button type="submit" class="btn btn-success">Save</button>
</form>
</div>
</div>
</div>
</div>
<div class="container mt-4">
<h3>Useful Links</h3>
<ul>
<li><a href="/hdhr/discover.json" target="_blank">HDHR Discovery JSON</a></li>
<li><a href="/hdhr/lineup.json" target="_blank">HDHR Lineup JSON</a></li>
<li><a href="/m3u/accounts/" target="_blank">M3U Accounts</a></li>
<li><a href="/epg/sources/" target="_blank">EPG Sources</a></li>
</ul>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(document).ready(function () {
$('#hdhrTable').DataTable();
});
function deleteHDHR(id) {
Swal.fire({
title: "Are you sure?",
text: "You won't be able to revert this!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
confirmButtonText: "Yes, delete it!"
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: `/hdhr/devices/${id}/`,
method: "DELETE",
success: function () {
Swal.fire("Deleted!", "The HDHR device has been removed.", "success").then(() => location.reload());
}
});
}
});
}
</script>
{% endblock %}
========= END OF FILE =========
File: hdhr.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels │
│ File: channels.html │
└───────────────────────────────────────────────┘
{% extends "base.html" %}
{% load static %}
{% block title %}Streams Dashboard{% endblock %}
{% block content %}
<div class="row">
<!-- ============== LEFT: CHANNELS ============== -->
<div class="col-lg-7 col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">Channels</h3>
<div>
<button class="btn btn-warning btn-sm" id="autoAssignBtn">
<i class="fa-solid fa-sort-numeric-up"></i> Auto Assign
</button>
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#addChannelModal">
<i class="fa-solid fa-plus"></i> Add Channel
</button>
<button id="deleteSelectedChannelsBtn" class="btn btn-danger btn-sm">
<i class="fa-solid fa-trash"></i> Delete Selected
</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>
</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>#</th>
<th>Logo</th>
<th>Name</th>
<th>EPG</th>
<th>Group</th>
<th>Actions</th>
</tr>
</thead>
<tbody><!-- Loaded via Ajax --></tbody>
</table>
</div>
</div>
</div>
<!-- ============== RIGHT: STREAMS ============== -->
<div class="col-lg-5 col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">Streams</h3>
<div>
<button id="createChannelsFromStreamsBtn" class="btn btn-primary btn-sm">
<i class="fa-solid fa-plus"></i> Create Channels
</button>
</div>
</div>
<div class="card-body p-2">
<table id="streamsTable" class="table table-hover table-sm w-100">
<thead>
<tr>
<th style="width:30px;">
<input type="checkbox" id="selectAllStreams">
</th>
<th>Stream Name</th>
<th>Group</th>
<th>Actions</th>
</tr>
</thead>
<tbody><!-- Loaded via Ajax --></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ===================== ACTION BUTTONS ===================== -->
<div class="mb-3">
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#refreshModal">
<i class="fa-solid fa-sync"></i> Refresh M3U
</button>
<button class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#backupModal">
<i class="fa-solid fa-download"></i> Backup
</button>
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#restoreModal">
<i class="fa-solid fa-upload"></i> Restore
</button>
</div>
<!-- ================== INCLUDE ALL MODALS ================== -->
{% include "channels/modals/add_channel.html" %}
{% include "channels/modals/edit_channel.html" %}
{% include "channels/modals/edit_logo.html" %}
{% include "channels/modals/delete_channel.html" %}
{% include "channels/modals/delete_stream.html" %}
{% include "channels/modals/add_m3u.html" %}
{% include "channels/modals/edit_m3u.html" %}
{% include "channels/modals/add_group.html" %}
{% include "channels/modals/delete_m3u.html" %}
{% include "channels/modals/backup.html" %}
{% include "channels/modals/restore.html" %}
{% include "channels/modals/refresh.html" %}
<!-- ================== OPTIONAL STYLES / DATATABLES CSS ============== -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.3.6/css/buttons.dataTables.min.css">
<!-- ============== JS Dependencies ============== -->
<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>
<!-- ============== MAIN SCRIPT ============== -->
<script>
$(document).ready(function () {
////////////////////////////////////////////////////////////////////////////
// CSRF Setup for Django
////////////////////////////////////////////////////////////////////////////
$.ajaxSetup({
headers: { "X-CSRFToken": "{{ csrf_token }}" }
});
////////////////////////////////////////////////////////////////////////////
// 1) Channels DataTable
////////////////////////////////////////////////////////////////////////////
const channelsDataTable = $("#channelsTable").DataTable({
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: "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}">
`;
},
orderable: false,
searchable: false
},
{ data: "channel_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
}
],
responsive: true,
pageLength: 10,
order: [[1, "asc"]]
});
// Helper to find next available channel_number by scanning loaded channels
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;
}
});
return maxNum + 1;
}
////////////////////////////////////////////////////////////////////////////
// 2) Streams DataTable
////////////////////////////////////////////////////////////////////////////
const streamsDataTable = $("#streamsTable").DataTable({
ajax: {
url: "/api/streams/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, 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}">
<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}">
<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}"
data-stream-name="${name}">
<i class="fa-solid fa-plus"></i>
</button>
</div>
`;
},
orderable: false,
searchable: false
}
],
responsive: true,
pageLength: 10
});
////////////////////////////////////////////////////////////////////////////
// 3) Clicking the “+” in Streams => open “Add Channel” & autofill channel name
////////////////////////////////////////////////////////////////////////////
let newAvailableStreamsTable = null;
let newActiveStreamsTable = null;
// We'll do the actual logic inside a small function so we can reuse it:
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/streams/streams/?unassigned=1", // or your real unassigned filter
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>
`
}
],
destroy: true,
searching: true,
paging: true,
pageLength: 5,
responsive: true
});
} else {
// re-load it
newAvailableStreamsTable.ajax.url("/api/streams/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>
`
}
],
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
});
// 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
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
});
// 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);
});
$.ajax({
url: "/api/channels/channels/",
type: "POST",
data: formData,
processData: false,
contentType: false,
success: function (createdChannel, status, xhr) {
if (xhr.status === 201 || xhr.status === 200) {
channelsDataTable.ajax.reload(null, false);
const modalEl = document.getElementById("addChannelModal");
bootstrap.Modal.getInstance(modalEl).hide();
Swal.fire("Channel Created", "New channel was added.", "success");
} else {
Swal.fire("Error", "Server did not return success code.", "error");
}
},
error: function () {
Swal.fire("Server Error", "Error creating channel.", "error");
}
});
});
////////////////////////////////////////////////////////////////////////////
// 4) Bulk ops: “Delete Selected Channels”, “Auto Assign”, “Bulk Create Channels from Streams”
////////////////////////////////////////////////////////////////////////////
$("#selectAllChannels").on("change", function () {
const checked = $(this).is(":checked");
$(".channel-checkbox").prop("checked", checked);
});
$("#deleteSelectedChannelsBtn").click(function () {
const channelIDs = [];
$(".channel-checkbox:checked").each(function () {
channelIDs.push($(this).data("channel-id"));
});
if (!channelIDs.length) {
Swal.fire("No channels selected", "Please select some channels.", "info");
return;
}
Swal.fire({
title: `Delete ${channelIDs.length} selected channels?`,
text: "This action cannot be undone!",
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete them!"
}).then((res) => {
if (res.isConfirmed) {
$.ajax({
url: "/api/channels/bulk-delete/",
type: "DELETE",
data: JSON.stringify({ channel_ids: channelIDs }),
contentType: "application/json",
success: function (data, status, xhr) {
if (xhr.status === 204) {
channelsDataTable.ajax.reload(null, false);
Swal.fire("Deleted!", "Selected channels have been deleted.", "success");
} else {
Swal.fire("Error", "Server did not return 204.", "error");
}
},
error: function () {
Swal.fire("Server Error", "Error sending bulk-delete request.", "error");
}
});
}
});
});
$("#autoAssignBtn").click(function () {
const channelIDs = [];
channelsDataTable.rows().every(function () {
channelIDs.push(this.data().id);
});
$.ajax({
url: "/api/channels/assign/",
method: "POST",
data: JSON.stringify({ channel_order: channelIDs }),
contentType: "application/json",
success: function (resp, status, xhr) {
if (xhr.status === 200) {
channelsDataTable.ajax.reload(null, false);
Swal.fire("Auto Assign", "Channels have been autoassigned!", "success");
} else {
Swal.fire("Auto Assign Failed", "No success response from server.", "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);
});
$("#createChannelsFromStreamsBtn").click(function () {
const streamIDs = [];
$(".stream-checkbox:checked").each(function () {
streamIDs.push($(this).data("stream-id"));
});
if (!streamIDs.length) {
Swal.fire("No streams selected", "Please select some streams.", "info");
return;
}
$.ajax({
url: "/api/channels/bulk-create/",
method: "POST",
data: JSON.stringify({ streams: streamIDs }),
contentType: "application/json",
success: function (resp, status, xhr) {
if (xhr.status === 201 || xhr.status === 200) {
channelsDataTable.ajax.reload(null, false);
Swal.fire("Channels Created", "Channels created successfully.", "success");
} else {
Swal.fire("Error", "Could not create channels.", "error");
}
},
error: function () {
Swal.fire("Request Failed", "Error creating channels from streams.", "error");
}
});
});
////////////////////////////////////////////////////////////////////////////
// 5) Edit Channel => load channel info + active/available streams
////////////////////////////////////////////////////////////////////////////
let editActiveStreamsTable = null;
let 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/streams/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>
`
}
],
destroy: true,
searching: true,
paging: true,
pageLength: 5,
responsive: true
});
} else {
editActiveStreamsTable.clear().draw();
editActiveStreamsTable.ajax.url(`/api/streams/streams/?assigned=${channelID}`).load();
}
// 2) “Available Streams” => not assigned
if (!editAvailableStreamsTable) {
editAvailableStreamsTable = $("#editAvailableStreamsTable").DataTable({
ajax: {
url: "/api/streams/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>
`
}
],
destroy: true,
searching: true,
paging: true,
pageLength: 5,
responsive: true
});
} else {
editAvailableStreamsTable.clear().draw();
editAvailableStreamsTable.ajax.url("/api/streams/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("");
}
}).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);
});
$.ajax({
url: `/api/channels/channels/${channelID}/`,
type: "PUT", // or PATCH if your API requires
data: formData,
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();
channelsDataTable.ajax.reload(null, false);
} else {
Swal.fire("Error", "Could not update channel.", "error");
}
},
error: function () {
Swal.fire("Server Error", "Error updating channel.", "error");
}
});
});
////////////////////////////////////////////////////////////////////////////
// 6) Delete Channel / Stream modals
////////////////////////////////////////////////////////////////////////////
$("#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);
});
$("#deleteChannelForm").submit(function (e) {
e.preventDefault();
const channelID = $("#deleteChannelIdHidden").val();
$.ajax({
url: `/api/channels/channels/${channelID}/`,
type: "DELETE",
success: function (data, status, xhr) {
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();
} else {
Swal.fire("Error", "Server did not return 204.", "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);
});
$("#deleteStreamForm").submit(function (e) {
e.preventDefault();
const streamID = $("#deleteStreamIdHidden").val();
$.ajax({
url: `/api/streams/streams/${streamID}/`,
type: "DELETE",
success: function (data, status, xhr) {
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();
} else {
Swal.fire("Error", "Server did not return 204.", "error");
}
},
error: function () {
Swal.fire("Server Error", "Error deleting stream.", "error");
}
});
});
////////////////////////////////////////////////////////////////////////////
// 7) Add Group
////////////////////////////////////////////////////////////////////////////
$("#addGroupForm").submit(function (e) {
e.preventDefault();
const groupName = $("#newGroupNameField").val();
$.ajax({
url: "/api/channels/groups/",
type: "POST",
data: JSON.stringify({ name: groupName }),
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));
$("#newGroupNameField").val("");
const modalEl = document.getElementById("addGroupModal");
bootstrap.Modal.getInstance(modalEl).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");
}
});
});
////////////////////////////////////////////////////////////////////////////
// 8) Edit Logo
////////////////////////////////////////////////////////////////////////////
$("#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}`);
});
$("#editLogoForm").submit(function (e) {
e.preventDefault();
const formData = new FormData(this);
const channelID = $("#channel_id_field").val();
$.ajax({
url: `/api/channels/channels/${channelID}/logo`,
type: "POST",
data: formData,
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();
} else {
Swal.fire("Error", "Server didn't return success for updating logo.", "error");
}
},
error: function () {
Swal.fire("Server Error", "Error updating channel logo.", "error");
}
});
});
////////////////////////////////////////////////////////////////////////////
// 9) M3U Refresh, Backup, Restore
////////////////////////////////////////////////////////////////////////////
$("#refreshForm").submit(function (e) {
e.preventDefault();
$.ajax({
url: "/api/m3u/refresh/",
type: "POST",
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();
} else {
Swal.fire("Error", "Server did not return 202.", "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");
});
});
$("#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();
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");
});
});
});
</script>
{% endblock %}
========= END OF FILE =========
File: channels.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: create_channel_from_stream.html │
└───────────────────────────────────────────────┘
<!-- templates/channels/modals/create_channel_from_stream.html -->
<div class="modal fade" id="createChannelFromStreamModal" tabindex="-1" aria-labelledby="createChannelFromStreamModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form id="createChannelFromStreamForm" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="createChannelFromStreamModalLabel">Create Channel (Single Stream)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="streamIdField" name="stream_id">
<div class="mb-3">
<label for="channelNameField" class="form-label">Channel Name</label>
<input type="text" class="form-control" id="channelNameField" name="channel_name" required>
</div>
<div class="mb-3">
<label for="channelNumberField" class="form-label">Channel Number</label>
<input type="number" class="form-control" id="channelNumberField" name="channel_number" required>
</div>
<div class="mb-3">
<label for="groupField" class="form-label">Group</label>
<input type="text" class="form-control" id="groupField" name="group" placeholder="Optional group name">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create Channel</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: create_channel_from_stream.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: delete_channel.html │
└───────────────────────────────────────────────┘
<!-- templates/channels/modals/delete_channel.html -->
<div class="modal fade" id="deleteChannelModal" tabindex="-1" aria-labelledby="deleteChannelModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="deleteChannelForm" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="deleteChannelModalLabel">Delete Channel</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete <strong id="channelName"></strong>?
<input type="hidden" id="deleteChannelIdHidden" name="channel_id" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Delete</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: delete_channel.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: edit_channel.html │
└───────────────────────────────────────────────┘
<!-- templates/channels/modals/edit_channel.html -->
<div class="modal fade" id="editChannelModal" tabindex="-1" aria-labelledby="editChannelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<form id="editChannelForm" method="post" enctype="multipart/form-data">
{% csrf_token %}
<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">
<div class="mb-3">
<label for="editChannelNameField" class="form-label">Name</label>
<input type="text" class="form-control" id="editChannelNameField" name="channel_name" required>
</div>
<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>
<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>
<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>
</div>
</div>
<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;">
</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>
</div>
</div>
</div>
</div>
<hr class="my-4">
<!-- 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>
<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>
========= END OF FILE =========
File: edit_channel.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: add_channel.html │
└───────────────────────────────────────────────┘
<!-- templates/channels/modals/add_channel.html -->
<div class="modal fade" id="addChannelModal" tabindex="-1" aria-labelledby="addChannelModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<form id="addChannelForm" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header bg-dark text-white">
<h5 class="modal-title" id="addChannelModalLabel">Add New 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">
<div class="mb-3">
<label for="newChannelNameField" class="form-label">Name</label>
<input type="text" class="form-control" id="newChannelNameField" name="channel_name" placeholder="Channel Name" required>
</div>
<div class="mb-3 d-flex align-items-center">
<label for="newGroupField" class="form-label me-2">Channel Group</label>
<select class="form-select" id="newGroupField" name="group" style="flex-grow: 1;">
<!-- Dynamically populated groups -->
</select>
<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>
<div class="mb-3">
<label for="newChannelNumberField" class="form-label">Channel #</label>
<input type="number" class="form-control" id="newChannelNumberField" name="channel_number" min="1" required>
</div>
</div>
<div class="col-md-6">
<div class="text-center mb-3">
<label class="form-label">Logo</label>
<div class="mb-2">
<img id="newLogoPreview" 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="newLogoFileField" name="logo_file">
<button class="btn btn-secondary" type="button" id="removeNewLogoButton">Remove</button>
</div>
</div>
</div>
</div>
<hr class="my-4">
<!-- Stream Management for the new channel -->
<div class="row">
<div class="col-md-6">
<h6 class="text-uppercase">Active Streams</h6>
<table id="newActiveStreamsTable" class="table table-sm table-bordered w-100">
<thead>
<tr>
<th>Name</th>
<th>M3U</th>
<th>Add</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="newAvailableStreamsTable" 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><!-- /.modal-body -->
<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 Channel</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: add_channel.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: refresh.html │
└───────────────────────────────────────────────┘
<div class="modal fade" id="refreshModal" tabindex="-1" aria-labelledby="refreshModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="refreshForm" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="refreshModalLabel">Refresh M3U Streams</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Click "Refresh" to re-download and parse all active M3U files.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Refresh</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: refresh.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: edit_m3u.html │
└───────────────────────────────────────────────┘
<!-- templates/channels/modals/edit_m3u.html -->
<div class="modal fade" id="editM3UModal" tabindex="-1" aria-labelledby="editM3UModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form id="editM3UForm" method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" id="editM3UIdHidden" name="m3u_id" />
<div class="modal-header">
<h5 class="modal-title" id="editM3UModalLabel">Edit M3U File</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="edit_name" class="form-label">Name</label>
<input type="text" id="edit_name" name="m3u_name" class="form-control" required>
</div>
<div class="mb-3">
<label for="edit_server_url" class="form-label">M3U URL</label>
<input type="text" id="edit_server_url" name="server_url" class="form-control">
</div>
<div class="mb-3">
<label for="edit_uploaded_file" class="form-label">OR Upload M3U File</label>
<input type="file" id="edit_uploaded_file" name="uploaded_file" class="form-control">
</div>
<div class="mb-3">
<label for="edit_server_group" class="form-label">M3U Group</label>
<input type="text" id="edit_server_group" name="server_group" class="form-control">
</div>
<div class="mb-3">
<label for="edit_max_streams" class="form-label">Maximum Streams (0 = unlimited)</label>
<input type="number" id="edit_max_streams" name="max_streams" class="form-control" min="0">
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="edit_is_active" name="is_active">
<label class="form-check-label" for="edit_is_active">Is active?</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save M3U</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: edit_m3u.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: delete_m3u.html │
└───────────────────────────────────────────────┘
<!-- templates/channels/modals/delete_m3u.html -->
<div class="modal fade" id="deleteM3UModal" tabindex="-1" aria-labelledby="deleteM3UModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="deleteM3UForm" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="deleteM3UModalLabel">Delete M3U</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this M3U?
<input type="hidden" id="deleteM3UIdHidden" name="m3u_id" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Delete</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: delete_m3u.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: add_m3u.html │
└───────────────────────────────────────────────┘
<div class="modal fade" id="addM3UModal" tabindex="-1" aria-labelledby="addM3UModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form method="POST" id="addM3UForm" enctype="multipart/form-data" action="/api/m3u/accounts/">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="addM3UModalLabel">Add M3U File</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="mb-3">
<label for="server_url" class="form-label">M3U URL</label>
<input type="text" name="server_url" class="form-control">
</div>
<div class="mb-3">
<label for="uploaded_file" class="form-label">OR Upload M3U File</label>
<input type="file" name="uploaded_file" class="form-control">
</div>
<div class="mb-3">
<label for="server_group" class="form-label">M3U Group</label>
<input type="text" name="server_group" class="form-control">
</div>
<div class="mb-3">
<label for="max_streams" class="form-label">Maximum Streams (0 = unlimited)</label>
<input type="number" name="max_streams" class="form-control" min="0" value="0">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="id_is_active" checked>
<label class="form-check-label" for="id_is_active">Is active?</label>
</div>
<h5 class="mt-4">Stream Filters</h5>
<div id="filtersContainer">
<div class="filter-row mb-2">
<div class="row g-2 align-items-center">
<div class="col-md-4">
<select name="filters[][type]" class="form-select">
<option value="stream">Stream</option>
<option value="group">Group</option>
</select>
</div>
<div class="col-md-6">
<input type="text" name="filters[][pattern]" class="form-control" placeholder="Regex Pattern">
</div>
<div class="col-md-2">
<select name="filters[][exclude]" class="form-select">
<option value="true">Exclude</option>
</select>
</div>
</div>
</div>
</div>
<button type="button" id="addFilterBtn" class="btn btn-sm btn-outline-primary">Add Filter</button>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="submit">Create M3U</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.getElementById('addFilterBtn').addEventListener('click', function () {
const container = document.getElementById('filtersContainer');
const filterRow = document.createElement('div');
filterRow.className = 'filter-row mb-2';
filterRow.innerHTML = `
<div class="row g-2 align-items-center">
<div class="col-md-4">
<select name="filters[][type]" class="form-select">
<option value="stream">Stream</option>
<option value="group">Group</option>
</select>
</div>
<div class="col-md-6">
<input type="text" name="filters[][pattern]" class="form-control" placeholder="Regex Pattern">
</div>
<div class="col-md-2">
<select name="filters[][exclude]" class="form-select">
<option value="true">Exclude</option>
</select>
</div>
</div>
`;
container.appendChild(filterRow);
});
</script>
========= END OF FILE =========
File: add_m3u.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: edit_logo.html │
└───────────────────────────────────────────────┘
<!-- templates/channels/modals/edit_logo.html -->
<div class="modal fade" id="editLogoModal" tabindex="-1" aria-labelledby="editLogoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
{% for channel in channels %}
<form method="POST" action="{% url 'channels:edit_logo' channel.id %}" id="editLogoForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="editLogoModalLabel">Edit Logo for {{ channel.channel_name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" name="channel_id" id="channel_id_field" value="{{ channel.id }}" />
<div class="mb-3">
<label for="logo_url" class="form-label">Logo URL</label>
<input type="url" class="form-control" id="logo_url" name="logo_url" placeholder="Enter new logo URL" value="{{ channel.logo_url }}">
</div>
<div class="mb-3">
<label for="logo_file" class="form-label">Upload Logo</label>
<input type="file" class="form-control" id="logo_file" name="logo_file">
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
{% endfor %}
</div>
</div>
</div>
========= END OF FILE =========
File: edit_logo.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: backup.html │
└───────────────────────────────────────────────┘
<div class="modal fade" id="backupModal" tabindex="-1" aria-labelledby="backupModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="backupForm" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="backupModalLabel">Create Backup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>This will create a JSON backup of your database in <code>dispatcharr_backup.json</code>.</p>
</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 Backup</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: backup.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: delete_stream.html │
└───────────────────────────────────────────────┘
<!-- templates/channels/modals/delete_stream.html -->
<div class="modal fade" id="deleteStreamModal" tabindex="-1" aria-labelledby="deleteStreamModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="deleteStreamForm" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="deleteStreamModalLabel">Delete Stream</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete <strong id="streamName"></strong>?
<input type="hidden" id="deleteStreamIdHidden" name="stream_id" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Delete</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: delete_stream.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: restore.html │
└───────────────────────────────────────────────┘
<div class="modal fade" id="restoreModal" tabindex="-1" aria-labelledby="restoreModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="restoreForm" method="post">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="restoreModalLabel">Restore Backup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>This will restore data from <code>dispatcharr_backup.json</code> if it exists.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">Restore Backup</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: restore.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: templates/channels/modals │
│ File: add_group.html │
└───────────────────────────────────────────────┘
<div class="modal fade" id="addGroupModal" tabindex="-1" aria-labelledby="addGroupModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="addGroupForm">
{% csrf_token %}
<div class="modal-header bg-dark text-white">
<h5 class="modal-title" id="addGroupModalLabel">Add New Group</h5>
<button type="button" class="btn-close text-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newGroupNameField" class="form-label">Group Name</label>
<input type="text" class="form-control" id="newGroupNameField" name="group_name" placeholder="Group Name" 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">Add Group</button>
</div>
</form>
</div>
</div>
</div>
========= END OF FILE =========
File: add_group.html
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: signals.py │
└───────────────────────────────────────────────┘
# apps/m3u/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import M3UAccount
from .tasks import refresh_single_m3u_account
@receiver(post_save, sender=M3UAccount)
def refresh_account_on_save(sender, instance, created, **kwargs):
"""
When an M3UAccount is saved (created or updated),
call a Celery task that fetches & parses that single account
if it is active or newly created.
"""
if created or instance.is_active:
refresh_single_m3u_account.delay(instance.id)
========= END OF FILE =========
File: signals.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: tasks.py │
└───────────────────────────────────────────────┘
# apps/m3u/tasks.py
import logging
import re
import requests
import os
from celery.app.control import Inspect
from celery import shared_task
from celery import current_app
from django.conf import settings
from django.core.cache import cache
from .models import M3UAccount
from apps.channels.models import Stream
logger = logging.getLogger(__name__)
LOCK_EXPIRE = 120 # Lock expires after 120 seconds
def _get_group_title(extinf_line: str) -> str:
"""Extract group title from EXTINF line."""
match = re.search(r'group-title="([^"]*)"', extinf_line)
return match.group(1) if match else "Default Group"
def _matches_filters(stream_name: str, group_name: str, filters) -> bool:
logger.info(f"Testing filter")
for f in filters:
pattern = f.regex_pattern
target = group_name if f.filter_type == 'group' else stream_name
logger.info(f"Testing {pattern} on: {target}")
if re.search(pattern, target or '', re.IGNORECASE):
logger.debug(f"Filter matched: {pattern} on {target}. Exclude={f.exclude}")
return f.exclude
return False
def acquire_lock(task_name, account_id):
"""Acquire a lock to prevent concurrent task execution."""
lock_id = f"task_lock_{task_name}_{account_id}"
lock_acquired = cache.add(lock_id, "locked", timeout=LOCK_EXPIRE)
if not lock_acquired:
logger.warning(f"Lock for {task_name} and account_id={account_id} already acquired. Task will not proceed.")
return lock_acquired
def release_lock(task_name, account_id):
"""Release the lock after task execution."""
lock_id = f"task_lock_{task_name}_{account_id}"
cache.delete(lock_id)
@shared_task
def refresh_m3u_accounts():
"""Queue background parse for all active M3UAccounts."""
active_accounts = M3UAccount.objects.filter(is_active=True)
count = 0
for account in active_accounts:
refresh_single_m3u_account.delay(account.id)
count += 1
msg = f"Queued M3U refresh for {count} active account(s)."
logger.info(msg)
return msg
@shared_task
def refresh_single_m3u_account(account_id):
"""Parse and refresh a single M3U account."""
logger.info(f"Task {refresh_single_m3u_account.request.id}: Starting refresh for account_id={account_id}")
if not acquire_lock('refresh_single_m3u_account', account_id):
return f"Task already running for account_id={account_id}."
try:
account = M3UAccount.objects.get(id=account_id, is_active=True)
filters = list(account.filters.all())
logger.info(f"Found active M3UAccount (id={account.id}, name={account.name}).")
except M3UAccount.DoesNotExist:
msg = f"M3UAccount with ID={account_id} not found or inactive."
logger.warning(msg)
release_lock('refresh_single_m3u_account', account_id)
return msg
except Exception as e:
logger.error(f"Error fetching M3UAccount {account_id}: {e}")
release_lock('refresh_single_m3u_account', account_id)
return str(e)
try:
lines = []
if account.server_url:
headers = {"User-Agent": "Mozilla/5.0"}
response = requests.get(account.server_url, timeout=60, headers=headers)
response.raise_for_status()
lines = response.text.splitlines()
elif account.uploaded_file:
file_path = account.uploaded_file.path
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.read().splitlines()
else:
err_msg = f"No server_url or uploaded_file provided for account_id={account_id}."
logger.error(err_msg)
return err_msg
except Exception as e:
err_msg = f"Failed fetching M3U: {e}"
logger.error(err_msg)
release_lock('refresh_single_m3u_account', account_id)
return err_msg
logger.info(f"M3U has {len(lines)} lines. Now parsing for Streams.")
skip_exts = ('.mkv', '.mp4', '.ts', '.m4v', '.wav', '.avi', '.flv', '.m4p', '.mpg',
'.mpeg', '.m2v', '.mp2', '.mpe', '.mpv')
created_count, updated_count, excluded_count = 0, 0, 0
current_info = None
for line in lines:
line = line.strip()
if line.startswith('#EXTINF'):
tvg_name_match = re.search(r'tvg-name="([^"]*)"', line)
tvg_logo_match = re.search(r'tvg-logo="([^"]*)"', line)
fallback_name = line.split(",", 1)[-1].strip() if "," in line else "Default Stream"
name = tvg_name_match.group(1) if tvg_name_match else fallback_name
logo_url = tvg_logo_match.group(1) if tvg_logo_match else ""
group_title = _get_group_title(line)
logger.debug(f"Parsed EXTINF: name={name}, logo_url={logo_url}, group_title={group_title}")
current_info = {"name": name, "logo_url": logo_url, "group_title": group_title}
elif current_info and line.startswith('http'):
lower_line = line.lower()
if any(lower_line.endswith(ext) for ext in skip_exts):
logger.debug(f"Skipping file with unsupported extension: {line}")
current_info = None
continue
if len(line) > 2000:
logger.warning(f"Stream URL too long, skipping: {line}")
excluded_count += 1
current_info = None
continue
if _matches_filters(current_info['name'], current_info['group_title'], filters):
logger.info(f"Stream excluded by filter: {current_info['name']} in group {current_info['group_title']}")
excluded_count += 1
current_info = None
continue
defaults = {"logo_url": current_info["logo_url"]}
try:
obj, created = Stream.objects.update_or_create(
name=current_info["name"],
custom_url=line,
m3u_account=account,
group_name=current_info["group_title"],
defaults=defaults
)
if created:
created_count += 1
else:
updated_count += 1
except Exception as e:
logger.error(f"Failed to update/create stream {current_info['name']}: {e}")
finally:
current_info = None
logger.info(f"Completed parsing. Created {created_count} new Streams, updated {updated_count} existing Streams, excluded {excluded_count} Streams.")
release_lock('refresh_single_m3u_account', account_id)
return f"Account {account_id} => Created {created_count}, updated {updated_count}, excluded {excluded_count} Streams."
def process_uploaded_m3u_file(file, account):
"""Save and parse an uploaded M3U file."""
upload_dir = os.path.join(settings.MEDIA_ROOT, 'm3u_uploads')
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, file.name)
with open(file_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
try:
parse_m3u_file(file_path, account)
except Exception as e:
logger.error(f"Error parsing uploaded M3U file {file_path}: {e}")
def parse_m3u_file(file_path, account):
"""Parse a local M3U file and create or update Streams."""
skip_exts = ('.mkv', '.mp4', '.ts', '.m4v', '.wav', '.avi', '.flv', '.m4p', '.mpg',
'.mpeg', '.m2v', '.mp2', '.mpe', '.mpv')
try:
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.read().splitlines()
except Exception as e:
logger.error(f"Failed to read M3U file {file_path}: {e}")
return f"Error reading M3U file {file_path}"
created_count, updated_count, excluded_count = 0, 0, 0
current_info = None
for line in lines:
line = line.strip()
if line.startswith('#EXTINF'):
tvg_name_match = re.search(r'tvg-name="([^"]*)"', line)
tvg_logo_match = re.search(r'tvg-logo="([^"]*)"', line)
fallback_name = line.split(",", 1)[-1].strip() if "," in line else "Stream"
name = tvg_name_match.group(1) if tvg_name_match else fallback_name
logo_url = tvg_logo_match.group(1) if tvg_logo_match else ""
current_info = {"name": name, "logo_url": logo_url}
elif current_info and line.startswith('http'):
lower_line = line.lower()
if any(lower_line.endswith(ext) for ext in skip_exts):
logger.info(f"Skipping file with unsupported extension: {line}")
current_info = None
continue
defaults = {"logo_url": current_info["logo_url"]}
try:
obj, created = Stream.objects.update_or_create(
name=current_info["name"],
custom_url=line,
m3u_account=account,
defaults=defaults
)
if created:
created_count += 1
else:
updated_count += 1
except Exception as e:
logger.error(f"Failed to update/create stream {current_info['name']}: {e}")
finally:
current_info = None
return f"Parsed local M3U file {file_path}, created {created_count} Streams, updated {updated_count} Streams, excluded {excluded_count} Streams."
========= END OF FILE =========
File: tasks.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: models.py │
└───────────────────────────────────────────────┘
# apps/m3u/models.py
from django.db import models
from django.core.exceptions import ValidationError
import re
class M3UAccount(models.Model):
"""Represents an M3U Account for IPTV streams."""
name = models.CharField(
max_length=255,
unique=True,
help_text="Unique name for this M3U account"
)
server_url = models.URLField(
blank=True,
null=True,
help_text="The base URL of the M3U server (optional if a file is uploaded)"
)
uploaded_file = models.FileField(
upload_to='m3u_uploads/',
blank=True,
null=True
)
server_group = models.ForeignKey(
'ServerGroup',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='m3u_accounts',
help_text="The server group this M3U account belongs to"
)
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"
)
def __str__(self):
return self.name
def clean(self):
if self.max_streams < 0:
raise ValidationError("Max streams cannot be negative.")
def display_action(self):
return "Exclude" if self.exclude else "Include"
def deactivate_streams(self):
"""Deactivate all streams linked to this account."""
for stream in self.streams.all():
stream.is_active = False
stream.save()
def reactivate_streams(self):
"""Reactivate all streams linked to this account."""
for stream in self.streams.all():
stream.is_active = True
stream.save()
class M3UFilter(models.Model):
"""Defines filters for M3U accounts based on stream name or group title."""
FILTER_TYPE_CHOICES = (
('group', 'Group Title'),
('name', 'Stream Name'),
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='filters',
help_text="The M3U account this filter is applied to."
)
filter_type = models.CharField(
max_length=50,
choices=FILTER_TYPE_CHOICES,
default='group',
help_text="Filter based on either group title or stream name."
)
regex_pattern = models.CharField(
max_length=200,
help_text="A regex pattern to match streams or groups."
)
exclude = models.BooleanField(
default=True,
help_text="If True, matching items are excluded; if False, only matches are included."
)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == 'group' else stream_name
return bool(re.search(self.regex_pattern, target, re.IGNORECASE))
def clean(self):
try:
re.compile(self.regex_pattern)
except re.error:
raise ValidationError(f"Invalid regex pattern: {self.regex_pattern}")
def __str__(self):
filter_type_display = dict(self.FILTER_TYPE_CHOICES).get(self.filter_type, 'Unknown')
exclude_status = "Exclude" if self.exclude else "Include"
return f"[{self.m3u_account.name}] {self.filter_type}: {self.regex_pattern} ({exclude_status})"
@staticmethod
def filter_streams(streams, filters):
included_streams = set()
excluded_streams = set()
for f in filters:
for stream in streams:
if f.applies_to(stream.name, stream.group_name):
if f.exclude:
excluded_streams.add(stream)
else:
included_streams.add(stream)
# If no include filters exist, assume all non-excluded streams are valid
if not any(not f.exclude for f in filters):
return streams.exclude(id__in=[s.id for s in excluded_streams])
return streams.filter(id__in=[s.id for s in included_streams])
class ServerGroup(models.Model):
"""Represents a logical grouping of servers or channels."""
name = models.CharField(
max_length=100,
unique=True,
help_text="Unique name for this server group."
)
# def related_channels(self):
# from apps.channels.models import Channel # Avoid circular imports
# return Channel.objects.filter(ChannelGroup=self.name)
def __str__(self):
return self.name
========= END OF FILE =========
File: models.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: serializers.py │
└───────────────────────────────────────────────┘
from rest_framework import serializers
from .models import M3UAccount, M3UFilter, ServerGroup
class M3UFilterSerializer(serializers.ModelSerializer):
"""Serializer for M3U Filters"""
class Meta:
model = M3UFilter
fields = ['id', 'filter_type', 'regex_pattern', 'exclude']
class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = M3UFilterSerializer(many=True, read_only=True)
class Meta:
model = M3UAccount
fields = ['id', 'name', 'server_url', 'uploaded_file', 'server_group',
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters']
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""
class Meta:
model = ServerGroup
fields = ['id', 'name']
========= END OF FILE =========
File: serializers.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: api_urls.py │
└───────────────────────────────────────────────┘
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView
app_name = 'm3u'
router = DefaultRouter()
router.register(r'accounts', M3UAccountViewSet, basename='m3u-account')
router.register(r'filters', M3UFilterViewSet, basename='m3u-filter')
router.register(r'server-groups', ServerGroupViewSet, basename='server-group')
urlpatterns = [
path('refresh/', RefreshM3UAPIView.as_view(), name='m3u_refresh'),
path('refresh/<int:account_id>/', RefreshSingleM3UAPIView.as_view(), name='m3u_refresh_single'),
]
urlpatterns += router.urls
========= END OF FILE =========
File: api_urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: apps.py │
└───────────────────────────────────────────────┘
# apps/m3u/apps.py
from django.apps import AppConfig
class M3UConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.m3u'
verbose_name = "M3U Management"
def ready(self):
import apps.m3u.signals # ensures M3U signals get registered
========= END OF FILE =========
File: apps.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: forms.py │
└───────────────────────────────────────────────┘
# apps/m3u/forms.py
from django import forms
from .models import M3UAccount, M3UFilter
import re
class M3UAccountForm(forms.ModelForm):
class Meta:
model = M3UAccount
fields = [
'name',
'server_url',
'uploaded_file',
'server_group',
'max_streams',
'is_active',
]
def clean_uploaded_file(self):
uploaded_file = self.cleaned_data.get('uploaded_file')
if uploaded_file:
if not uploaded_file.name.endswith('.m3u'):
raise forms.ValidationError("The uploaded file must be an M3U file.")
return uploaded_file
def clean(self):
cleaned_data = super().clean()
url = cleaned_data.get('server_url')
file = cleaned_data.get('uploaded_file')
# Ensure either `server_url` or `uploaded_file` is provided
if not url and not file:
raise forms.ValidationError("Either an M3U URL or a file upload is required.")
return cleaned_data
class M3UFilterForm(forms.ModelForm):
class Meta:
model = M3UFilter
fields = ['m3u_account', 'filter_type', 'regex_pattern', 'exclude']
def clean_regex_pattern(self):
pattern = self.cleaned_data['regex_pattern']
try:
re.compile(pattern)
except re.error:
raise forms.ValidationError("Invalid regex pattern")
return pattern
========= END OF FILE =========
File: forms.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: api_views.py │
└───────────────────────────────────────────────┘
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 drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.core.cache import cache
from .models import M3UAccount, M3UFilter, ServerGroup
from .serializers import M3UAccountSerializer, M3UFilterSerializer, ServerGroupSerializer
from .tasks import refresh_single_m3u_account, refresh_m3u_accounts
# 🔹 1) M3U Account API (CRUD)
class M3UAccountViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U accounts"""
queryset = M3UAccount.objects.all()
serializer_class = M3UAccountSerializer
permission_classes = [IsAuthenticated]
# 🔹 2) M3U Filter API (CRUD)
class M3UFilterViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U filters"""
queryset = M3UFilter.objects.all()
serializer_class = M3UFilterSerializer
permission_classes = [IsAuthenticated]
# 🔹 3) Server Group API (CRUD)
class ServerGroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Server Groups"""
queryset = ServerGroup.objects.all()
serializer_class = ServerGroupSerializer
permission_classes = [IsAuthenticated]
# 🔹 4) Refresh All M3U Accounts
class RefreshM3UAPIView(APIView):
"""Triggers refresh for all active M3U accounts"""
@swagger_auto_schema(
operation_description="Triggers a refresh of all active M3U accounts",
responses={202: "M3U refresh initiated"}
)
def post(self, request, format=None):
refresh_m3u_accounts.delay()
return Response({'success': True, 'message': 'M3U refresh initiated.'}, status=status.HTTP_202_ACCEPTED)
# 🔹 5) Refresh Single M3U Account
class RefreshSingleM3UAPIView(APIView):
"""Triggers refresh for a single M3U account"""
@swagger_auto_schema(
operation_description="Triggers a refresh of a single M3U account",
responses={202: "M3U account refresh initiated"}
)
def post(self, request, account_id, format=None):
refresh_single_m3u_account.delay(account_id)
return Response({'success': True, 'message': f'M3U account {account_id} refresh initiated.'}, status=status.HTTP_202_ACCEPTED)
========= END OF FILE =========
File: api_views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: admin.py │
└───────────────────────────────────────────────┘
from django.contrib import admin
from django.utils.html import format_html
from .models import M3UAccount, M3UFilter, ServerGroup
class M3UFilterInline(admin.TabularInline):
model = M3UFilter
extra = 1
verbose_name = "M3U Filter"
verbose_name_plural = "M3U Filters"
@admin.register(M3UAccount)
class M3UAccountAdmin(admin.ModelAdmin):
list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'uploaded_file_link', 'created_at', 'updated_at')
list_filter = ('is_active',)
search_fields = ('name', 'server_url', 'server_group__name')
inlines = [M3UFilterInline]
actions = ['activate_accounts', 'deactivate_accounts']
def uploaded_file_link(self, obj):
if obj.uploaded_file:
return format_html("<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url)
return "No file uploaded"
uploaded_file_link.short_description = "Uploaded File"
@admin.action(description='Activate selected accounts')
def activate_accounts(self, request, queryset):
queryset.update(is_active=True)
@admin.action(description='Deactivate selected accounts')
def deactivate_accounts(self, request, queryset):
queryset.update(is_active=False)
@admin.register(M3UFilter)
class M3UFilterAdmin(admin.ModelAdmin):
list_display = ('m3u_account', 'filter_type', 'regex_pattern', 'exclude')
list_filter = ('filter_type', 'exclude')
search_fields = ('regex_pattern',)
ordering = ('m3u_account',)
@admin.register(ServerGroup)
class ServerGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
========= END OF FILE =========
File: admin.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: utils.py │
└───────────────────────────────────────────────┘
# apps/m3u/utils.py
import threading
lock = threading.Lock()
# Dictionary to track usage: {m3u_account_id: current_usage}
active_streams_map = {}
def increment_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
current_usage += 1
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def decrement_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
if current_usage > 0:
current_usage -= 1
if current_usage == 0:
del active_streams_map[account.id]
else:
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
========= END OF FILE =========
File: utils.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: urls.py │
└───────────────────────────────────────────────┘
from django.urls import path
from .views import M3UDashboardView
urlpatterns = [
path('dashboard', M3UDashboardView.as_view(), name='m3u_dashboard'),
]
========= END OF FILE =========
File: urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/m3u │
│ File: views.py │
└───────────────────────────────────────────────┘
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
from apps.m3u.models import M3UAccount
import json
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(login_required, name='dispatch')
class M3UDashboardView(View):
def get(self, request, *args, **kwargs):
"""
Handles GET requests for the M3U dashboard.
Renders the m3u.html template with M3U account data.
"""
m3u_accounts = M3UAccount.objects.all()
return render(request, 'm3u/m3u.html', {'m3u_accounts': m3u_accounts})
def post(self, request, *args, **kwargs):
"""
Handles POST requests to create a new M3U account.
Expects JSON data in the request body.
"""
try:
data = json.loads(request.body)
new_account = M3UAccount.objects.create(**data)
return JsonResponse({
'id': new_account.id,
'message': 'M3U account created successfully!'
}, status=201)
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
========= END OF FILE =========
File: views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/dashboard │
│ File: models.py │
└───────────────────────────────────────────────┘
from django.db import models
class Settings(models.Model):
# General Settings
server_name = models.CharField(max_length=255, default="Dispatcharr")
time_zone = models.CharField(max_length=50, default="UTC")
default_logo_url = models.URLField(blank=True, null=True)
max_concurrent_streams = models.PositiveIntegerField(default=10)
auto_backup_frequency = models.CharField(
max_length=50,
choices=[("daily", "Daily"), ("weekly", "Weekly"), ("monthly", "Monthly")],
default="weekly"
)
enable_debug_logs = models.BooleanField(default=False)
# Schedules Direct Settings
schedules_direct_username = models.CharField(max_length=255, blank=True, null=True)
schedules_direct_password = models.CharField(max_length=255, blank=True, null=True)
schedules_direct_update_frequency = models.CharField(
max_length=50,
choices=[("12h", "Every 12 Hours"), ("daily", "Daily")],
default="daily"
)
schedules_direct_api_key = models.CharField(max_length=255, blank=True, null=True)
# Stream and Channel Settings
transcoding_bitrate = models.PositiveIntegerField(default=2000) # in kbps
transcoding_audio_codec = models.CharField(
max_length=50,
choices=[("aac", "AAC"), ("mp3", "MP3")],
default="aac"
)
transcoding_resolution = models.CharField(
max_length=50,
choices=[("720p", "720p"), ("1080p", "1080p")],
default="1080p"
)
failover_behavior = models.CharField(
max_length=50,
choices=[("sequential", "Sequential"), ("random", "Random")],
default="sequential"
)
stream_health_check_frequency = models.PositiveIntegerField(default=5) # in minutes
# Notifications
email_notifications = models.BooleanField(default=False)
webhook_url = models.URLField(blank=True, null=True)
cpu_alert_threshold = models.PositiveIntegerField(default=90) # Percentage
memory_alert_threshold = models.PositiveIntegerField(default=90) # Percentage
# API Settings
hdhr_integration = models.BooleanField(default=True)
custom_api_endpoints = models.JSONField(blank=True, null=True)
# Backup and Restore
backup_path = models.CharField(max_length=255, default="backups/")
backup_frequency = models.CharField(
max_length=50,
choices=[("daily", "Daily"), ("weekly", "Weekly"), ("monthly", "Monthly")],
default="weekly"
)
# Advanced
ffmpeg_path = models.CharField(max_length=255, default="/usr/bin/ffmpeg")
custom_transcoding_flags = models.TextField(blank=True, null=True)
celery_worker_concurrency = models.PositiveIntegerField(default=4)
========= END OF FILE =========
File: models.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/dashboard │
│ File: api_urls.py │
└───────────────────────────────────────────────┘
from django.urls import path
from .views import dashboard_view, settings_view, live_dashboard_data
app_name = 'dashboard'
urlpatterns = [
path('dashboard-data/', live_dashboard_data, name='dashboard_data'),
]
========= END OF FILE =========
File: api_urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/dashboard │
│ File: apps.py │
└───────────────────────────────────────────────┘
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.dashboard'
========= END OF FILE =========
File: apps.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/dashboard │
│ File: admin.py │
└───────────────────────────────────────────────┘
from django.contrib import admin
========= END OF FILE =========
File: admin.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/dashboard │
│ File: tests.py │
└───────────────────────────────────────────────┘
from django.test import TestCase
# Create your tests here.
========= END OF FILE =========
File: tests.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/dashboard │
│ File: urls.py │
└───────────────────────────────────────────────┘
from django.urls import path
from .views import dashboard_view, settings_view, live_dashboard_data
app_name = 'dashboard'
urlpatterns = [
path('', dashboard_view, name='dashboard'),
path('settings/', settings_view, name='settings'),
path('api/dashboard-data/', live_dashboard_data, name='dashboard_data'),
]
========= END OF FILE =========
File: urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/dashboard │
│ File: views.py │
└───────────────────────────────────────────────┘
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from psutil import cpu_percent, virtual_memory, net_io_counters
from apps.channels.models import Stream
from django.http import JsonResponse # ADD THIS LINE
@login_required
def dashboard_view(request):
# Fetch system metrics
try:
cpu_usage = cpu_percent(interval=1)
ram = virtual_memory()
ram_usage = f"{ram.used / (1024 ** 3):.1f} GB / {ram.total / (1024 ** 3):.1f} GB"
network = net_io_counters()
network_traffic = f"{network.bytes_sent / (1024 ** 2):.1f} MB"
except Exception as e:
cpu_usage = "N/A"
ram_usage = "N/A"
network_traffic = "N/A"
print(f"Error fetching system metrics: {e}")
# Fetch active streams and related channels
active_streams = Stream.objects.filter(current_viewers__gt=0).prefetch_related('channels')
active_streams_list = [
f"Stream {i + 1}: {stream.custom_url or 'Unknown'} ({stream.current_viewers} viewers)"
for i, stream in enumerate(active_streams)
]
# Pass data to the template
context = {
"cpu_usage": f"{cpu_usage}%",
"ram_usage": ram_usage,
"current_streams": active_streams.count(),
"network_traffic": network_traffic,
"active_streams": active_streams_list,
}
return render(request, "core/dashboard.html", context)
@login_required
def settings_view(request):
# Placeholder for settings functionality
return render(request, 'core/settings.html')
@login_required
def live_dashboard_data(request):
try:
cpu_usage = cpu_percent(interval=1)
ram = virtual_memory()
network = net_io_counters()
ram_usage = f"{ram.used / (1024 ** 3):.1f} GB / {ram.total / (1024 ** 3):.1f} GB"
network_traffic = f"{network.bytes_sent / (1024 ** 2):.1f} MB"
# Mocked example data for the charts
cpu_data = [45, 50, 60, 55, 70, 65]
ram_data = [6.5, 7.0, 7.5, 8.0, 8.5, 9.0]
network_data = [120, 125, 130, 128, 126, 124]
active_streams = Stream.objects.filter(current_viewers__gt=0)
active_streams_list = [
f"Stream {i + 1}: {stream.custom_url or 'Unknown'} ({stream.current_viewers} viewers)"
for i, stream in enumerate(active_streams)
]
data = {
"cpu_usage": f"{cpu_usage}%",
"ram_usage": ram_usage,
"current_streams": active_streams.count(),
"network_traffic": network_traffic,
"active_streams": active_streams_list,
"cpu_data": cpu_data,
"ram_data": ram_data,
"network_data": network_data,
}
except Exception as e:
data = {
"error": str(e)
}
return JsonResponse(data)
========= END OF FILE =========
File: views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/accounts │
│ File: signals.py │
└───────────────────────────────────────────────┘
# apps/accounts/signals.py
# Example: automatically create something on user creation
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import User
@receiver(post_save, sender=User)
def handle_new_user(sender, instance, created, **kwargs):
if created:
# e.g. initialize default avatar config
if not instance.avatar_config:
instance.avatar_config = {"style": "circle"}
instance.save()
========= END OF FILE =========
File: signals.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/accounts │
│ File: models.py │
└───────────────────────────────────────────────┘
# apps/accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser, Permission
class User(AbstractUser):
"""
Custom user model for Dispatcharr.
Inherits from Django's AbstractUser to add additional fields if needed.
"""
avatar_config = models.JSONField(default=dict, blank=True, null=True)
channel_groups = models.ManyToManyField(
'channels.ChannelGroup', # Updated reference to renamed model
blank=True,
related_name="users"
)
def __str__(self):
return self.username
def get_groups(self):
"""
Returns the groups (roles) the user belongs to.
"""
return self.groups.all()
def get_permissions(self):
"""
Returns the permissions assigned to the user and their groups.
"""
return self.user_permissions.all() | Permission.objects.filter(group__user=self)
========= END OF FILE =========
File: models.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/accounts │
│ File: serializers.py │
└───────────────────────────────────────────────┘
from rest_framework import serializers
from django.contrib.auth.models import Group, Permission
from .models import User
# 🔹 Fix for Permission serialization
class PermissionSerializer(serializers.ModelSerializer):
class Meta:
model = Permission
fields = ['id', 'name', 'codename']
# 🔹 Fix for Group serialization
class GroupSerializer(serializers.ModelSerializer):
permissions = serializers.PrimaryKeyRelatedField(
many=True, queryset=Permission.objects.all()
) # ✅ Fixes ManyToManyField `_meta` error
class Meta:
model = Group
fields = ['id', 'name', 'permissions']
# 🔹 Fix for User serialization
class UserSerializer(serializers.ModelSerializer):
groups = serializers.SlugRelatedField(
many=True, queryset=Group.objects.all(), slug_field="name"
) # ✅ Fix ManyToMany `_meta` error
class Meta:
model = User
fields = ['id', 'username', 'email', 'groups']
========= END OF FILE =========
File: serializers.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/accounts │
│ File: api_urls.py │
└───────────────────────────────────────────────┘
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
AuthViewSet, UserViewSet, GroupViewSet,
list_permissions
)
app_name = 'accounts'
# 🔹 Register ViewSets with a Router
router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user')
router.register(r'groups', GroupViewSet, basename='group')
# 🔹 Custom Authentication Endpoints
auth_view = AuthViewSet.as_view({
'post': 'login'
})
logout_view = AuthViewSet.as_view({
'post': 'logout'
})
# 🔹 Define API URL patterns
urlpatterns = [
# Authentication
path('auth/login/', auth_view, name='user-login'),
path('auth/logout/', logout_view, name='user-logout'),
# Permissions API
path('permissions/', list_permissions, name='list-permissions'),
]
# 🔹 Include ViewSet routes
urlpatterns += router.urls
========= END OF FILE =========
File: api_urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/accounts │
│ File: apps.py │
└───────────────────────────────────────────────┘
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.accounts'
verbose_name = "Accounts & Authentication"
========= END OF FILE =========
File: apps.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/accounts │
│ File: forms.py │
└───────────────────────────────────────────────┘
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import Permission
from django.contrib.auth.models import Group as AuthGroup
from apps.channels.models import ChannelGroup
from .models import User
from .models import User
class UserRegistrationForm(UserCreationForm):
groups = forms.ModelMultipleChoiceField(
queryset=AuthGroup.objects.all(),
required=False,
widget=forms.CheckboxSelectMultiple
)
class Meta:
model = User
fields = ['username', 'groups', 'password1', 'password2', ]
def save(self, commit=True):
user = super().save(commit=False)
if commit:
user.save()
self.save_m2m() # Save the many-to-many field data
return user
class GroupForm(forms.ModelForm):
permissions = forms.ModelMultipleChoiceField(
queryset=Permission.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False
)
class Meta:
model = AuthGroup
fields = ['name', 'permissions']
class UserEditForm(forms.ModelForm):
auth_groups = forms.ModelMultipleChoiceField(
queryset=AuthGroup.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Auth Groups"
)
channel_groups = forms.ModelMultipleChoiceField(
queryset=ChannelGroup.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Channel Groups"
)
class Meta:
model = User
fields = ['username', 'email', 'auth_groups', 'channel_groups']
========= END OF FILE =========
File: forms.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/accounts │
│ File: api_views.py │
└───────────────────────────────────────────────┘
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import Group, Permission
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework import viewsets
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from .models import User
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
# 🔹 1) Authentication APIs
class AuthViewSet(viewsets.ViewSet):
"""Handles user login and logout"""
@swagger_auto_schema(
operation_description="Authenticate and log in a user",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['username', 'password'],
properties={
'username': openapi.Schema(type=openapi.TYPE_STRING),
'password': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD)
},
),
responses={200: "Login successful", 400: "Invalid credentials"},
)
def login(self, request):
"""Logs in a user and returns user details"""
username = request.data.get('username')
password = request.data.get('password')
user = authenticate(request, username=username, password=password)
if user:
login(request, user)
return Response({
"message": "Login successful",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"groups": list(user.groups.values_list('name', flat=True))
}
})
return Response({"error": "Invalid credentials"}, status=400)
@swagger_auto_schema(
operation_description="Log out the current user",
responses={200: "Logout successful"}
)
def logout(self, request):
"""Logs out the authenticated user"""
logout(request)
return Response({"message": "Logout successful"})
# 🔹 2) User Management APIs
class UserViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Users"""
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Retrieve a list of users",
responses={200: UserSerializer(many=True)}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Retrieve a specific user by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Create a new user")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Update a user")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Delete a user")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
# 🔹 3) Group Management APIs
class GroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Groups"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Retrieve a list of groups",
responses={200: GroupSerializer(many=True)}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Retrieve a specific group by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Create a new group")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Update a group")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Delete a group")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
# 🔹 4) Permissions List API
@swagger_auto_schema(
method='get',
operation_description="Retrieve a list of all permissions",
responses={200: PermissionSerializer(many=True)}
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def list_permissions(request):
"""Returns a list of all available permissions"""
permissions = Permission.objects.all()
serializer = PermissionSerializer(permissions, many=True)
return Response(serializer.data)
========= END OF FILE =========
File: api_views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/accounts │
│ File: admin.py │
└───────────────────────────────────────────────┘
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin, GroupAdmin
from django.contrib.auth.models import Group
from .models import User
@admin.register(User)
class CustomUserAdmin(UserAdmin):
fieldsets = (
(None, {'fields': ('username', 'password', 'avatar_config', 'groups')}),
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
# Unregister default Group admin and re-register it.
admin.site.unregister(Group)
admin.site.register(Group, GroupAdmin)
========= END OF FILE =========
File: admin.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: tasks.py │
└───────────────────────────────────────────────┘
from celery import shared_task
from .models import EPGSource, Program
from apps.channels.models import Channel
from django.utils import timezone
import requests
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from django.db import transaction
@shared_task
def refresh_epg_data():
active_sources = EPGSource.objects.filter(is_active=True)
for source in active_sources:
if source.source_type == 'xmltv':
fetch_xmltv(source)
elif source.source_type == 'schedules_direct':
fetch_schedules_direct(source)
return "EPG data refreshed."
def fetch_xmltv(source):
try:
response = requests.get(source.url, timeout=30)
response.raise_for_status()
root = ET.fromstring(response.content)
with transaction.atomic():
for programme in root.findall('programme'):
start_time = parse_xmltv_time(programme.get('start'))
stop_time = parse_xmltv_time(programme.get('stop'))
channel_tvg_id = programme.get('channel')
title = programme.findtext('title', default='No Title')
desc = programme.findtext('desc', default='')
# Find or create the channel
try:
channel = Channel.objects.get(tvg_id=channel_tvg_id)
except Channel.DoesNotExist:
# Optionally, skip programs for unknown channels
continue
# Create or update the program
Program.objects.update_or_create(
channel=channel,
title=title,
start_time=start_time,
end_time=stop_time,
defaults={'description': desc}
)
except Exception as e:
# Log the error appropriately
print(f"Error fetching XMLTV from {source.name}: {e}")
def fetch_schedules_direct(source):
try:
# Example: Implement Schedules Direct API fetching
# Note: You'll need to handle authentication, pagination, etc.
# This is a simplified example.
api_url = 'https://api.schedulesdirect.org/20141201/json/premium/subscriptions'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {source.api_key}',
}
# Fetch subscriptions (channels)
response = requests.get(api_url, headers=headers, timeout=30)
response.raise_for_status()
subscriptions = response.json()
# Fetch schedules for each subscription
for sub in subscriptions:
channel_tvg_id = sub.get('stationID')
# Fetch schedules
schedules_url = f"https://api.schedulesdirect.org/20141201/json/schedules/{channel_tvg_id}"
sched_response = requests.get(schedules_url, headers=headers, timeout=30)
sched_response.raise_for_status()
schedules = sched_response.json()
with transaction.atomic():
try:
channel = Channel.objects.get(tvg_id=channel_tvg_id)
except Channel.DoesNotExist:
# Optionally, skip programs for unknown channels
continue
for sched in schedules.get('schedules', []):
title = sched.get('title', 'No Title')
desc = sched.get('description', '')
start_time = parse_schedules_direct_time(sched.get('startTime'))
end_time = parse_schedules_direct_time(sched.get('endTime'))
Program.objects.update_or_create(
channel=channel,
title=title,
start_time=start_time,
end_time=end_time,
defaults={'description': desc}
)
except Exception as e:
# Log the error appropriately
print(f"Error fetching Schedules Direct data from {source.name}: {e}")
def parse_xmltv_time(time_str):
# XMLTV time format: '20250130120000 +0000'
dt = datetime.strptime(time_str[:14], '%Y%m%d%H%M%S')
tz_sign = time_str[15]
tz_hours = int(time_str[16:18])
tz_minutes = int(time_str[18:20])
if tz_sign == '+':
dt = dt - timedelta(hours=tz_hours, minutes=tz_minutes)
elif tz_sign == '-':
dt = dt + timedelta(hours=tz_hours, minutes=tz_minutes)
return timezone.make_aware(dt, timezone=timezone.utc)
def parse_schedules_direct_time(time_str):
# Schedules Direct time format: ISO 8601, e.g., '2025-01-30T12:00:00Z'
dt = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ')
return timezone.make_aware(dt, timezone=timezone.utc)
========= END OF FILE =========
File: tasks.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: models.py │
└───────────────────────────────────────────────┘
from django.db import models
from django.utils import timezone
from apps.channels.models import Channel
class EPGSource(models.Model):
SOURCE_TYPE_CHOICES = [
('xmltv', 'XMLTV URL'),
('schedules_direct', 'Schedules Direct API'),
]
name = models.CharField(max_length=255, unique=True)
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES)
url = models.URLField(blank=True, null=True) # For XMLTV
api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct
is_active = models.BooleanField(default=True)
def __str__(self):
return self.name
class Program(models.Model):
channel = models.ForeignKey('channels.Channel', on_delete=models.CASCADE, related_name="programs")
title = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
start_time = models.DateTimeField()
end_time = models.DateTimeField()
def __str__(self):
return f"{self.title} ({self.start_time} - {self.end_time})"
========= END OF FILE =========
File: models.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: serializers.py │
└───────────────────────────────────────────────┘
from rest_framework import serializers
from .models import Program, EPGSource
from apps.channels.models import Channel
class EPGSourceSerializer(serializers.ModelSerializer):
class Meta:
model = EPGSource
fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active']
class ProgramSerializer(serializers.ModelSerializer):
channel = serializers.SerializerMethodField()
def get_channel(self, obj):
return {"id": obj.channel.id, "name": obj.channel.name} if obj.channel else None
class Meta:
model = Program
fields = ['id', 'channel', 'title', 'description', 'start_time', 'end_time']
========= END OF FILE =========
File: serializers.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: api_urls.py │
└───────────────────────────────────────────────┘
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView
app_name = 'epg'
router = DefaultRouter()
router.register(r'sources', EPGSourceViewSet, basename='epg-source')
router.register(r'programs', ProgramViewSet, basename='program')
urlpatterns = [
path('grid/', EPGGridAPIView.as_view(), name='epg_grid'),
path('import/', EPGImportAPIView.as_view(), name='epg_import'),
]
urlpatterns += router.urls
========= END OF FILE =========
File: api_urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: apps.py │
└───────────────────────────────────────────────┘
from django.apps import AppConfig
class EpgConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.epg'
verbose_name = "EPG Management"
========= END OF FILE =========
File: apps.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: api_views.py │
└───────────────────────────────────────────────┘
from rest_framework import generics, status, viewsets
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.utils import timezone
from datetime import timedelta
from .models import Program, EPGSource
from .serializers import ProgramSerializer, EPGSourceSerializer
from .tasks import refresh_epg_data
# 🔹 1) EPG Source API (CRUD)
class EPGSourceViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for EPG sources"""
queryset = EPGSource.objects.all()
serializer_class = EPGSourceSerializer
permission_classes = [IsAuthenticated]
# 🔹 2) Program API (CRUD)
class ProgramViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for EPG programs"""
queryset = Program.objects.all()
serializer_class = ProgramSerializer
permission_classes = [IsAuthenticated]
# 🔹 3) EPG Grid View: Shows programs airing within the next 12 hours
class EPGGridAPIView(APIView):
"""Returns all programs airing in the next 12 hours"""
@swagger_auto_schema(
operation_description="Retrieve upcoming EPG programs within the next 12 hours",
responses={200: ProgramSerializer(many=True)}
)
def get(self, request, format=None):
now = timezone.now()
twelve_hours_later = now + timedelta(hours=12)
programs = Program.objects.select_related('channel').filter(
start_time__gte=now, start_time__lte=twelve_hours_later
)
serializer = ProgramSerializer(programs, many=True)
return Response({'data': serializer.data}, status=status.HTTP_200_OK)
# 🔹 4) EPG Import View: Triggers an import of EPG data
class EPGImportAPIView(APIView):
"""Triggers an EPG data refresh"""
@swagger_auto_schema(
operation_description="Triggers an EPG data import",
responses={202: "EPG data import initiated"}
)
def post(self, request, format=None):
refresh_epg_data.delay() # Trigger Celery task
return Response({'success': True, 'message': 'EPG data import initiated.'}, status=status.HTTP_202_ACCEPTED)
========= END OF FILE =========
File: api_views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: admin.py │
└───────────────────────────────────────────────┘
from django.contrib import admin
from .models import EPGSource, Program
@admin.register(EPGSource)
class EPGSourceAdmin(admin.ModelAdmin):
list_display = ['name', 'source_type', 'is_active']
list_filter = ['source_type', 'is_active']
search_fields = ['name']
@admin.register(Program)
class ProgramAdmin(admin.ModelAdmin):
list_display = ['title', 'get_channel_tvg_id', 'start_time', 'end_time']
list_filter = ['channel']
search_fields = ['title', 'channel__channel_name']
def get_channel_tvg_id(self, obj):
return obj.channel.tvg_id if obj.channel else ''
get_channel_tvg_id.short_description = 'Channel TVG ID'
get_channel_tvg_id.admin_order_field = 'channel__tvg_id'
========= END OF FILE =========
File: admin.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: urls.py │
└───────────────────────────────────────────────┘
from django.urls import path
from .views import EPGDashboardView
app_name = 'epg_dashboard'
urlpatterns = [
path('dashboard/', EPGDashboardView.as_view(), name='epg_dashboard'),
]
========= END OF FILE =========
File: urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/epg │
│ File: views.py │
└───────────────────────────────────────────────┘
from django.views import View
from django.shortcuts import render
from django.http import JsonResponse
from rest_framework.parsers import JSONParser
from .models import EPGSource
from .serializers import EPGSourceSerializer
class EPGDashboardView(View):
def get(self, request, *args, **kwargs):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
sources = EPGSource.objects.all()
serializer = EPGSourceSerializer(sources, many=True)
return JsonResponse({'data': serializer.data}, safe=False)
return render(request, 'epg/epg.html', {'epg_sources': EPGSource.objects.all()})
def post(self, request, *args, **kwargs):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
data = JSONParser().parse(request)
serializer = EPGSourceSerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse({'success': True, 'data': serializer.data}, status=201)
return JsonResponse({'success': False, 'errors': serializer.errors}, status=400)
return JsonResponse({'success': False, 'error': 'Invalid request.'}, status=400)
========= END OF FILE =========
File: views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/api │
│ File: urls.py │
└───────────────────────────────────────────────┘
from django.urls import path, include
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from rest_framework.permissions import AllowAny
app_name = 'api'
# Configure Swagger Schema
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
default_version='v1',
description="API documentation for Dispatcharr",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="support@dispatcharr.local"),
license=openapi.License(name="Unlicense"),
),
public=True,
permission_classes=(AllowAny,),
)
urlpatterns = [
path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')),
#path('backup/', include(('apps.backup.api_urls', 'backup'), namespace='backup')),
path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')),
path('epg/', include(('apps.epg.api_urls', 'epg'), namespace='epg')),
path('hdhr/', include(('apps.hdhr.api_urls', 'hdhr'), namespace='hdhr')),
path('m3u/', include(('apps.m3u.api_urls', 'm3u'), namespace='m3u')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),
#path('streams/', include(('apps.streams.api_urls', 'streams'), namespace='streams')),
# Swagger Documentation api_urls
path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
]
========= END OF FILE =========
File: urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/hdhr │
│ File: models.py │
└───────────────────────────────────────────────┘
from django.db import models
class HDHRDevice(models.Model):
friendly_name = models.CharField(max_length=100, default='Dispatcharr HDHomeRun')
device_id = models.CharField(max_length=32, unique=True)
tuner_count = models.PositiveIntegerField(default=3)
def __str__(self):
return self.friendly_name
========= END OF FILE =========
File: models.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/hdhr │
│ File: serializers.py │
└───────────────────────────────────────────────┘
from rest_framework import serializers
from .models import HDHRDevice
class HDHRDeviceSerializer(serializers.ModelSerializer):
"""Serializer for HDHomeRun device information"""
class Meta:
model = HDHRDevice
fields = ['id', 'friendly_name', 'device_id', 'tuner_count']
========= END OF FILE =========
File: serializers.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/hdhr │
│ File: api_urls.py │
└───────────────────────────────────────────────┘
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
========= END OF FILE =========
File: api_urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/hdhr │
│ File: apps.py │
└───────────────────────────────────────────────┘
from django.apps import AppConfig
from . import ssdp
class HdhrConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.hdhr'
verbose_name = "HDHomeRun Emulation"
def ready(self):
# Start SSDP services when the app is ready
ssdp.start_ssdp()
========= END OF FILE =========
File: apps.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/hdhr │
│ File: api_views.py │
└───────────────────────────────────────────────┘
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")
========= END OF FILE =========
File: api_views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/hdhr │
│ File: admin.py │
└───────────────────────────────────────────────┘
from django.contrib import admin
from .models import HDHRDevice
@admin.register(HDHRDevice)
class HDHRDeviceAdmin(admin.ModelAdmin):
list_display = ('friendly_name', 'device_id', 'tuner_count')
========= END OF FILE =========
File: admin.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/hdhr │
│ File: ssdp.py │
└───────────────────────────────────────────────┘
import socket
import threading
import time
# 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."""
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"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"
f"\r\n"
)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.sendto(response.encode("utf-8"), addr)
sock.close()
def ssdp_listener():
"""Listen for SSDP M-SEARCH requests and respond."""
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)
def ssdp_broadcaster():
"""Broadcast SSDP NOTIFY messages periodically."""
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"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n"
f"NT: {DEVICE_TYPE}\r\n"
f"NTS: ssdp:alive\r\n"
f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n"
f"\r\n"
)
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}.")
========= END OF FILE =========
File: ssdp.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: models.py │
└───────────────────────────────────────────────┘
from django.db import models
from django.core.exceptions import ValidationError
# If you have an M3UAccount model in apps.m3u, you can still import it:
from apps.m3u.models import M3UAccount
class Stream(models.Model):
"""
Represents a single stream (e.g. from an M3U source or custom URL).
"""
name = models.CharField(max_length=255, default="Default Stream")
url = models.URLField()
custom_url = models.URLField(max_length=2000, blank=True, null=True)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="streams"
)
logo_url = models.URLField(max_length=2000, blank=True, null=True)
tvg_id = models.CharField(max_length=255, blank=True, null=True)
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)
class Meta:
# If you use m3u_account, you might do unique_together = ('name','custom_url','m3u_account')
verbose_name = "Stream"
verbose_name_plural = "Streams"
ordering = ['-updated_at']
def __str__(self):
return self.name or self.custom_url or f"Stream ID {self.id}"
class ChannelManager(models.Manager):
def active(self):
return self.filter(is_active=True)
class Channel(models.Model):
channel_number = models.IntegerField()
channel_name = models.CharField(max_length=255)
logo_url = models.URLField(max_length=2000, blank=True, null=True)
logo_file = models.ImageField(
upload_to='logos/', # Will store in MEDIA_ROOT/logos
blank=True,
null=True
)
# M2M to Stream now in the same file
streams = models.ManyToManyField(
Stream,
blank=True,
related_name='channels'
)
channel_group = models.ForeignKey(
'ChannelGroup',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='channels',
help_text="Channel group this channel belongs to."
)
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):
# Enforce unique channel_number within a given group
existing = Channel.objects.filter(
channel_number=self.channel_number,
channel_group=self.channel_group
).exclude(id=self.id)
if existing.exists():
raise ValidationError(
f"Channel number {self.channel_number} already exists in group {self.channel_group}."
)
def __str__(self):
return f"{self.channel_number} - {self.channel_name}"
class ChannelGroup(models.Model):
name = models.CharField(max_length=100, unique=True)
def related_channels(self):
# local import if needed to avoid cyc. Usually fine in a single file though
return Channel.objects.filter(channel_group=self)
def __str__(self):
return self.name
========= END OF FILE =========
File: models.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: serializers.py │
└───────────────────────────────────────────────┘
from rest_framework import serializers
from .models import Stream, Channel, ChannelGroup
#
# Stream
#
class StreamSerializer(serializers.ModelSerializer):
class Meta:
model = Stream
fields = [
'id',
'name',
'url',
'custom_url',
'm3u_account', # Uncomment if using M3U fields
'logo_url',
'tvg_id',
'local_file',
'current_viewers',
'is_transcoded',
'ffmpeg_preset',
'updated_at',
'group_name',
]
#
# Channel Group
#
class ChannelGroupSerializer(serializers.ModelSerializer):
class Meta:
model = ChannelGroup
fields = ['id', 'name']
#
# Channel
#
class ChannelSerializer(serializers.ModelSerializer):
# Show nested group data, or ID
channel_group = ChannelGroupSerializer(read_only=True)
channel_group_id = serializers.PrimaryKeyRelatedField(
queryset=ChannelGroup.objects.all(),
source="channel_group",
write_only=True,
required=False
)
# Possibly show streams inline, or just by ID
# streams = StreamSerializer(many=True, read_only=True)
class Meta:
model = Channel
fields = [
'id',
'channel_number',
'channel_name',
'logo_url',
'logo_file',
'channel_group',
'channel_group_id',
'tvg_id',
'tvg_name',
'is_active',
'is_looping',
'shuffle_mode',
'streams'
]
========= END OF FILE =========
File: serializers.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: api_urls.py │
└───────────────────────────────────────────────┘
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
StreamViewSet,
ChannelViewSet,
ChannelGroupViewSet,
BulkDeleteStreamsAPIView,
BulkDeleteChannelsViewSet
)
app_name = 'channels' # for DRF routing
router = DefaultRouter()
router.register(r'streams', StreamViewSet, basename='stream')
router.register(r'groups', ChannelGroupViewSet, basename='channel-group')
router.register(r'channels', ChannelViewSet, basename='channel')
router.register(r'bulk-delete-channels', BulkDeleteChannelsViewSet, basename='bulk-delete-channels')
urlpatterns = [
# Bulk delete for streams is a single APIView, not a ViewSet
path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'),
]
urlpatterns += router.urls
========= END OF FILE =========
File: api_urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: apps.py │
└───────────────────────────────────────────────┘
from django.apps import AppConfig
class ChannelsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.channels'
verbose_name = "Channel & Stream Management"
========= END OF FILE =========
File: apps.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: forms.py │
└───────────────────────────────────────────────┘
from django import forms
from .models import Stream, Channel, ChannelGroup
#
# ChannelGroup Form
#
class ChannelGroupForm(forms.ModelForm):
class Meta:
model = ChannelGroup
fields = ['name']
#
# Channel Form
#
class ChannelForm(forms.ModelForm):
channel_group = forms.ModelChoiceField(
queryset=ChannelGroup.objects.all(),
required=False,
label="Channel Group",
empty_label="--- No group ---"
)
class Meta:
model = Channel
fields = [
'channel_number',
'channel_name',
'channel_group',
'is_active',
'is_looping',
'shuffle_mode',
]
#
# Example: Stream Form (optional if you want a ModelForm for Streams)
#
class StreamForm(forms.ModelForm):
class Meta:
model = Stream
fields = [
'name',
'url',
'custom_url',
'logo_url',
'tvg_id',
'local_file',
'is_transcoded',
'ffmpeg_preset',
'group_name',
]
========= END OF FILE =========
File: forms.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: api_views.py │
└───────────────────────────────────────────────┘
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 rest_framework.decorators import action
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from .models import Stream, Channel, ChannelGroup
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer
# ─────────────────────────────────────────────────────────
# 1) Stream API (CRUD)
# ─────────────────────────────────────────────────────────
class StreamViewSet(viewsets.ModelViewSet):
queryset = Stream.objects.all()
serializer_class = StreamSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = super().get_queryset()
assigned = self.request.query_params.get('assigned')
if assigned is not None:
# Streams that belong to a given channel?
qs = qs.filter(channels__id=assigned)
unassigned = self.request.query_params.get('unassigned')
if unassigned == '1':
# Streams that are not linked to any channel
qs = qs.filter(channels__isnull=True)
return qs
# ─────────────────────────────────────────────────────────
# 2) Channel Group Management (CRUD)
# ─────────────────────────────────────────────────────────
class ChannelGroupViewSet(viewsets.ModelViewSet):
queryset = ChannelGroup.objects.all()
serializer_class = ChannelGroupSerializer
permission_classes = [IsAuthenticated]
# ─────────────────────────────────────────────────────────
# 3) Channel Management (CRUD)
# ─────────────────────────────────────────────────────────
class ChannelViewSet(viewsets.ModelViewSet):
queryset = Channel.objects.all()
serializer_class = ChannelSerializer
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
method='post',
operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_order"],
properties={
"channel_order": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of channel IDs in the new order"
)
}
),
responses={200: "Channels have been auto-assigned!"}
)
@action(detail=False, methods=['post'], url_path='assign')
def assign(self, request):
channel_order = request.data.get('channel_order', [])
for order, channel_id in enumerate(channel_order, start=1):
Channel.objects.filter(id=channel_id).update(channel_number=order)
return Response({"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK)
@swagger_auto_schema(
method='post',
operation_description=(
"Create a new channel from an existing stream.\n"
"Request body must contain: 'stream_id', 'channel_number', 'channel_name'."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_id", "channel_number", "channel_name"],
properties={
"stream_id": openapi.Schema(
type=openapi.TYPE_INTEGER, description="ID of the stream to link"
),
"channel_number": openapi.Schema(
type=openapi.TYPE_INTEGER, description="Desired channel_number"
),
"channel_name": openapi.Schema(
type=openapi.TYPE_STRING, description="Desired channel name"
)
}
),
responses={201: ChannelSerializer()}
)
@action(detail=False, methods=['post'], url_path='from-stream')
def from_stream(self, request):
stream_id = request.data.get('stream_id')
if not stream_id:
return Response({"error": "Missing stream_id"}, status=status.HTTP_400_BAD_REQUEST)
stream = get_object_or_404(Stream, pk=stream_id)
channel_data = {
'channel_number': request.data.get('channel_number', 0),
'channel_name': request.data.get('channel_name', f"Channel from {stream.name}"),
}
serializer = self.get_serializer(data=channel_data)
serializer.is_valid(raise_exception=True)
channel = serializer.save()
# Optionally attach the stream to that channel
channel.streams.add(stream)
return Response(serializer.data, status=status.HTTP_201_CREATED)
# ─────────────────────────────────────────────────────────
# 4) Bulk Delete Streams
# ─────────────────────────────────────────────────────────
class BulkDeleteStreamsAPIView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Bulk delete streams by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_ids"],
properties={
"stream_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Stream IDs to delete"
)
},
),
responses={204: "Streams deleted"}
)
def delete(self, request, *args, **kwargs):
stream_ids = request.data.get('stream_ids', [])
Stream.objects.filter(id__in=stream_ids).delete()
return Response({"message": "Streams deleted successfully!"}, status=status.HTTP_204_NO_CONTENT)
# ─────────────────────────────────────────────────────────
# 5) Bulk Delete Channels
# ─────────────────────────────────────────────────────────
class BulkDeleteChannelsViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Bulk delete channels by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_ids"],
properties={
"channel_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Channel IDs to delete"
)
},
),
responses={204: "Channels deleted"}
)
def destroy(self, request):
channel_ids = request.data.get('channel_ids', [])
Channel.objects.filter(id__in=channel_ids).delete()
return Response({"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT)
========= END OF FILE =========
File: api_views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: admin.py │
└───────────────────────────────────────────────┘
from django.contrib import admin
from .models import Stream, Channel, ChannelGroup
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = (
'id', 'name', 'group_name', 'custom_url',
'current_viewers', 'is_transcoded', 'updated_at',
)
list_filter = ('group_name', 'is_transcoded')
search_fields = ('name', 'custom_url', 'group_name')
ordering = ('-updated_at',)
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
list_display = (
'channel_number', 'channel_name', 'channel_group',
'is_active', 'is_looping', 'shuffle_mode', 'tvg_name'
)
list_filter = ('channel_group', 'is_active', 'is_looping', 'shuffle_mode')
search_fields = ('channel_name', 'channel_group__name', 'tvg_name')
ordering = ('channel_number',)
@admin.register(ChannelGroup)
class ChannelGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
========= END OF FILE =========
File: admin.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: utils.py │
└───────────────────────────────────────────────┘
import threading
lock = threading.Lock()
# Dictionary to track usage: {account_id: current_usage}
active_streams_map = {}
def increment_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
current_usage += 1
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def decrement_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
if current_usage > 0:
current_usage -= 1
if current_usage == 0:
del active_streams_map[account.id]
else:
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
========= END OF FILE =========
File: utils.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: urls.py │
└───────────────────────────────────────────────┘
from django.urls import path
from .views import StreamDashboardView, channels_dashboard_view
app_name = 'channels_dashboard'
urlpatterns = [
# Example “dashboard” routes for streams
path('streams/', StreamDashboardView.as_view(), name='stream_dashboard'),
# Example “dashboard” route for channels
path('channels/', channels_dashboard_view, name='channels_dashboard'),
]
========= END OF FILE =========
File: urls.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels │
│ File: views.py │
└───────────────────────────────────────────────┘
from django.views import View
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import render
from .models import Stream
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(login_required, name='dispatch')
class StreamDashboardView(View):
"""
Example “dashboard” style view for Streams
"""
def get(self, request, *args, **kwargs):
streams = Stream.objects.values(
'id', 'name', 'url', 'custom_url',
'group_name', 'current_viewers'
)
return JsonResponse({'data': list(streams)}, safe=False)
def post(self, request, *args, **kwargs):
"""
Creates a new Stream from JSON data
"""
import json
try:
data = json.loads(request.body)
new_stream = Stream.objects.create(**data)
return JsonResponse({
'id': new_stream.id,
'message': 'Stream created successfully!'
}, status=201)
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
@login_required
def channels_dashboard_view(request):
"""
Example “dashboard” style view for Channels
"""
return render(request, 'channels/channels.html')
========= END OF FILE =========
File: views.py
===============================
┌───────────────────────────────────────────────┐
│ Directory: apps/channels/management/commands │
│ File: remove_duplicates.py │
└───────────────────────────────────────────────┘
from django.core.management.base import BaseCommand
from apps.channels.models import Stream, Channel, ChannelGroup
from apps.m3u.models import M3UAccount
class Command(BaseCommand):
help = "Delete all Channels, Streams, M3Us from the database (example)."
def handle(self, *args, **kwargs):
# Delete all Streams
stream_count = Stream.objects.count()
Stream.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {stream_count} Streams."))
# Or delete Channels:
channel_count = Channel.objects.count()
Channel.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {channel_count} Channels."))
# If you have M3UAccount:
m3u_count = M3UAccount.objects.count()
M3UAccount.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {m3u_count} M3U accounts."))
self.stdout.write(self.style.SUCCESS("Successfully deleted the requested objects."))
========= END OF FILE =========
File: remove_duplicates.py
===============================