From 88866cc905bae365d1b5bb216f56ca36c8bcdc77 Mon Sep 17 00:00:00 2001 From: MooseyOnTheLoosey Date: Thu, 8 May 2025 09:12:10 -0500 Subject: [PATCH 1/7] Updated channel numbers from integer to float --- apps/channels/api_views.py | 24 ++++++++++++------- .../0018_alter_channel_channel_number.py | 18 ++++++++++++++ apps/channels/models.py | 3 ++- apps/channels/serializers.py | 6 ++++- 4 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 apps/channels/migrations/0018_alter_channel_channel_number.py diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ab206afb..ed070baa 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -23,6 +23,8 @@ import mimetypes from rest_framework.pagination import PageNumberPagination +import logging +logger = logging.getLogger(__name__) class OrInFilter(django_filters.Filter): """ @@ -239,7 +241,7 @@ class ChannelViewSet(viewsets.ModelViewSet): type=openapi.TYPE_INTEGER, description="ID of the stream to link" ), "channel_number": openapi.Schema( - type=openapi.TYPE_INTEGER, + type=openapi.TYPE_NUMBER, description="(Optional) Desired channel number. Must not be in use." ), "name": openapi.Schema( @@ -255,6 +257,7 @@ class ChannelViewSet(viewsets.ModelViewSet): 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) + logger.debug(f"Stream found: {stream.id}, Custom Properties: {stream.custom_properties}") channel_group = stream.channel_group name = request.data.get('name') @@ -266,17 +269,21 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = None if 'tvg-chno' in stream_custom_props: - channel_number = int(stream_custom_props['tvg-chno']) + channel_number = float(stream_custom_props['tvg-chno']) + logger.debug(f"Channel number from tvg-chno: {channel_number}") elif 'channel-number' in stream_custom_props: - channel_number = int(stream_custom_props['channel-number']) + channel_number = float(stream_custom_props['channel-number']) + logger.debug(f"Channel number from channel-number: {channel_number}") if channel_number is None: provided_number = request.data.get('channel_number') + logger.debug(f"Provided channel number: {provided_number}") if provided_number is None: channel_number = Channel.get_next_available_channel_number() else: try: - channel_number = int(provided_number) + channel_number = float(provided_number) + logger.debug(f"Provided channel number2: {provided_number}") except ValueError: return Response({"error": "channel_number must be an integer."}, status=status.HTTP_400_BAD_REQUEST) # If the provided number is already used, return an error. @@ -295,6 +302,7 @@ class ChannelViewSet(viewsets.ModelViewSet): 'channel_group_id': channel_group.id, 'streams': [stream_id], } + logger.debug(f"Final channel data: {channel_data}") if stream.logo_url: logo, _ = Logo.objects.get_or_create(url=stream.logo_url, defaults={ @@ -330,7 +338,7 @@ class ChannelViewSet(viewsets.ModelViewSet): type=openapi.TYPE_INTEGER, description="ID of the stream to link" ), "channel_number": openapi.Schema( - type=openapi.TYPE_INTEGER, + type=openapi.TYPE_NUMBER, description="(Optional) Desired channel number. Must not be in use." ), "name": openapi.Schema( @@ -387,9 +395,9 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = None if 'tvg-chno' in stream_custom_props: - channel_number = int(stream_custom_props['tvg-chno']) + channel_number = float(stream_custom_props['tvg-chno']) elif 'channel-number' in stream_custom_props: - channel_number = int(stream_custom_props['channel-number']) + channel_number = float(stream_custom_props['channel-number']) # Determine channel number: if provided, use it (if free); else auto assign. if channel_number is None: @@ -398,7 +406,7 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = get_auto_number() else: try: - channel_number = int(provided_number) + channel_number = float(provided_number) except ValueError: errors.append({"item": item, "error": "channel_number must be an integer."}) continue diff --git a/apps/channels/migrations/0018_alter_channel_channel_number.py b/apps/channels/migrations/0018_alter_channel_channel_number.py new file mode 100644 index 00000000..7e5163d4 --- /dev/null +++ b/apps/channels/migrations/0018_alter_channel_channel_number.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-08 14:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0017_alter_channelgroup_name'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='channel_number', + field=models.FloatField(db_index=True), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 4485936e..fb208bcd 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -210,7 +210,8 @@ class ChannelManager(models.Manager): class Channel(models.Model): - channel_number = models.IntegerField(db_index=True) + channel_number = models.FloatField(db_index=True) + logger.debug(f"Saving channel with channel_number: {channel_number}") name = models.CharField(max_length=255) logo = models.ForeignKey( 'Logo', diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 69f25286..2134eef5 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -7,6 +7,9 @@ from django.urls import reverse from rest_framework import serializers from django.utils import timezone +import logging +logger = logging.getLogger(__name__) + class LogoSerializer(serializers.ModelSerializer): cache_url = serializers.SerializerMethodField() @@ -114,7 +117,8 @@ class BulkChannelProfileMembershipSerializer(serializers.Serializer): # class ChannelSerializer(serializers.ModelSerializer): # Show nested group data, or ID - channel_number = serializers.IntegerField(allow_null=True, required=False) + channel_number = serializers.FloatField(allow_null=True, required=False) + logger.debug(f"Serializer validating channel number: {channel_number}") channel_group_id = serializers.PrimaryKeyRelatedField( queryset=ChannelGroup.objects.all(), source="channel_group", From f6ea1b41b32cbfd9833ff95e849d1dd2ea48f1a1 Mon Sep 17 00:00:00 2001 From: MooseyOnTheLoosey Date: Thu, 8 May 2025 09:15:08 -0500 Subject: [PATCH 2/7] Removing debug --- apps/channels/api_views.py | 8 -------- apps/channels/serializers.py | 4 ---- 2 files changed, 12 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ed070baa..a6200ae5 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -23,9 +23,6 @@ import mimetypes from rest_framework.pagination import PageNumberPagination -import logging -logger = logging.getLogger(__name__) - class OrInFilter(django_filters.Filter): """ Custom filter that handles the OR condition instead of AND. @@ -270,20 +267,16 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = None if 'tvg-chno' in stream_custom_props: channel_number = float(stream_custom_props['tvg-chno']) - logger.debug(f"Channel number from tvg-chno: {channel_number}") elif 'channel-number' in stream_custom_props: channel_number = float(stream_custom_props['channel-number']) - logger.debug(f"Channel number from channel-number: {channel_number}") if channel_number is None: provided_number = request.data.get('channel_number') - logger.debug(f"Provided channel number: {provided_number}") if provided_number is None: channel_number = Channel.get_next_available_channel_number() else: try: channel_number = float(provided_number) - logger.debug(f"Provided channel number2: {provided_number}") except ValueError: return Response({"error": "channel_number must be an integer."}, status=status.HTTP_400_BAD_REQUEST) # If the provided number is already used, return an error. @@ -302,7 +295,6 @@ class ChannelViewSet(viewsets.ModelViewSet): 'channel_group_id': channel_group.id, 'streams': [stream_id], } - logger.debug(f"Final channel data: {channel_data}") if stream.logo_url: logo, _ = Logo.objects.get_or_create(url=stream.logo_url, defaults={ diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 2134eef5..c3772f34 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -7,9 +7,6 @@ from django.urls import reverse from rest_framework import serializers from django.utils import timezone -import logging -logger = logging.getLogger(__name__) - class LogoSerializer(serializers.ModelSerializer): cache_url = serializers.SerializerMethodField() @@ -118,7 +115,6 @@ class BulkChannelProfileMembershipSerializer(serializers.Serializer): class ChannelSerializer(serializers.ModelSerializer): # Show nested group data, or ID channel_number = serializers.FloatField(allow_null=True, required=False) - logger.debug(f"Serializer validating channel number: {channel_number}") channel_group_id = serializers.PrimaryKeyRelatedField( queryset=ChannelGroup.objects.all(), source="channel_group", From 5bae7997c03927871be9b2b0de4c042bdd562fb7 Mon Sep 17 00:00:00 2001 From: MooseyOnTheLoosey Date: Thu, 8 May 2025 09:50:19 -0500 Subject: [PATCH 3/7] Removing more debug --- apps/channels/api_views.py | 2 +- apps/channels/models.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index a6200ae5..13535c8c 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -23,6 +23,7 @@ import mimetypes from rest_framework.pagination import PageNumberPagination + class OrInFilter(django_filters.Filter): """ Custom filter that handles the OR condition instead of AND. @@ -254,7 +255,6 @@ class ChannelViewSet(viewsets.ModelViewSet): 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) - logger.debug(f"Stream found: {stream.id}, Custom Properties: {stream.custom_properties}") channel_group = stream.channel_group name = request.data.get('name') diff --git a/apps/channels/models.py b/apps/channels/models.py index fb208bcd..fc6af558 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -211,7 +211,6 @@ class ChannelManager(models.Manager): class Channel(models.Model): channel_number = models.FloatField(db_index=True) - logger.debug(f"Saving channel with channel_number: {channel_number}") name = models.CharField(max_length=255) logo = models.ForeignKey( 'Logo', From 3b2250895df836308c1c12435241fc30e6dafc09 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 May 2025 14:38:28 -0500 Subject: [PATCH 4/7] Fixed migrations. --- ...channel_number.py => 0020_alter_channel_channel_number.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename apps/channels/migrations/{0018_alter_channel_channel_number.py => 0020_alter_channel_channel_number.py} (71%) diff --git a/apps/channels/migrations/0018_alter_channel_channel_number.py b/apps/channels/migrations/0020_alter_channel_channel_number.py similarity index 71% rename from apps/channels/migrations/0018_alter_channel_channel_number.py rename to apps/channels/migrations/0020_alter_channel_channel_number.py index 7e5163d4..0a1b6ead 100644 --- a/apps/channels/migrations/0018_alter_channel_channel_number.py +++ b/apps/channels/migrations/0020_alter_channel_channel_number.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.6 on 2025-05-08 14:05 +# Generated by Django 5.1.6 on 2025-05-15 19:37 from django.db import migrations, models @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dispatcharr_channels', '0017_alter_channelgroup_name'), + ('dispatcharr_channels', '0019_channel_tvc_guide_stationid'), ] operations = [ From 8ee1581588598cb03e1359981c273a13c14e6de0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 May 2025 15:32:21 -0500 Subject: [PATCH 5/7] Forms support floats now. --- apps/channels/api_views.py | 11 ++++++++--- apps/channels/forms.py | 7 +++++++ apps/channels/serializers.py | 19 ++++++++++++++++++- frontend/src/api.js | 4 ++-- frontend/src/components/forms/Channel.jsx | 3 +++ 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b612fa2c..bbcbf686 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -230,11 +230,11 @@ class ChannelViewSet(viewsets.ModelViewSet): type=openapi.TYPE_OBJECT, required=["channel_ids"], properties={ - "starting_number": openapi.Schema(type=openapi.TYPE_STRING, description="Starting channel number to assign"), + "starting_number": openapi.Schema(type=openapi.TYPE_NUMBER, description="Starting channel number to assign (can be decimal)"), "channel_ids": openapi.Schema( type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to assign" + description="Channel IDs to assign" ) } ), @@ -244,7 +244,12 @@ class ChannelViewSet(viewsets.ModelViewSet): def assign(self, request): with transaction.atomic(): channel_ids = request.data.get('channel_ids', []) - channel_num = request.data.get('starting_number', 1) + # Ensure starting_number is processed as a float + try: + channel_num = float(request.data.get('starting_number', 1)) + except (ValueError, TypeError): + channel_num = 1.0 + for channel_id in channel_ids: Channel.objects.filter(id=channel_id).update(channel_number=channel_num) channel_num = channel_num + 1 diff --git a/apps/channels/forms.py b/apps/channels/forms.py index 342bd0fe..a566adbd 100644 --- a/apps/channels/forms.py +++ b/apps/channels/forms.py @@ -14,6 +14,13 @@ class ChannelGroupForm(forms.ModelForm): # Channel Form # class ChannelForm(forms.ModelForm): + # Explicitly define channel_number as FloatField to ensure decimal values work + channel_number = forms.FloatField( + required=False, + widget=forms.NumberInput(attrs={'step': '0.1'}), # Allow decimal steps + help_text="Channel number can include decimals (e.g., 1.1, 2.5)" + ) + channel_group = forms.ModelChoiceField( queryset=ChannelGroup.objects.all(), required=False, diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 712d8569..5423037f 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -115,7 +115,14 @@ class BulkChannelProfileMembershipSerializer(serializers.Serializer): # class ChannelSerializer(serializers.ModelSerializer): # Show nested group data, or ID - channel_number = serializers.FloatField(allow_null=True, required=False) + # Ensure channel_number is explicitly typed as FloatField and properly validated + channel_number = serializers.FloatField( + allow_null=True, + required=False, + error_messages={ + 'invalid': 'Channel number must be a valid decimal number.' + } + ) channel_group_id = serializers.PrimaryKeyRelatedField( queryset=ChannelGroup.objects.all(), source="channel_group", @@ -234,6 +241,16 @@ class ChannelSerializer(serializers.ModelSerializer): return instance + def validate_channel_number(self, value): + """Ensure channel_number is properly processed as a float""" + if value is None: + return value + + try: + # Ensure it's processed as a float + return float(value) + except (ValueError, TypeError): + raise serializers.ValidationError("Channel number must be a valid decimal number.") def validate_stream_profile(self, value): """Handle special case where empty/0 values mean 'use default' (null)""" diff --git a/frontend/src/api.js b/frontend/src/api.js index 7a378262..73bbde7d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -368,8 +368,8 @@ export default class API { payload.channel_number !== null && payload.channel_number !== undefined ) { - const parsedNumber = parseInt(payload.channel_number, 10); - payload.channel_number = isNaN(parsedNumber) ? null : parsedNumber; + // Ensure channel_number is explicitly treated as a float + payload.channel_number = parseFloat(payload.channel_number); } const response = await request( diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 71f7c7b2..ac048712 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -667,6 +667,9 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { : '' } size="xs" + step={0.1} // Add step prop to allow decimal inputs + precision={1} // Specify decimal precision + removeTrailingZeros // Optional: remove trailing zeros for cleaner display /> Date: Thu, 15 May 2025 16:13:08 -0500 Subject: [PATCH 6/7] EPG and M3U support decimals if the channel has a decimal otherwise use integer. --- apps/output/views.py | 43 +++++++++++++++---- .../src/components/tables/ChannelsTable.jsx | 17 +++++--- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/apps/output/views.py b/apps/output/views.py index 38b69bde..6df54c31 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -24,7 +24,18 @@ def generate_m3u(request, profile_name=None): m3u_content = "#EXTM3U\n" for channel in channels: group_title = channel.channel_group.name if channel.channel_group else "Default" - tvg_id = channel.channel_number or channel.id + + # Format channel number as integer if it has no decimal component + if channel.channel_number is not None: + if channel.channel_number == int(channel.channel_number): + formatted_channel_number = int(channel.channel_number) + else: + formatted_channel_number = channel.channel_number + else: + formatted_channel_number = "" + + # Use formatted channel number for tvg_id to ensure proper matching with EPG + tvg_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) tvg_name = channel.name tvg_logo = "" @@ -36,11 +47,9 @@ def generate_m3u(request, profile_name=None): if channel.tvc_guide_stationid: tvc_guide_stationid = f'tvc-guide-stationid="{channel.tvc_guide_stationid}" ' - 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}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n' + f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n' ) base_url = request.build_absolute_uri('/')[:-1] @@ -98,9 +107,17 @@ def generate_epg(request, profile_name=None): # Retrieve all active channels for channel in channels: - channel_id = channel.channel_number or channel.id + # Format channel number as integer if it has no decimal component - same as M3U generation + if channel.channel_number is not None: + if channel.channel_number == int(channel.channel_number): + formatted_channel_number = str(int(channel.channel_number)) + else: + formatted_channel_number = str(channel.channel_number) + else: + formatted_channel_number = str(channel.id) + display_name = channel.epg_data.name if channel.epg_data else channel.name - xml_lines.append(f' ') + xml_lines.append(f' ') xml_lines.append(f' {html.escape(display_name)}') # Add channel logo if available @@ -111,16 +128,24 @@ def generate_epg(request, profile_name=None): xml_lines.append(' ') for channel in channels: - channel_id = channel.channel_number or channel.id + # Use the same formatting for channel ID in program entries + if channel.channel_number is not None: + if channel.channel_number == int(channel.channel_number): + formatted_channel_number = str(int(channel.channel_number)) + else: + formatted_channel_number = str(channel.channel_number) + else: + formatted_channel_number = str(channel.id) + display_name = channel.epg_data.name if channel.epg_data else channel.name if not channel.epg_data: - xml_lines = xml_lines + generate_dummy_epg(display_name, channel_id) + xml_lines = xml_lines + generate_dummy_epg(display_name, formatted_channel_number) else: programs = channel.epg_data.programs.all() for prog in programs: start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z") stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z") - xml_lines.append(f' ') + xml_lines.append(f' ') xml_lines.append(f' {html.escape(prog.title)}') # Add subtitle if available diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 2716e994..cb81e988 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -593,11 +593,18 @@ const ChannelsTable = ({ }) => { id: 'channel_number', accessorKey: 'channel_number', size: 40, - cell: ({ getValue }) => ( - - {getValue()} - - ), + cell: ({ getValue }) => { + const value = getValue(); + // Format as integer if no decimal component + const formattedValue = value !== null && value !== undefined ? + (value === Math.floor(value) ? Math.floor(value) : value) : ''; + + return ( + + {formattedValue} + + ); + }, }, { id: 'name', From 8c86f7656a84d2aa5e95a3e4c56d9a03d8ba13fd Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 May 2025 16:21:14 -0500 Subject: [PATCH 7/7] Convert HDHR floats correctly. --- apps/hdhr/api_views.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 676c0fb9..b4f895d4 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -129,16 +129,24 @@ class LineupAPIView(APIView): else: channels = Channel.objects.all().order_by('channel_number') - lineup = [ - { - "GuideNumber": str(ch.channel_number), + lineup = [] + for ch in channels: + # Format channel number as integer if it has no decimal component + if ch.channel_number is not None: + if ch.channel_number == int(ch.channel_number): + formatted_channel_number = str(int(ch.channel_number)) + else: + formatted_channel_number = str(ch.channel_number) + else: + formatted_channel_number = "" + + lineup.append({ + "GuideNumber": formatted_channel_number, "GuideName": ch.name, "URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"), - "Guide_ID": str(ch.channel_number), - "Station": str(ch.channel_number), - } - for ch in channels - ] + "Guide_ID": formatted_channel_number, + "Station": formatted_channel_number, + }) return JsonResponse(lineup, safe=False)