forked from Mirrors/Dispatcharr
6348 lines
240 KiB
Text
6348 lines
240 KiB
Text
├── ./
|
||
│ ├── 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>© {{ 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>© {{ 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” & auto‐fill 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 it’s found
|
||
const availableData = newAvailableStreamsTable.rows().data().toArray();
|
||
const match = availableData.find(s => s.id === rowData.id);
|
||
if (match) {
|
||
// remove from "available"
|
||
const idx = newAvailableStreamsTable.row((_, d) => d.id === rowData.id);
|
||
idx.remove().draw();
|
||
// add to "active"
|
||
newActiveStreamsTable.row.add(rowData).draw();
|
||
}
|
||
}, 400); // small delay to ensure DataTables have re‐initialized
|
||
});
|
||
|
||
// 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 auto‐assigned!", "success");
|
||
} else {
|
||
Swal.fire("Auto Assign Failed", "No success response from server.", "error");
|
||
}
|
||
},
|
||
error: function () {
|
||
Swal.fire("Auto Assign Error", "An error occurred auto‐assigning channels.", "error");
|
||
}
|
||
});
|
||
});
|
||
|
||
$("#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 channel’s details to fill name/number/logo/group
|
||
$.getJSON(`/api/channels/channels/${channelID}/`, function (channel) {
|
||
$("#editChannelIdField").val(channelID);
|
||
$("#editChannelNameField").val(channel.channel_name || "");
|
||
$("#editChannelNumberField").val(channel.channel_number || 0);
|
||
$("#editLogoPreview").attr("src", channel.logo_url || "/static/default-logo.png");
|
||
if (channel.channel_group && channel.channel_group.id) {
|
||
$("#editGroupField").val(channel.channel_group.id);
|
||
} else {
|
||
$("#editGroupField").val("");
|
||
}
|
||
}).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
|
||
===============================
|
||
|