diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 23632704..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 @@ -266,7 +271,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( @@ -293,9 +298,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']) if channel_number is None: provided_number = request.data.get('channel_number') @@ -303,7 +308,7 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = Channel.get_next_available_channel_number() else: try: - channel_number = int(provided_number) + channel_number = float(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. @@ -362,7 +367,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( @@ -419,9 +424,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: @@ -430,7 +435,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/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/migrations/0020_alter_channel_channel_number.py b/apps/channels/migrations/0020_alter_channel_channel_number.py new file mode 100644 index 00000000..0a1b6ead --- /dev/null +++ b/apps/channels/migrations/0020_alter_channel_channel_number.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-15 19:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0019_channel_tvc_guide_stationid'), + ] + + 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 dc5c50fb..191eb45e 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -209,7 +209,7 @@ class ChannelManager(models.Manager): class Channel(models.Model): - channel_number = models.IntegerField() + channel_number = models.FloatField(db_index=True) name = models.CharField(max_length=255) logo = models.ForeignKey( 'Logo', diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index ce3910a4..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.IntegerField(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/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) 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/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 /> { 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',