From 839abd281ca6a1cded692d1876cc06659c333758 Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 7 Apr 2025 20:49:42 -0400 Subject: [PATCH] m3u file upload fixed, hdhr support profiles now, i don't remember what I did to the UI anymore --- apps/channels/api_views.py | 7 ++ apps/hdhr/api_views.py | 28 +++-- apps/hdhr/urls.py | 9 +- apps/m3u/api_views.py | 69 ++++++++++--- frontend/src/api.js | 2 +- frontend/src/components/forms/Channel.jsx | 1 - frontend/src/components/forms/M3U.jsx | 4 +- frontend/src/pages/DVR.jsx | 26 +++-- frontend/src/pages/Settings.jsx | 120 +++++++++------------- frontend/src/store/channels.jsx | 18 +++- 10 files changed, 170 insertions(+), 114 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b3d97fee..2df48b42 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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): diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 4dd9c07d..5d706ef8 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -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, diff --git a/apps/hdhr/urls.py b/apps/hdhr/urls.py index 2a1a06e0..2659cd7b 100644 --- a/apps/hdhr/urls.py +++ b/apps/hdhr/urls.py @@ -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('/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'), + path('discover.json', DiscoverAPIView.as_view(), name='discover_no_profile'), + path('/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'), + path('lineup.json', LineupAPIView.as_view(), name='lineup_no_profile'), + path('/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'), ] diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 8737a07a..054bdaa9 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -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""" diff --git a/frontend/src/api.js b/frontend/src/api.js index ee598b6c..020b4ebd 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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]); diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index ca2f2330..d67c646c 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -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); diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 0b3ce020..50efc713 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -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, diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 7f3e009e..e7a26186 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -48,6 +48,8 @@ const RecordingCard = ({ recording }) => { recordingName = customProps.program.title; } + console.log(recording); + return ( { backgroundColor: '#27272A', }} > - + {recordingName} @@ -77,11 +79,23 @@ const RecordingCard = ({ recording }) => { - Channel: {channels[recording.channel].name} - - Start: {dayjs(recording.start_time).format('MMMM D, YYYY h:MMa')} - End: {dayjs(recording.end_time).format('MMMM D, YYYY h:MMa')} - + + Channel: + {channels[recording.channel].name} + + + + Start: + + {dayjs(new Date(recording.start_time)).format('MMMM D, YYYY h:MMa')} + + + + End: + + {dayjs(new Date(recording.end_time)).format('MMMM D, YYYY h:MMa')} + + ); }; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index cb442a7a..83e5a2eb 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 setting’s value from what’s 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); - // Don’t 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 setting’s 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 setting’s value from what’s 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 (
{ Settings -
{ - 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'); - }} - > + { - formik.setFieldValue('default-stream-profile', value); - }} - error={formik.errors['default-stream-profile']} data={streamProfiles.map((option) => ({ value: `${option.id}`, label: option.name, }))} />