diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 0973dce0..636d4875 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -18,6 +18,7 @@ from apps.accounts.permissions import ( ) from core.models import UserAgent, CoreSettings +from core.utils import RedisClient from .models import ( Stream, @@ -493,7 +494,9 @@ class ChannelViewSet(viewsets.ModelViewSet): operation_description=( "Create a new channel from an existing stream. " "If 'channel_number' is provided, it will be used (if available); " - "otherwise, the next available channel number is assigned." + "otherwise, the next available channel number is assigned. " + "If 'channel_profile_ids' is provided, the channel will only be added to those profiles. " + "Accepts either a single ID or an array of IDs." ), request_body=openapi.Schema( type=openapi.TYPE_OBJECT, @@ -509,6 +512,11 @@ class ChannelViewSet(viewsets.ModelViewSet): "name": openapi.Schema( type=openapi.TYPE_STRING, description="Desired channel name" ), + "channel_profile_ids": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Items(type=openapi.TYPE_INTEGER), + description="(Optional) Channel profile ID(s) to add the channel to. Can be a single ID or array of IDs. If not provided, channel is added to all profiles." + ), }, ), responses={201: ChannelSerializer()}, @@ -590,8 +598,50 @@ class ChannelViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(data=channel_data) serializer.is_valid(raise_exception=True) - channel = serializer.save() - channel.streams.add(stream) + + with transaction.atomic(): + channel = serializer.save() + channel.streams.add(stream) + + # Handle channel profile membership + channel_profile_ids = request.data.get("channel_profile_ids") + if channel_profile_ids is not None: + # Normalize single ID to array + if not isinstance(channel_profile_ids, list): + channel_profile_ids = [channel_profile_ids] + + if channel_profile_ids: + # Add channel only to the specified profiles + try: + channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids) + if len(channel_profiles) != len(channel_profile_ids): + missing_ids = set(channel_profile_ids) - set(channel_profiles.values_list('id', flat=True)) + return Response( + {"error": f"Channel profiles with IDs {list(missing_ids)} not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in channel_profiles + ]) + except Exception as e: + return Response( + {"error": f"Error creating profile memberships: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # Default behavior: add to all profiles + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + return Response(serializer.data, status=status.HTTP_201_CREATED) @swagger_auto_schema( @@ -599,7 +649,8 @@ class ChannelViewSet(viewsets.ModelViewSet): operation_description=( "Bulk create channels from existing streams. For each object, if 'channel_number' is provided, " "it is used (if available); otherwise, the next available number is auto-assigned. " - "Each object must include 'stream_id' and 'name'." + "Each object must include 'stream_id' and 'name'. " + "Supports single profile ID or array of profile IDs in 'channel_profile_ids'." ), request_body=openapi.Schema( type=openapi.TYPE_ARRAY, @@ -618,6 +669,11 @@ class ChannelViewSet(viewsets.ModelViewSet): "name": openapi.Schema( type=openapi.TYPE_STRING, description="Desired channel name" ), + "channel_profile_ids": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Items(type=openapi.TYPE_INTEGER), + description="(Optional) Channel profile ID(s) to add the channel to. Can be a single ID or array of IDs." + ), }, ), ), @@ -652,13 +708,15 @@ class ChannelViewSet(viewsets.ModelViewSet): channels_to_create = [] streams_map = [] logo_map = [] + profile_map = [] # Track which profiles each channel should be added to + for item in data_list: stream_id = item.get("stream_id") - if not all([stream_id]): + if not stream_id: errors.append( { "item": item, - "error": "Missing required fields: stream_id and name are required.", + "error": "Missing required field: stream_id is required.", } ) continue @@ -703,7 +761,7 @@ class ChannelViewSet(viewsets.ModelViewSet): errors.append( { "item": item, - "error": "channel_number must be an integer.", + "error": "channel_number must be a number.", } ) continue @@ -745,6 +803,15 @@ class ChannelViewSet(viewsets.ModelViewSet): channels_to_create.append(channel) streams_map.append([stream_id]) + # Store which profiles this channel should be added to - normalize to array + channel_profile_ids = item.get("channel_profile_ids") + if channel_profile_ids is not None: + # Normalize single ID to array + if not isinstance(channel_profile_ids, list): + channel_profile_ids = [channel_profile_ids] + + profile_map.append(channel_profile_ids) + if stream.logo_url: logos_to_create.append( Logo( @@ -756,9 +823,6 @@ class ChannelViewSet(viewsets.ModelViewSet): else: logo_map.append(None) - # channel = serializer.save() - # channel.streams.add(stream) - # created_channels.append(serializer.data) else: errors.append({"item": item, "error": serializer.errors}) @@ -772,31 +836,67 @@ class ChannelViewSet(viewsets.ModelViewSet): ) } - profiles = ChannelProfile.objects.all() + # Get all profiles for default assignment + all_profiles = ChannelProfile.objects.all() channel_profile_memberships = [] + if channels_to_create: with transaction.atomic(): created_channels = Channel.objects.bulk_create(channels_to_create) update = [] - for channel, stream_ids, logo_url in zip( - created_channels, streams_map, logo_map + for channel, stream_ids, logo_url, channel_profile_ids in zip( + created_channels, streams_map, logo_map, profile_map ): 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"]) + # Handle channel profile membership based on channel_profile_ids + if channel_profile_ids: + # Add channel only to the specified profiles + try: + specific_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids) + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in specific_profiles + ]) + except Exception: + # If profiles don't exist, add to all profiles as fallback + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in all_profiles + ]) + else: + # Default behavior: add to all profiles + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in all_profiles + ]) + # Bulk create profile memberships + if channel_profile_memberships: + ChannelProfileMembership.objects.bulk_create( + channel_profile_memberships + ) + + # Update logos + if update: + Channel.objects.bulk_update(update, ["logo"]) + + # Set stream relationships for channel, stream_ids in zip(created_channels, streams_map): channel.streams.set(stream_ids) @@ -1122,7 +1222,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 +1304,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""" @@ -1258,6 +1364,17 @@ class LogoViewSet(viewsets.ModelViewSet): for chunk in file.chunks(): destination.write(chunk) + # Mark file as processed in Redis to prevent file scanner notifications + try: + redis_client = RedisClient.get_client() + if redis_client: + # Use the same key format as the file scanner + redis_key = f"processed_file:{file_path}" + redis_client.setex(redis_key, 60 * 60 * 24 * 3, "api_upload") # 3 day TTL + logger.debug(f"Marked uploaded logo file as processed in Redis: {file_path}") + except Exception as e: + logger.warning(f"Failed to mark logo file as processed in Redis: {e}") + logo, _ = Logo.objects.get_or_create( url=file_path, defaults={ 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/apps/channels/signals.py b/apps/channels/signals.py index f98c1c97..b8656ecc 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -62,15 +62,6 @@ def refresh_epg_programs(sender, instance, created, **kwargs): logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data") parse_programs_for_tvg_id.delay(instance.epg_data.id) -@receiver(post_save, sender=Channel) -def add_new_channel_to_groups(sender, instance, created, **kwargs): - if created: - profiles = ChannelProfile.objects.all() - ChannelProfileMembership.objects.bulk_create([ - ChannelProfileMembership(channel_profile=profile, channel=instance) - for profile in profiles - ]) - @receiver(post_save, sender=ChannelProfile) def create_profile_memberships(sender, instance, created, **kwargs): if created: diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index e3893dc1..40a395ce 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -18,7 +18,7 @@ from channels.layers import get_channel_layer from django.utils import timezone import time import json -from core.utils import RedisClient, acquire_task_lock, release_task_lock +from core.utils import RedisClient, acquire_task_lock, release_task_lock, natural_sort_key from core.models import CoreSettings, UserAgent from asgiref.sync import async_to_sync from core.xtream_codes import Client as XCClient @@ -882,6 +882,8 @@ def sync_auto_channels(account_id, scan_start_time=None): name_regex_pattern = None name_replace_pattern = None name_match_regex = None + channel_profile_ids = None + channel_sort_order = None if group_relation.custom_properties: try: group_custom_props = json.loads(group_relation.custom_properties) @@ -890,12 +892,16 @@ def sync_auto_channels(account_id, scan_start_time=None): name_regex_pattern = group_custom_props.get("name_regex_pattern") name_replace_pattern = group_custom_props.get("name_replace_pattern") name_match_regex = group_custom_props.get("name_match_regex") + channel_profile_ids = group_custom_props.get("channel_profile_ids") + channel_sort_order = group_custom_props.get("channel_sort_order") except Exception: force_dummy_epg = False override_group_id = None name_regex_pattern = None name_replace_pattern = None name_match_regex = None + channel_profile_ids = None + channel_sort_order = None # Determine which group to use for created channels target_group = channel_group @@ -913,18 +919,36 @@ def sync_auto_channels(account_id, scan_start_time=None): m3u_account=account, channel_group=channel_group, last_seen__gte=scan_start_time - ).order_by('name') + ) # --- 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 ) except re.error as e: logger.warning(f"Invalid name_match_regex '{name_match_regex}' for group '{channel_group.name}': {e}. Skipping name filter.") + # --- APPLY CHANNEL SORT ORDER --- + streams_is_list = False # Track if we converted to list + if channel_sort_order and channel_sort_order != '': + if channel_sort_order == 'name': + # Use natural sorting for names to handle numbers correctly + current_streams = list(current_streams) + current_streams.sort(key=lambda stream: natural_sort_key(stream.name)) + streams_is_list = True + elif channel_sort_order == 'tvg_id': + current_streams = current_streams.order_by('tvg_id') + elif channel_sort_order == 'updated_at': + current_streams = current_streams.order_by('updated_at') + else: + logger.warning(f"Unknown channel_sort_order '{channel_sort_order}' for group '{channel_group.name}'. Using provider order.") + current_streams = current_streams.order_by('id') + else: + current_streams = current_streams.order_by('id') + # If channel_sort_order is empty or None, use provider order (no additional sorting) + # Get existing auto-created channels for this account (regardless of current group) # We'll find them by their stream associations instead of just group location existing_channels = Channel.objects.filter( @@ -951,7 +975,10 @@ def sync_auto_channels(account_id, scan_start_time=None): # Track which streams we've processed processed_stream_ids = set() - if not current_streams.exists(): + # Check if we have streams - handle both QuerySet and list cases + has_streams = len(current_streams) > 0 if streams_is_list else current_streams.exists() + + if not has_streams: logger.debug(f"No streams found in group {channel_group.name}") # Delete all existing auto channels if no streams channels_to_delete = [ch for ch in existing_channel_map.values()] @@ -962,12 +989,63 @@ def sync_auto_channels(account_id, scan_start_time=None): logger.debug(f"Deleted {deleted_count} auto channels (no streams remaining)") continue + # Prepare profiles to assign to new channels + from apps.channels.models import ChannelProfile, ChannelProfileMembership + if channel_profile_ids and isinstance(channel_profile_ids, list) and len(channel_profile_ids) > 0: + # Convert all to int (in case they're strings) + try: + profile_ids = [int(pid) for pid in channel_profile_ids] + except Exception: + profile_ids = [] + profiles_to_assign = list(ChannelProfile.objects.filter(id__in=profile_ids)) + else: + profiles_to_assign = list(ChannelProfile.objects.all()) + # Process each current stream current_channel_number = start_number + # Always renumber all existing channels to match current sort order + # This ensures channels are always in the correct sequence + channels_to_renumber = [] + temp_channel_number = start_number + + # Get all channel numbers that are already in use by other channels (not auto-created by this account) + used_numbers = set(Channel.objects.exclude( + auto_created=True, + auto_created_by=account + ).values_list('channel_number', flat=True)) + + for stream in current_streams: + if stream.id in existing_channel_map: + channel = existing_channel_map[stream.id] + + # Find next available number starting from temp_channel_number + target_number = temp_channel_number + while target_number in used_numbers: + target_number += 1 + + # Add this number to used_numbers so we don't reuse it in this batch + used_numbers.add(target_number) + + if channel.channel_number != target_number: + channel.channel_number = target_number + channels_to_renumber.append(channel) + logger.debug(f"Will renumber channel '{channel.name}' to {target_number}") + + temp_channel_number += 1.0 + if temp_channel_number % 1 != 0: # Has decimal + temp_channel_number = int(temp_channel_number) + 1.0 + + # Bulk update channel numbers if any need renumbering + if channels_to_renumber: + Channel.objects.bulk_update(channels_to_renumber, ['channel_number']) + logger.info(f"Renumbered {len(channels_to_renumber)} channels to maintain sort order") + + # Reset channel number counter for processing new channels + current_channel_number = start_number + for stream in current_streams: processed_stream_ids.add(stream.id) - try: # Parse custom properties for additional info stream_custom_props = json.loads(stream.custom_properties) if stream.custom_properties else {} @@ -989,7 +1067,7 @@ def sync_auto_channels(account_id, scan_start_time=None): existing_channel = existing_channel_map.get(stream.id) if existing_channel: - # Update existing channel if needed + # Update existing channel if needed (channel number already handled above) channel_updated = False # Use new_name instead of stream.name @@ -1038,22 +1116,55 @@ def sync_auto_channels(account_id, scan_start_time=None): channels_updated += 1 logger.debug(f"Updated auto channel: {existing_channel.channel_number} - {existing_channel.name}") + # Update channel profile memberships for existing channels + current_memberships = set( + ChannelProfileMembership.objects.filter( + channel=existing_channel, + enabled=True + ).values_list('channel_profile_id', flat=True) + ) + + target_profile_ids = set(profile.id for profile in profiles_to_assign) + + # Only update if memberships have changed + if current_memberships != target_profile_ids: + # Disable all current memberships + ChannelProfileMembership.objects.filter( + channel=existing_channel + ).update(enabled=False) + + # Enable/create memberships for target profiles + for profile in profiles_to_assign: + membership, created = ChannelProfileMembership.objects.get_or_create( + channel_profile=profile, + channel=existing_channel, + defaults={'enabled': True} + ) + if not created and not membership.enabled: + membership.enabled = True + membership.save() + + logger.debug(f"Updated profile memberships for auto channel: {existing_channel.name}") + else: # Create new channel # Find next available channel number - while Channel.objects.filter(channel_number=current_channel_number).exists(): - current_channel_number += 0.1 + target_number = current_channel_number + while target_number in used_numbers: + target_number += 1 + + # Add this number to used_numbers + used_numbers.add(target_number) - # Create the channel with auto-created tracking in the target group channel = Channel.objects.create( - channel_number=current_channel_number, + channel_number=target_number, name=new_name, tvg_id=stream.tvg_id, tvc_guide_stationid=tvc_guide_stationid, - channel_group=target_group, # Use target group (could be override) - user_level=0, # Default user level - auto_created=True, # Mark as auto-created - auto_created_by=account # Track which M3U account created it + channel_group=target_group, + user_level=0, + auto_created=True, + auto_created_by=account ) # Associate the stream with the channel @@ -1063,6 +1174,14 @@ def sync_auto_channels(account_id, scan_start_time=None): order=0 ) + # Assign to correct profiles + memberships = [ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles_to_assign + ] + if memberships: + ChannelProfileMembership.objects.bulk_create(memberships) + # Try to match EPG data if stream.tvg_id and not force_dummy_epg: epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first() @@ -1084,12 +1203,13 @@ def sync_auto_channels(account_id, scan_start_time=None): channel.save(update_fields=['logo']) channels_created += 1 - current_channel_number += 1.0 - if current_channel_number % 1 != 0: # Has decimal - current_channel_number = int(current_channel_number) + 1.0 - logger.debug(f"Created auto channel: {channel.channel_number} - {channel.name}") + # Increment channel number for next iteration + current_channel_number += 1.0 + if current_channel_number % 1 != 0: # Has decimal + current_channel_number = int(current_channel_number) + 1.0 + except Exception as e: logger.error(f"Error processing auto channel for stream {stream.name}: {str(e)}") continue 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' ') diff --git a/core/utils.py b/core/utils.py index 9951ce26..932af979 100644 --- a/core/utils.py +++ b/core/utils.py @@ -3,6 +3,7 @@ import logging import time import os import threading +import re from django.conf import settings from redis.exceptions import ConnectionError, TimeoutError from django.core.cache import cache @@ -15,6 +16,29 @@ logger = logging.getLogger(__name__) # Import the command detector from .command_utils import is_management_command +def natural_sort_key(text): + """ + Convert a string into a list of string and number chunks for natural sorting. + "PPV 10" becomes ['PPV ', 10] so it sorts correctly with "PPV 2". + + This function enables natural/alphanumeric sorting where numbers within strings + are treated as actual numbers rather than strings. + + Args: + text (str): The text to convert for sorting + + Returns: + list: A list of strings and integers for proper sorting + + Example: + >>> sorted(['PPV 1', 'PPV 10', 'PPV 2'], key=natural_sort_key) + ['PPV 1', 'PPV 2', 'PPV 10'] + """ + def convert(chunk): + return int(chunk) if chunk.isdigit() else chunk.lower() + + return [convert(c) for c in re.split('([0-9]+)', text)] + class RedisClient: _client = None _pubsub_client = None 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/WebSocket.jsx b/frontend/src/WebSocket.jsx index 2e210461..599b55d5 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -219,6 +219,7 @@ export const WebsocketProvider = ({ children }) => { updatePlaylist(updateData); fetchPlaylists(); // Refresh playlists to ensure UI is up-to-date + fetchChannelProfiles(); // Ensure channel profiles are updated } else { // Log when playlist can't be found for debugging purposes console.warn( @@ -499,6 +500,7 @@ export const WebsocketProvider = ({ children }) => { const fetchEPGData = useEPGsStore((s) => s.fetchEPGData); const fetchEPGs = useEPGsStore((s) => s.fetchEPGs); const fetchLogos = useChannelsStore((s) => s.fetchLogos); + const fetchChannelProfiles = useChannelsStore((s) => s.fetchChannelProfiles); const ret = useMemo(() => { return [isReady, ws.current?.send.bind(ws.current), val]; diff --git a/frontend/src/api.js b/frontend/src/api.js index 7cbb6214..effc0bdd 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -528,13 +528,14 @@ export default class API { } ); - if (response.created.length > 0) { + if (response.created && response.created.length > 0) { useChannelsStore.getState().addChannels(response.created); } return response; } catch (e) { errorNotification('Failed to create channels', e); + throw e; // Re-throw to allow proper error handling in calling code } } @@ -1299,9 +1300,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 +1323,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/Channel.jsx b/frontend/src/components/forms/Channel.jsx index c7d8ed6c..c8a1f60d 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -193,6 +193,10 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { formik.resetForm(); API.requeryChannels(); + + // Refresh channel profiles to update the membership information + useChannelsStore.getState().fetchChannelProfiles(); + setSubmitting(false); setTvgFilter(''); setLogoFilter(''); diff --git a/frontend/src/components/forms/Channels.jsx b/frontend/src/components/forms/Channels.jsx index e67d9419..2d35f3fa 100644 --- a/frontend/src/components/forms/Channels.jsx +++ b/frontend/src/components/forms/Channels.jsx @@ -189,6 +189,10 @@ const ChannelsForm = ({ channel = null, isOpen, onClose }) => { formik.resetForm(); API.requeryChannels(); + + // Refresh channel profiles to update the membership information + useChannelsStore.getState().fetchChannelProfiles(); + setSubmitting(false); setTvgFilter(''); setLogoFilter(''); diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index e209659c..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 @@ -223,10 +268,10 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
- Drag image here or click to select + {selectedFile ? `Selected: ${selectedFile.name}` : 'Drag image here or click to select'} - Supports PNG, JPEG, GIF, WebP files + {selectedFile ? 'File will be uploaded when you click Create/Update' : 'Supports PNG, JPEG, GIF, WebP, SVG files'}
@@ -242,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 + + )} +