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 %} - - -{% endblock %} -{% block content %} -
-
-
-

Schedule Direct Settings

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-

FFmpeg Settings

-
-
-
- - -
-
- - -
-
-
-
- -
-
-{% 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 %} -
-
-
-
-
-

Dispatcharr Login

-
-
-
- {% csrf_token %} -
- - -
-
- - -
-
- -
-
-
-
-
-
-
-{% 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 %} - - -{% endblock %} -{% block content %} -
-
-

M3U Accounts

- -
-
- - - - - - - - - - - - -
IDNameServer URLUploaded FileActiveActions
-
-
- - - -{% 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 %} - -
-
-
-

M3U Accounts

- -
-
- - - - - - - - - - - {% for m3u in m3u_accounts %} - - - - - - - {% endfor %} - -
NameURL/FileMax StreamsActions
{{ 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" }} - - - -
-
-
-
- - - - -{% 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 admin_title %}Admin{% 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 %} - - -{% endblock %} -{% block content %} - - -
- -
-
-
-

CPU & RAM Usage

-
-
-
-
-
-
- - -
-
-
-

Network Traffic & Current Streams

-
-
-
-
-
-
-
- - -
-
-

Active Streams

-
-
- - - - - - - - - - - - - - -
Stream NameViewersM3U AccountDetails
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 %} - -
-
-
-

EPG Sources

- -
-
- - - - - - - - - - - {% for epg in epg_sources %} - - - - - - - {% endfor %} - -
NameSource TypeURL/API KeyActions
{{ epg.name }}{{ epg.source_type }}{{ epg.url|default:epg.api_key }} - - - -
-
-
-
- - - - -{% 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 %} - -
-
-
-

HDHomeRun Devices

- -
-
- - - - - - - - - - - {% for device in hdhr_devices %} - - - - - - - {% endfor %} - -
Device NameDevice IDTunersActions
{{ device.friendly_name }}{{ device.device_id }}{{ device.tuner_count }} - - -
-
-
-
- - - - -
-

Useful Links

- -
- -{% 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 %} -
- -
-
-
-

Channels

-
- - - - - - - - -
-
-
- - - - - - - - - - - - - -
- - #LogoNameEPGGroupActions
-
-
-
- - -
-
-
-

Streams

-
- -
-
-
- - - - - - - - - - -
- - Stream NameGroupActions
-
-
-
-
- - -
- - - -
- - -{% 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 │ -└───────────────────────────────────────────────┘ - - - - -========= 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 @@