mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge pull request #228 from Dispatcharr/dev
Dispatcharr Release Notes - v0.6.1
This commit is contained in:
commit
8dc6b12e8b
21 changed files with 996 additions and 567 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 version="1.0" encoding="UTF-8"?>')
|
||||
xml_lines.append(
|
||||
'<tv generator-info-name="Dispatcharr" generator-info-url="https://github.com/Dispatcharr/Dispatcharr">'
|
||||
)
|
||||
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 version="1.0" encoding="UTF-8"?>')
|
||||
xml_lines.append(
|
||||
'<tv generator-info-name="Dispatcharr" generator-info-url="https://github.com/Dispatcharr/Dispatcharr">'
|
||||
)
|
||||
|
||||
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' <channel id="{channel_id}">')
|
||||
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
|
||||
xml_lines.append(f' <icon src="{html.escape(tvg_logo)}" />')
|
||||
# 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(" </channel>")
|
||||
# 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 <channel> 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' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">')
|
||||
xml_lines.append(f' <title>{html.escape(prog.title)}</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" <sub-title>{html.escape(prog.sub_title)}</sub-title>"
|
||||
display_name = channel.epg_data.name if channel.epg_data else channel.name
|
||||
xml_lines.append(f' <channel id="{channel_id}">')
|
||||
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
|
||||
xml_lines.append(f' <icon src="{html.escape(tvg_logo)}" />')
|
||||
xml_lines.append(" </channel>")
|
||||
|
||||
# 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' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">\n'
|
||||
yield f" <title>{html.escape(program['title'])}</title>\n"
|
||||
yield f" <desc>{html.escape(program['description'])}</desc>\n"
|
||||
yield f" </programme>\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" <desc>{html.escape(prog.description)}</desc>"
|
||||
)
|
||||
# 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' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">']
|
||||
program_xml.append(f' <title>{html.escape(prog.title)}</title>')
|
||||
|
||||
# Add categories if available
|
||||
if "categories" in custom_data and custom_data["categories"]:
|
||||
for category in custom_data["categories"]:
|
||||
xml_lines.append(
|
||||
f" <category>{html.escape(category)}</category>"
|
||||
# Add subtitle if available
|
||||
if prog.sub_title:
|
||||
program_xml.append(f" <sub-title>{html.escape(prog.sub_title)}</sub-title>")
|
||||
|
||||
# Add description if available
|
||||
if prog.description:
|
||||
program_xml.append(f" <desc>{html.escape(prog.description)}</desc>")
|
||||
|
||||
# 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" <category>{html.escape(category)}</category>")
|
||||
|
||||
# Handle episode numbering - multiple formats supported
|
||||
# Standard episode number if available
|
||||
if "episode" in custom_data:
|
||||
program_xml.append(f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>')
|
||||
|
||||
# Handle onscreen episode format (like S06E128)
|
||||
if "onscreen_episode" in custom_data:
|
||||
program_xml.append(f' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>')
|
||||
|
||||
# Handle dd_progid format
|
||||
if 'dd_progid' in custom_data:
|
||||
program_xml.append(f' <episode-num system="dd_progid">{html.escape(custom_data["dd_progid"])}</episode-num>')
|
||||
|
||||
# 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' <episode-num system="xmltv_ns">{season}.{episode}.</episode-num>')
|
||||
|
||||
# Handle episode numbering - multiple formats supported
|
||||
# Standard episode number if available
|
||||
if "episode" in custom_data:
|
||||
xml_lines.append(
|
||||
f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>'
|
||||
)
|
||||
# Add rating if available
|
||||
if "rating" in custom_data:
|
||||
rating_system = custom_data.get("rating_system", "TV Parental Guidelines")
|
||||
program_xml.append(f' <rating system="{html.escape(rating_system)}">')
|
||||
program_xml.append(f' <value>{html.escape(custom_data["rating"])}</value>')
|
||||
program_xml.append(f" </rating>")
|
||||
|
||||
# Handle onscreen episode format (like S06E128)
|
||||
if "onscreen_episode" in custom_data:
|
||||
xml_lines.append(
|
||||
f' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>'
|
||||
)
|
||||
# Add actors/directors/writers if available
|
||||
if "credits" in custom_data:
|
||||
program_xml.append(f" <credits>")
|
||||
for role, people in custom_data["credits"].items():
|
||||
if isinstance(people, list):
|
||||
for person in people:
|
||||
program_xml.append(f" <{role}>{html.escape(person)}</{role}>")
|
||||
else:
|
||||
program_xml.append(f" <{role}>{html.escape(people)}</{role}>")
|
||||
program_xml.append(f" </credits>")
|
||||
|
||||
# Handle dd_progid format
|
||||
if 'dd_progid' in custom_data:
|
||||
xml_lines.append(f' <episode-num system="dd_progid">{html.escape(custom_data["dd_progid"])}</episode-num>')
|
||||
# Add program date/year if available
|
||||
if "year" in custom_data:
|
||||
program_xml.append(f' <date>{html.escape(custom_data["year"])}</date>')
|
||||
|
||||
# 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' <episode-num system="xmltv_ns">{season}.{episode}.</episode-num>'
|
||||
)
|
||||
# Add country if available
|
||||
if "country" in custom_data:
|
||||
program_xml.append(f' <country>{html.escape(custom_data["country"])}</country>')
|
||||
|
||||
# Add rating if available
|
||||
if "rating" in custom_data:
|
||||
rating_system = custom_data.get(
|
||||
"rating_system", "TV Parental Guidelines"
|
||||
)
|
||||
xml_lines.append(
|
||||
f' <rating system="{html.escape(rating_system)}">'
|
||||
)
|
||||
xml_lines.append(
|
||||
f' <value>{html.escape(custom_data["rating"])}</value>'
|
||||
)
|
||||
xml_lines.append(f" </rating>")
|
||||
# Add icon if available
|
||||
if "icon" in custom_data:
|
||||
program_xml.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
|
||||
|
||||
# Add actors/directors/writers if available
|
||||
if "credits" in custom_data:
|
||||
xml_lines.append(f" <credits>")
|
||||
for role, people in custom_data["credits"].items():
|
||||
if isinstance(people, list):
|
||||
for person in people:
|
||||
xml_lines.append(
|
||||
f" <{role}>{html.escape(person)}</{role}>"
|
||||
)
|
||||
else:
|
||||
xml_lines.append(
|
||||
f" <{role}>{html.escape(people)}</{role}>"
|
||||
)
|
||||
xml_lines.append(f" </credits>")
|
||||
# Add special flags as proper tags
|
||||
if custom_data.get("previously_shown", False):
|
||||
program_xml.append(f" <previously-shown />")
|
||||
|
||||
# Add program date/year if available
|
||||
if "year" in custom_data:
|
||||
xml_lines.append(
|
||||
f' <date>{html.escape(custom_data["year"])}</date>'
|
||||
)
|
||||
if custom_data.get("premiere", False):
|
||||
program_xml.append(f" <premiere />")
|
||||
|
||||
# Add country if available
|
||||
if "country" in custom_data:
|
||||
xml_lines.append(
|
||||
f' <country>{html.escape(custom_data["country"])}</country>'
|
||||
)
|
||||
if custom_data.get("new", False):
|
||||
program_xml.append(f" <new />")
|
||||
|
||||
# Add icon if available
|
||||
if "icon" in custom_data:
|
||||
xml_lines.append(
|
||||
f' <icon src="{html.escape(custom_data["icon"])}" />'
|
||||
)
|
||||
if custom_data.get('live', False):
|
||||
program_xml.append(f' <live />')
|
||||
|
||||
# Add special flags as proper tags
|
||||
if custom_data.get("previously_shown", False):
|
||||
xml_lines.append(f" <previously-shown />")
|
||||
except Exception as e:
|
||||
program_xml.append(f" <!-- Error parsing custom properties: {html.escape(str(e))} -->")
|
||||
|
||||
if custom_data.get("premiere", False):
|
||||
xml_lines.append(f" <premiere />")
|
||||
program_xml.append(" </programme>")
|
||||
|
||||
if custom_data.get("new", False):
|
||||
xml_lines.append(f" <new />")
|
||||
# Add to batch
|
||||
program_batch.extend(program_xml)
|
||||
|
||||
if custom_data.get('live', False):
|
||||
xml_lines.append(f' <live />')
|
||||
# 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" <!-- Error parsing custom properties: {html.escape(str(e))} -->"
|
||||
)
|
||||
# Send remaining programs in batch
|
||||
if program_batch:
|
||||
yield '\n'.join(program_batch) + '\n'
|
||||
|
||||
xml_lines.append(" </programme>")
|
||||
|
||||
xml_lines.append("</tv>")
|
||||
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 "</tv>\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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)' && (
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedChannelGroup('-1');
|
||||
form.setValues({ channel_group: '(no change)' });
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
|
|
@ -244,7 +278,10 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
|
|||
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 }) => {
|
|||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" variant="default" disabled={form.submitting}>
|
||||
<Button type="submit" variant="default" disabled={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
|
||||
<M3UProfile
|
||||
</Modal> <M3UProfile
|
||||
m3u={playlist}
|
||||
profile={profile}
|
||||
isOpen={profileEditorOpen}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() => executeDeleteProfile(deleteTarget)}
|
||||
title="Confirm Profile Deletion"
|
||||
message={
|
||||
profileToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to delete the following profile?
|
||||
|
||||
Name: ${profileToDelete.name}
|
||||
Max Streams: ${profileToDelete.max_streams}
|
||||
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
) : (
|
||||
'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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
</Text>
|
||||
|
||||
<Group gap={5} style={{ paddingLeft: 10 }}>
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover withArrow shadow="md" zIndex={1000} position="bottom-start" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<Tv2 size={18} />}
|
||||
|
|
@ -879,7 +947,14 @@ const ChannelsTable = ({}) => {
|
|||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<Group
|
||||
gap="sm"
|
||||
style={{
|
||||
minWidth: 250,
|
||||
maxWidth: 'min(400px, 80vw)',
|
||||
width: 'max-content'
|
||||
}}
|
||||
>
|
||||
<TextInput value={hdhrUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyHDHRUrl}
|
||||
|
|
@ -892,8 +967,7 @@ const ChannelsTable = ({}) => {
|
|||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover withArrow shadow="md" zIndex={1000} position="bottom-start" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<ScreenShare size={18} />}
|
||||
|
|
@ -909,21 +983,72 @@ const ChannelsTable = ({}) => {
|
|||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput value={m3uUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyM3UUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Stack
|
||||
gap="sm"
|
||||
style={{
|
||||
minWidth: 300,
|
||||
maxWidth: 'min(500px, 90vw)',
|
||||
width: 'max-content'
|
||||
}}
|
||||
onClick={stopPropagation}
|
||||
onMouseDown={stopPropagation}
|
||||
>
|
||||
<TextInput
|
||||
value={buildM3UUrl()}
|
||||
size="xs"
|
||||
readOnly
|
||||
label="Generated URL"
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
onClick={copyM3UUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="16" />
|
||||
</ActionIcon>
|
||||
}
|
||||
/><Group justify="space-between">
|
||||
<Text size="sm">Use cached logos</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={m3uParams.cachedlogos}
|
||||
onChange={(event) => setM3uParams(prev => ({
|
||||
...prev,
|
||||
cachedlogos: event.target.checked
|
||||
}))}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Direct stream URLs</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={m3uParams.direct}
|
||||
onChange={(event) => setM3uParams(prev => ({
|
||||
...prev,
|
||||
direct: event.target.checked
|
||||
}))}
|
||||
/>
|
||||
</Group> <Select
|
||||
label="TVG-ID Source"
|
||||
size="xs"
|
||||
value={m3uParams.tvg_id_source}
|
||||
onChange={(value) => setM3uParams(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' }
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover withArrow shadow="md" zIndex={1000} position="bottom-start" withinPortal>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<Scroll size={18} />}
|
||||
|
|
@ -940,17 +1065,70 @@ const ChannelsTable = ({}) => {
|
|||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput value={epgUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyEPGUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Stack
|
||||
gap="sm"
|
||||
style={{
|
||||
minWidth: 300,
|
||||
maxWidth: 'min(450px, 85vw)',
|
||||
width: 'max-content'
|
||||
}}
|
||||
onClick={stopPropagation}
|
||||
onMouseDown={stopPropagation}
|
||||
>
|
||||
<TextInput
|
||||
value={buildEPGUrl()}
|
||||
size="xs"
|
||||
readOnly
|
||||
label="Generated URL"
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
onClick={copyEPGUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="16" />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Use cached logos</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={epgParams.cachedlogos}
|
||||
onChange={(event) => setEpgParams(prev => ({
|
||||
...prev,
|
||||
cachedlogos: event.target.checked
|
||||
}))}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
label="TVG-ID Source"
|
||||
size="xs"
|
||||
value={epgParams.tvg_id_source}
|
||||
onChange={(value) => 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' }
|
||||
]}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Days (0 = all data)"
|
||||
size="xs"
|
||||
min={0}
|
||||
max={365}
|
||||
value={epgParams.days}
|
||||
onChange={(value) => setEpgParams(prev => ({
|
||||
...prev,
|
||||
days: value || 0
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
|
|
@ -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)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import useChannelsStore from '../../../store/channels';
|
|||
import useAuthStore from '../../../store/auth';
|
||||
import { USER_LEVELS } from '../../../constants';
|
||||
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
|
||||
import ConfirmationDialog from '../../ConfirmationDialog';
|
||||
import useWarningsStore from '../../../store/warnings';
|
||||
|
||||
const CreateProfilePopover = React.memo(() => {
|
||||
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={<SquarePen size={18} />}
|
||||
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}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteProfileOpen}
|
||||
onClose={() => setConfirmDeleteProfileOpen(false)}
|
||||
onConfirm={() => executeDeleteProfile(profileToDelete?.id)}
|
||||
title="Confirm Profile Deletion"
|
||||
message={
|
||||
profileToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to delete the following profile?
|
||||
|
||||
Name: ${profileToDelete.name}
|
||||
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
) : (
|
||||
'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"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editEPG()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add EPG
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
|
|
@ -533,29 +549,10 @@ const EPGsTable = () => {
|
|||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
padding: 0,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Tooltip label="Assign">
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editEPG()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add EPG
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
|
|
@ -563,14 +560,14 @@ const EPGsTable = () => {
|
|||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(40vh - 10px)',
|
||||
height: 'calc(40vh - 15px)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
overflowX: 'auto',
|
||||
border: 'solid 1px rgb(68,68,68)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
}}
|
||||
|
|
@ -593,15 +590,14 @@ const EPGsTable = () => {
|
|||
|
||||
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.`}
|
||||
|
|
|
|||
|
|
@ -803,7 +803,7 @@ const M3UTable = () => {
|
|||
return (
|
||||
<Box>
|
||||
<Flex
|
||||
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10 }}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
|
|
@ -820,6 +820,21 @@ const M3UTable = () => {
|
|||
>
|
||||
M3U Accounts
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={14} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editPlaylist()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add M3U
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
|
|
@ -835,29 +850,10 @@ const M3UTable = () => {
|
|||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
padding: 0,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Tooltip label="Assign">
|
||||
<Button
|
||||
leftSection={<SquarePlus size={14} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editPlaylist()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add M3U
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
|
|
@ -865,14 +861,14 @@ const M3UTable = () => {
|
|||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(40vh - 10px)',
|
||||
height: 'calc(40vh - 15px)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
overflowX: 'auto',
|
||||
border: 'solid 1px rgb(68,68,68)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ const StreamProfiles = () => {
|
|||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 50,
|
||||
size: 60,
|
||||
cell: ({ row, cell }) => (
|
||||
<Center>
|
||||
<Switch
|
||||
|
|
@ -137,7 +137,7 @@ const StreamProfiles = () => {
|
|||
{
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<CustomTable table={table} />
|
||||
<div style={{ minWidth: 600 }}>
|
||||
<CustomTable table={table} />
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ const StreamRowActions = ({
|
|||
);
|
||||
};
|
||||
|
||||
const StreamsTable = ({}) => {
|
||||
const StreamsTable = ({ }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
/**
|
||||
|
|
@ -653,7 +653,7 @@ const StreamsTable = ({}) => {
|
|||
|
||||
<Paper
|
||||
style={{
|
||||
height: 'calc(100vh - 75px)',
|
||||
height: 'calc(100vh - 60px)',
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
|
|
@ -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)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ const UserAgentsTable = () => {
|
|||
accessorKey: 'is_active',
|
||||
sortingFn: 'basic',
|
||||
enableSorting: false,
|
||||
size: 60,
|
||||
cell: ({ cell }) => (
|
||||
<Center>
|
||||
{cell.getValue() ? <Check color="green" /> : <X color="red" />}
|
||||
|
|
@ -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',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
overflowX: 'auto',
|
||||
border: 'solid 1px rgb(68,68,68)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
}}
|
||||
>
|
||||
<CustomTable table={table} />
|
||||
<div style={{ minWidth: 500 }}>
|
||||
<CustomTable table={table} />
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ const ChannelsPage = () => {
|
|||
if (!authUser.id) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (authUser.user_level <= USER_LEVELS.STANDARD) {
|
||||
return (
|
||||
<Box style={{ padding: 10 }}>
|
||||
|
|
@ -22,17 +21,22 @@ const ChannelsPage = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100%', display: 'flex' }}>
|
||||
<div style={{ height: '100vh', width: '100%', display: 'flex', overflowX: 'auto' }}>
|
||||
<Allotment
|
||||
defaultSizes={[50, 50]}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
style={{ height: '100%', width: '100%', minWidth: '600px' }}
|
||||
className="custom-allotment"
|
||||
minSize={100}
|
||||
>
|
||||
<div style={{ padding: 10 }}>
|
||||
<ChannelsTable />
|
||||
<div style={{ padding: 10, overflowX: 'auto', minWidth: '100px' }}>
|
||||
<div style={{ minWidth: '600px' }}>
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 10 }}>
|
||||
<StreamsTable />
|
||||
<div style={{ padding: 10, overflowX: 'auto', minWidth: '100px' }}>
|
||||
<div style={{ minWidth: '600px' }}>
|
||||
<StreamsTable />
|
||||
</div>
|
||||
</div>
|
||||
</Allotment>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
return (
|
||||
if (error) return <div>Error: {error}</div>; return (
|
||||
<Stack
|
||||
style={{
|
||||
padding: 10,
|
||||
height: 'calc(100vh - 60px)', // Set a specific height to ensure proper display
|
||||
height: '100%', // Set a specific height to ensure proper display
|
||||
minWidth: '1100px', // Prevent tables from becoming too cramped
|
||||
overflowX: 'auto', // Enable horizontal scrolling when needed
|
||||
overflowY: 'auto', // Enable vertical scrolling on the container
|
||||
}}
|
||||
spacing="xs" // Reduce spacing to give tables more room
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box
|
||||
className="guide-program-container"
|
||||
|
|
@ -588,7 +597,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Box
|
||||
style={{
|
||||
transform: `translateX(${textOffsetLeft}px)`,
|
||||
transition: 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
component="div"
|
||||
size={isExpanded ? 'lg' : 'md'}
|
||||
|
|
@ -624,23 +638,28 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
>
|
||||
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Description is always shown but expands when row is expanded */}
|
||||
</Box> {/* Description is always shown but expands when row is expanded */}
|
||||
{program.description && (
|
||||
<Text
|
||||
size="xs"
|
||||
<Box
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
whiteSpace: isExpanded ? 'normal' : 'nowrap',
|
||||
textOverflow: isExpanded ? 'clip' : 'ellipsis',
|
||||
overflow: isExpanded ? 'auto' : 'hidden',
|
||||
color: isPast ? '#718096' : '#cbd5e0',
|
||||
maxHeight: isExpanded ? '80px' : 'unset',
|
||||
transform: `translateX(${textOffsetLeft}px)`,
|
||||
transition: 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
{program.description}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
whiteSpace: isExpanded ? 'normal' : 'nowrap',
|
||||
textOverflow: isExpanded ? 'clip' : 'ellipsis',
|
||||
overflow: isExpanded ? 'auto' : 'hidden',
|
||||
color: isPast ? '#718096' : '#cbd5e0',
|
||||
maxHeight: isExpanded ? '80px' : 'unset',
|
||||
}}
|
||||
>
|
||||
{program.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 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') && (
|
||||
<Button variant="subtle" onClick={clearFilters} size="sm" compact>
|
||||
<Button variant="subtle" onClick={clearFilters} size="sm">
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -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 (
|
||||
<Box
|
||||
key={time.format()}
|
||||
return (
|
||||
<Box
|
||||
key={time.format()}
|
||||
style={{
|
||||
width: HOUR_WIDTH,
|
||||
height: '40px',
|
||||
position: 'relative',
|
||||
color: '#a0aec0',
|
||||
borderRight: '1px solid #8DAFAA',
|
||||
cursor: 'pointer',
|
||||
borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries
|
||||
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days
|
||||
}}
|
||||
onClick={(e) => 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 */}
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
width: HOUR_WIDTH,
|
||||
height: '40px',
|
||||
position: 'relative',
|
||||
color: '#a0aec0',
|
||||
borderRight: '1px solid #8DAFAA',
|
||||
cursor: 'pointer',
|
||||
borderLeft: isNewDay ? '2px solid #3BA882' : 'none', // Highlight day boundaries
|
||||
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421', // Subtle background for new days
|
||||
position: 'absolute',
|
||||
top: '8px', // Consistent positioning for all hours
|
||||
left: '4px',
|
||||
transform: 'none',
|
||||
borderRadius: '2px',
|
||||
lineHeight: 1.2,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onClick={(e) => 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 */}
|
||||
<Text
|
||||
size="sm"
|
||||
span
|
||||
size="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px', // Consistent positioning for all hours
|
||||
left: '4px',
|
||||
transform: 'none',
|
||||
borderRadius: '2px',
|
||||
lineHeight: 1.2,
|
||||
textAlign: 'left',
|
||||
display: 'block',
|
||||
opacity: 0.7,
|
||||
fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions
|
||||
color: isNewDay ? '#3BA882' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Show day above time for every hour using the same format */}
|
||||
<Text
|
||||
span
|
||||
size="xs"
|
||||
style={{
|
||||
display: 'block',
|
||||
opacity: 0.7,
|
||||
fontWeight: isNewDay ? 600 : 400, // Still emphasize day transitions
|
||||
color: isNewDay ? '#3BA882' : undefined,
|
||||
}}
|
||||
>
|
||||
{formatDayLabel(time)}{' '}
|
||||
{/* Use same formatDayLabel function for all hours */}
|
||||
</Text>
|
||||
{time.format('h:mm')}
|
||||
<Text span size="xs" ml={1} opacity={0.7}>
|
||||
{time.format('A')}
|
||||
</Text>
|
||||
{formatDayLabel(time)}{' '}
|
||||
{/* Use same formatDayLabel function for all hours */}
|
||||
</Text>
|
||||
{time.format('h:mm')}
|
||||
<Text span size="xs" ml={1} opacity={0.7}>
|
||||
{time.format('A')}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{/* Hour boundary marker - more visible */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '1px',
|
||||
backgroundColor: '#27272A',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
{/* Hour boundary marker - more visible */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '1px',
|
||||
backgroundColor: '#27272A',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Quarter hour tick marks */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 1px',
|
||||
}}
|
||||
>
|
||||
{[15, 30, 45].map((minute) => (
|
||||
<Box
|
||||
key={minute}
|
||||
style={{
|
||||
width: '1px',
|
||||
height: '8px',
|
||||
backgroundColor: '#718096',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: `${(minute / 60) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{/* Quarter hour tick marks */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 1px',
|
||||
}}
|
||||
>
|
||||
{[15, 30, 45].map((minute) => (
|
||||
<Box
|
||||
key={minute}
|
||||
style={{
|
||||
width: '1px',
|
||||
height: '8px',
|
||||
backgroundColor: '#718096',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: `${(minute / 60) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ const SettingsPage = () => {
|
|||
variant="separated"
|
||||
defaultValue="ui-settings"
|
||||
onChange={setAccordianValue}
|
||||
style={{ minWidth: 400 }}
|
||||
>
|
||||
<Accordion.Item value="ui-settings">
|
||||
<Accordion.Control>UI Settings</Accordion.Control>
|
||||
|
|
|
|||
|
|
@ -480,6 +480,8 @@ const ChannelCard = ({
|
|||
style={{
|
||||
color: '#fff',
|
||||
backgroundColor: '#27272A',
|
||||
maxWidth: '700px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack style={{ position: 'relative' }}>
|
||||
|
|
@ -613,13 +615,13 @@ const ChannelCard = ({
|
|||
</Tooltip>
|
||||
)}
|
||||
{channel.ffmpeg_speed && (
|
||||
<Tooltip label={`Current Speed: ${channel.ffmpeg_speed}x`}>
|
||||
<Tooltip label={`Current Speed: ${parseFloat(channel.ffmpeg_speed).toFixed(2)}x`}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={parseFloat(channel.ffmpeg_speed) >= 1.0 ? "green" : "red"}
|
||||
>
|
||||
{channel.ffmpeg_speed}x
|
||||
{parseFloat(channel.ffmpeg_speed).toFixed(2)}x
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -854,18 +856,15 @@ const ChannelsPage = () => {
|
|||
}, []);
|
||||
setClients(clientStats);
|
||||
}, [channelStats, channels, channelsByUUID, streamProfiles]);
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 1, md: 2, lg: 3, xl: 3 }}
|
||||
spacing="md"
|
||||
style={{ padding: 10 }}
|
||||
breakpoints={[
|
||||
{ maxWidth: '72rem', cols: 2, spacing: 'md' },
|
||||
{ maxWidth: '48rem', cols: 1, spacing: 'md' },
|
||||
]}
|
||||
verticalSpacing="lg"
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '1rem',
|
||||
padding: '10px',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(500px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{Object.keys(activeChannels).length === 0 ? (
|
||||
<Box
|
||||
|
|
@ -879,24 +878,18 @@ const ChannelsPage = () => {
|
|||
No active channels currently streaming
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
Object.values(activeChannels).map((channel) => (
|
||||
<Box
|
||||
key={channel.channel_id}
|
||||
style={{ minWidth: '420px', width: '100%' }}
|
||||
>
|
||||
<ChannelCard
|
||||
channel={channel}
|
||||
clients={clients}
|
||||
stopClient={stopClient}
|
||||
stopChannel={stopChannel}
|
||||
logos={logos}
|
||||
channelsByUUID={channelsByUUID}
|
||||
/>
|
||||
</Box>
|
||||
))
|
||||
) : (Object.values(activeChannels).map((channel) => (
|
||||
<ChannelCard
|
||||
key={channel.channel_id}
|
||||
channel={channel}
|
||||
clients={clients}
|
||||
stopClient={stopClient}
|
||||
stopChannel={stopChannel}
|
||||
logos={logos}
|
||||
channelsByUUID={channelsByUUID}
|
||||
/>))
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
<UserForm
|
||||
</Center> <UserForm
|
||||
user={selectedUser}
|
||||
isOpen={userModalOpen}
|
||||
onClose={closeUserModal}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() => executeDeleteUser(deleteTarget)}
|
||||
title="Confirm User Deletion"
|
||||
message={
|
||||
userToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`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.`}
|
||||
</div>
|
||||
) : (
|
||||
'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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue