From 7ae7dbe175ef3d6493df35d93e037a6cf5634e93 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Tue, 18 Feb 2025 16:48:01 -0600 Subject: [PATCH] Pre Alpha Changes Removed .DS_Store and added to gitignore M3U add created M3U delete fixed Removed unused files Django admin not so ugly now --- .DS_Store | Bin 10244 -> 0 bytes .gitignore | 2 + FileTree.py | 77 - FileTree.txt | 6348 ----------------- apps/.DS_Store | Bin 12292 -> 0 bytes apps/accounts/.DS_Store | Bin 6148 -> 0 bytes apps/api/.DS_Store | Bin 6148 -> 0 bytes apps/channels/.DS_Store | Bin 8196 -> 0 bytes apps/channels/management/.DS_Store | Bin 6148 -> 0 bytes apps/dashboard/.DS_Store | Bin 6148 -> 0 bytes apps/epg/.DS_Store | Bin 6148 -> 0 bytes apps/hdhr/.DS_Store | Bin 6148 -> 0 bytes apps/m3u/.DS_Store | Bin 6148 -> 0 bytes dispatcharr/.DS_Store | Bin 6148 -> 0 bytes docker/Dockerfile | 2 +- media/.DS_Store | Bin 8196 -> 0 bytes media/epg_uploads/.DS_Store | Bin 6148 -> 0 bytes media/logos/.DS_Store | Bin 6148 -> 0 bytes media/m3u_uploads/.DS_Store | Bin 6148 -> 0 bytes static/.DS_Store | Bin 8196 -> 0 bytes static/admin/.DS_Store | Bin 8196 -> 0 bytes static/admin/css/.DS_Store | Bin 6148 -> 0 bytes static/admin/img/.DS_Store | Bin 8196 -> 0 bytes static/admin/js/.DS_Store | Bin 6148 -> 0 bytes static/ts_buffers/.DS_Store | Bin 6148 -> 0 bytes staticfiles/.DS_Store | Bin 8196 -> 0 bytes staticfiles/admin/.DS_Store | Bin 8196 -> 0 bytes staticfiles/admin/css/.DS_Store | Bin 6148 -> 0 bytes staticfiles/admin/img/.DS_Store | Bin 8196 -> 0 bytes staticfiles/admin/js/.DS_Store | Bin 6148 -> 0 bytes staticfiles/drf-yasg/.DS_Store | Bin 6148 -> 0 bytes staticfiles/rest_framework/.DS_Store | Bin 6148 -> 0 bytes templates/.DS_Store | Bin 8196 -> 0 bytes templates/admin/.DS_Store | Bin 6148 -> 0 bytes .../admin/{base.htmlold.html => base.html} | 5 +- templates/channels/.DS_Store | Bin 6148 -> 0 bytes templates/channels/modals/.DS_Store | Bin 6148 -> 0 bytes templates/m3u/m3u.html | 216 +- 38 files changed, 158 insertions(+), 6492 deletions(-) delete mode 100644 .DS_Store create mode 100644 .gitignore delete mode 100755 FileTree.py delete mode 100644 FileTree.txt delete mode 100644 apps/.DS_Store delete mode 100644 apps/accounts/.DS_Store delete mode 100644 apps/api/.DS_Store delete mode 100644 apps/channels/.DS_Store delete mode 100644 apps/channels/management/.DS_Store delete mode 100644 apps/dashboard/.DS_Store delete mode 100644 apps/epg/.DS_Store delete mode 100644 apps/hdhr/.DS_Store delete mode 100644 apps/m3u/.DS_Store delete mode 100644 dispatcharr/.DS_Store delete mode 100644 media/.DS_Store delete mode 100644 media/epg_uploads/.DS_Store delete mode 100644 media/logos/.DS_Store delete mode 100644 media/m3u_uploads/.DS_Store delete mode 100644 static/.DS_Store delete mode 100644 static/admin/.DS_Store delete mode 100644 static/admin/css/.DS_Store delete mode 100644 static/admin/img/.DS_Store delete mode 100644 static/admin/js/.DS_Store delete mode 100644 static/ts_buffers/.DS_Store delete mode 100644 staticfiles/.DS_Store delete mode 100644 staticfiles/admin/.DS_Store delete mode 100644 staticfiles/admin/css/.DS_Store delete mode 100644 staticfiles/admin/img/.DS_Store delete mode 100644 staticfiles/admin/js/.DS_Store delete mode 100644 staticfiles/drf-yasg/.DS_Store delete mode 100644 staticfiles/rest_framework/.DS_Store delete mode 100644 templates/.DS_Store delete mode 100644 templates/admin/.DS_Store rename templates/admin/{base.htmlold.html => base.html} (98%) delete mode 100644 templates/channels/.DS_Store delete mode 100644 templates/channels/modals/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index db15727cb5bf7f2440916e5bfcf40d784e0af881..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10244 zcmeGhOKclO^i6)Xo0e=6yJ-Nys!=bao6_7S+d?SyX&MO ziZC}mRZl%}p(lFkpl-vXaM(YR$}b}M$bqCkp?0S)HT5JgN&8Iv;%7b^3;J3egr_+g3Y`jE-?h5 z?7*}GYXV{q79v;?3RdDAF^FKtcG;mzJFq68UrR#cH>^=-{4(ya`w8byoqW<^IV9*gIs-kqJ)NNPYH zo*3I5kato;7cUOT6T{=XyYX1EZ}8GWZbL5{^fMs?aqjezN`4>8CoR&aUpxL}xOYJkygr+uQq0PU_Apg^iNQmO4dsYG(ARx~vtbk<~s> zmu>Zi&5)eb6`)&Ci|_6=EpAtkCb8GR$9lz7RpV;O)QWR!YnEy+d+?Q<)L|7&s_XOA z(rk?yE2|c2dB#@DGsYUl88=bGa~0NQEvcr3a39a|iej3IQBpJ8*K{SX>T^bh+BPjS zN;Vn_GH9o_R@F0?LVFc2dB=T0-?)ztdlKASoD?y6lArzJMeKSay1yyvS zDz1QGEV~+*H7#7d4L86Jte<6X@A})2l4W5V`3rv9=Ba~dJ?`+}*xlhQew2)kpa-N5 z`X*tv!V;S9*f)z$ey;^vUszZT_IquJecQLb;5PJ)QKoxV45RVdvGqf?#rW3|@XB_t zjbIHe#db+6V6FQ&ir2CsT>lfhg-GYMVB5LgyQq^8O1~{@f8M;yZ5c}Kwd!HiUi-G~ za{Gpi8?ba7a>X7~RuhyVY7w80~;NCS}u{u3G?Ez{ZQ3@+8Zc?qtX-L=bDuVZC} z(bWXxEco#K5r8l3I-cJhVc!tqyda)t2*R3xh+#SQKLf<2-ao#qi_ZV}=~%xc3w)sY Qj=%mRM0Eat^5_4*0kqrnGXMYp 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 %} -
-
-
- - -
-
Anything you want
- © {{ current_year|default:"2025" }} Dispatcharr. All rights reserved. -
-
- - - - - - - {% 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 %} -
-
-
- -
-
-
- - -
-
Anything you want
- © {{ current_year|default:"2025" }} Dispatcharr. All rights reserved. -
-
- - - - - - - {% 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 }} - - -
-
-
-
- - - - - - -{% 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 c5c39f89b56ee41754912daa9e362bf06aa27f5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12292 zcmeHNO>7%Q6n-yusok`V^W*$cz#>AbXe69CKSTx9b%RO(H6gCrl(xA3**HtqyJpu( zC|0GMkf5liN+39JsMH=oTqw5|iQa%42gJ{TD-v8lz@@zTvAys4K8~7Cj+AB}nl}ij`E2f(uB~MvJ%=o_lGRPk z<^p;_BbHOgb+cftrK}aR;BeWrs0f8a;Y?(pS{+G@497+%$IlJNsw45Kso~h<=)}2m z;ZStwsr~brWus(T=cyYMP|h*1OFY-pcPZ!7H9fy7yM`BQUHh@tweXJiox65-L^?aV zI=Z`iqP=~61DQy7E}LI2+N`;UEW@%>#)@{bXzPV^Ih)tb;zC7xqmtI&)H0E-7S?&K z@M@#z=-D#y^fU_i>kiwRX7cyVE-pG+WxnOzLvKw z!!-P!3z}aW{~6i zBr)m&d7bY;fqA_{EeZ)h2@67it zHBp-U3uN?5um%_41Go%VX?6P!et|zQC{2D0pTz_CJkH=Nn87o*Y4RM)M$vU`BH$oL zykt#J%MDm)vF(}f@zRZh4Lp>r!Yyi>E@PVB+G0`LX@j^z>vox^j!$b^INYsTy>aB) z?oWBEJY0>|ZOrAOG=NarK8^m@t z@pyRL*7)J|*VhL^Z)71+eBmbwiA7h&S^LJ|DWJesnUSbzyqlP3{Iz~ zQ`EKE4N7Y-*#lr|AiP+4Wq9k>Fg;V`V=&Dg5i zG~FTuGu6yXoO!nAMX_rDuD2MU0v!MmU9j0=s4;mizGaQjB0Q&kq^KxIDNMV)si;i}0)hON?+szr#fO*<|6Y!itzX z#*8D)b(N@&@>dBPD=&MRYl(Ysm=_RcHZ7P%>=)LFQ-9DO_M z^|K4Jf1?2RY_X{A(V{6}3YY>b1!R4Q>4HhX(xWaN40;40HrcGkYdK2@Ck~hdEIslG z&3P)(Q(ZV>I8SGL9P*NYrAJSP3x^LEMt0$Z;&gQOA8R^X;?bfhU SJ+g&ne*{zp3#P!2D)0?Jv6%t@ diff --git a/apps/api/.DS_Store b/apps/api/.DS_Store deleted file mode 100644 index 77a956a06762d6c737a9c7d5b8dcaf3b7a32ad0c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyH3ME5S)WZL`svA^1dJve_*0eQ1bzX7$gcyL7)_L?)V%&5oUImhhu0d(5|#Q z_n7h7Q~2%xl=c4h3|In~Glh6jnTF<-lQ@bIVXAl?r)vcS5=i9-46Kp{f_;2f%D!ZqRm$+0!oHBJ;tD%w=h1JhKdLky!=EUj>O=%P`3d|}XV)u%S*?o$p;`hCK0pIEbyaNkt zD)s`n{9Pv5jAHd@kG9A`?vf|knP$g( zOipJs=;<{(==1`A5Lc_8S)n+0>U7B}Sru!e)#amB?8L)C!|6W}cMmxaI)~Rz=aKJ? z+6(8`c@#T-Y5C>~XDn ztF~mX-d@??FIyL`Tw8n4-U-5pizPu~`ujrj>i(#FiL1=XO}nnWk!P25+>c*BtlfVt z9XGShkpFbsU)fGO?WP!TW}CaMeiZOXxR97*84Jqc+FqWMl33cLJDh{tcyJ#d4iTVy zF7L7({Bnw0I&M1@KEXQaL>8=L^pLlG#HLG*%%WqrO>sTdK5k7i3Y>HWib{Y*5&z%K zKmR}JGMV`s1^#OV*j%&KY+#CqzkSlP6|uIByooH5u$w596cjQX2g-09c=QiLT-z`e WoVvz|LewCcg8(UmDU1StRe>J|hfz}i diff --git a/apps/channels/management/.DS_Store b/apps/channels/management/.DS_Store deleted file mode 100644 index 7271da39e392b9e2d9e7c10cc52ea825c83a066e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKL5tHs6n?W?ZE6vEP}qwQ@Mu{RSX^N*vDU-7HzRsbsYz3|!6Xx!)UuR9P9EHg z;xAZ##r^^>f)~MGBkFrIQ%IOy(Tj-A3va%eH}6g6ebdZz0DukVkqgiR01K6{bqR|z zg!)M}*-)0th)j$TLkS~%wjtNNjlnQr82H;5pl`P;zS$TusDHml`w1V12@lbOwud{Y z+uwy?Z5@@v5sV=~ox+61X-2VYzZR7oy8f7_>s{m3`QxX2I8?K;?tlHXckuoL%}nN? z%_im1Z+Pd^`Yb4E-1x@8A;l*^ej+HE`N13Xxlv<9^A8tAuI76h=1E+Xj`M|WY_=|3 zY+G%sYd!O)eBqZtIWGpm>+1EJdk;o0k~HJ5DN)?Q zaSsh#^)kKazjT4`g|SLtVd&Gm6;j@no+{aKdq>=66b z`H-IxodLy<)z!O<#V}wP_|F-j{lP{h^b}SS#nyp^xB?)y&@2S&;@m{E$$;o7tR$iZ zg~?Dv8OqcZgUN8T+se;VSV@%Oz{Hzz9Q9_VZYWH=9qqPo2j)pMrD4D@&}3j!HhXmb z-#h>Q-wZM>!+>GnpJITuhW>DXDXFtnOODQ38}&IV3HenLMFH=u1KDupHz1=I`jTM%#I%18JE%j##S5?k()d(&WJ`tl)@R40g4Xe`|K@CVx$jr(#Mk4bc<_DRf}zb zV^o0WZYXoMq!rcYZ*x_Y^I1{Oz~JR^10Vm0q4t+m(T3)94}YMX`)Oo-VrhKTvQlst z^oWsGvPv4GxQoUpeef$>Po3=d277>>OZ1;p(VdaJH8N7H*c{yT9{zWW|AJ@F>9w0r zU*MnD-WN?XQ@|831&&Su?AdJTP|-?Lz!WeAwhHj~!NVD|h(S?49Vp}o0PG_mP)H42qggPDVyQaby=yC{9L4+zXvtrf8)pU<&Li zaH7o-@Bf$k&;Pqg)-nZ5fq$id>rJnx6TDK~TU#&3d##7x!P%G>6t^j;$W{z4Z^fr@ ZF~mJrfLX+#hzQJn1VjcaOo4+c@C#anYs~-v diff --git a/apps/epg/.DS_Store b/apps/epg/.DS_Store deleted file mode 100644 index 3393e78b882c4b6f87b22914c73af9fa57bdddcb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKOHRWu5FNLb2o#|%*&yWvmAFADWx<*Qlzt+p2yFxuR94w@2d=L;Ebf+L}bUa&8g!)-m`u_1V!|rIsyph4^*X)lgfHRvd;yu936fgx$fwBU;Kg4jxM6vKFTL&7s0suQ07Q-{&C72T{CW?he zyn#7S1$wH}D~9uQjK><6C>9<)ot$1ioUZKjhT>#(%pXfQxx}NjrhqB1sX*Izdwl+% zHQ)a?N!Bw3Oo4x;fa?q|h69wOXKST6K5Jw6C7g}p3XeiTqsOs4@KJmS7sI=lAApHs S;SnP+`y(JSSYrzOr~+Sa6s3Fs diff --git a/apps/hdhr/.DS_Store b/apps/hdhr/.DS_Store deleted file mode 100644 index e8a27017b1233e24c0addaa334794673210e0a7d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!A` zK{aXHb47On;}|oisAGjZ8QNFrHP#e;2HYOFTV%_d6zE(8(de3)0;Yf|(5e9TY_|ST z(OOf$6fgx!1^D|A!xoUoYFBK z`?$nop{R6n{_x>^X6H{RPO@YESi;FAiq@I}ra)DJj&?`9|6jJB|EnbHnF6N3zf!<; zr`OX7O7eSatvKFmWB3i6jpGW%l7b7_iV@3O@d;cE<1t?V6OV-=Mqu_wKxDAS6!=vI FegM~(ijV*R diff --git a/apps/m3u/.DS_Store b/apps/m3u/.DS_Store deleted file mode 100644 index ce09ba18f8b17ba7569273d3747df56f48d2ba62..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKOHRWu6nt(gHMFSek_}Q$P>CCaQWmT^K<~>`L znL0KaLJ+j?uy#{YsEO$y=!h^ja883)(r&;4^DW zH8Q7udja)zn_tPPQ_kT{r^TNDX0cO1(DAH_#V68IMK12A%!TSN&${|JZ;nyCZ7 G>cA&7)X&ZU diff --git a/dispatcharr/.DS_Store b/dispatcharr/.DS_Store deleted file mode 100644 index 2bed9964ea52a15657978d076ff1959de4ffd7cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}&EG47SUpZt7)6j(LJf9Jy6v960j;z(zGDRok>5q22Nnycu`i1U@@qDrt8` z(_~l9mpFE;e2JQbhv%_^n`Cj>z20}&XECG zyF%A&K}%|`-{GNN&8GEg3J=*HcToSWg4%D?nhrFh7w8*RGS1(q>=T~1ji?;HTbd)2 z?2$Cb2fRlO?*YcGsD^Hkrz0YpC*v0wy}~ZsA;S`Rwp~%&OY-EjM~3=@_on6jetHi4 zm3X6RVhk7q#=w~|fHPa9Uj($$7%&Emfg=O*e{fL2GNKQtzYY}o1OUo~N(FVfEJ}=v zSVr^#(F5TS3JjqfpBNm%VRzB5jOYV~aN_v*(5oDJIKEI=v!m@`gm9@~g)v|ZBn(`I z_E66M`_u3LB+8n`fH81M3~+DqI2mI`@oa6)P0m^eeTIsNpAUEpK}An;_{vdy4OM~N XNd;I&^Z{Xk*hN5Tu)-MlR|b9nsL5+_ 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 77ef101eb6b3da16eca9190f70c53776a66a9aff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM&2G~`5S~p_>L>z52&5t|mbgYDZYXKR#SP&FFh2)C!LFm!!tt6oX%11OTsXm% z8wVubpf_HCN8rke~U zLlh3s+3libn~5t0H20SAffF5xy%AFCIGHAs?l}qv9UEF1H-|Oo<%F-Z+VQGQ^ohw43svWr&9@e_bf2joR#Vc9l^3s9I;|?r#WXgk*|? zr&D5+H%3iCPCnh7efuZ{S+O`5q1eJC9>B&4%o*0muEPl0xH>NE3MmZF6ya=z6k3{s ztgMg{>vM5_Zfy!xarFu*yss%5lZxU}aZy+*!~ijnV_-w|Y>WK=LHYTA&W}2X0b=0d zFu)AUrsIe*9Y;L-!w|1+G!;Is#-&2+!9oWC Mk_Kvsfj`Q?cLt0+8UO$Q diff --git a/media/epg_uploads/.DS_Store b/media/epg_uploads/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0OKu;wsu04e}rVUfPPj@2BK z{j$v1l4&`Qo}nHfc@T>oKNkK>EHw}TB0vO)01+Spr-K0Q+0v{9`@Zg_ng|eq|B`@w zK5#73yT<3*t49Y`Dg{8lg3ZeC81n$7;l{hh=h`bNey7eJn2|D*VlXYoelEk&yT<3* zYdJ722WCcQCPQIrbkuPb4$Re_Y9c@cW(mmLeHA(oKo6pc{9WEicd=~zRHmebNPm>2 zCe#1ur*V`Gs@1P-X?bP!%$l`kty|B%Ju&hIoxw0`cKT1{*&`uh|Mzjne;kFQcKO0R zkqkOf684oK3PKEd_9RLIF=~oo5@bs2=?<%8mD=UXcwBF6+Whv`zRkx@b-QNsEj-yT zSr@P0yz{XAGVUefjk;!F=8*yC^!Y*k7LN)uio1@tBYLUozjW>UWN%NjT4uiZsL9hW z?>|dA1$|Ehe~C!<3L{)`GcMzV|+%p5rz%m0=yJw*VF~m?uIlG`{~1SSJ7Kr%w`8xcY~uLmnWZOdHM3DVTlRl^ z`9_o&Tu-)tW9=WMR?QqDY=-;j528>;U7VrlHsBpdh+w!x|5vo8=}&oDG3Rcd*z4GF zC(pj?xKnsL_EjEM{5cO_fbj>=gRY$<;tpEUQ5vsvk|dSCPZG6hi*Rx8@);dA*>EsV z5{0vgUbI>y&yqyp7Lp_b?-$cUP>QFCS3Xvp*G{jXkD6Z-rLkFwJllb?SrJ}WH*11x zV&YQnQpfi{5y8R?TtEi0I_7Pv{}0yx{(k{?#vwBU%)p;AAaXUoRz+`htFJ)OE$?DG r#707XiPj2&o%JDr&+|VFaqOZiVLYP~t#Jm0e+W=CaK{Y%QwDwkC}bBP diff --git a/static/admin/css/.DS_Store b/static/admin/css/.DS_Store deleted file mode 100644 index 1f10560fae44eac51d892789ee62c76287e3295b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKF;2rU6n!og6cA7`u^{CHNZcSm8DQlAKwBt?w5gf`YX&wr1L6kUf{l@DFvA4z ze_JR`W7rTv{*wI?=f7|JeJ73qnC;8%CeQ@XU=ge@u&OY5E@jGUu4R>IG)4z0`WPa? z$&`0!2nqxR{+a^v?ly6ZON{yM7T@17-{B3mWXSfCw9`(~w)z5F#ElO%olXO7M##MW zk0t*VMm-~AfF3ys&dD9KRQ6`pOY9|d$sOSWhy2#0yz`9H+*UF(_T45=Vst%yzL%Ku zI3;7?A7!mq$ry2jo_WTeEgw$l5YJ zPskkV%p}5c`K-(%WEB)+%zfvrxhs0l>gWcXWs1?{dNh(PuW!=Z zJm{qTA$@2!lBbDS{j}9`YrApx_PYOk|0L_XJAfI40TW-C@`tOjJ<8j{=e%# z|4)*_T~Ht>@UIjw_0~aan^SUo>(u0AuZ>uaSj5CHx2P0WZadZs*^1X#)c7ot3u5dr Tx5yS+_z_SULKPJFRRul)g(v== diff --git a/static/admin/img/.DS_Store b/static/admin/img/.DS_Store deleted file mode 100644 index 54ede44b6daaee87c64d588d514e6bca6535ccd0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM!EO^V5PeQ65fN0{QlUx+X@8)R`Uh#79EyMg`2c7)VOz1yMrm5$mOE#zhbc{BEETma_idUycr0cf#`+-kF_DAF%=sWjZ1 zN0f$mV2mM7F~vSEmPW@Hk%7oSWFRsS8Hf!04-D{|Eu~fS-M785jSNHv){+4^9}-rP zT_@*O>e0bQQvk{hzShEL<^hUHoa{O|w^E_hX|o68pvI*b#=_a3$Z*Q8lXELA9LB<7 z+|0&hC`M-|F45sIT`OZ78HfxlGa$M94pL_AIYvSLo}%F2SLm?bAUny6^eivVI9|5c zA#3NC7Cr&H%=|Anazafr>O0qafGJ*5Z-I=p!@hA@Pb1@2-SqUd@tVjR>az29>{B>< z<9i9!=PK+MXR_jl;TlzV+g8*F#;a&jkCH zPvXnG&JP-Mz8*gYLOs=9kQngsz8_^*jJSrC9?(eeHQe(oc~5-JjyB?dA15=9-CQGD z?R%hd?dp49!yP=u369ana}3ZAcc*&WAAJ`uIPwrZJfrN8;dhvY81-$4xuQjK<0vOi z<*MYQ3Qm|EG9WQDB4$RP+S|(2ryWm{K*u(92y&(zWqRMaQiJU6K*N2VyU<8F<-YoJ zLR8uyJ`KWg zekTL+?l$S1CREW4wcg+DUOHRVWjUVKV|cTt#oNj2^UF~?V)9MI{CahV#Er5=4|GX0 z)KJr1vV(RHWn(7ut2G=vE zEE$mRLx2hvhK*wUbil+T0I&~t6pZDUO>%-^Vb~~Q2Ev*O)Ks=325UO}!Qu+TMp4s= z?eM|2GTRA-DHz07j96*K`_L%x2N?hh!$uJvi2n#g8r(PoKgz&6;}%V; diff --git a/static/ts_buffers/.DS_Store b/static/ts_buffers/.DS_Store deleted file mode 100644 index c2638158c2813b9f8c00059e1d094143e8cd6cf6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyG{c^3>-s>pfo8d_X{EdqF+=$r1DMhsaquuUKX)J5No9;k=QFOc!~!qa-)%?L-zS`VhD$u)3Kw7b zyQj@|x8bS(v>W>3J&Y@v6p#W^Knh3!De!v*y!XKeL(Hx%pEI&VsY(JSlIBUb$24bfaRm{YakO7b5TukE`B; zhTp5C&)ybcr`ibpwgNP2K2#n*Y=kw@D~fJdixjr32b`3Xs-!RM?p}AZ8SduR_cDCf z&2Hv0eB;K(-d@T%cjfBMdzGD_8H(5Hriqz7gO>F9QM-?$m`g$3Ek6^@mKs05@;y1) z6s3}J7<)~>_3_OINly&l6t!bCTH7%TmkQEQ4F9~`4g(R^(JSvb^?O=ql>+LJKW)-o zcRj;%>p4w&KA+*6*Zzd`5v~n5pGHLbv;_*f%5_PApgQUrwJ4-|>`N0OmC31&N2&vS z8~9avfonuV`Ul79CxYSY!6)bsU2`BLe(b+fAu&QP5g23j65;WU!z7)77e4O4Llt<_ zhA&N=_rOD&c9I;-$iRtnFcF*ZZ)NG#akj?zI?=|!f;jYhA|}BB&q@wX!}|#Sdx(WC z{CxNxC4Fa?2YerinfCJ8$jdz|@BZzcHPX4ngi6}R5SN@t`ttIv3fhs^Oitir-NeLA zus^WSwWe*Sz`(4wxh(VlJLA9qPvOq2Ny~s`;G`H}bER^r2y2J$$%BSvuFX-OqRJxm z`dTUo8kLSis&pLk=nq32bEry8!N|Ur=s`L04*~k$$*JTl_W6$&H~aioaX;e AssI20 diff --git a/staticfiles/admin/.DS_Store b/staticfiles/admin/.DS_Store deleted file mode 100644 index f6e79840c34827c5285a1c6d57748f8e70c36f70..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM&59F25Ux(L?!;9IsIY>Wxxqa%7y$>z`9(aChkB<2tTbJu6^ z6+G_?c=O_6AI0lh)!oTX=SKuVU95uc>glSlr~0Glo=N~fREO;{z$O4V*hm*Puv;Qz zr?wy&Gecm3c#y4TD4Y0HSn%ZuW`G%B2ABb6fEoBV7{EK5#949gYb*E605kAkGC=2p zi;c8rbfUF7I>^boFc2jOYWJu|=zEHXf~dlp&{L;N_j>i0)zxqqfa!HidP6!%P( zf70xQK|CmxPDLi0Tf4OGtUDXdYyVh|{6S+djH`|QOL}@HW!RjK8_nlIXH+lT*q70u z5k#H70tD?2y1aT3L~S{$%3;)w71q}VC-3Czgr+2nGxEe@1JX{*w2f`L!x~t6O~u wif(xq+aWd*@=LT<5bUfE0eqhQVTfZFT?ykEooJ0SDEvc!qJcYR;Eyu!17m#`5dZ)H diff --git a/staticfiles/admin/css/.DS_Store b/staticfiles/admin/css/.DS_Store deleted file mode 100644 index 1f10560fae44eac51d892789ee62c76287e3295b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKF;2rU6n!og6cA7`u^{CHNZcSm8DQlAKwBt?w5gf`YX&wr1L6kUf{l@DFvA4z ze_JR`W7rTv{*wI?=f7|JeJ73qnC;8%CeQ@XU=ge@u&OY5E@jGUu4R>IG)4z0`WPa? z$&`0!2nqxR{+a^v?ly6ZON{yM7T@17-{B3mWXSfCw9`(~w)z5F#ElO%olXO7M##MW zk0t*VMm-~AfF3ys&dD9KRQ6`pOY9|d$sOSWhy2#0yz`9H+*UF(_T45=Vst%yzL%Ku zI3;7?A7!mq$ry2jo_WTeEgw$l5YJ zPskkV%p}5c`K-(%WEB)+%zfvrxhs0l>gWcXWs1?{dNh(PuW!=Z zJm{qTA$@2!lBbDS{j}9`YrApx_PYOk|0L_XJAfI40TW-C@`tOjJ<8j{=e%# z|4)*_T~Ht>@UIjw_0~aan^SUo>(u0AuZ>uaSj5CHx2P0WZadZs*^1X#)c7ot3u5dr Tx5yS+_z_SULKPJFRRul)g(v== diff --git a/staticfiles/admin/img/.DS_Store b/staticfiles/admin/img/.DS_Store deleted file mode 100644 index 54ede44b6daaee87c64d588d514e6bca6535ccd0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM!EO^V5PeQ65fN0{QlUx+X@8)R`Uh#79EyMg`2c7)VOz1yMrm5$mOE#zhbc{BEETma_idUycr0cf#`+-kF_DAF%=sWjZ1 zN0f$mV2mM7F~vSEmPW@Hk%7oSWFRsS8Hf!04-D{|Eu~fS-M785jSNHv){+4^9}-rP zT_@*O>e0bQQvk{hzShEL<^hUHoa{O|w^E_hX|o68pvI*b#=_a3$Z*Q8lXELA9LB<7 z+|0&hC`M-|F45sIT`OZ78HfxlGa$M94pL_AIYvSLo}%F2SLm?bAUny6^eivVI9|5c zA#3NC7Cr&H%=|Anazafr>O0qafGJ*5Z-I=p!@hA@Pb1@2-SqUd@tVjR>az29>{B>< z<9i9!=PK+MXR_jl;TlzV+g8*F#;a&jkCH zPvXnG&JP-Mz8*gYLOs=9kQngsz8_^*jJSrC9?(eeHQe(oc~5-JjyB?dA15=9-CQGD z?R%hd?dp49!yP=u369ana}3ZAcc*&WAAJ`uIPwrZJfrN8;dhvY81-$4xuQjK<0vOi z<*MYQ3Qm|EG9WQDB4$RP+S|(2ryWm{K*u(92y&(zWqRMaQiJU6K*N2VyU<8F<-YoJ zLR8uyJ`KWg zekTL+?l$S1CREW4wcg+DUOHRVWjUVKV|cTt#oNj2^UF~?V)9MI{CahV#Er5=4|GX0 z)KJr1vV(RHWn(7ut2G=vE zEE$mRLx2hvhK*wUbil+T0I&~t6pZDUO>%-^Vb~~Q2Ev*O)Ks=325UO}!Qu+TMp4s= z?eM|2GTRA-DHz07j96*K`_L%x2N?hh!$uJvi2n#g8r(PoKgz&6;}%V; diff --git a/staticfiles/drf-yasg/.DS_Store b/staticfiles/drf-yasg/.DS_Store deleted file mode 100644 index 27f5da215cb00c6832a2ca5c344b454b61bbb79e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKu};G<5PdF1Y6TJ*8S?`j`Ujy363qPpXj2uWRH-NxBmMyXgN@H%Vqixsd;|j% z?`*4JnkXAW=&tfT=X__MU#wgWz>L<#5J&*@*aSO$sv41TXmVKgeb4<(ma6GLR z%Pn6u{7nYr-R>QS$I!@70h`&3uchHm@4bD8J-c@;kr^H#o$6_KPt+AGN25wW{dRpJgQa(lmTU6!+?ArQZ~UXp!MiJ9aQcL zKucSW?;uZCQ|>8oA3W|kUl8`%D}&3z;yF*KH`=_ZQa_O)Y_Eoj7>!RtjA3V iJFXPdS4#0An-kho=@7Gk)*~$x{UhLLP^Ap~Dg&RbLVo7} diff --git a/staticfiles/rest_framework/.DS_Store b/staticfiles/rest_framework/.DS_Store deleted file mode 100644 index 6e6f171eb09981070906344d2031f8036507d10f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKy-ve05I(nw6n=&d3=9mO*!u>d3OiCKHq=%WP(vyec0T|Q!N$m2FtD>Co`Z?+ z?4!hO&4>WqNxm<+^ZAoMDRz#C%;0v?C+ZPVfx(zCOi(xDj7j`gjdPNoc~UKLOUVg7%Q6n+!CbWJGTv~e2?gjV>1)JTn+R;^T_x^7Sj91?Y-lF%P_*E?~xUC&s% z>zHDKeBi>5IB?*=5h27C2?@9%B)Gzf0~anFd*}&?0}^k39NY1xLPDw{b;g=`&-31! z_ul96jNcjnNUal80OkO|!Y-&ijYCEga?vN+l%6ReC1?-8{BF|rBkoV=nhAq|LBJqj z5HJWB1P%rQcxQ`d?Q`!dsTmCd27xCM0dYP!*afX>+E-K_9XQAm0BIhlWkVk80BPfB zTGh0#s5HejRrNr$DbXbcQRXOjggVlyrhP?~IS^$IM0+N>LLt&S+Bs4kNL5iY8Uzdi zlL&~~{SbH%KnudL`u%&q9fe7{u<#q1%H3RaHPgEKdLo35wytyC~| z``8}6Yc#k$oLalzMxEK=gk0-tOewq3jKg4Mb2Fjo`Ut;Kw~r=`m`BknPr@|hY~yBv z+iN+ct!1{!(c?C5t(CSACD&=3Af2#T-|*ttV@+DSe=G8Q8m+JzPgC9!nqoCfWHqK@ zCF$F3dySAa%a;@8Bo+9Uj16Bu`F|7s)Admb^?Zkyptwd6U%19pWkLslk?he%ueai|;2kZq%%} z4c=-?-Kk$1SvoDgw4|TM;RV_Mq4d?ym$UVP|D@O68u6Ly3)d5I?fJqL@NDrQK|Flm z$i)zhMK0C=$j};%J$~ntR^@9Md#2_v`bTSy#jiZ)@EB-AGb1J~8I5D@g-_qpaqLQm zz9*#_{_==KfIZIexGNj=dG+&O|Nc|uJ|Z7_&5~#M&EW~Z2%QYCS~BYMn}5D|cQ;RI z*WyEulhO=-3SBcETm*6|v^i1#zq0@H|AU)=iOe8i5cuB+ko=OnRKsNkKR@=aC~9p7 z`yK3}3BA6e(gX)tjw6-jIMUre43T$GRoGND?JJ5i*dF_XfZ=Z@=^xDfZ|;A!6P~E? EUuj$Ix&QzG diff --git a/templates/admin/.DS_Store b/templates/admin/.DS_Store deleted file mode 100644 index f497737a375ea0a1f551dbb630287cecc1b5026f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ5EDE3>-s>2%40XdxA*ZU=@XenhT&1B#<7YArj@QxDHp~W-$H;5j|*-XwX=) zXV>d_n>WRC48T_V?K!XjFsD1>#lzJ6+<6h~@_b{$xQa}nw0VyB_q`+?#@ZL+CpC&3w0VyB_J{9oqL!&$P z!Z9&E9Sku75NAw>aUHV+v3Y{n3&%ueXqHrBQmsY|OFHwd>U!arm~>d&%;(h2RuhWF z?aa3*hxJ57DIf(76*$iA!u$UP{fGJgkffaykOKco0h_I_)+@eJ_14MDd9Q8sYr5CG s(cQQX3PZGGVzgs!yd9rKQPwqI^Sl?1i9u&R=tTV)a9w0l;I9?<03;|I`v3p{ 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 @@