mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
m3u file upload fixed, hdhr support profiles now, i don't remember what I did to the UI anymore
This commit is contained in:
parent
5f3ff480af
commit
839abd281c
10 changed files with 170 additions and 114 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue