mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge pull request #99 from Dispatcharr/Decimals-in-channels
Decimals in channels
This commit is contained in:
commit
0843776b6b
10 changed files with 127 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue