Merge pull request #99 from Dispatcharr/Decimals-in-channels

Decimals in channels
This commit is contained in:
SergeantPanda 2025-05-15 16:54:23 -05:00 committed by GitHub
commit 0843776b6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 127 additions and 37 deletions

View file

@ -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

View file

@ -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,

View file

@ -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),
),
]

View file

@ -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',

View file

@ -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)"""

View file

@ -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)

View file

@ -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' <channel id="{channel_id}">')
xml_lines.append(f' <channel id="{formatted_channel_number}">')
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
# Add channel logo if available
@ -111,16 +128,24 @@ def generate_epg(request, profile_name=None):
xml_lines.append(' </channel>')
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' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">')
xml_lines.append(f' <programme start="{start_str}" stop="{stop_str}" channel="{formatted_channel_number}">')
xml_lines.append(f' <title>{html.escape(prog.title)}</title>')
# Add subtitle if available

View file

@ -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(

View file

@ -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
/>
<TextInput

View file

@ -593,11 +593,18 @@ const ChannelsTable = ({ }) => {
id: 'channel_number',
accessorKey: 'channel_number',
size: 40,
cell: ({ getValue }) => (
<Flex justify="flex-end" style={{ width: '100%' }}>
{getValue()}
</Flex>
),
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 (
<Flex justify="flex-end" style={{ width: '100%' }}>
{formattedValue}
</Flex>
);
},
},
{
id: 'name',