From 5148a5a79bd7f568de935e9ecaba047029c61180 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 20 Jul 2025 20:01:51 -0500 Subject: [PATCH 01/12] Use channel name for display name in EPG output. --- apps/output/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/output/views.py b/apps/output/views.py index 67d72bd2..8d58a1b3 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -378,8 +378,7 @@ def generate_epg(request, profile_name=None, user=None): tvg_logo = direct_logo else: tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) - - display_name = channel.epg_data.name if channel.epg_data else channel.name + display_name = channel.name xml_lines.append(f' ') xml_lines.append(f' {html.escape(display_name)}') xml_lines.append(f' ') From 7eef45f1c04ab7ee79f179da110649ba9a7a32ea Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 20 Jul 2025 20:13:59 -0500 Subject: [PATCH 02/12] Only use whole numbers when looking for a number not in use. --- apps/m3u/tasks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index e3893dc1..7c67b404 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -918,7 +918,6 @@ def sync_auto_channels(account_id, scan_start_time=None): # --- FILTER STREAMS BY NAME MATCH REGEX IF SPECIFIED --- if name_match_regex: try: - compiled_name_match_regex = re.compile(name_match_regex, re.IGNORECASE) current_streams = current_streams.filter( name__iregex=name_match_regex ) @@ -1042,7 +1041,7 @@ def sync_auto_channels(account_id, scan_start_time=None): # Create new channel # Find next available channel number while Channel.objects.filter(channel_number=current_channel_number).exists(): - current_channel_number += 0.1 + current_channel_number += 1 # Create the channel with auto-created tracking in the target group channel = Channel.objects.create( From 1475ca70ab479318ead164ccdc45aad406a5f6e7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 27 Jul 2025 14:18:48 -0500 Subject: [PATCH 03/12] Fixes being unable to add a new logo via URL. --- apps/channels/api_views.py | 10 ++++++++-- apps/channels/serializers.py | 11 +++++++++++ dispatcharr/utils.py | 6 +++--- frontend/src/api.js | 22 ++++++++++------------ frontend/src/components/forms/Logo.jsx | 7 ++++--- 5 files changed, 36 insertions(+), 20 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 0973dce0..73c6412e 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1122,7 +1122,7 @@ class CleanupUnusedLogosAPIView(APIView): def post(self, request): """Delete all logos with no channel associations""" delete_files = request.data.get("delete_files", False) - + unused_logos = Logo.objects.filter(channels__isnull=True) deleted_count = unused_logos.count() logo_names = list(unused_logos.values_list('name', flat=True)) @@ -1204,7 +1204,13 @@ class LogoViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): """Update an existing logo""" - return super().update(request, *args, **kwargs) + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + if serializer.is_valid(): + logo = serializer.save() + return Response(self.get_serializer(logo).data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, *args, **kwargs): """Delete a logo and remove it from any channels using it""" diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 82b5f808..9273b265 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -38,6 +38,17 @@ class LogoSerializer(serializers.ModelSerializer): return value + def create(self, validated_data): + """Handle logo creation with proper URL validation""" + return Logo.objects.create(**validated_data) + + def update(self, instance, validated_data): + """Handle logo updates""" + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + def get_cache_url(self, obj): # return f"/api/channels/logos/{obj.id}/cache/" request = self.context.get("request") diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index 5e1ad087..260515fc 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -21,10 +21,10 @@ def json_success_response(data=None, status=200): def validate_logo_file(file): """Validate uploaded logo file size and MIME type.""" - valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp"] + valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"] if file.content_type not in valid_mime_types: - raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP.") - if file.size > 5 * 1024 * 1024: # Increased to 5MB + raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, SVG.") + if file.size > 5 * 1024 * 1024: # 5MB raise ValidationError("File too large. Max 5MB.") diff --git a/frontend/src/api.js b/frontend/src/api.js index 7cbb6214..154a1341 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1299,9 +1299,17 @@ export default class API { static async createLogo(values) { try { + // Use FormData for logo creation to match backend expectations + const formData = new FormData(); + for (const [key, value] of Object.entries(values)) { + if (value !== null && value !== undefined) { + formData.append(key, value); + } + } + const response = await request(`${host}/api/channels/logos/`, { method: 'POST', - body: values, + body: formData, }); useChannelsStore.getState().addLogo(response); @@ -1314,19 +1322,9 @@ export default class API { static async updateLogo(id, values) { try { - // Convert values to FormData for the multipart/form-data content type - const formData = new FormData(); - - // Add each field to the form data - Object.keys(values).forEach(key => { - if (values[key] !== null && values[key] !== undefined) { - formData.append(key, values[key]); - } - }); - const response = await request(`${host}/api/channels/logos/${id}/`, { method: 'PUT', - body: formData, // Send as FormData instead of JSON + body: values, // This will be converted to JSON in the request function }); useChannelsStore.getState().updateLogo(response); diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index e209659c..1aef1e66 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -206,9 +206,10 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { @@ -226,7 +227,7 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { Drag image here or click to select - Supports PNG, JPEG, GIF, WebP files + Supports PNG, JPEG, GIF, WebP, SVG files From a7b9d278c27f81051d53cf850fa125084628aab1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 27 Jul 2025 14:44:20 -0500 Subject: [PATCH 04/12] In the logo manager, don't save when file is selected, wait for create button to be clicked. --- frontend/src/components/forms/Logo.jsx | 125 +++++++++++++++++-------- 1 file changed, 88 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index 1aef1e66..6b8877bf 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -21,6 +21,7 @@ import API from '../../api'; const LogoForm = ({ logo = null, isOpen, onClose }) => { const [logoPreview, setLogoPreview] = useState(null); const [uploading, setUploading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); // Store selected file const formik = useFormik({ initialValues: { @@ -46,6 +47,37 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { }), onSubmit: async (values, { setSubmitting }) => { try { + setUploading(true); + + // If we have a selected file, upload it first + if (selectedFile) { + try { + const uploadResponse = await API.uploadLogo(selectedFile); + // Use the uploaded file data instead of form values + values.name = uploadResponse.name; + values.url = uploadResponse.url; + } catch (uploadError) { + let errorMessage = 'Failed to upload logo file'; + + if (uploadError.code === 'NETWORK_ERROR' || uploadError.message?.includes('timeout')) { + errorMessage = 'Upload timed out. Please try again.'; + } else if (uploadError.status === 413) { + errorMessage = 'File too large. Please choose a smaller file.'; + } else if (uploadError.body?.error) { + errorMessage = uploadError.body.error; + } + + notifications.show({ + title: 'Upload Error', + message: errorMessage, + color: 'red', + }); + return; // Don't proceed with creation if upload fails + } + } + + // Now create or update the logo with the final values + // Only proceed if we don't already have a logo from file upload if (logo) { await API.updateLogo(logo.id, values); notifications.show({ @@ -53,13 +85,22 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { message: 'Logo updated successfully', color: 'green', }); - } else { + } else if (!selectedFile) { + // Only create a new logo entry if we're not uploading a file + // (file upload already created the logo entry) await API.createLogo(values); notifications.show({ title: 'Success', message: 'Logo created successfully', color: 'green', }); + } else { + // File was uploaded and logo was already created + notifications.show({ + title: 'Success', + message: 'Logo uploaded successfully', + color: 'green', + }); } onClose(); } catch (error) { @@ -79,6 +120,7 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { }); } finally { setSubmitting(false); + setUploading(false); } }, }); @@ -94,9 +136,11 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { formik.resetForm(); setLogoPreview(null); } + // Clear any selected file when logo changes + setSelectedFile(null); }, [logo, isOpen]); - const handleFileUpload = async (files) => { + const handleFileSelect = (files) => { if (files.length === 0) return; const file = files[0]; @@ -111,53 +155,53 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { return; } - setUploading(true); + // Store the file for later upload and create preview + setSelectedFile(file); - try { - const response = await API.uploadLogo(file); + // Generate a local preview URL + const previewUrl = URL.createObjectURL(file); + setLogoPreview(previewUrl); - // Update form with uploaded file info - formik.setFieldValue('name', response.name); - formik.setFieldValue('url', response.url); - setLogoPreview(response.cache_url); - - notifications.show({ - title: 'Success', - message: 'Logo uploaded successfully', - color: 'green', - }); - } catch (error) { - let errorMessage = 'Failed to upload logo'; - - // Handle specific timeout errors - if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) { - errorMessage = 'Upload timed out. Please try again.'; - } else if (error.status === 413) { - errorMessage = 'File too large. Please choose a smaller file.'; - } else if (error.body?.error) { - errorMessage = error.body.error; - } - - notifications.show({ - title: 'Error', - message: errorMessage, - color: 'red', - }); - } finally { - setUploading(false); + // Auto-fill the name field if empty + if (!formik.values.name) { + const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, ""); + formik.setFieldValue('name', nameWithoutExtension); } + + // Set a placeholder URL (will be replaced after upload) + formik.setFieldValue('url', 'file://pending-upload'); }; const handleUrlChange = (event) => { const url = event.target.value; formik.setFieldValue('url', url); + // Clear any selected file when manually entering URL + if (selectedFile) { + setSelectedFile(null); + // Revoke the object URL to free memory + if (logoPreview && logoPreview.startsWith('blob:')) { + URL.revokeObjectURL(logoPreview); + } + } + // Update preview for remote URLs if (url && url.startsWith('http')) { setLogoPreview(url); + } else if (!url) { + setLogoPreview(null); } }; + // Clean up object URLs when component unmounts or preview changes + useEffect(() => { + return () => { + if (logoPreview && logoPreview.startsWith('blob:')) { + URL.revokeObjectURL(logoPreview); + } + }; + }, [logoPreview]); + return ( { Upload Logo File {
- Drag image here or click to select + {selectedFile ? `Selected: ${selectedFile.name}` : 'Drag image here or click to select'} - Supports PNG, JPEG, GIF, WebP, SVG files + {selectedFile ? 'File will be uploaded when you click Create/Update' : 'Supports PNG, JPEG, GIF, WebP, SVG files'}
@@ -243,6 +287,7 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => { {...formik.getFieldProps('url')} onChange={handleUrlChange} error={formik.touched.url && formik.errors.url} + disabled={!!selectedFile} // Disable when file is selected /> { error={formik.touched.name && formik.errors.name} /> + {selectedFile && ( + + Selected file: {selectedFile.name} - will be uploaded when you submit + + )} +