diff --git a/apps/api/urls.py b/apps/api/urls.py index 485ee079..a2810f06 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -21,11 +21,11 @@ schema_view = get_schema_view( 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('core/', include(('core.api_urls', 'core'), namespace='core')), # 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')), diff --git a/apps/channels/models.py b/apps/channels/models.py index a5b55c74..dc4c38d7 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -1,5 +1,6 @@ from django.db import models from django.core.exceptions import ValidationError +from core.models import StreamProfile # If you have an M3UAccount model in apps.m3u, you can still import it: from apps.m3u.models import M3UAccount @@ -71,6 +72,15 @@ class Channel(models.Model): tvg_name = models.CharField(max_length=255, blank=True, null=True) objects = ChannelManager() + stream_profile = models.ForeignKey( + StreamProfile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='channels' + ) + + def clean(self): # Enforce unique channel_number within a given group existing = Channel.objects.filter( diff --git a/apps/ffmpeg/apps.py b/apps/ffmpeg/apps.py deleted file mode 100644 index c6386dcc..00000000 --- a/apps/ffmpeg/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - -class FfmpegConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.ffmpeg' - verbose_name = "FFmpeg Streaming" diff --git a/apps/ffmpeg/urls.py b/apps/ffmpeg/urls.py deleted file mode 100644 index ec8ce8f6..00000000 --- a/apps/ffmpeg/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path -from .views import stream_view, serve_hls_segment - -app_name = 'ffmpeg' - -urlpatterns = [ - path('/', stream_view, name='stream'), - path('//', serve_hls_segment, name='serve_hls_segment'), -] diff --git a/apps/ffmpeg/views.py b/apps/ffmpeg/views.py deleted file mode 100644 index c586ad97..00000000 --- a/apps/ffmpeg/views.py +++ /dev/null @@ -1,75 +0,0 @@ -import os -import redis -import subprocess - -from django.conf import settings -from django.http import ( - StreamingHttpResponse, - HttpResponseServerError, - FileResponse, - Http404, -) -from django.db.models import F -from apps.channels.models import Channel, Stream - -# Configure Redis -redis_host = os.environ.get("REDIS_HOST", "redis") -redis_port = int(os.environ.get("REDIS_PORT", 6379)) -redis_db = int(os.environ.get("REDIS_DB", 0)) -redis_client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) - -def serve_hls_segment(request, stream_id, filename): - # Remove any trailing slashes from the filename. / caused problems. - filename = filename.rstrip('/') - - # Construct the file path (e.g., /tmp/hls_4/segment_001.ts) - file_path = os.path.join('/tmp', f'hls_{stream_id}', filename) - - if os.path.exists(file_path): - return FileResponse(open(file_path, 'rb'), content_type='video/MP2T') - else: - raise Http404("Segment not found") - - -def stream_view(request, stream_id): - try: - channel = Channel.objects.get(id=stream_id) - if not channel.streams.exists(): - return HttpResponseServerError("No stream found for this channel.") - # Pick the first available stream and get its actual model instance. - stream = channel.streams.first() - # Use the custom URL if available; otherwise, the regular URL. - input_url = stream.custom_url or stream.url - - # Increment the viewer count atomically. - Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') + 1) - - ffmpeg_cmd = [ - "ffmpeg", - "-i", input_url, - "-c:v", "copy", - "-c:a", "copy", - "-f", "mpegts", - "-" # output to stdout - ] - process = subprocess.Popen( - ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - except Exception as e: - return HttpResponseServerError(f"Error starting stream: {e}") - - def stream_generator(process, stream): - try: - while True: - chunk = process.stdout.read(8192) - if not chunk: - break - yield chunk - finally: - # Decrement the viewer count when the stream finishes or the connection closes. - Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') - 1) - - return StreamingHttpResponse( - stream_generator(process, stream), - content_type="video/MP2T" - ) diff --git a/apps/m3u/admin.py b/apps/m3u/admin.py index 4ad27ccc..d4d6885b 100644 --- a/apps/m3u/admin.py +++ b/apps/m3u/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.utils.html import format_html -from .models import M3UAccount, M3UFilter, ServerGroup +from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent class M3UFilterInline(admin.TabularInline): model = M3UFilter @@ -10,12 +10,21 @@ class M3UFilterInline(admin.TabularInline): @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',) + list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'user_agent_display', 'uploaded_file_link', 'created_at', 'updated_at') + list_filter = ('is_active', 'server_group') search_fields = ('name', 'server_url', 'server_group__name') inlines = [M3UFilterInline] actions = ['activate_accounts', 'deactivate_accounts'] + # Handle both ForeignKey and ManyToManyField cases for UserAgent + def user_agent_display(self, obj): + if hasattr(obj, 'user_agent'): # ForeignKey case + return obj.user_agent.user_agent if obj.user_agent else "None" + elif hasattr(obj, 'user_agents'): # ManyToManyField case + return ", ".join([ua.user_agent for ua in obj.user_agents.all()]) or "None" + return "None" + user_agent_display.short_description = "User Agent(s)" + def uploaded_file_link(self, obj): if obj.uploaded_file: return format_html("Download M3U", obj.uploaded_file.url) @@ -30,6 +39,10 @@ class M3UAccountAdmin(admin.ModelAdmin): def deactivate_accounts(self, request, queryset): queryset.update(is_active=False) + # Add ManyToManyField for Django Admin (if applicable) + if hasattr(M3UAccount, 'user_agents'): + filter_horizontal = ('user_agents',) # Only for ManyToManyField + @admin.register(M3UFilter) class M3UFilterAdmin(admin.ModelAdmin): list_display = ('m3u_account', 'filter_type', 'regex_pattern', 'exclude') @@ -41,3 +54,4 @@ class M3UFilterAdmin(admin.ModelAdmin): class ServerGroupAdmin(admin.ModelAdmin): list_display = ('name',) search_fields = ('name',) + diff --git a/apps/m3u/api_urls.py b/apps/m3u/api_urls.py index 9d340e8f..c676b333 100644 --- a/apps/m3u/api_urls.py +++ b/apps/m3u/api_urls.py @@ -1,6 +1,6 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView +from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet app_name = 'm3u' diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 64b60688..2f6c6d9b 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -7,36 +7,38 @@ from drf_yasg import openapi from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.core.cache import cache + +# Import all models, including UserAgent. from .models import M3UAccount, M3UFilter, ServerGroup -from .serializers import M3UAccountSerializer, M3UFilterSerializer, ServerGroupSerializer +from core.models import UserAgent +from core.serializers import UserAgentSerializer +# Import all serializers, including the UserAgentSerializer. +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""" @@ -48,8 +50,6 @@ class RefreshM3UAPIView(APIView): 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""" @@ -59,4 +59,12 @@ class RefreshSingleM3UAPIView(APIView): ) 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) + return Response({'success': True, 'message': f'M3U account {account_id} refresh initiated.'}, + status=status.HTTP_202_ACCEPTED) + +class UserAgentViewSet(viewsets.ModelViewSet): + """Handles CRUD operations for User Agents""" + queryset = UserAgent.objects.all() + serializer_class = UserAgentSerializer + permission_classes = [IsAuthenticated] + diff --git a/apps/m3u/migrations/0004_alter_m3uaccount_user_agent_delete_useragent.py b/apps/m3u/migrations/0004_alter_m3uaccount_user_agent_delete_useragent.py new file mode 100644 index 00000000..4dbf71b2 --- /dev/null +++ b/apps/m3u/migrations/0004_alter_m3uaccount_user_agent_delete_useragent.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-02-21 14:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ('m3u', '0003_m3uaccount_user_agent'), + ] + + operations = [ + migrations.AlterField( + model_name='m3uaccount', + name='user_agent', + field=models.ForeignKey(blank=True, help_text='The User-Agent associated with this M3U account.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='core.useragent'), + ), + migrations.DeleteModel( + name='UserAgent', + ), + ] diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 004e4fb2..8f20a329 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from .models import M3UAccount, M3UFilter, ServerGroup - +from core.models import UserAgent class M3UFilterSerializer(serializers.ModelSerializer): """Serializer for M3U Filters""" @@ -13,11 +13,18 @@ class M3UFilterSerializer(serializers.ModelSerializer): class M3UAccountSerializer(serializers.ModelSerializer): """Serializer for M3U Account""" filters = M3UFilterSerializer(many=True, read_only=True) + # Include user_agent as a mandatory field using its primary key. + user_agent = serializers.PrimaryKeyRelatedField( + queryset=UserAgent.objects.all(), + required=True + ) class Meta: model = M3UAccount - fields = ['id', 'name', 'server_url', 'uploaded_file', 'server_group', - 'max_streams', 'is_active', 'created_at', 'updated_at', 'filters'] + fields = [ + 'id', 'name', 'server_url', 'uploaded_file', 'server_group', + 'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent' + ] class ServerGroupSerializer(serializers.ModelSerializer): @@ -26,3 +33,5 @@ class ServerGroupSerializer(serializers.ModelSerializer): class Meta: model = ServerGroup fields = ['id', 'name'] + + diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index b1374d12..c4bcfd66 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -4,8 +4,7 @@ import re import requests import os from celery.app.control import Inspect -from celery import shared_task -from celery import current_app +from celery import shared_task, current_app from django.conf import settings from django.core.cache import cache from .models import M3UAccount @@ -15,15 +14,13 @@ 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") + logger.info("Testing filter") for f in filters: pattern = f.regex_pattern target = group_name if f.filter_type == 'group' else stream_name @@ -33,7 +30,6 @@ def _matches_filters(stream_name: str, group_name: str, filters) -> bool: 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}" @@ -42,13 +38,11 @@ def acquire_lock(task_name, account_id): 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.""" @@ -62,10 +56,8 @@ def refresh_m3u_accounts(): 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): @@ -88,7 +80,13 @@ def refresh_single_m3u_account(account_id): try: lines = [] if account.server_url: - headers = {"User-Agent": "Mozilla/5.0"} + if not account.user_agent: + err_msg = f"User-Agent not provided for account id {account_id}." + logger.error(err_msg) + release_lock('refresh_single_m3u_account', account_id) + return err_msg + + headers = {"User-Agent": account.user_agent.user_agent} response = requests.get(account.server_url, timeout=60, headers=headers) response.raise_for_status() lines = response.text.splitlines() @@ -99,6 +97,7 @@ def refresh_single_m3u_account(account_id): else: err_msg = f"No server_url or uploaded_file provided for account_id={account_id}." logger.error(err_msg) + release_lock('refresh_single_m3u_account', account_id) return err_msg except Exception as e: err_msg = f"Failed fetching M3U: {e}" @@ -168,7 +167,6 @@ def refresh_single_m3u_account(account_id): 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') @@ -184,7 +182,6 @@ def process_uploaded_m3u_file(file, 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', diff --git a/apps/output/urls.py b/apps/output/urls.py index 9c496622..7aaf9d4e 100644 --- a/apps/output/urls.py +++ b/apps/output/urls.py @@ -1,9 +1,11 @@ from django.urls import path, include from .views import generate_m3u +from core.views import stream_view + app_name = 'output' urlpatterns = [ path('m3u/', generate_m3u, name='generate_m3u'), - path('stream/', include(('apps.ffmpeg.urls', 'ffmpeg'), namespace='ffmpeg')), + path('stream//', stream_view, name='stream'), ] diff --git a/apps/output/views.py b/apps/output/views.py index d3199ccc..4f614734 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1,32 +1,30 @@ +# apps/output/views.py from django.http import HttpResponse from django.urls import reverse from apps.channels.models import Channel def generate_m3u(request): """ - Dynamically generate an M3U file from channels with extended metadata, - and have the stream URL point to the ffmpeg relay. + Dynamically generate an M3U file from channels. + The stream URL now points to the new stream_view that uses StreamProfile. """ m3u_content = "#EXTM3U\n" - channels = Channel.objects.order_by('channel_number') - for channel in channels: group_title = channel.channel_group.name if channel.channel_group else "Default" tvg_id = channel.tvg_id or "" tvg_name = channel.tvg_name or channel.channel_name - tvg_logo = channel.logo_url or "" # Adjust if you have a fallback + tvg_logo = channel.logo_url or "" channel_number = channel.channel_number extinf_line = ( f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" ' f'tvg-chno="{channel_number}" group-title="{group_title}",{channel.channel_name}\n' ) + # Use the new stream view from outputs app + stream_url = request.build_absolute_uri(reverse('output:stream', args=[channel.id])) + m3u_content += extinf_line + stream_url + "\n" - ffmpeg_url = request.build_absolute_uri(reverse('ffmpeg:stream', args=[channel.id])) - m3u_content += extinf_line + ffmpeg_url + "\n" - - # Return the generated content with the appropriate MIME type. response = HttpResponse(m3u_content, content_type="application/x-mpegURL") response['Content-Disposition'] = 'attachment; filename="channels.m3u"' return response diff --git a/apps/ffmpeg/__init__.py b/core/__init__.py similarity index 100% rename from apps/ffmpeg/__init__.py rename to core/__init__.py diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 00000000..4ff53406 --- /dev/null +++ b/core/admin.py @@ -0,0 +1,69 @@ +# core/admin.py + +from django.contrib import admin +from .models import UserAgent, StreamProfile, CoreSettings + +@admin.register(UserAgent) +class UserAgentAdmin(admin.ModelAdmin): + list_display = ( + "user_agent_name", + "user_agent", + "description", + "is_active", + "created_at", + "updated_at", + ) + search_fields = ("user_agent_name", "user_agent", "description") + list_filter = ("is_active",) + readonly_fields = ("created_at", "updated_at") + +@admin.register(StreamProfile) +class StreamProfileAdmin(admin.ModelAdmin): + list_display = ( + "profile_name", + "command", + "is_active", + "user_agent", + ) + search_fields = ("profile_name", "command", "user_agent") + list_filter = ("is_active",) + +@admin.register(CoreSettings) +class CoreSettingsAdmin(admin.ModelAdmin): + """ + Because CoreSettings is typically a single 'singleton' row, + you can either allow multiple or restrict it. For now, we + just list and allow editing of any instance. + """ + list_display = ( + "default_user_agent", + "default_stream_profile", + "stream_command_timeout", + "enable_stream_logging", + "useragent_cache_timeout", + "streamprofile_cache_timeout", + "streamlink_path", + "vlc_path", + ) + fieldsets = ( + (None, { + "fields": ( + "default_user_agent", + "default_stream_profile", + "stream_command_timeout", + "enable_stream_logging", + ) + }), + ("Caching", { + "fields": ( + "useragent_cache_timeout", + "streamprofile_cache_timeout", + ) + }), + ("Paths", { + "fields": ( + "streamlink_path", + "vlc_path", + ) + }), + ) diff --git a/core/api_urls.py b/core/api_urls.py new file mode 100644 index 00000000..6e240927 --- /dev/null +++ b/core/api_urls.py @@ -0,0 +1,14 @@ +# core/api_urls.py + +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet + +router = DefaultRouter() +router.register(r'useragents', UserAgentViewSet, basename='useragent') +router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile') +router.register(r'settings', CoreSettingsViewSet, basename='coresettings') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/core/api_views.py b/core/api_views.py new file mode 100644 index 00000000..26dfaadd --- /dev/null +++ b/core/api_views.py @@ -0,0 +1,54 @@ +# core/api_views.py + +from rest_framework import viewsets, status +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from .models import UserAgent, StreamProfile, CoreSettings +from .serializers import UserAgentSerializer, StreamProfileSerializer, CoreSettingsSerializer +from rest_framework.permissions import IsAuthenticated + +class UserAgentViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows user agents to be viewed, created, edited, or deleted. + """ + queryset = UserAgent.objects.all() + serializer_class = UserAgentSerializer + +class StreamProfileViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows stream profiles to be viewed, created, edited, or deleted. + """ + queryset = StreamProfile.objects.all() + serializer_class = StreamProfileSerializer + +class CoreSettingsViewSet(viewsets.ModelViewSet): + """ + API endpoint for editing core settings. + This is treated as a singleton: only one instance should exist. + """ + queryset = CoreSettings.objects.all() + serializer_class = CoreSettingsSerializer + permission_classes = [IsAuthenticated] + + def create(self, request, *args, **kwargs): + if CoreSettings.objects.exists(): + return Response( + {"detail": "Core settings already exist. Use PUT to update."}, + status=status.HTTP_400_BAD_REQUEST + ) + return super().create(request, *args, **kwargs) + + def list(self, request, *args, **kwargs): + # Always return the singleton instance (creating it if needed) + settings_instance, created = CoreSettings.objects.get_or_create(pk=1) + serializer = self.get_serializer(settings_instance) + return Response([serializer.data]) # Return as a list for DRF router compatibility + + def retrieve(self, request, *args, **kwargs): + # Retrieve the singleton instance + settings_instance = get_object_or_404(CoreSettings, pk=1) + serializer = self.get_serializer(settings_instance) + return Response(serializer.data) + + + diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 00000000..8115ae60 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/core/models.py b/core/models.py new file mode 100644 index 00000000..df43f94a --- /dev/null +++ b/core/models.py @@ -0,0 +1,94 @@ +# core/models.py +from django.db import models + +class UserAgent(models.Model): + user_agent_name = models.CharField( + max_length=512, + unique=True, + help_text="The User-Agent name." + ) + user_agent = models.CharField( + max_length=512, + unique=True, + help_text="The complete User-Agent string sent by the client." + ) + description = models.CharField( + max_length=255, + blank=True, + help_text="An optional description of the client or device type." + ) + is_active = models.BooleanField( + default=True, + help_text="Whether this user agent is currently allowed/recognized." + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.user_agent_name + +class StreamProfile(models.Model): + profile_name = models.CharField(max_length=255, help_text="Name of the stream profile") + command = models.CharField( + max_length=255, + help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')" + ) + parameters = models.TextField( + help_text="Command-line parameters. Use {userAgent} and {streamUrl} as placeholders." + ) + is_active = models.BooleanField(default=True, help_text="Whether this profile is active") + user_agent = models.CharField( + max_length=512, + blank=True, + null=True, + help_text="Optional user agent to use. If not set, you can fall back to a default." + ) + + def __str__(self): + return self.profile_name + + +class CoreSettings(models.Model): + default_user_agent = models.CharField( + max_length=512, + default="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/112.0.0.0 Safari/537.36", + help_text="The default User-Agent string to use if none is provided." + ) + default_stream_profile = models.CharField( + max_length=255, + default="default_profile", + help_text="Name or identifier for the default stream profile." + ) + stream_command_timeout = models.PositiveIntegerField( + default=300, + help_text="Timeout in seconds for running stream commands." + ) + enable_stream_logging = models.BooleanField( + default=True, + help_text="Toggle verbose logging for stream commands." + ) + useragent_cache_timeout = models.PositiveIntegerField( + default=300, + help_text="Cache timeout in seconds for user agent data." + ) + streamprofile_cache_timeout = models.PositiveIntegerField( + default=300, + help_text="Cache timeout in seconds for stream profile data." + ) + streamlink_path = models.CharField( + max_length=255, + default="/usr/bin/streamlink", + help_text="Override path for the streamlink command." + ) + vlc_path = models.CharField( + max_length=255, + default="/usr/bin/vlc", + help_text="Override path for the VLC command." + ) + + def __str__(self): + return "Core Settings" + + class Meta: + verbose_name = "Core Setting" + verbose_name_plural = "Core Settings" diff --git a/core/serializers.py b/core/serializers.py new file mode 100644 index 00000000..32f70ebd --- /dev/null +++ b/core/serializers.py @@ -0,0 +1,19 @@ +# core/serializers.py + +from rest_framework import serializers +from .models import UserAgent, StreamProfile, CoreSettings + +class UserAgentSerializer(serializers.ModelSerializer): + class Meta: + model = UserAgent + fields = ['id', 'user_agent_name', 'user_agent', 'description', 'is_active', 'created_at', 'updated_at'] + +class StreamProfileSerializer(serializers.ModelSerializer): + class Meta: + model = StreamProfile + fields = ['id', 'profile_name', 'command', 'parameters', 'is_active', 'user_agent'] + +class CoreSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = CoreSettings + fields = '__all__' diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/core/views.py b/core/views.py new file mode 100644 index 00000000..e2b3c1b1 --- /dev/null +++ b/core/views.py @@ -0,0 +1,82 @@ +import os +import sys +import subprocess +import logging + +from django.conf import settings +from django.http import StreamingHttpResponse, HttpResponseServerError +from django.db.models import F + +from apps.channels.models import Channel, Stream +from core.models import StreamProfile + +# Configure logging to output to the console. +logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) +logger = logging.getLogger(__name__) + +def stream_view(request, stream_id): + """ + Streams the first available stream for the given channel. + It uses the channel’s assigned StreamProfile. + """ + try: + # Retrieve the channel by the provided stream_id. + channel = Channel.objects.get(id=stream_id) + logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.channel_name) + + # Ensure the channel has at least one stream. + if not channel.streams.exists(): + logger.error("No streams found for channel ID=%s", channel.id) + return HttpResponseServerError("No stream found for this channel.") + + # Get the first available stream. + stream = channel.streams.first() + logger.debug("Using stream: ID=%s, Name=%s", stream.id, stream.name) + + # Use the custom URL if available; otherwise, use the standard URL. + input_url = stream.custom_url or stream.url + logger.debug("Input URL: %s", input_url) + + # Get the stream profile set on the channel. + # (Ensure your Channel model has a 'stream_profile' field.) + profile = channel.stream_profile + if not profile: + logger.error("No stream profile set for channel ID=%s", channel.id) + return HttpResponseServerError("No stream profile set for this channel.") + logger.debug("Stream profile used: %s", profile.profile_name) + + # Determine the user agent to use. + user_agent = profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0") + logger.debug("User agent: %s", user_agent) + + # Substitute placeholders in the parameters template. + parameters = profile.parameters.format(userAgent=user_agent, streamUrl=input_url) + logger.debug("Formatted parameters: %s", parameters) + + # Build the final command. + cmd = [profile.command] + parameters.split() + logger.debug("Executing command: %s", cmd) + + # Increment the viewer count. + Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') + 1) + logger.debug("Viewer count incremented for stream ID=%s", stream.id) + + # Start the streaming process. + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except Exception as e: + logger.exception("Error starting stream for channel ID=%s", stream_id) + return HttpResponseServerError(f"Error starting stream: {e}") + + def stream_generator(proc, s): + try: + while True: + chunk = proc.stdout.read(8192) + if not chunk: + break + yield chunk + finally: + # Decrement the viewer count once streaming ends. + Stream.objects.filter(id=s.id).update(current_viewers=F('current_viewers') - 1) + logger.debug("Viewer count decremented for stream ID=%s", s.id) + + return StreamingHttpResponse(stream_generator(process, stream), content_type="video/MP2T") diff --git a/dispatcharr/admin.py b/dispatcharr/admin.py new file mode 100644 index 00000000..fc4a377b --- /dev/null +++ b/dispatcharr/admin.py @@ -0,0 +1,8 @@ +""" +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() diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 32b163fc..67b6b1a1 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -17,6 +17,7 @@ INSTALLED_APPS = [ 'apps.hdhr', 'apps.m3u', 'apps.output', + 'core', 'drf_yasg', 'django.contrib.admin', 'django.contrib.auth', @@ -35,7 +36,6 @@ MIDDLEWARE = [ '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', ] @@ -98,20 +98,12 @@ 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/' diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index ebc63e2f..85c3fdf4 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -38,7 +38,6 @@ urlpatterns = [ #path('backup/', include(('apps.backup.urls', 'backup'), namespace='backup')), path('dashboard/', include(('apps.dashboard.urls', 'dashboard'), namespace='dashboard')), path('output/', include('apps.output.urls', namespace='output')), - path('stream/', include(('apps.ffmpeg.urls', 'ffmpeg'), namespace='ffmpeg')), # Swagger UI: diff --git a/docker/Dockerfile b/docker/Dockerfile index bc45cc9b..57d0d966 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,8 +1,10 @@ FROM python:3.10-slim -# Install required packages +# Install required packages including ffmpeg, streamlink, and vlc RUN apt-get update && apt-get install -y \ ffmpeg \ + streamlink \ + vlc \ libpq-dev \ gcc \ && rm -rf /var/lib/apt/lists/* @@ -26,8 +28,8 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt RUN python manage.py collectstatic --noinput || true RUN python manage.py migrate --noinput || true -# Expose the port -EXPOSE 8000 +# Expose port 9191 (this is the port the app will listen on inside the container) +EXPOSE 9191 -# Command to run the application -CMD ["gunicorn", "--workers=4", "--worker-class=gevent", "--timeout=300", "--bind", "0.0.0.0:8000", "dispatcharr.wsgi:application"] +# Command to run the application binding to port 9191 +CMD ["gunicorn", "--workers=4", "--worker-class=gevent", "--timeout=300", "--bind", "0.0.0.0:9191", "dispatcharr.wsgi:application"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 413bd5d1..36ade6df 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,7 +5,7 @@ services: dockerfile: docker/Dockerfile container_name: dispatcharr_web ports: - - "9191:8000" + - "9191:9191" depends_on: - db - redis @@ -30,7 +30,7 @@ services: volumes: - ../:/app environment: - - POSTGRES_HOST=db + - POSTGRES_HOST=localhost - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret diff --git a/docker/requirements.txt b/docker/requirements.txt deleted file mode 100644 index 88ccef39..00000000 --- a/docker/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index be709a3b..6112ce54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,14 @@ -Django==4.2.2 -gunicorn==20.1.0 -psycopg2-binary==2.9.6 +Django==5.1.6 +gunicorn==23.0.0 +psycopg2-binary==2.9.10 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 +djangorestframework==3.15.2 +requests==2.32.3 +psutil==7.0.0 pillow drf-yasg>=1.20.0 +streamlink +python-vlc +yt-dlp +gevent==24.11.1 diff --git a/templates/admin/app_cards.html b/templates/admin/app_cards.html new file mode 100755 index 00000000..a231b64f --- /dev/null +++ b/templates/admin/app_cards.html @@ -0,0 +1,25 @@ +
+
+ {% for app in app_list %} +
+
+
+
{{ app.name }}
+
+
+ +
+
+
+ {% endfor %} +
+
diff --git a/templates/admin/base.html b/templates/admin/base.html deleted file mode 100755 index 41a9a6f9..00000000 --- a/templates/admin/base.html +++ /dev/null @@ -1,202 +0,0 @@ -{% 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 %} - - - - - diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html new file mode 100755 index 00000000..8916b146 --- /dev/null +++ b/templates/admin/base_site.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% load i18n static %} + +{% block title %}Admin Dashboard | Dispatcharr{% endblock %} + +{% block extrastyle %} + {{ block.super }} + + +{% endblock %} + +{% block content %} +
+
+ {% for app in app_list %} +
+
+
+
{{ app.name }}
+
+
+ +
+
+
+ {% endfor %} +
+
+{% endblock %} + +{% block extra_js %} + {{ block.super }} + + + + + + + +{% endblock %} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100755 index 00000000..d9f7388f --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,4 @@ +{% extends "admin/base_site.html" %} +{% block content %} + {% include "admin/app_cards.html" %} +{% endblock %} diff --git a/templates/epg/epg.html b/templates/epg/epg.html index 639cf078..8f8a28a0 100755 --- a/templates/epg/epg.html +++ b/templates/epg/epg.html @@ -33,6 +33,28 @@ +
+
+

User Agents

+ +
+
+ + + + + + + + + + + +
IDUser AgentDescriptionActiveActions
+
+
diff --git a/templates/ffmpeg.html b/templates/ffmpeg.html deleted file mode 100755 index 07b742e0..00000000 --- a/templates/ffmpeg.html +++ /dev/null @@ -1,77 +0,0 @@ -{% 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 %} diff --git a/templates/m3u/m3u.html b/templates/m3u/m3u.html index 9238be33..f09db2a0 100755 --- a/templates/m3u/m3u.html +++ b/templates/m3u/m3u.html @@ -2,217 +2,445 @@ {% block title %}M3U Management - Dispatcharr{% endblock %} {% block page_header %}M3U Management{% endblock %} {% block content %} -
-
-
-

M3U Accounts

- -
-
- - - - - - - - - - - -
NameURL/FileMax StreamsActions
-
+ +
+
+

M3U Accounts

+ +
+
+ + + + + + + + + + + +
NameURL/FileMax StreamsActions
+
+
+ +
+
+

User Agents

+ +
+
+ + + + + + + + + + + +
IDUser AgentDescriptionActiveActions
+
+
- +