diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 3ffb98af..b651081e 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -8,7 +8,7 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction -import os, json, requests +import os, json, requests, logging from apps.accounts.permissions import ( Authenticated, IsAdmin, @@ -48,6 +48,9 @@ import mimetypes from rest_framework.pagination import PageNumberPagination +logger = logging.getLogger(__name__) + + class OrInFilter(django_filters.Filter): """ Custom filter that handles the OR condition instead of AND. @@ -275,30 +278,76 @@ class ChannelViewSet(viewsets.ModelViewSet): @action(detail=False, methods=["patch"], url_path="edit/bulk") def edit_bulk(self, request): - data_list = request.data - if not isinstance(data_list, list): + """ + Bulk edit channels. + Expects a list of channels with their updates. + """ + data = request.data + if not isinstance(data, list): return Response( - {"error": "Expected a list of channel objects objects"}, + {"error": "Expected a list of channel updates"}, status=status.HTTP_400_BAD_REQUEST, ) updated_channels = [] - try: - with transaction.atomic(): - for item in data_list: - channel = Channel.objects.id(id=item.pop("id")) - for key, value in item.items(): - setattr(channel, key, value) + errors = [] - channel.save(update_fields=item.keys()) - updated_channels.append(channel) - except Exception as e: - logger.error("Error during bulk channel edit", e) - return Response({"error": e}, status=500) + for channel_data in data: + channel_id = channel_data.get("id") + if not channel_id: + errors.append({"error": "Channel ID is required"}) + continue - response_data = ChannelSerializer(updated_channels, many=True).data + try: + channel = Channel.objects.get(id=channel_id) - return Response(response_data, status=status.HTTP_200_OK) + # Handle channel_group_id properly - convert string to integer if needed + if 'channel_group_id' in channel_data: + group_id = channel_data['channel_group_id'] + if group_id is not None: + try: + channel_data['channel_group_id'] = int(group_id) + except (ValueError, TypeError): + channel_data['channel_group_id'] = None + + # Use the serializer to validate and update + serializer = ChannelSerializer( + channel, data=channel_data, partial=True + ) + + if serializer.is_valid(): + updated_channel = serializer.save() + updated_channels.append(updated_channel) + else: + errors.append({ + "channel_id": channel_id, + "errors": serializer.errors + }) + + except Channel.DoesNotExist: + errors.append({ + "channel_id": channel_id, + "error": "Channel not found" + }) + except Exception as e: + errors.append({ + "channel_id": channel_id, + "error": str(e) + }) + + if errors: + return Response( + {"errors": errors, "updated_count": len(updated_channels)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Serialize the updated channels for response + serialized_channels = ChannelSerializer(updated_channels, many=True).data + + return Response({ + "message": f"Successfully updated {len(updated_channels)} channels", + "channels": serialized_channels + }) @action(detail=False, methods=["get"], url_path="ids") def get_ids(self, request, *args, **kwargs): @@ -401,6 +450,8 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = float(stream_custom_props["tvg-chno"]) elif "channel-number" in stream_custom_props: channel_number = float(stream_custom_props["channel-number"]) + elif "num" in stream_custom_props: + channel_number = float(stream_custom_props["num"]) if channel_number is None: provided_number = request.data.get("channel_number") @@ -546,6 +597,8 @@ class ChannelViewSet(viewsets.ModelViewSet): channel_number = float(stream_custom_props["tvg-chno"]) elif "channel-number" in stream_custom_props: channel_number = float(stream_custom_props["channel-number"]) + elif "num" in stream_custom_props: + channel_number = float(stream_custom_props["num"]) # Get the tvc_guide_stationid from custom properties if it exists tvc_guide_stationid = None if "tvc-guide-stationid" in stream_custom_props: diff --git a/apps/output/views.py b/apps/output/views.py index 4be0f811..4ef9f4f2 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1,5 +1,5 @@ import ipaddress -from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden +from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse from rest_framework.response import Response from django.urls import reverse from apps.channels.models import Channel, ChannelProfile, ChannelGroup @@ -12,11 +12,10 @@ from dispatcharr.utils import network_access_allowed from django.utils import timezone from django.shortcuts import get_object_or_404 from datetime import datetime, timedelta -import re import html # Add this import for XML escaping +import json # Add this import for JSON parsing +import time # Add this import for keep-alive delays from tzlocal import get_localzone -import time -import json from urllib.parse import urlparse import base64 @@ -278,118 +277,76 @@ def generate_dummy_epg( def generate_epg(request, profile_name=None, user=None): """ - Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models. + Dynamically generate an XMLTV (EPG) file using streaming response to handle keep-alives. Since the EPG data is stored independently of Channels, we group programmes by their associated EPGData record. - This version filters data based on the 'days' parameter. + This version filters data based on the 'days' parameter and sends keep-alives during processing. """ - xml_lines = [] - xml_lines.append('') - xml_lines.append( - '' - ) + def epg_generator(): + """Generator function that yields EPG data with keep-alives during processing""" # Send initial HTTP headers as comments (these will be ignored by XML parsers but keep connection alive) - if user is not None: - if user.user_level == 0: - filters = { - "channelprofilemembership__enabled": True, - "user_level__lte": user.user_level, - } + xml_lines = [] + xml_lines.append('') + xml_lines.append( + '' + ) - if user.channel_profiles.count() != 0: - channel_profiles = user.channel_profiles.all() - filters["channelprofilemembership__channel_profile__in"] = ( - channel_profiles + # Get channels based on user/profile + if user is not None: + if user.user_level == 0: + filters = { + "channelprofilemembership__enabled": True, + "user_level__lte": user.user_level, + } + + if user.channel_profiles.count() != 0: + channel_profiles = user.channel_profiles.all() + filters["channelprofilemembership__channel_profile__in"] = ( + channel_profiles + ) + + channels = Channel.objects.filter(**filters).order_by("channel_number") + else: + channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( + "channel_number" ) - - channels = Channel.objects.filter(**filters).order_by("channel_number") else: - channels = Channel.objects.filter(user_level__lte=user.user_level).order_by( - "channel_number" - ) - else: - if profile_name is not None: - channel_profile = ChannelProfile.objects.get(name=profile_name) - channels = Channel.objects.filter( - channelprofilemembership__channel_profile=channel_profile, - channelprofilemembership__enabled=True, - ) - else: - channels = Channel.objects.all() - - # Check if the request wants to use direct logo URLs instead of cache - use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' - - # Get the source to use for tvg-id value - # Options: 'channel_number' (default), 'tvg_id', 'gracenote' - tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() - - # Get the number of days for EPG data - try: - # Default to 0 days (everything) for real EPG if not specified - days_param = request.GET.get('days', '0') - num_days = int(days_param) - # Set reasonable limits - num_days = max(0, min(num_days, 365)) # Between 0 and 365 days - except ValueError: - num_days = 0 # Default to all data if invalid value - - # For dummy EPG, use either the specified value or default to 3 days - dummy_days = num_days if num_days > 0 else 3 - - # Calculate cutoff date for EPG data filtering (only if days > 0) - now = timezone.now() - cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None - - # Retrieve all active channels - for channel in channels: - # Format channel number as integer if it has no decimal component - same as M3U generation - if channel.channel_number is not None: - if channel.channel_number == int(channel.channel_number): - formatted_channel_number = int(channel.channel_number) + if profile_name is not None: + channel_profile = ChannelProfile.objects.get(name=profile_name) + channels = Channel.objects.filter( + channelprofilemembership__channel_profile=channel_profile, + channelprofilemembership__enabled=True, + ) else: - formatted_channel_number = channel.channel_number - else: - formatted_channel_number = "" + channels = Channel.objects.all() - # Determine the channel ID based on the selected source - if tvg_id_source == 'tvg_id' and channel.tvg_id: - channel_id = channel.tvg_id - elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: - channel_id = channel.tvc_guide_stationid - else: - # Default to channel number (original behavior) - channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) + # Check if the request wants to use direct logo URLs instead of cache + use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' - # Add channel logo if available - tvg_logo = "" - if channel.logo: - if use_cached_logos: - # Use cached logo as before - tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) - else: - # Try to find direct logo URL from channel's streams - direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None - # If direct logo found, use it; otherwise fall back to cached version - if direct_logo: - 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 - xml_lines.append(f' ') - xml_lines.append(f' {html.escape(display_name)}') - xml_lines.append(f' ') + # Get the source to use for tvg-id value + # Options: 'channel_number' (default), 'tvg_id', 'gracenote' + tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() - xml_lines.append(" ") + # Get the number of days for EPG data + try: + # Default to 0 days (everything) for real EPG if not specified + days_param = request.GET.get('days', '0') + num_days = int(days_param) + # Set reasonable limits + num_days = max(0, min(num_days, 365)) # Between 0 and 365 days + except ValueError: + num_days = 0 # Default to all data if invalid value - for channel in channels: - # Use the same channel ID determination for program entries - if tvg_id_source == 'tvg_id' and channel.tvg_id: - channel_id = channel.tvg_id - elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: - channel_id = channel.tvc_guide_stationid - else: - # Get formatted channel number + # For dummy EPG, use either the specified value or default to 3 days + dummy_days = num_days if num_days > 0 else 3 + + # Calculate cutoff date for EPG data filtering (only if days > 0) + now = timezone.now() + cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None + + # Process channels for the section + for channel in channels: + # Format channel number as integer if it has no decimal component - same as M3U generation if channel.channel_number is not None: if channel.channel_number == int(channel.channel_number): formatted_channel_number = int(channel.channel_number) @@ -397,167 +354,214 @@ def generate_epg(request, profile_name=None, user=None): formatted_channel_number = channel.channel_number else: formatted_channel_number = "" - # Default to channel number - channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) - display_name = channel.epg_data.name if channel.epg_data else channel.name - if not channel.epg_data: - # Use the enhanced dummy EPG generation function with defaults - program_length_hours = 4 # Default to 4-hour program blocks - generate_dummy_epg( - channel_id, - display_name, - xml_lines, - num_days=dummy_days, # Use dummy_days (3 days by default) - program_length_hours=program_length_hours - ) - else: - # For real EPG data - filter only if days parameter was specified - if num_days > 0: - programs = channel.epg_data.programs.filter( - start_time__gte=now, - start_time__lt=cutoff_date - ) + # Determine the channel ID based on the selected source + if tvg_id_source == 'tvg_id' and channel.tvg_id: + channel_id = channel.tvg_id + elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: + channel_id = channel.tvc_guide_stationid else: - # Return all programs if days=0 or not specified - programs = channel.epg_data.programs.all() + # Default to channel number (original behavior) + channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) - for prog in programs: - start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z") - stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z") - xml_lines.append(f' ') - xml_lines.append(f' {html.escape(prog.title)}') + # Add channel logo if available + tvg_logo = "" + if channel.logo: + if use_cached_logos: + # Use cached logo as before + tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) + else: + # Try to find direct logo URL from channel's streams + direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None + # If direct logo found, use it; otherwise fall back to cached version + if direct_logo: + tvg_logo = direct_logo + else: + tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id])) - # Add subtitle if available - if prog.sub_title: - xml_lines.append( - f" {html.escape(prog.sub_title)}" + display_name = channel.epg_data.name if channel.epg_data else channel.name + xml_lines.append(f' ') + xml_lines.append(f' {html.escape(display_name)}') + xml_lines.append(f' ') + xml_lines.append(" ") + + # Send all channel definitions + yield '\n'.join(xml_lines) + '\n' + xml_lines = [] # Clear to save memory + + # Process programs for each channel + for channel in channels: + + # Use the same channel ID determination for program entries + if tvg_id_source == 'tvg_id' and channel.tvg_id: + channel_id = channel.tvg_id + elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: + channel_id = channel.tvc_guide_stationid + else: + # Get formatted channel number + if channel.channel_number is not None: + if channel.channel_number == int(channel.channel_number): + formatted_channel_number = int(channel.channel_number) + else: + formatted_channel_number = channel.channel_number + else: + formatted_channel_number = "" + # Default to channel number + channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) + + display_name = channel.epg_data.name if channel.epg_data else channel.name + + if not channel.epg_data: + # Use the enhanced dummy EPG generation function with defaults + program_length_hours = 4 # Default to 4-hour program blocks + dummy_programs = generate_dummy_programs(channel_id, display_name, num_days=dummy_days, program_length_hours=program_length_hours) + + for program in dummy_programs: + # Format times in XMLTV format + start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") + stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") + + # Create program entry with escaped channel name + yield f' \n' + yield f" {html.escape(program['title'])}\n" + yield f" {html.escape(program['description'])}\n" + yield f" \n" + + else: + # For real EPG data - filter only if days parameter was specified + if num_days > 0: + programs = channel.epg_data.programs.filter( + start_time__gte=now, + start_time__lt=cutoff_date ) + else: + # Return all programs if days=0 or not specified + programs = channel.epg_data.programs.all() - # Add description if available - if prog.description: - xml_lines.append( - f" {html.escape(prog.description)}" - ) + # Process programs in chunks to avoid memory issues + program_batch = [] + batch_size = 100 - # Process custom properties if available - if prog.custom_properties: - try: - import json + for prog in programs.iterator(): # Use iterator to avoid loading all at once + start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z") + stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z") - custom_data = json.loads(prog.custom_properties) + program_xml = [f' '] + program_xml.append(f' {html.escape(prog.title)}') - # Add categories if available - if "categories" in custom_data and custom_data["categories"]: - for category in custom_data["categories"]: - xml_lines.append( - f" {html.escape(category)}" + # Add subtitle if available + if prog.sub_title: + program_xml.append(f" {html.escape(prog.sub_title)}") + + # Add description if available + if prog.description: + program_xml.append(f" {html.escape(prog.description)}") + + # Process custom properties if available + if prog.custom_properties: + try: + custom_data = json.loads(prog.custom_properties) + + # Add categories if available + if "categories" in custom_data and custom_data["categories"]: + for category in custom_data["categories"]: + program_xml.append(f" {html.escape(category)}") + + # Handle episode numbering - multiple formats supported + # Standard episode number if available + if "episode" in custom_data: + program_xml.append(f' E{custom_data["episode"]}') + + # Handle onscreen episode format (like S06E128) + if "onscreen_episode" in custom_data: + program_xml.append(f' {html.escape(custom_data["onscreen_episode"])}') + + # Handle dd_progid format + if 'dd_progid' in custom_data: + program_xml.append(f' {html.escape(custom_data["dd_progid"])}') + + # Add season and episode numbers in xmltv_ns format if available + if "season" in custom_data and "episode" in custom_data: + season = ( + int(custom_data["season"]) - 1 + if str(custom_data["season"]).isdigit() + else 0 ) + episode = ( + int(custom_data["episode"]) - 1 + if str(custom_data["episode"]).isdigit() + else 0 + ) + program_xml.append(f' {season}.{episode}.') - # Handle episode numbering - multiple formats supported - # Standard episode number if available - if "episode" in custom_data: - xml_lines.append( - f' E{custom_data["episode"]}' - ) + # Add rating if available + if "rating" in custom_data: + rating_system = custom_data.get("rating_system", "TV Parental Guidelines") + program_xml.append(f' ') + program_xml.append(f' {html.escape(custom_data["rating"])}') + program_xml.append(f" ") - # Handle onscreen episode format (like S06E128) - if "onscreen_episode" in custom_data: - xml_lines.append( - f' {html.escape(custom_data["onscreen_episode"])}' - ) + # Add actors/directors/writers if available + if "credits" in custom_data: + program_xml.append(f" ") + for role, people in custom_data["credits"].items(): + if isinstance(people, list): + for person in people: + program_xml.append(f" <{role}>{html.escape(person)}") + else: + program_xml.append(f" <{role}>{html.escape(people)}") + program_xml.append(f" ") - # Handle dd_progid format - if 'dd_progid' in custom_data: - xml_lines.append(f' {html.escape(custom_data["dd_progid"])}') + # Add program date/year if available + if "year" in custom_data: + program_xml.append(f' {html.escape(custom_data["year"])}') - # Add season and episode numbers in xmltv_ns format if available - if "season" in custom_data and "episode" in custom_data: - season = ( - int(custom_data["season"]) - 1 - if str(custom_data["season"]).isdigit() - else 0 - ) - episode = ( - int(custom_data["episode"]) - 1 - if str(custom_data["episode"]).isdigit() - else 0 - ) - xml_lines.append( - f' {season}.{episode}.' - ) + # Add country if available + if "country" in custom_data: + program_xml.append(f' {html.escape(custom_data["country"])}') - # Add rating if available - if "rating" in custom_data: - rating_system = custom_data.get( - "rating_system", "TV Parental Guidelines" - ) - xml_lines.append( - f' ' - ) - xml_lines.append( - f' {html.escape(custom_data["rating"])}' - ) - xml_lines.append(f" ") + # Add icon if available + if "icon" in custom_data: + program_xml.append(f' ') - # Add actors/directors/writers if available - if "credits" in custom_data: - xml_lines.append(f" ") - for role, people in custom_data["credits"].items(): - if isinstance(people, list): - for person in people: - xml_lines.append( - f" <{role}>{html.escape(person)}" - ) - else: - xml_lines.append( - f" <{role}>{html.escape(people)}" - ) - xml_lines.append(f" ") + # Add special flags as proper tags + if custom_data.get("previously_shown", False): + program_xml.append(f" ") - # Add program date/year if available - if "year" in custom_data: - xml_lines.append( - f' {html.escape(custom_data["year"])}' - ) + if custom_data.get("premiere", False): + program_xml.append(f" ") - # Add country if available - if "country" in custom_data: - xml_lines.append( - f' {html.escape(custom_data["country"])}' - ) + if custom_data.get("new", False): + program_xml.append(f" ") - # Add icon if available - if "icon" in custom_data: - xml_lines.append( - f' ' - ) + if custom_data.get('live', False): + program_xml.append(f' ') - # Add special flags as proper tags - if custom_data.get("previously_shown", False): - xml_lines.append(f" ") + except Exception as e: + program_xml.append(f" ") - if custom_data.get("premiere", False): - xml_lines.append(f" ") + program_xml.append(" ") - if custom_data.get("new", False): - xml_lines.append(f" ") + # Add to batch + program_batch.extend(program_xml) - if custom_data.get('live', False): - xml_lines.append(f' ') + # Send batch when full or send keep-alive + if len(program_batch) >= batch_size: + yield '\n'.join(program_batch) + '\n' + program_batch = [] # Send keep-alive every batch - except Exception as e: - xml_lines.append( - f" " - ) + # Send remaining programs in batch + if program_batch: + yield '\n'.join(program_batch) + '\n' - xml_lines.append(" ") - - xml_lines.append("") - xml_content = "\n".join(xml_lines) - - response = HttpResponse(xml_content, content_type="application/xml") - response["Content-Disposition"] = 'attachment; filename="epg.xml"' + # Send final closing tag and completion message + yield "\n" # Return streaming response + response = StreamingHttpResponse( + streaming_content=epg_generator(), + content_type="application/xml" + ) + response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"' + response["Cache-Control"] = "no-cache" return response diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index bf5d4981..4699091a 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -472,7 +472,7 @@ class ProxyServer: if b'state' in metadata: state = metadata[b'state'].decode('utf-8') active_states = [ChannelState.INITIALIZING, ChannelState.CONNECTING, - ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE] + ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE, ChannelState.BUFFERING] if state in active_states: logger.info(f"Channel {channel_id} already being initialized with state {state}") # Create buffer and client manager only if we don't have them @@ -689,7 +689,8 @@ class ProxyServer: owner = metadata.get(b'owner', b'').decode('utf-8') # States that indicate the channel is running properly - valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING] + valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, + ChannelState.CONNECTING, ChannelState.BUFFERING, ChannelState.INITIALIZING] # If the channel is in a valid state, check if the owner is still active if state in valid_states: diff --git a/frontend/src/api.js b/frontend/src/api.js index 17c38b90..391eaae9 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -390,9 +390,9 @@ export default class API { static async updateChannels(ids, values) { const body = []; - for (const id in ids) { + for (const id of ids) { body.push({ - id, + id: id, ...values, }); } @@ -406,7 +406,7 @@ export default class API { } ); - useChannelsStore.getState().updateChannels(response); + // Don't automatically update the store here - let the caller handle it return response; } catch (e) { errorNotification('Failed to update channels', e); diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 12ee52b8..2ba3245c 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -35,7 +35,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const streamProfiles = useStreamProfilesStore((s) => s.profiles); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); - const [selectedChannelGroup, setSelectedChannelGroup] = useState(''); + const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1'); + const [isSubmitting, setIsSubmitting] = useState(false); const [groupPopoverOpened, setGroupPopoverOpened] = useState(false); const [groupFilter, setGroupFilter] = useState(''); @@ -44,34 +45,51 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const form = useForm({ mode: 'uncontrolled', initialValues: { - channel_group: '', - stream_profile_id: '0', + channel_group: '(no change)', + stream_profile_id: '-1', user_level: '-1', }, }); const onSubmit = async () => { + setIsSubmitting(true); + const values = { ...form.getValues(), - channel_group_id: selectedChannelGroup, - }; - - if (!values.stream_profile_id || values.stream_profile_id === '0') { - values.stream_profile_id = null; + }; // Handle channel group ID - convert to integer if it exists + if (selectedChannelGroup && selectedChannelGroup !== '-1') { + values.channel_group_id = parseInt(selectedChannelGroup); + } else { + delete values.channel_group_id; } - if (!values.channel_group_id) { - delete values.channel_group_id; + // Handle stream profile ID - convert special values + if (!values.stream_profile_id || values.stream_profile_id === '-1') { + delete values.stream_profile_id; + } else if (values.stream_profile_id === '0' || values.stream_profile_id === 0) { + values.stream_profile_id = null; // Convert "use default" to null } if (values.user_level == '-1') { delete values.user_level; } - await API.batchUpdateChannels({ - ids: channelIds, - values, - }); + // Remove the channel_group field from form values as we use channel_group_id + delete values.channel_group; + + try { + await API.updateChannels(channelIds, values); + // Refresh both the channels table data and the main channels store + await Promise.all([ + API.requeryChannels(), + useChannelsStore.getState().fetchChannels() + ]); + onClose(); + } catch (error) { + console.error('Failed to update channels:', error); + } finally { + setIsSubmitting(false); + } }; // useEffect(() => { @@ -107,10 +125,12 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); } }; - - const filteredGroups = groupOptions.filter((group) => - group.name.toLowerCase().includes(groupFilter.toLowerCase()) - ); + const filteredGroups = [ + { id: '-1', name: '(no change)' }, + ...groupOptions.filter((group) => + group.name.toLowerCase().includes(groupFilter.toLowerCase()) + ) + ]; if (!isOpen) { return <>; @@ -150,7 +170,21 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { key={form.key('channel_group')} onClick={() => setGroupPopoverOpened(true)} size="xs" - style={{ flex: 1 }} + style={{ flex: 1 }} rightSection={ + form.getValues().channel_group && form.getValues().channel_group !== '(no change)' && ( + { + e.stopPropagation(); + setSelectedChannelGroup('-1'); + form.setValues({ channel_group: '(no change)' }); + }} + > + + + ) + } /> { name="stream_profile_id" {...form.getInputProps('stream_profile_id')} key={form.key('stream_profile_id')} - data={[{ value: '0', label: '(use default)' }].concat( + data={[ + { value: '-1', label: '(no change)' }, + { value: '0', label: '(use default)' } + ].concat( streamProfiles.map((option) => ({ value: `${option.id}`, label: option.name, @@ -264,7 +301,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { label: '(no change)', }, ].concat( - Object.entries(USER_LEVELS).map(([label, value]) => { + Object.entries(USER_LEVELS).map(([, value]) => { return { label: USER_LEVEL_LABELS[value], value: `${value}`, @@ -274,9 +311,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { /> - - diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx index f4cdbe62..163f981b 100644 --- a/frontend/src/components/forms/M3UProfiles.jsx +++ b/frontend/src/components/forms/M3UProfiles.jsx @@ -1,7 +1,9 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import API from '../../api'; import M3UProfile from './M3UProfile'; import usePlaylistsStore from '../../store/playlists'; +import ConfirmationDialog from '../ConfirmationDialog'; +import useWarningsStore from '../../store/warnings'; import { Card, Checkbox, @@ -23,10 +25,15 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const theme = useMantineTheme(); const allProfiles = usePlaylistsStore((s) => s.profiles); + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); const [profileEditorOpen, setProfileEditorOpen] = useState(false); const [profile, setProfile] = useState(null); const [profiles, setProfiles] = useState([]); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [profileToDelete, setProfileToDelete] = useState(null); useEffect(() => { try { @@ -50,13 +57,30 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { setProfileEditorOpen(true); }; - const deleteProfile = async (id) => { if (!playlist || !playlist.id) return; + + // Get profile details for the confirmation dialog + const profileObj = profiles.find(p => p.id === id); + setProfileToDelete(profileObj); + setDeleteTarget(id); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-profile')) { + return executeDeleteProfile(id); + } + + setConfirmDeleteOpen(true); + }; + + const executeDeleteProfile = async (id) => { + if (!playlist || !playlist.id) return; try { await API.deleteM3UProfile(playlist.id, id); + setConfirmDeleteOpen(false); } catch (error) { console.error('Error deleting profile:', error); + setConfirmDeleteOpen(false); } }; @@ -171,14 +195,38 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { New - - - + + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteProfile(deleteTarget)} + title="Confirm Profile Deletion" + message={ + profileToDelete ? ( +
+ {`Are you sure you want to delete the following profile? + +Name: ${profileToDelete.name} +Max Streams: ${profileToDelete.max_streams} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this profile? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-profile" + onSuppressChange={suppressWarning} + size="md" + /> ); }; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 3bf71d00..8c19671a 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -41,6 +41,9 @@ import { Pagination, NativeSelect, UnstyledButton, + Stack, + Select, + NumberInput, } from '@mantine/core'; import { getCoreRowModel, flexRender } from '@tanstack/react-table'; import './table.css'; @@ -211,7 +214,7 @@ const ChannelRowActions = React.memo( } ); -const ChannelsTable = ({}) => { +const ChannelsTable = ({ }) => { const theme = useMantineTheme(); /** @@ -283,14 +286,25 @@ const ChannelsTable = ({}) => { const [isLoading, setIsLoading] = useState(true); const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase); - const [epgUrl, setEPGUrl] = useState(epgUrlBase); - const [m3uUrl, setM3UUrl] = useState(m3uUrlBase); + const [epgUrl, setEPGUrl] = useState(epgUrlBase); const [m3uUrl, setM3UUrl] = useState(m3uUrlBase); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [isBulkDelete, setIsBulkDelete] = useState(false); const [channelToDelete, setChannelToDelete] = useState(null); + // M3U and EPG URL configuration state + const [m3uParams, setM3uParams] = useState({ + cachedlogos: true, + direct: false, + tvg_id_source: 'channel_number' + }); + const [epgParams, setEpgParams] = useState({ + cachedlogos: true, + tvg_id_source: 'channel_number', + days: 0 + }); + /** * Dereived variables */ @@ -361,10 +375,22 @@ const ChannelsTable = ({}) => { }; const editChannel = async (ch = null) => { - if (selectedChannelIds.length > 0) { + // Use table's selected state instead of store state to avoid stale selections + const currentSelection = table ? table.getState().selectedTableIds : []; + console.log('editChannel called with:', { ch, currentSelection, tableExists: !!table }); + + if (currentSelection.length > 1) { setChannelBatchModalOpen(true); } else { - setChannel(ch); + // If no channel object is passed but we have a selection, get the selected channel + let channelToEdit = ch; + if (!channelToEdit && currentSelection.length === 1) { + const selectedId = currentSelection[0]; + + // Use table data since that's what's currently displayed + channelToEdit = data.find(d => d.id === selectedId); + } + setChannel(channelToEdit); setChannelModalOpen(true); } }; @@ -514,16 +540,47 @@ const ChannelsTable = ({}) => { } } }; + // Build URLs with parameters + const buildM3UUrl = () => { + const params = new URLSearchParams(); + if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false'); + if (m3uParams.direct) params.append('direct', 'true'); + if (m3uParams.tvg_id_source !== 'channel_number') params.append('tvg_id_source', m3uParams.tvg_id_source); + const baseUrl = m3uUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; + }; + + const buildEPGUrl = () => { + const params = new URLSearchParams(); + if (!epgParams.cachedlogos) params.append('cachedlogos', 'false'); + if (epgParams.tvg_id_source !== 'channel_number') params.append('tvg_id_source', epgParams.tvg_id_source); + if (epgParams.days > 0) params.append('days', epgParams.days.toString()); + + const baseUrl = epgUrl; + return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl; + }; // Example copy URLs const copyM3UUrl = () => { - copyToClipboard(m3uUrl); + copyToClipboard(buildM3UUrl()); + notifications.show({ + title: 'M3U URL Copied!', + message: 'The M3U URL has been copied to your clipboard.', + }); }; const copyEPGUrl = () => { - copyToClipboard(epgUrl); + copyToClipboard(buildEPGUrl()); + notifications.show({ + title: 'EPG URL Copied!', + message: 'The EPG URL has been copied to your clipboard.', + }); }; const copyHDHRUrl = () => { copyToClipboard(hdhrUrl); + notifications.show({ + title: 'HDHR URL Copied!', + message: 'The HDHR URL has been copied to your clipboard.', + }); }; const onSortingChange = (column) => { @@ -566,7 +623,7 @@ const ChannelsTable = ({}) => { setHDHRUrl(`${hdhrUrlBase}${profileString}`); setEPGUrl(`${epgUrlBase}${profileString}`); setM3UUrl(`${m3uUrlBase}${profileString}`); - }, [selectedProfileId]); + }, [selectedProfileId, profiles]); useEffect(() => { const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0 @@ -575,7 +632,19 @@ const ChannelsTable = ({}) => { totalCount ); setPaginationString(`${startItem} to ${endItem} of ${totalCount}`); - }, [data]); + }, [pagination.pageIndex, pagination.pageSize, totalCount]); + + // Clear selection when data changes (e.g., when navigating back to the page) + useEffect(() => { + setSelectedChannelIds([]); + }, [data, setSelectedChannelIds]); + + // Clear selection when component unmounts + useEffect(() => { + return () => { + setSelectedChannelIds([]); + }; + }, [setSelectedChannelIds]); const columns = useMemo( () => [ @@ -812,8 +881,8 @@ const ChannelsTable = ({}) => { return hasStreams ? {} // Default style for channels with streams : { - className: 'no-streams-row', // Add a class instead of background color - }; + className: 'no-streams-row', // Add a class instead of background color + }; }, }); @@ -860,9 +929,8 @@ const ChannelsTable = ({}) => { > Links: - - + - + { - - + - - - - - - + + + +
+ } + /> + Use cached logos + setM3uParams(prev => ({ + ...prev, + cachedlogos: event.target.checked + }))} + /> + + + + Direct stream URLs + setM3uParams(prev => ({ + ...prev, + direct: event.target.checked + }))} + /> + setEpgParams(prev => ({ + ...prev, + tvg_id_source: value + }))} + comboboxProps={{ withinPortal: false }} + data={[ + { value: 'channel_number', label: 'Channel Number' }, + { value: 'tvg_id', label: 'TVG-ID' }, + { value: 'gracenote', label: 'Gracenote Station ID' } + ]} + /> + setEpgParams(prev => ({ + ...prev, + days: value || 0 + }))} + /> + @@ -962,7 +1140,7 @@ const ChannelsTable = ({}) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 58px)', + height: 'calc(100vh - 60px)', backgroundColor: '#27272A', }} > @@ -971,6 +1149,7 @@ const ChannelsTable = ({}) => { editChannel={editChannel} deleteChannels={deleteChannels} selectedTableIds={table.selectedTableIds} + table={table} /> {/* Table or ghost empty state inside Paper */} @@ -985,7 +1164,7 @@ const ChannelsTable = ({}) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 110px)', + height: 'calc(100vh - 100px)', }} > { const [opened, setOpened] = useState(false); @@ -103,18 +105,35 @@ const ChannelTableHeader = ({ const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); + const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false); + const [profileToDelete, setProfileToDelete] = useState(null); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId); const authUser = useAuthStore((s) => s.user); - + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); const closeAssignChannelNumbersModal = () => { setAssignNumbersModalOpen(false); }; const deleteProfile = async (id) => { + // Get profile details for the confirmation dialog + const profileObj = profiles[id]; + setProfileToDelete(profileObj); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-profile')) { + return executeDeleteProfile(id); + } + + setConfirmDeleteProfileOpen(true); + }; + + const executeDeleteProfile = async (id) => { await API.deleteChannelProfile(id); + setConfirmDeleteProfileOpen(false); }; const matchEpg = async () => { @@ -210,7 +229,7 @@ const ChannelTableHeader = ({ leftSection={} variant="default" size="xs" - onClick={editChannel} + onClick={() => editChannel()} disabled={ selectedTableIds.length == 0 || authUser.user_level != USER_LEVELS.ADMIN @@ -292,6 +311,31 @@ const ChannelTableHeader = ({ isOpen={assignNumbersModalOpen} onClose={closeAssignChannelNumbersModal} /> + + setConfirmDeleteProfileOpen(false)} + onConfirm={() => executeDeleteProfile(profileToDelete?.id)} + title="Confirm Profile Deletion" + message={ + profileToDelete ? ( +
+ {`Are you sure you want to delete the following profile? + +Name: ${profileToDelete.name} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this profile? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-profile" + onSuppressChange={suppressWarning} + size="md" + /> ); }; diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index 6978d005..2101a12f 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -500,6 +500,7 @@ const EPGsTable = () => { style={{ display: 'flex', alignItems: 'center', + justifyContent: 'space-between', paddingBottom: 10, }} gap={15} @@ -518,6 +519,21 @@ const EPGsTable = () => { > EPGs + { // alignItems: 'center', // backgroundColor: theme.palette.background.paper, justifyContent: 'flex-end', - padding: 10, + padding: 0, // gap: 1, }} > - - - - -
@@ -563,14 +560,14 @@ const EPGsTable = () => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(40vh - 10px)', + height: 'calc(40vh - 15px)', }} > { Name: ${epgToDelete.name} Source Type: ${epgToDelete.source_type} -${ - epgToDelete.url - ? `URL: ${epgToDelete.url}` - : epgToDelete.api_key - ? `API Key: ${epgToDelete.api_key}` - : epgToDelete.file_path - ? `File Path: ${epgToDelete.file_path}` - : '' -} +${epgToDelete.url + ? `URL: ${epgToDelete.url}` + : epgToDelete.api_key + ? `API Key: ${epgToDelete.api_key}` + : epgToDelete.file_path + ? `File Path: ${epgToDelete.file_path}` + : '' + } This will remove all related program information and channel associations. This action cannot be undone.`} diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index ac7ee631..c71789f2 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -803,7 +803,7 @@ const M3UTable = () => { return ( { > M3U Accounts + { // alignItems: 'center', // backgroundColor: theme.palette.background.paper, justifyContent: 'flex-end', - padding: 10, + padding: 0, // gap: 1, }} > - - - - - @@ -865,14 +861,14 @@ const M3UTable = () => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(40vh - 10px)', + height: 'calc(40vh - 15px)', }} > { { header: 'Active', accessorKey: 'is_active', - size: 50, + size: 60, cell: ({ row, cell }) => (
{ { id: 'actions', header: 'Actions', - size: tableSize == 'compact' ? 75 : 100, + size: tableSize == 'compact' ? 50 : 75, }, ], [] @@ -310,12 +310,14 @@ const StreamProfiles = () => { style={{ flex: 1, overflowY: 'auto', - overflowX: 'hidden', + overflowX: 'auto', border: 'solid 1px rgb(68,68,68)', borderRadius: 'var(--mantine-radius-default)', }} > - +
+ +
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 73fee4c7..64f9bbef 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -170,7 +170,7 @@ const StreamRowActions = ({ ); }; -const StreamsTable = ({}) => { +const StreamsTable = ({ }) => { const theme = useMantineTheme(); /** @@ -653,7 +653,7 @@ const StreamsTable = ({}) => { @@ -678,10 +678,10 @@ const StreamsTable = ({}) => { style={ selectedStreamIds.length > 0 && selectedChannelIds.length === 1 ? { - borderWidth: '1px', - borderColor: theme.tailwind.green[5], - color: 'white', - } + borderWidth: '1px', + borderColor: theme.tailwind.green[5], + color: 'white', + } : undefined } disabled={ @@ -801,7 +801,7 @@ const StreamsTable = ({}) => { style={{ display: 'flex', flexDirection: 'column', - height: 'calc(100vh - 110px)', + height: 'calc(100vh - 100px)', }} > { accessorKey: 'is_active', sortingFn: 'basic', enableSorting: false, + size: 60, cell: ({ cell }) => (
{cell.getValue() ? : } @@ -108,7 +109,7 @@ const UserAgentsTable = () => { { id: 'actions', header: 'Actions', - size: tableSize == 'compact' ? 75 : 100, + size: tableSize == 'compact' ? 50 : 75, }, ], [] @@ -234,18 +235,22 @@ const UserAgentsTable = () => { display: 'flex', flexDirection: 'column', maxHeight: 300, + width: '100%', + overflow: 'hidden', }} > - +
+ +
diff --git a/frontend/src/index.css b/frontend/src/index.css index f14083c8..5c37b48b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -12,7 +12,7 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: #2E2F34; + background-color: #18181b; /* Ensure the global background is dark */ color: #ffffff; } diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index fe15e713..93300f1b 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -12,7 +12,6 @@ const ChannelsPage = () => { if (!authUser.id) { return <>; } - if (authUser.user_level <= USER_LEVELS.STANDARD) { return ( @@ -22,17 +21,22 @@ const ChannelsPage = () => { } return ( -
+
-
- +
+
+ +
-
- +
+
+ +
diff --git a/frontend/src/pages/ContentSources.jsx b/frontend/src/pages/ContentSources.jsx index 24e736d4..79ec53f2 100644 --- a/frontend/src/pages/ContentSources.jsx +++ b/frontend/src/pages/ContentSources.jsx @@ -7,15 +7,15 @@ import { Box, Stack } from '@mantine/core'; const M3UPage = () => { const isLoading = useUserAgentsStore((state) => state.isLoading); const error = useUserAgentsStore((state) => state.error); - if (isLoading) return
Loading...
; - if (error) return
Error: {error}
; - - return ( + if (error) return
Error: {error}
; return ( diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index c0042c31..cfc1d298 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -110,7 +110,7 @@ export default function TVChannelGuide({ startDate, endDate }) { // Apply channel group filter if (selectedGroupId !== 'all') { result = result.filter( - (channel) => channel.channel_group?.id === parseInt(selectedGroupId) + (channel) => channel.channel_group_id === parseInt(selectedGroupId) ); } @@ -492,7 +492,6 @@ export default function TVChannelGuide({ startDate, endDate }) { guideRef.current.scrollLeft = scrollPosition; } }; - // Renders each program block function renderProgram(program, channelStart) { const programKey = `${program.tvg_id}-${program.start_time}`; @@ -522,18 +521,9 @@ export default function TVChannelGuide({ startDate, endDate }) { const isLive = now.isAfter(programStart) && now.isBefore(programEnd); // Determine if the program has ended - const isPast = now.isAfter(programEnd); - - // Check if this program is expanded + const isPast = now.isAfter(programEnd); // Check if this program is expanded const isExpanded = expandedProgramId === program.id; - // Calculate how much of the program is cut off - const cutOffMinutes = Math.max( - 0, - channelStart.diff(programStart, 'minute') - ); - const cutOffPx = (cutOffMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; - // Set the height based on expanded state const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT; @@ -542,6 +532,25 @@ export default function TVChannelGuide({ startDate, endDate }) { const MIN_EXPANDED_WIDTH = 450; // Minimum width in pixels when expanded const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH); + // Calculate text positioning for long programs that start before the visible area + const currentScrollLeft = guideRef.current?.scrollLeft || 0; + const programStartInView = leftPx + gapSize; + const programEndInView = leftPx + gapSize + widthPx; + const viewportLeft = currentScrollLeft; + + // Check if program starts before viewport but extends into it + const startsBeforeView = programStartInView < viewportLeft; + const extendsIntoView = programEndInView > viewportLeft; + + // Calculate text offset to position it at the visible portion + let textOffsetLeft = 0; + if (startsBeforeView && extendsIntoView) { + // Position text at the start of the visible area, but not beyond the program end + const visibleStart = Math.max(viewportLeft - programStartInView, 0); + const maxOffset = widthPx - 200; // Leave some space for text, don't push to very end + textOffsetLeft = Math.min(visibleStart, maxOffset); + } + return ( - + {programStart.format('h:mma')} - {programEnd.format('h:mma')} - - - {/* Description is always shown but expands when row is expanded */} + {/* Description is always shown but expands when row is expanded */} {program.description && ( - - {program.description} - + + {program.description} + + )} {/* Expanded content */} @@ -693,11 +712,10 @@ export default function TVChannelGuide({ startDate, endDate }) { // Get unique channel group IDs from the channels that have program data const usedGroupIds = new Set(); guideChannels.forEach((channel) => { - if (channel.channel_group?.id) { - usedGroupIds.add(channel.channel_group.id); + if (channel.channel_group_id) { + usedGroupIds.add(channel.channel_group_id); } }); - // Only add groups that are actually used by channels in the guide Object.values(channelGroups) .filter((group) => usedGroupIds.has(group.id)) @@ -709,7 +727,6 @@ export default function TVChannelGuide({ startDate, endDate }) { }); }); } - return options; }, [channelGroups, guideChannels]); @@ -838,7 +855,7 @@ export default function TVChannelGuide({ startDate, endDate }) { {(searchQuery !== '' || selectedGroupId !== 'all' || selectedProfileId !== 'all') && ( - )} @@ -908,101 +925,100 @@ export default function TVChannelGuide({ startDate, endDate }) { borderBottom: '1px solid #27272A', width: hourTimeline.length * HOUR_WIDTH, }} - > - {hourTimeline.map((hourData, hourIndex) => { - const { time, isNewDay, dayLabel } = hourData; + > {hourTimeline.map((hourData) => { + const { time, isNewDay } = hourData; - return ( - handleTimeClick(time, e)} + > + {/* Remove the special day label for new days since we'll show day for all hours */} + + {/* Position time label at the left border of each hour block */} + handleTimeClick(time, e)} > - {/* Remove the special day label for new days since we'll show day for all hours */} - - {/* Position time label at the left border of each hour block */} + {/* Show day above time for every hour using the same format */} - {/* Show day above time for every hour using the same format */} - - {formatDayLabel(time)}{' '} - {/* Use same formatDayLabel function for all hours */} - - {time.format('h:mm')} - - {time.format('A')} - + {formatDayLabel(time)}{' '} + {/* Use same formatDayLabel function for all hours */} + {time.format('h:mm')} + + {time.format('A')} + + - {/* Hour boundary marker - more visible */} - + {/* Hour boundary marker - more visible */} + - {/* Quarter hour tick marks */} - - {[15, 30, 45].map((minute) => ( - - ))} - + {/* Quarter hour tick marks */} + + {[15, 30, 45].map((minute) => ( + + ))} - ); - })} + + ); + })} @@ -1087,7 +1103,7 @@ export default function TVChannelGuide({ startDate, endDate }) { borderRight: '1px solid #27272A', // Increased border width for visibility borderBottom: '1px solid #27272A', // Match the row border boxShadow: '2px 0 5px rgba(0,0,0,0.2)', // Added shadow for depth - position: 'sticky', + //position: 'sticky', left: 0, zIndex: 30, // Higher than expanded programs to prevent overlap height: rowHeight, diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 1d724bc9..a5b07fa2 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -256,6 +256,7 @@ const SettingsPage = () => { variant="separated" defaultValue="ui-settings" onChange={setAccordianValue} + style={{ minWidth: 400 }} > UI Settings diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ba9829fe..c3709c17 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -480,6 +480,8 @@ const ChannelCard = ({ style={{ color: '#fff', backgroundColor: '#27272A', + maxWidth: '700px', + width: '100%', }} > @@ -613,13 +615,13 @@ const ChannelCard = ({ )} {channel.ffmpeg_speed && ( - + = 1.0 ? "green" : "red"} > - {channel.ffmpeg_speed}x + {parseFloat(channel.ffmpeg_speed).toFixed(2)}x )} @@ -854,18 +856,15 @@ const ChannelsPage = () => { }, []); setClients(clientStats); }, [channelStats, channels, channelsByUUID, streamProfiles]); - return ( - {Object.keys(activeChannels).length === 0 ? ( { No active channels currently streaming - ) : ( - Object.values(activeChannels).map((channel) => ( - - - - )) + ) : (Object.values(activeChannels).map((channel) => ( + )) )} - +
); }; diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx index 52e79869..765eedc8 100644 --- a/frontend/src/pages/Users.jsx +++ b/frontend/src/pages/Users.jsx @@ -18,6 +18,8 @@ import UserForm from '../components/forms/User'; import useAuthStore from '../store/auth'; import API from '../api'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../constants'; +import ConfirmationDialog from '../components/ConfirmationDialog'; +import useWarningsStore from '../store/warnings'; const UsersPage = () => { const theme = useMantineTheme(); @@ -27,6 +29,12 @@ const UsersPage = () => { const [selectedUser, setSelectedUser] = useState(null); const [userModalOpen, setUserModalOpen] = useState(false); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [userToDelete, setUserToDelete] = useState(null); + + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); console.log(authUser); @@ -34,14 +42,28 @@ const UsersPage = () => { setSelectedUser(null); setUserModalOpen(false); }; - const editUser = (user) => { setSelectedUser(user); setUserModalOpen(true); }; const deleteUser = (id) => { - API.deleteUser(id); + // Get user details for the confirmation dialog + const user = users.find((u) => u.id === id); + setUserToDelete(user); + setDeleteTarget(id); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-user')) { + return executeDeleteUser(id); + } + + setConfirmDeleteOpen(true); + }; + + const executeDeleteUser = async (id) => { + await API.deleteUser(id); + setConfirmDeleteOpen(false); }; return ( @@ -118,13 +140,38 @@ const UsersPage = () => { })} -
- - + + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteUser(deleteTarget)} + title="Confirm User Deletion" + message={ + userToDelete ? ( +
+ {`Are you sure you want to delete the following user? + +Username: ${userToDelete.username} +Email: ${userToDelete.email} +User Level: ${USER_LEVEL_LABELS[userToDelete.user_level]} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this user? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-user" + onSuppressChange={suppressWarning} + size="md" + /> ); }; diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 54b1b20f..beb62fe1 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -152,14 +152,18 @@ const useChannelsStore = create((set, get) => ({ })), updateChannels: (channels) => { - const channelsByUUID = {}; + // Ensure channels is an array + if (!Array.isArray(channels)) { + console.error('updateChannels expects an array, received:', typeof channels, channels); + return; + } const channelsByUUID = {}; const updatedChannels = channels.reduce((acc, chan) => { - channelsByUUID[chan.uuid] = chan; + channelsByUUID[chan.uuid] = chan.id; acc[chan.id] = chan; return acc; }, {}); - return set((state) => ({ + set((state) => ({ channels: { ...state.channels, ...updatedChannels,