diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index db15727c..00000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..9f2e8485
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.DS_Store
+__pycache__/
diff --git a/FileTree.py b/FileTree.py
deleted file mode 100755
index 00f74287..00000000
--- a/FileTree.py
+++ /dev/null
@@ -1,77 +0,0 @@
-#!/usr/bin/env python3
-
-import os
-
-# Specify the names of the script file and output file to exclude them
-SCRIPT_NAME = "FileTree.py"
-OUTPUT_FILE = "FileTree.txt"
-EXCLUDED_FILES = {SCRIPT_NAME, OUTPUT_FILE, ".DS_Store", "__init__.py", "FileTree.old.txt"}
-EXCLUDED_DIRS = {"__pycache__", "migrations", "static", "staticfiles", "media", ".venv", ".idea"} # Exclude directories like __pycache__
-
-def generate_file_tree(output_file):
- """Generate a pretty file tree of the current directory and subdirectories."""
- with open(output_file, 'w') as f:
- for root, dirs, files in os.walk('.'): # Walk through the directory tree
- # Remove excluded directories from the traversal
- dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
- level = root.count(os.sep)
- indent = '│ ' * level
- f.write(f"{indent}├── {os.path.basename(root)}/\n")
- sub_indent = '│ ' * (level + 1)
- for i, file in enumerate(files):
- if file not in EXCLUDED_FILES:
- connector = '└── ' if i == len(files) - 1 else '├── '
- f.write(f"{sub_indent}{connector}{file}\n")
-
-def append_file_contents(output_file):
- """Append contents of each file in the current directory and subdirectories to the output file, excluding specified files."""
- # Determine the maximum width for the boxes
- max_width = 20 # Default minimum width
- file_paths = []
- for root, dirs, files in os.walk('.'): # Walk through the directory tree
- # Remove excluded directories from the traversal
- dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
- for file_name in files:
- if file_name not in EXCLUDED_FILES:
- file_path = os.path.join(root, file_name)
- relative_path = os.path.relpath(file_path, start='.')
- directory = os.path.dirname(relative_path)
- base_name = os.path.basename(relative_path)
- file_paths.append((directory, base_name))
- max_width = max(max_width, len(directory) + 10, len(base_name) + 10)
-
- max_width += 4 # Add padding for aesthetics
-
- # Append file contents with uniform box size
- with open(output_file, 'a') as f:
- for directory, base_name in file_paths:
- # Add the formatted header for the file
- horizontal_line = f"┌{'─' * max_width}┐"
- directory_line = f"│ Directory: {directory:<{max_width - 12}}│"
- file_line = f"│ File: {base_name:<{max_width - 12}}│"
- bottom_line = f"└{'─' * max_width}┘"
-
- f.write(f"\n{horizontal_line}\n")
- f.write(f"{directory_line}\n")
- f.write(f"{file_line}\n")
- f.write(f"{bottom_line}\n\n")
-
- # Append the contents of the file
- file_path = os.path.join(directory, base_name)
- try:
- with open(file_path, 'r', errors='ignore') as file:
- f.write(file.read())
- except Exception as e:
- f.write(f"Error reading {file_path}: {e}\n")
-
- # Add a visually distinct footer to signify the end of the file
- f.write(f"\n========= END OF FILE =========\n")
- f.write(f"File: {base_name}\n")
- f.write(f"===============================\n\n")
-
-def main():
- generate_file_tree(OUTPUT_FILE)
- append_file_contents(OUTPUT_FILE)
-
-if __name__ == "__main__":
- main()
diff --git a/FileTree.txt b/FileTree.txt
deleted file mode 100644
index 1306700b..00000000
--- a/FileTree.txt
+++ /dev/null
@@ -1,6348 +0,0 @@
-├── ./
-│ ├── 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 %}
-
-
-
-
- {% block title %}Dispatcharr{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% block extra_css %}{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% block content %}{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% block extra_js %}{% endblock %}
-
-
-
-
-
-
-========= 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 %}
-Home
-Settings
-{% endblock %}
-{% block content %}
-
-{% endblock %}
-{% block extra_js %}
-
-{% endblock %}
-
-========= END OF FILE =========
-File: ffmpeg.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates │
-│ File: login.html │
-└───────────────────────────────────────────────┘
-
-{% extends "base.html" %}
-{% block title %}Login - Dispatcharr{% endblock %}
-{% block content %}
-
-{% endblock %}
-{% block extra_js %}
-
-{% 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 %}
-Home
-M3U Management
-{% endblock %}
-{% block content %}
-
-
-
-
-
-
- | ID |
- Name |
- Server URL |
- Uploaded File |
- Active |
- Actions |
-
-
-
-
-
-
-
-
-
-
-
-
-
-{% endblock %}
-{% block extra_js %}
-
-{% 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 %}
-
-
-
-
-
-
-
-
- | Name |
- URL/File |
- Max Streams |
- Actions |
-
-
-
- {% for m3u in m3u_accounts %}
-
- | {{ m3u.name }} |
-
- {% if m3u.server_url %}
- M3U URL
- {% elif m3u.uploaded_file and m3u.uploaded_file.url %}
- Download File
- {% else %}
- No URL or file
- {% endif %}
- |
- {{ m3u.max_streams|default:"N/A" }} |
-
-
-
-
- |
-
- {% endfor %}
-
-
-
-
-
-
-
-
-
-{% endblock %}
-
-{% block extra_js %}
-
-
-{% endblock %}
-
-========= END OF FILE =========
-File: m3u.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/admin │
-│ File: base.htmlold.html │
-└───────────────────────────────────────────────┘
-
-{% load static %}
-
-
-
-
- {% block title %}Dispatcharr{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% block extra_css %}{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% block content %}{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% block extra_js %}{% endblock %}
-
-
-
-
-
-
-========= 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 %}
-Home
-Dashboard
-{% endblock %}
-{% block content %}
-
-
-
-
-
-
-
-
-
-
-
- | Stream Name |
- Viewers |
- M3U Account |
- Details |
-
-
-
-
- | No active streams. |
-
-
-
-
-
-
-{% endblock %}
-{% block extra_js %}
-
-
-{% 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 %}
-
-
-
-
-
-
-
-
- | Name |
- Source Type |
- URL/API Key |
- Actions |
-
-
-
- {% for epg in epg_sources %}
-
- | {{ epg.name }} |
- {{ epg.source_type }} |
- {{ epg.url|default:epg.api_key }} |
-
-
-
-
- |
-
- {% endfor %}
-
-
-
-
-
-
-
-
-
-{% 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 %}
-
-
-
-
-
-
-
-
- | Device Name |
- Device ID |
- Tuners |
- Actions |
-
-
-
- {% for device in hdhr_devices %}
-
- | {{ device.friendly_name }} |
- {{ device.device_id }} |
- {{ device.tuner_count }} |
-
-
-
- |
-
- {% endfor %}
-
-
-
-
-
-
-
-
-
-
-
-{% endblock %}
-
-{% block extra_js %}
-
-
-{% 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 %}
-
-
-
-
-
-
-
-
-
-
-{% 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" %}
-
-
-
-
-
-
-
-
-
-
-
-
-{% endblock %}
-
-========= END OF FILE =========
-File: channels.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: create_channel_from_stream.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: create_channel_from_stream.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: delete_channel.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: delete_channel.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: edit_channel.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: edit_channel.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: add_channel.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: add_channel.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: refresh.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: refresh.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: edit_m3u.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: edit_m3u.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: delete_m3u.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: delete_m3u.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: add_m3u.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: add_m3u.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: edit_logo.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
- {% for channel in channels %}
-
- {% endfor %}
-
-
-
-
-========= END OF FILE =========
-File: edit_logo.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: backup.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: backup.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: delete_stream.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: delete_stream.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: restore.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-========= END OF FILE =========
-File: restore.html
-===============================
-
-
-┌───────────────────────────────────────────────┐
-│ Directory: templates/channels/modals │
-│ File: add_group.html │
-└───────────────────────────────────────────────┘
-
-
-
-
-
-
-
-
-
-========= 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//', 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("Download M3U", 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"""
-
- 12345678
- Dispatcharr HDHomeRun
- HDTC-2US
- hdhomerun3_atsc
- 20200101
- test_auth_token
- {base_url}
- {base_url}/lineup.json
- """
-
- 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
-===============================
-
diff --git a/apps/.DS_Store b/apps/.DS_Store
deleted file mode 100644
index c5c39f89..00000000
Binary files a/apps/.DS_Store and /dev/null differ
diff --git a/apps/accounts/.DS_Store b/apps/accounts/.DS_Store
deleted file mode 100644
index b3c0f9da..00000000
Binary files a/apps/accounts/.DS_Store and /dev/null differ
diff --git a/apps/api/.DS_Store b/apps/api/.DS_Store
deleted file mode 100644
index 77a956a0..00000000
Binary files a/apps/api/.DS_Store and /dev/null differ
diff --git a/apps/channels/.DS_Store b/apps/channels/.DS_Store
deleted file mode 100644
index 0ddba8a6..00000000
Binary files a/apps/channels/.DS_Store and /dev/null differ
diff --git a/apps/channels/management/.DS_Store b/apps/channels/management/.DS_Store
deleted file mode 100644
index 7271da39..00000000
Binary files a/apps/channels/management/.DS_Store and /dev/null differ
diff --git a/apps/dashboard/.DS_Store b/apps/dashboard/.DS_Store
deleted file mode 100644
index 72cfea50..00000000
Binary files a/apps/dashboard/.DS_Store and /dev/null differ
diff --git a/apps/epg/.DS_Store b/apps/epg/.DS_Store
deleted file mode 100644
index 3393e78b..00000000
Binary files a/apps/epg/.DS_Store and /dev/null differ
diff --git a/apps/hdhr/.DS_Store b/apps/hdhr/.DS_Store
deleted file mode 100644
index e8a27017..00000000
Binary files a/apps/hdhr/.DS_Store and /dev/null differ
diff --git a/apps/m3u/.DS_Store b/apps/m3u/.DS_Store
deleted file mode 100644
index ce09ba18..00000000
Binary files a/apps/m3u/.DS_Store and /dev/null differ
diff --git a/dispatcharr/.DS_Store b/dispatcharr/.DS_Store
deleted file mode 100644
index 2bed9964..00000000
Binary files a/dispatcharr/.DS_Store and /dev/null differ
diff --git a/docker/Dockerfile b/docker/Dockerfile
index a534f138..bc45cc9b 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -13,7 +13,6 @@ 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/
@@ -21,6 +20,7 @@ COPY . /app/
# Set environment variables
ENV DJANGO_SETTINGS_MODULE=dispatcharr.settings
ENV PYTHONUNBUFFERED=1
+ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
# Run Django commands
RUN python manage.py collectstatic --noinput || true
diff --git a/media/.DS_Store b/media/.DS_Store
deleted file mode 100644
index 77ef101e..00000000
Binary files a/media/.DS_Store and /dev/null differ
diff --git a/media/epg_uploads/.DS_Store b/media/epg_uploads/.DS_Store
deleted file mode 100644
index 5008ddfc..00000000
Binary files a/media/epg_uploads/.DS_Store and /dev/null differ
diff --git a/media/logos/.DS_Store b/media/logos/.DS_Store
deleted file mode 100644
index 5008ddfc..00000000
Binary files a/media/logos/.DS_Store and /dev/null differ
diff --git a/media/m3u_uploads/.DS_Store b/media/m3u_uploads/.DS_Store
deleted file mode 100644
index 5008ddfc..00000000
Binary files a/media/m3u_uploads/.DS_Store and /dev/null differ
diff --git a/static/.DS_Store b/static/.DS_Store
deleted file mode 100644
index afdebead..00000000
Binary files a/static/.DS_Store and /dev/null differ
diff --git a/static/admin/.DS_Store b/static/admin/.DS_Store
deleted file mode 100644
index a46b7977..00000000
Binary files a/static/admin/.DS_Store and /dev/null differ
diff --git a/static/admin/css/.DS_Store b/static/admin/css/.DS_Store
deleted file mode 100644
index 1f10560f..00000000
Binary files a/static/admin/css/.DS_Store and /dev/null differ
diff --git a/static/admin/img/.DS_Store b/static/admin/img/.DS_Store
deleted file mode 100644
index 54ede44b..00000000
Binary files a/static/admin/img/.DS_Store and /dev/null differ
diff --git a/static/admin/js/.DS_Store b/static/admin/js/.DS_Store
deleted file mode 100644
index 60bd0c82..00000000
Binary files a/static/admin/js/.DS_Store and /dev/null differ
diff --git a/static/ts_buffers/.DS_Store b/static/ts_buffers/.DS_Store
deleted file mode 100644
index c2638158..00000000
Binary files a/static/ts_buffers/.DS_Store and /dev/null differ
diff --git a/staticfiles/.DS_Store b/staticfiles/.DS_Store
deleted file mode 100644
index 39b74287..00000000
Binary files a/staticfiles/.DS_Store and /dev/null differ
diff --git a/staticfiles/admin/.DS_Store b/staticfiles/admin/.DS_Store
deleted file mode 100644
index f6e79840..00000000
Binary files a/staticfiles/admin/.DS_Store and /dev/null differ
diff --git a/staticfiles/admin/css/.DS_Store b/staticfiles/admin/css/.DS_Store
deleted file mode 100644
index 1f10560f..00000000
Binary files a/staticfiles/admin/css/.DS_Store and /dev/null differ
diff --git a/staticfiles/admin/img/.DS_Store b/staticfiles/admin/img/.DS_Store
deleted file mode 100644
index 54ede44b..00000000
Binary files a/staticfiles/admin/img/.DS_Store and /dev/null differ
diff --git a/staticfiles/admin/js/.DS_Store b/staticfiles/admin/js/.DS_Store
deleted file mode 100644
index 60bd0c82..00000000
Binary files a/staticfiles/admin/js/.DS_Store and /dev/null differ
diff --git a/staticfiles/drf-yasg/.DS_Store b/staticfiles/drf-yasg/.DS_Store
deleted file mode 100644
index 27f5da21..00000000
Binary files a/staticfiles/drf-yasg/.DS_Store and /dev/null differ
diff --git a/staticfiles/rest_framework/.DS_Store b/staticfiles/rest_framework/.DS_Store
deleted file mode 100644
index 6e6f171e..00000000
Binary files a/staticfiles/rest_framework/.DS_Store and /dev/null differ
diff --git a/templates/.DS_Store b/templates/.DS_Store
deleted file mode 100644
index d7f4b24e..00000000
Binary files a/templates/.DS_Store and /dev/null differ
diff --git a/templates/admin/.DS_Store b/templates/admin/.DS_Store
deleted file mode 100644
index f497737a..00000000
Binary files a/templates/admin/.DS_Store and /dev/null differ
diff --git a/templates/admin/base.htmlold.html b/templates/admin/base.html
similarity index 98%
rename from templates/admin/base.htmlold.html
rename to templates/admin/base.html
index 583e08a8..41a9a6f9 100755
--- a/templates/admin/base.htmlold.html
+++ b/templates/admin/base.html
@@ -112,7 +112,7 @@
-
+
Settings
@@ -127,7 +127,8 @@
-
+
+
+
@@ -19,28 +20,7 @@
| Actions |
-
- {% for m3u in m3u_accounts %}
-
- | {{ m3u.name }} |
-
- {% if m3u.server_url %}
- M3U URL
- {% elif m3u.uploaded_file and m3u.uploaded_file.url %}
- Download File
- {% else %}
- No URL or file
- {% endif %}
- |
- {{ m3u.max_streams|default:"N/A" }} |
-
-
-
-
- |
-
- {% endfor %}
-
+
@@ -55,22 +35,24 @@