Merge pull request #228 from Dispatcharr/dev

Dispatcharr Release Notes - v0.6.1
This commit is contained in:
SergeantPanda 2025-06-27 16:17:29 -05:00 committed by GitHub
commit 8dc6b12e8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 996 additions and 567 deletions

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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);

View file

@ -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>

View file

@ -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"
/>
</>
);
};

View file

@ -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

View file

@ -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>
);
};

View file

@ -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.`}

View file

@ -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)',
}}

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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;
}

View file

@ -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>

View file

@ -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
>

View file

@ -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,

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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"
/>
</>
);
};

View file

@ -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,