From 6afd5a38c9429459c12c757eef46e3f0b6c96527 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 18:44:53 -0500 Subject: [PATCH 1/6] Add timeouts to logo fetching to avoid hanging UI if a logo is unreachable. Also add default user-agent to request to prevent servers from denying request. Fixes #217 and Fixes #101 --- apps/channels/api_views.py | 55 +++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index f0f59f29..310fccbb 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -17,6 +17,8 @@ from apps.accounts.permissions import ( permission_classes_by_method, ) +from core.models import UserAgent, CoreSettings + from .models import ( Stream, Channel, @@ -1038,6 +1040,31 @@ class LogoViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def create(self, request, *args, **kwargs): + """Create a new logo entry""" + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + logo = serializer.save() + return Response(self.get_serializer(logo).data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + """Update an existing logo""" + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """Delete a logo""" + logo = self.get_object() + + # Check if logo is being used by any channels + if logo.channels.exists(): + return Response( + {"error": f"Cannot delete logo as it is used by {logo.channels.count()} channel(s)"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().destroy(request, *args, **kwargs) + @action(detail=False, methods=["post"]) def upload(self, request): if "file" not in request.FILES: @@ -1062,7 +1089,7 @@ class LogoViewSet(viewsets.ModelViewSet): ) return Response( - {"id": logo.id, "name": logo.name, "url": logo.url}, + LogoSerializer(logo, context={'request': request}).data, status=status.HTTP_201_CREATED, ) @@ -1092,7 +1119,22 @@ class LogoViewSet(viewsets.ModelViewSet): else: # Remote image try: - remote_response = requests.get(logo_url, stream=True) + # Get the default user agent + try: + default_user_agent_id = CoreSettings.get_default_user_agent_id() + user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id)) + user_agent = user_agent_obj.user_agent + except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError): + # Fallback to hardcoded if default not found + user_agent = 'Dispatcharr/1.0' + + # Add proper timeouts to prevent hanging + remote_response = requests.get( + logo_url, + stream=True, + timeout=(3, 5), # (connect_timeout, read_timeout) + headers={'User-Agent': user_agent} + ) if remote_response.status_code == 200: # Try to get content type from response headers first content_type = remote_response.headers.get("Content-Type") @@ -1114,7 +1156,14 @@ class LogoViewSet(viewsets.ModelViewSet): ) return response raise Http404("Remote image not found") - except requests.RequestException: + except requests.exceptions.Timeout: + logger.warning(f"Timeout fetching logo from {logo_url}") + raise Http404("Logo request timed out") + except requests.exceptions.ConnectionError: + logger.warning(f"Connection error fetching logo from {logo_url}") + raise Http404("Unable to connect to logo server") + except requests.RequestException as e: + logger.warning(f"Error fetching logo from {logo_url}: {e}") raise Http404("Error fetching remote image") From a5f7a88ba034ee3678edd6c48c9e3cfb030dc86b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 16:54:37 -0500 Subject: [PATCH 2/6] Add option to force dummy epg. --- apps/channels/serializers.py | 27 ++++++- apps/m3u/api_views.py | 7 ++ .../src/components/forms/M3UGroupFilter.jsx | 81 +++++++++++++++---- 3 files changed, 96 insertions(+), 19 deletions(-) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 278399dd..84a68e50 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -302,13 +302,34 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): enabled = serializers.BooleanField() auto_channel_sync = serializers.BooleanField(default=False) auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False) + custom_properties = serializers.JSONField(required=False) class Meta: model = ChannelGroupM3UAccount - fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start"] + fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"] - # Optionally, if you only need the id of the ChannelGroup, you can customize it like this: - # channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all()) + def to_representation(self, instance): + ret = super().to_representation(instance) + # Ensure custom_properties is always a dict or None + val = ret.get("custom_properties") + if isinstance(val, str): + import json + try: + ret["custom_properties"] = json.loads(val) + except Exception: + ret["custom_properties"] = None + return ret + + def to_internal_value(self, data): + # Accept both dict and JSON string for custom_properties + val = data.get("custom_properties") + if isinstance(val, str): + import json + try: + data["custom_properties"] = json.loads(val) + except Exception: + pass + return super().to_internal_value(data) class RecordingSerializer(serializers.ModelSerializer): diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 39b9e22e..d3739f19 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -15,6 +15,7 @@ import os from rest_framework.decorators import action from django.conf import settings from .tasks import refresh_m3u_groups +import json from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile from core.models import UserAgent @@ -154,6 +155,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet): enabled = setting.get("enabled", True) auto_sync = setting.get("auto_channel_sync", False) sync_start = setting.get("auto_sync_channel_start") + custom_properties = setting.get("custom_properties", {}) if group_id: ChannelGroupM3UAccount.objects.update_or_create( @@ -163,6 +165,11 @@ class M3UAccountViewSet(viewsets.ModelViewSet): "enabled": enabled, "auto_channel_sync": auto_sync, "auto_sync_channel_start": sync_start, + "custom_properties": ( + custom_properties + if isinstance(custom_properties, str) + else json.dumps(custom_properties) + ), }, ) diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index e0a204ab..b7d095d3 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -43,12 +43,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { } setGroupStates( - playlist.channel_groups.map((group) => ({ - ...group, - name: channelGroups[group.channel_group].name, - auto_channel_sync: group.auto_channel_sync || false, - auto_sync_channel_start: group.auto_sync_channel_start || 1.0, - })) + playlist.channel_groups.map((group) => { + // Parse custom_properties if present + let customProps = {}; + if (group.custom_properties) { + try { + customProps = typeof group.custom_properties === 'string' + ? JSON.parse(group.custom_properties) + : group.custom_properties; + } catch (e) { + customProps = {}; + } + } + return { + ...group, + name: channelGroups[group.channel_group].name, + auto_channel_sync: group.auto_channel_sync || false, + auto_sync_channel_start: group.auto_sync_channel_start || 1.0, + custom_properties: customProps, + }; + }) ); }, [playlist, channelGroups]); @@ -79,11 +93,36 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { ); }; + // Toggle force_dummy_epg in custom_properties for a group + const toggleForceDummyEPG = (id) => { + setGroupStates( + groupStates.map((state) => { + if (state.channel_group == id) { + const customProps = { ...(state.custom_properties || {}) }; + customProps.force_dummy_epg = !customProps.force_dummy_epg; + return { + ...state, + custom_properties: customProps, + }; + } + return state; + }) + ); + }; + const submit = async () => { setIsLoading(true); try { + // Prepare groupStates for API: custom_properties must be stringified + const payload = groupStates.map((state) => ({ + ...state, + custom_properties: state.custom_properties + ? JSON.stringify(state.custom_properties) + : undefined, + })); + // Update group settings via API endpoint - await API.updateM3UGroupSettings(playlist.id, groupStates); + await API.updateM3UGroupSettings(playlist.id, payload); // Show notification about the refresh process notifications.show({ @@ -224,15 +263,25 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { /> {group.auto_channel_sync && group.enabled && ( - updateChannelStart(group.channel_group, value)} - min={1} - step={1} - size="xs" - precision={1} - /> + <> + updateChannelStart(group.channel_group, value)} + min={1} + step={1} + size="xs" + precision={1} + /> + + {/* Force Dummy EPG Checkbox */} + toggleForceDummyEPG(group.channel_group)} + size="xs" + /> + )} From e9055a5ad6aa92b53f03d61f4a60229bd631f637 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 17:36:32 -0500 Subject: [PATCH 3/6] Activate setting --- apps/m3u/tasks.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 03888ead..800ff67a 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -868,6 +868,16 @@ def sync_auto_channels(account_id): channel_group = group_relation.channel_group start_number = group_relation.auto_sync_channel_start or 1.0 + # Get force_dummy_epg from group custom_properties + group_custom_props = {} + force_dummy_epg = False + if group_relation.custom_properties: + try: + group_custom_props = json.loads(group_relation.custom_properties) + force_dummy_epg = group_custom_props.get("force_dummy_epg", False) + except Exception: + force_dummy_epg = False + logger.info(f"Processing auto sync for group: {channel_group.name} (start: {start_number})") # Get all current streams in this group for this M3U account @@ -956,7 +966,7 @@ def sync_auto_channels(account_id): # Handle EPG data updates current_epg_data = None - if stream.tvg_id: + if stream.tvg_id and not force_dummy_epg: current_epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first() if existing_channel.epg_data != current_epg_data: @@ -994,11 +1004,14 @@ def sync_auto_channels(account_id): ) # Try to match EPG data - if stream.tvg_id: + if stream.tvg_id and not force_dummy_epg: epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first() if epg_data: channel.epg_data = epg_data channel.save(update_fields=['epg_data']) + elif stream.tvg_id and force_dummy_epg: + channel.epg_data = None + channel.save(update_fields=['epg_data']) # Handle logo if stream.logo_url: From 8fa27904e76b393d3c885e9708e66e6fdda13f50 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 17 Jul 2025 18:42:42 -0500 Subject: [PATCH 4/6] Add ability to override group name during auto channel creation. --- apps/m3u/tasks.py | 42 ++++++++++++++----- .../src/components/forms/M3UGroupFilter.jsx | 26 ++++++++++++ .../src/components/tables/ChannelsTable.jsx | 5 ++- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 800ff67a..5ee669f0 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -847,7 +847,6 @@ def sync_auto_channels(account_id): """ from apps.channels.models import Channel, ChannelGroup, ChannelGroupM3UAccount, Stream, ChannelStream from apps.epg.models import EPGData - import json try: account = M3UAccount.objects.get(id=account_id) @@ -868,15 +867,27 @@ def sync_auto_channels(account_id): channel_group = group_relation.channel_group start_number = group_relation.auto_sync_channel_start or 1.0 - # Get force_dummy_epg from group custom_properties + # Get force_dummy_epg and group_override from group custom_properties group_custom_props = {} force_dummy_epg = False + override_group_id = None if group_relation.custom_properties: try: group_custom_props = json.loads(group_relation.custom_properties) force_dummy_epg = group_custom_props.get("force_dummy_epg", False) + override_group_id = group_custom_props.get("group_override") except Exception: force_dummy_epg = False + override_group_id = None + + # Determine which group to use for created channels + target_group = channel_group + if override_group_id: + try: + target_group = ChannelGroup.objects.get(id=override_group_id) + logger.info(f"Using override group '{target_group.name}' instead of '{channel_group.name}' for auto-created channels") + except ChannelGroup.DoesNotExist: + logger.warning(f"Override group with ID {override_group_id} not found, using original group '{channel_group.name}'") logger.info(f"Processing auto sync for group: {channel_group.name} (start: {start_number})") @@ -886,20 +897,22 @@ def sync_auto_channels(account_id): channel_group=channel_group ).order_by('name') - # Get existing auto-created channels for this account in this group + # Get existing auto-created channels for this account (regardless of current group) + # We'll find them by their stream associations instead of just group location existing_channels = Channel.objects.filter( - channel_group=channel_group, auto_created=True, auto_created_by=account ).select_related('logo', 'epg_data') # Create mapping of existing channels by their associated stream + # This approach finds channels even if they've been moved to different groups existing_channel_map = {} for channel in existing_channels: - # Get streams associated with this channel that belong to our M3U account + # Get streams associated with this channel that belong to our M3U account and original group channel_streams = ChannelStream.objects.filter( channel=channel, - stream__m3u_account=account + stream__m3u_account=account, + stream__channel_group=channel_group # Match streams from the original group ).select_related('stream') # Map each of our M3U account's streams to this channel @@ -913,9 +926,10 @@ def sync_auto_channels(account_id): if not current_streams.exists(): logger.debug(f"No streams found in group {channel_group.name}") # Delete all existing auto channels if no streams - if existing_channels.exists(): - deleted_count = existing_channels.count() - existing_channels.delete() + channels_to_delete = [ch for ch in existing_channel_map.values()] + if channels_to_delete: + deleted_count = len(channels_to_delete) + Channel.objects.filter(id__in=[ch.id for ch in channels_to_delete]).delete() channels_deleted += deleted_count logger.debug(f"Deleted {deleted_count} auto channels (no streams remaining)") continue @@ -951,6 +965,12 @@ def sync_auto_channels(account_id): existing_channel.tvc_guide_stationid = tvc_guide_stationid channel_updated = True + # Check if channel group needs to be updated (in case override was added/changed) + if existing_channel.channel_group != target_group: + existing_channel.channel_group = target_group + channel_updated = True + logger.info(f"Moved auto channel '{existing_channel.name}' from '{existing_channel.channel_group.name if existing_channel.channel_group else 'None'}' to '{target_group.name}'") + # Handle logo updates current_logo = None if stream.logo_url: @@ -984,13 +1004,13 @@ def sync_auto_channels(account_id): while Channel.objects.filter(channel_number=current_channel_number).exists(): current_channel_number += 0.1 - # Create the channel with auto-created tracking + # Create the channel with auto-created tracking in the target group channel = Channel.objects.create( channel_number=current_channel_number, name=stream.name, tvg_id=stream.tvg_id, tvc_guide_stationid=tvc_guide_stationid, - channel_group=channel_group, + channel_group=target_group, # Use target group (could be override) user_level=0, # Default user level auto_created=True, # Mark as auto-created auto_created_by=account # Track which M3U account created it diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index b7d095d3..f94f35ef 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -281,6 +281,32 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { onChange={() => toggleForceDummyEPG(group.channel_group)} size="xs" /> + + {/* Override Channel Group Select */} + { - const newValue = value ? parseInt(value) : null; - setGroupStates( - groupStates.map((state) => ({ - ...state, - custom_properties: { - ...state.custom_properties, - group_override: newValue, - }, - })) - ); - }} - data={Object.values(channelGroups).map((g) => ({ - value: g.id.toString(), - label: g.name, - }))} - clearable - searchable - size="xs" - /> + {/* Override Channel Group */} + + { + const isEnabled = event.currentTarget.checked; + setGroupStates( + groupStates.map((state) => { + if (state.channel_group == group.channel_group) { + const newCustomProps = { ...(state.custom_properties || {}) }; + if (isEnabled) { + newCustomProps.group_override = null; + } else { + delete newCustomProps.group_override; + } + return { + ...state, + custom_properties: newCustomProps, + }; + } + return state; + }) + ); + }} + size="xs" + /> +