m3u file upload fixed, hdhr support profiles now, i don't remember what I did to the UI anymore

This commit is contained in:
dekzter 2025-04-07 20:49:42 -04:00
parent 5f3ff480af
commit 839abd281c
10 changed files with 170 additions and 114 deletions

View file

@ -381,6 +381,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
channel_logos = {logo.url: logo for logo in Logo.objects.filter(url__in=[url for url in logo_map if url is not None])}
profiles = ChannelProfile.objects.all()
channel_profile_memberships = []
if channels_to_create:
with transaction.atomic():
created_channels = Channel.objects.bulk_create(channels_to_create)
@ -390,7 +392,12 @@ class ChannelViewSet(viewsets.ModelViewSet):
if logo_url:
channel.logo = channel_logos[logo_url]
update.append(channel)
channel_profile_memberships = channel_profile_memberships + [
ChannelProfileMembership(channel_profile=profile, channel=channel)
for profile in profiles
]
ChannelProfileMembership.objects.bulk_create(channel_profile_memberships)
Channel.objects.bulk_update(update, ['logo'])
for channel, stream_ids in zip(created_channels, streams_map):

View file

@ -6,7 +6,7 @@ from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel
from apps.channels.models import Channel, ChannelProfile
from .models import HDHRDevice
from .serializers import HDHRDeviceSerializer
from django.contrib.auth.decorators import login_required
@ -38,8 +38,12 @@ class DiscoverAPIView(APIView):
operation_description="Retrieve HDHomeRun device discovery information",
responses={200: openapi.Response("HDHR Discovery JSON")}
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
def get(self, request, profile=None):
uri_parts = ["hdhr"]
if profile is not None:
uri_parts.append(profile)
base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip('/')
device = HDHRDevice.objects.first()
if not device:
@ -75,13 +79,23 @@ class LineupAPIView(APIView):
operation_description="Retrieve the available channel lineup",
responses={200: openapi.Response("Channel Lineup JSON")}
)
def get(self, request):
channels = Channel.objects.all().order_by('channel_number')
def get(self, request, profile=None):
if profile is not None:
channel_profile = ChannelProfile.objects.get(name=profile)
channels = Channel.objects.filter(
channelprofilemembership__channel_profile=channel_profile,
channelprofilemembership__enabled=True
).order_by('channel_number')
else:
channels = Channel.objects.all().order_by('channel_number')
lineup = [
{
"GuideNumber": str(ch.channel_number),
"GuideName": ch.name,
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
"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
]
@ -96,7 +110,7 @@ class LineupStatusAPIView(APIView):
operation_description="Retrieve the HDHomeRun lineup status",
responses={200: openapi.Response("Lineup Status JSON")}
)
def get(self, request):
def get(self, request, profile=None):
data = {
"ScanInProgress": 0,
"ScanPossible": 0,

View file

@ -10,9 +10,12 @@ router.register(r'devices', HDHRDeviceViewSet, basename='hdhr-device')
urlpatterns = [
path('dashboard/', hdhr_dashboard_view, name='hdhr_dashboard'),
path('', hdhr_dashboard_view, name='hdhr_dashboard'),
path('discover.json', DiscoverAPIView.as_view(), name='discover'),
path('lineup.json', LineupAPIView.as_view(), name='lineup'),
path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status'),
path('<str:profile>/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'),
path('discover.json', DiscoverAPIView.as_view(), name='discover_no_profile'),
path('<str:profile>/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'),
path('lineup.json', LineupAPIView.as_view(), name='lineup_no_profile'),
path('<str:profile>/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'),
path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_no_profile'),
path('device.xml', HDHRDeviceXMLAPIView.as_view(), name='device_xml'),
]

View file

@ -9,6 +9,7 @@ from django.http import JsonResponse
from django.core.cache import cache
import os
from rest_framework.decorators import action
from django.conf import settings
# Import all models, including UserAgent.
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
@ -24,6 +25,8 @@ from .serializers import (
)
from .tasks import refresh_single_m3u_account, refresh_m3u_accounts
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
class M3UAccountViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U accounts"""
@ -31,28 +34,60 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
serializer_class = M3UAccountSerializer
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['post'])
def upload(self, request):
if 'file' not in request.FILES:
return Response({'error': 'No file uploaded'}, status=status.HTTP_400_BAD_REQUEST)
def create(self, request, *args, **kwargs):
# Handle file upload first, if any
file_path = None
if 'file' in request.FILES:
file = request.FILES['file']
file_name = file.name
file_path = os.path.join('/data/uploads/m3us', file_name)
file = request.FILES['file']
file_name = file.name
file_path = os.path.join('/data/uploads/m3us', file_name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
# Add file_path to the request data so it's available during creation
request.data._mutable = True # Allow modification of the request data
request.data['file_path'] = file_path # Include the file path if a file was uploaded
request.data.pop('server_url')
request.data._mutable = False # Make the request data immutable again
new_obj_data = request.data.copy()
new_obj_data['file_path'] = file_path
# Now call super().create() to create the instance
response = super().create(request, *args, **kwargs)
serializer = self.get_serializer(data=new_obj_data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
# After the instance is created, return the response
return response
return Response(serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
instance = self.get_object()
# Handle file upload first, if any
file_path = None
if 'file' in request.FILES:
file = request.FILES['file']
file_name = file.name
file_path = os.path.join('/data/uploads/m3us', file_name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
# Add file_path to the request data so it's available during creation
request.data._mutable = True # Allow modification of the request data
request.data['file_path'] = file_path # Include the file path if a file was uploaded
request.data.pop('server_url')
request.data._mutable = False # Make the request data immutable again
if instance.file_path and os.path.exists(instance.file_path):
os.remove(instance.file_path)
# Now call super().create() to create the instance
response = super().update(request, *args, **kwargs)
# After the instance is created, return the response
return response
class M3UFilterViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U filters"""

View file

@ -502,7 +502,7 @@ export default class API {
let body = null;
let endpoint = `${host}/api/m3u/accounts/`;
if (values.file) {
endpoint = `${host}/api/m3u/accounts/upload/`;
// endpoint = `${host}/api/m3u/accounts/upload/`;
body = new FormData();
for (const prop in values) {
body.append(prop, values[prop]);

View file

@ -66,7 +66,6 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
const handleLogoChange = async (files) => {
if (files.length === 1) {
console.log(files[0]);
const retval = await API.uploadLogo(files[0]);
await fetchLogos();
setLogoPreview(retval.cache_url);

View file

@ -188,7 +188,9 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onChange={(value) => {
formik.setValues('user_agent', value);
}}
error={formik.errors.user_agent ? formik.touched.user_agent : ''}
data={userAgents.map((ua) => ({
label: ua.name,

View file

@ -48,6 +48,8 @@ const RecordingCard = ({ recording }) => {
recordingName = customProps.program.title;
}
console.log(recording);
return (
<Card
shadow="sm"
@ -59,7 +61,7 @@ const RecordingCard = ({ recording }) => {
backgroundColor: '#27272A',
}}
>
<Flex justify="space-between" align="center">
<Flex justify="space-between" align="center" style={{ paddingBottom: 5 }}>
<Group>
<Text fw={500}>{recordingName}</Text>
</Group>
@ -77,11 +79,23 @@ const RecordingCard = ({ recording }) => {
</Center>
</Flex>
<Text size="sm">Channel: {channels[recording.channel].name}</Text>
<Text size="sm">
Start: {dayjs(recording.start_time).format('MMMM D, YYYY h:MMa')}
End: {dayjs(recording.end_time).format('MMMM D, YYYY h:MMa')}
</Text>
<Group justify="space-between">
<Text size="sm">Channel:</Text>
<Text size="sm">{channels[recording.channel].name}</Text>
</Group>
<Group justify="space-between">
<Text size="sm">Start:</Text>
<Text size="sm">
{dayjs(new Date(recording.start_time)).format('MMMM D, YYYY h:MMa')}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm">End:</Text>
<Text size="sm">
{dayjs(new Date(recording.end_time)).format('MMMM D, YYYY h:MMa')}
</Text>
</Group>
</Card>
);
};

View file

@ -1,13 +1,10 @@
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../api';
import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
import { Button, Center, Flex, Paper, Select, Title } from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
const SettingsPage = () => {
const { settings } = useSettingsStore();
@ -265,58 +262,51 @@ const SettingsPage = () => {
{ value: 'zw', label: 'ZW' },
];
console.log(settings);
const formik = useFormik({
const form = useForm({
mode: 'uncontrolled',
initialValues: {
'default-user-agent': `${settings['default-user-agent'].id}`,
'default-stream-profile': `${settings['default-stream-profile'].id}`,
'preferred-region': settings['preferred-region']?.value || 'us',
'default-user-agent': '',
'default-stream-profile': '',
'preferred-region': '',
},
validationSchema: Yup.object({
'default-user-agent': Yup.string().required('User-Agent is required'),
'default-stream-profile': Yup.string().required(
'Stream Profile is required'
),
'preferred-region': Yup.string().required('Region is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log(values);
const changedSettings = {};
for (const settingKey in values) {
// If the user changed the settings value from whats in the DB:
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
changedSettings[settingKey] = values[settingKey];
}
}
// Update each changed setting in the backend
for (const updatedKey in changedSettings) {
await API.updateSetting({
...settings[updatedKey],
value: changedSettings[updatedKey],
});
}
setSubmitting(false);
// Dont necessarily resetForm, in case the user wants to see new values
validate: {
'default-user-agent': isNotEmpty('Select a channel'),
'default-stream-profile': isNotEmpty('Select a start time'),
'preferred-region': isNotEmpty('Select an end time'),
},
});
// Initialize form values once settings / userAgents / profiles are loaded
useEffect(() => {
formik.setValues(
Object.values(settings).reduce((acc, setting) => {
// If the settings value is numeric, parse it
// Otherwise, just store as string
const possibleNumber = parseInt(setting.value, 10);
acc[setting.key] = isNaN(possibleNumber)
? setting.value
: possibleNumber;
return acc;
}, {})
);
// eslint-disable-next-line
}, [settings, userAgents, streamProfiles]);
if (settings) {
form.setInitialValues(
Object.entries(settings).reduce((acc, [key, value]) => {
// Modify each value based on its own properties
acc[key] = value.value;
return acc;
}, {})
);
}
}, [settings]);
const onSubmit = async () => {
const values = form.getValues();
const changedSettings = {};
for (const settingKey in values) {
// If the user changed the settings value from whats in the DB:
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
changedSettings[settingKey] = values[settingKey];
}
}
// Update each changed setting in the backend
for (const updatedKey in changedSettings) {
await API.updateSetting({
...settings[updatedKey],
value: changedSettings[updatedKey],
});
}
};
return (
<Center
@ -331,24 +321,13 @@ const SettingsPage = () => {
<Title order={4} align="center">
Settings
</Title>
<form
onSubmit={(e) => {
e.preventDefault(); // Prevents default form behavior
console.log('Form submission triggered');
console.log('Formik Errors before submit:', formik.errors);
formik.handleSubmit(e);
console.log('After formik.handleSubmit call');
}}
>
<form onSubmit={form.onSubmit(onSubmit)}>
<Select
{...form.getInputProps('default-user-agent')}
key={form.key('default-user-agent')}
id={settings['default-user-agent']?.id}
name={settings['default-user-agent']?.key}
label={settings['default-user-agent']?.name}
value={formik.values['default-user-agent'] || ''}
onChange={(value) => {
formik.setFieldValue('default-user-agent', value);
}}
error={formik.errors['default-user-agent']}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
@ -356,27 +335,22 @@ const SettingsPage = () => {
/>
<Select
{...form.getInputProps('default-stream-profile')}
key={form.key('default-stream-profile')}
id={settings['default-stream-profile']?.id}
name={settings['default-stream-profile']?.key}
label={settings['default-stream-profile']?.name}
value={formik.values['default-stream-profile'] || ''}
onChange={(value) => {
formik.setFieldValue('default-stream-profile', value);
}}
error={formik.errors['default-stream-profile']}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
{...form.getInputProps('preferred-region')}
key={form.key('preferred-region')}
id={settings['preferred-region']?.id || 'preferred-region'}
name={settings['preferred-region']?.key || 'preferred-region'}
label={settings['preferred-region']?.name || 'Preferred Region'}
value={formik.values['preferred-region'] || ''}
onChange={(value) => {
formik.setFieldValue('preferred-region', value);
}}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
@ -384,7 +358,7 @@ const SettingsPage = () => {
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" disabled={formik.isSubmitting} size="sm">
<Button type="submit" disabled={form.submitting} size="sm">
Submit
</Button>
</Flex>

View file

@ -109,23 +109,31 @@ const useChannelsStore = create((set, get) => ({
set((state) => {
const channelsByUUID = {};
const logos = {};
const profileChannels = [];
const channelsByID = newChannels.reduce((acc, channel) => {
acc[channel.id] = channel;
channelsByUUID[channel.uuid] = channel.id;
if (channel.logo) {
logos[channel.logo.id] = channel.logo;
}
profileChannels.push({
id: channel.id,
enabled: true,
});
return acc;
}, {});
const profileChannels = newChannels.map((channel) => ({
id: channel.id,
enabled: true,
}));
const profiles = { ...state.profiles };
Object.values(profiles).forEach((item) => {
item.channels.concat(profileChannels); // Append a new channel object
item.channels = item.channels.concat(profileChannels); // Append a new channel object
});
console.log(profileChannels);
console.log(profiles);
return {
channels: {
...state.channels,