diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py
index 3ffb98af..b651081e 100644
--- a/apps/channels/api_views.py
+++ b/apps/channels/api_views.py
@@ -8,7 +8,7 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
-import os, json, requests
+import os, json, requests, logging
from apps.accounts.permissions import (
Authenticated,
IsAdmin,
@@ -48,6 +48,9 @@ import mimetypes
from rest_framework.pagination import PageNumberPagination
+logger = logging.getLogger(__name__)
+
+
class OrInFilter(django_filters.Filter):
"""
Custom filter that handles the OR condition instead of AND.
@@ -275,30 +278,76 @@ class ChannelViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=["patch"], url_path="edit/bulk")
def edit_bulk(self, request):
- data_list = request.data
- if not isinstance(data_list, list):
+ """
+ Bulk edit channels.
+ Expects a list of channels with their updates.
+ """
+ data = request.data
+ if not isinstance(data, list):
return Response(
- {"error": "Expected a list of channel objects objects"},
+ {"error": "Expected a list of channel updates"},
status=status.HTTP_400_BAD_REQUEST,
)
updated_channels = []
- try:
- with transaction.atomic():
- for item in data_list:
- channel = Channel.objects.id(id=item.pop("id"))
- for key, value in item.items():
- setattr(channel, key, value)
+ errors = []
- channel.save(update_fields=item.keys())
- updated_channels.append(channel)
- except Exception as e:
- logger.error("Error during bulk channel edit", e)
- return Response({"error": e}, status=500)
+ for channel_data in data:
+ channel_id = channel_data.get("id")
+ if not channel_id:
+ errors.append({"error": "Channel ID is required"})
+ continue
- response_data = ChannelSerializer(updated_channels, many=True).data
+ try:
+ channel = Channel.objects.get(id=channel_id)
- return Response(response_data, status=status.HTTP_200_OK)
+ # Handle channel_group_id properly - convert string to integer if needed
+ if 'channel_group_id' in channel_data:
+ group_id = channel_data['channel_group_id']
+ if group_id is not None:
+ try:
+ channel_data['channel_group_id'] = int(group_id)
+ except (ValueError, TypeError):
+ channel_data['channel_group_id'] = None
+
+ # Use the serializer to validate and update
+ serializer = ChannelSerializer(
+ channel, data=channel_data, partial=True
+ )
+
+ if serializer.is_valid():
+ updated_channel = serializer.save()
+ updated_channels.append(updated_channel)
+ else:
+ errors.append({
+ "channel_id": channel_id,
+ "errors": serializer.errors
+ })
+
+ except Channel.DoesNotExist:
+ errors.append({
+ "channel_id": channel_id,
+ "error": "Channel not found"
+ })
+ except Exception as e:
+ errors.append({
+ "channel_id": channel_id,
+ "error": str(e)
+ })
+
+ if errors:
+ return Response(
+ {"errors": errors, "updated_count": len(updated_channels)},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Serialize the updated channels for response
+ serialized_channels = ChannelSerializer(updated_channels, many=True).data
+
+ return Response({
+ "message": f"Successfully updated {len(updated_channels)} channels",
+ "channels": serialized_channels
+ })
@action(detail=False, methods=["get"], url_path="ids")
def get_ids(self, request, *args, **kwargs):
@@ -401,6 +450,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
channel_number = float(stream_custom_props["tvg-chno"])
elif "channel-number" in stream_custom_props:
channel_number = float(stream_custom_props["channel-number"])
+ elif "num" in stream_custom_props:
+ channel_number = float(stream_custom_props["num"])
if channel_number is None:
provided_number = request.data.get("channel_number")
@@ -546,6 +597,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
channel_number = float(stream_custom_props["tvg-chno"])
elif "channel-number" in stream_custom_props:
channel_number = float(stream_custom_props["channel-number"])
+ elif "num" in stream_custom_props:
+ channel_number = float(stream_custom_props["num"])
# Get the tvc_guide_stationid from custom properties if it exists
tvc_guide_stationid = None
if "tvc-guide-stationid" in stream_custom_props:
diff --git a/apps/output/views.py b/apps/output/views.py
index 4be0f811..4ef9f4f2 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -1,5 +1,5 @@
import ipaddress
-from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden
+from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse
from rest_framework.response import Response
from django.urls import reverse
from apps.channels.models import Channel, ChannelProfile, ChannelGroup
@@ -12,11 +12,10 @@ from dispatcharr.utils import network_access_allowed
from django.utils import timezone
from django.shortcuts import get_object_or_404
from datetime import datetime, timedelta
-import re
import html # Add this import for XML escaping
+import json # Add this import for JSON parsing
+import time # Add this import for keep-alive delays
from tzlocal import get_localzone
-import time
-import json
from urllib.parse import urlparse
import base64
@@ -278,118 +277,76 @@ def generate_dummy_epg(
def generate_epg(request, profile_name=None, user=None):
"""
- Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models.
+ Dynamically generate an XMLTV (EPG) file using streaming response to handle keep-alives.
Since the EPG data is stored independently of Channels, we group programmes
by their associated EPGData record.
- This version filters data based on the 'days' parameter.
+ This version filters data based on the 'days' parameter and sends keep-alives during processing.
"""
- xml_lines = []
- xml_lines.append('')
- xml_lines.append(
- ''
- )
+ def epg_generator():
+ """Generator function that yields EPG data with keep-alives during processing""" # Send initial HTTP headers as comments (these will be ignored by XML parsers but keep connection alive)
- if user is not None:
- if user.user_level == 0:
- filters = {
- "channelprofilemembership__enabled": True,
- "user_level__lte": user.user_level,
- }
+ xml_lines = []
+ xml_lines.append('')
+ xml_lines.append(
+ ''
+ )
- if user.channel_profiles.count() != 0:
- channel_profiles = user.channel_profiles.all()
- filters["channelprofilemembership__channel_profile__in"] = (
- channel_profiles
+ # Get channels based on user/profile
+ if user is not None:
+ if user.user_level == 0:
+ filters = {
+ "channelprofilemembership__enabled": True,
+ "user_level__lte": user.user_level,
+ }
+
+ if user.channel_profiles.count() != 0:
+ channel_profiles = user.channel_profiles.all()
+ filters["channelprofilemembership__channel_profile__in"] = (
+ channel_profiles
+ )
+
+ channels = Channel.objects.filter(**filters).order_by("channel_number")
+ else:
+ channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
+ "channel_number"
)
-
- channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
- channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
- "channel_number"
- )
- else:
- if profile_name is not None:
- channel_profile = ChannelProfile.objects.get(name=profile_name)
- channels = Channel.objects.filter(
- channelprofilemembership__channel_profile=channel_profile,
- channelprofilemembership__enabled=True,
- )
- else:
- channels = Channel.objects.all()
-
- # Check if the request wants to use direct logo URLs instead of cache
- use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false'
-
- # Get the source to use for tvg-id value
- # Options: 'channel_number' (default), 'tvg_id', 'gracenote'
- tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower()
-
- # Get the number of days for EPG data
- try:
- # Default to 0 days (everything) for real EPG if not specified
- days_param = request.GET.get('days', '0')
- num_days = int(days_param)
- # Set reasonable limits
- num_days = max(0, min(num_days, 365)) # Between 0 and 365 days
- except ValueError:
- num_days = 0 # Default to all data if invalid value
-
- # For dummy EPG, use either the specified value or default to 3 days
- dummy_days = num_days if num_days > 0 else 3
-
- # Calculate cutoff date for EPG data filtering (only if days > 0)
- now = timezone.now()
- cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None
-
- # Retrieve all active channels
- for channel in channels:
- # Format channel number as integer if it has no decimal component - same as M3U generation
- if channel.channel_number is not None:
- if channel.channel_number == int(channel.channel_number):
- formatted_channel_number = int(channel.channel_number)
+ if profile_name is not None:
+ channel_profile = ChannelProfile.objects.get(name=profile_name)
+ channels = Channel.objects.filter(
+ channelprofilemembership__channel_profile=channel_profile,
+ channelprofilemembership__enabled=True,
+ )
else:
- formatted_channel_number = channel.channel_number
- else:
- formatted_channel_number = ""
+ channels = Channel.objects.all()
- # Determine the channel ID based on the selected source
- if tvg_id_source == 'tvg_id' and channel.tvg_id:
- channel_id = channel.tvg_id
- elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid:
- channel_id = channel.tvc_guide_stationid
- else:
- # Default to channel number (original behavior)
- channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
+ # Check if the request wants to use direct logo URLs instead of cache
+ use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false'
- # Add channel logo if available
- tvg_logo = ""
- if channel.logo:
- if use_cached_logos:
- # Use cached logo as before
- tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
- else:
- # Try to find direct logo URL from channel's streams
- direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None
- # If direct logo found, use it; otherwise fall back to cached version
- if direct_logo:
- tvg_logo = direct_logo
- else:
- tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
- display_name = channel.epg_data.name if channel.epg_data else channel.name
- xml_lines.append(f' ')
- xml_lines.append(f' {html.escape(display_name)}')
- xml_lines.append(f' ')
+ # Get the source to use for tvg-id value
+ # Options: 'channel_number' (default), 'tvg_id', 'gracenote'
+ tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower()
- xml_lines.append(" ")
+ # Get the number of days for EPG data
+ try:
+ # Default to 0 days (everything) for real EPG if not specified
+ days_param = request.GET.get('days', '0')
+ num_days = int(days_param)
+ # Set reasonable limits
+ num_days = max(0, min(num_days, 365)) # Between 0 and 365 days
+ except ValueError:
+ num_days = 0 # Default to all data if invalid value
- for channel in channels:
- # Use the same channel ID determination for program entries
- if tvg_id_source == 'tvg_id' and channel.tvg_id:
- channel_id = channel.tvg_id
- elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid:
- channel_id = channel.tvc_guide_stationid
- else:
- # Get formatted channel number
+ # For dummy EPG, use either the specified value or default to 3 days
+ dummy_days = num_days if num_days > 0 else 3
+
+ # Calculate cutoff date for EPG data filtering (only if days > 0)
+ now = timezone.now()
+ cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None
+
+ # Process channels for the section
+ for channel in channels:
+ # Format channel number as integer if it has no decimal component - same as M3U generation
if channel.channel_number is not None:
if channel.channel_number == int(channel.channel_number):
formatted_channel_number = int(channel.channel_number)
@@ -397,167 +354,214 @@ def generate_epg(request, profile_name=None, user=None):
formatted_channel_number = channel.channel_number
else:
formatted_channel_number = ""
- # Default to channel number
- channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
- display_name = channel.epg_data.name if channel.epg_data else channel.name
- if not channel.epg_data:
- # Use the enhanced dummy EPG generation function with defaults
- program_length_hours = 4 # Default to 4-hour program blocks
- generate_dummy_epg(
- channel_id,
- display_name,
- xml_lines,
- num_days=dummy_days, # Use dummy_days (3 days by default)
- program_length_hours=program_length_hours
- )
- else:
- # For real EPG data - filter only if days parameter was specified
- if num_days > 0:
- programs = channel.epg_data.programs.filter(
- start_time__gte=now,
- start_time__lt=cutoff_date
- )
+ # Determine the channel ID based on the selected source
+ if tvg_id_source == 'tvg_id' and channel.tvg_id:
+ channel_id = channel.tvg_id
+ elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid:
+ channel_id = channel.tvc_guide_stationid
else:
- # Return all programs if days=0 or not specified
- programs = channel.epg_data.programs.all()
+ # Default to channel number (original behavior)
+ channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
- for prog in programs:
- start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
- stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
- xml_lines.append(f' ')
- xml_lines.append(f' {html.escape(prog.title)}')
+ # Add channel logo if available
+ tvg_logo = ""
+ if channel.logo:
+ if use_cached_logos:
+ # Use cached logo as before
+ tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
+ else:
+ # Try to find direct logo URL from channel's streams
+ direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None
+ # If direct logo found, use it; otherwise fall back to cached version
+ if direct_logo:
+ tvg_logo = direct_logo
+ else:
+ tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
- # Add subtitle if available
- if prog.sub_title:
- xml_lines.append(
- f" {html.escape(prog.sub_title)}"
+ display_name = channel.epg_data.name if channel.epg_data else channel.name
+ xml_lines.append(f' ')
+ xml_lines.append(f' {html.escape(display_name)}')
+ xml_lines.append(f' ')
+ xml_lines.append(" ")
+
+ # Send all channel definitions
+ yield '\n'.join(xml_lines) + '\n'
+ xml_lines = [] # Clear to save memory
+
+ # Process programs for each channel
+ for channel in channels:
+
+ # Use the same channel ID determination for program entries
+ if tvg_id_source == 'tvg_id' and channel.tvg_id:
+ channel_id = channel.tvg_id
+ elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid:
+ channel_id = channel.tvc_guide_stationid
+ else:
+ # Get formatted channel number
+ if channel.channel_number is not None:
+ if channel.channel_number == int(channel.channel_number):
+ formatted_channel_number = int(channel.channel_number)
+ else:
+ formatted_channel_number = channel.channel_number
+ else:
+ formatted_channel_number = ""
+ # Default to channel number
+ channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
+
+ display_name = channel.epg_data.name if channel.epg_data else channel.name
+
+ if not channel.epg_data:
+ # Use the enhanced dummy EPG generation function with defaults
+ program_length_hours = 4 # Default to 4-hour program blocks
+ dummy_programs = generate_dummy_programs(channel_id, display_name, num_days=dummy_days, program_length_hours=program_length_hours)
+
+ for program in dummy_programs:
+ # Format times in XMLTV format
+ start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z")
+ stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z")
+
+ # Create program entry with escaped channel name
+ yield f' \n'
+ yield f" {html.escape(program['title'])}\n"
+ yield f" {html.escape(program['description'])}\n"
+ yield f" \n"
+
+ else:
+ # For real EPG data - filter only if days parameter was specified
+ if num_days > 0:
+ programs = channel.epg_data.programs.filter(
+ start_time__gte=now,
+ start_time__lt=cutoff_date
)
+ else:
+ # Return all programs if days=0 or not specified
+ programs = channel.epg_data.programs.all()
- # Add description if available
- if prog.description:
- xml_lines.append(
- f" {html.escape(prog.description)}"
- )
+ # Process programs in chunks to avoid memory issues
+ program_batch = []
+ batch_size = 100
- # Process custom properties if available
- if prog.custom_properties:
- try:
- import json
+ for prog in programs.iterator(): # Use iterator to avoid loading all at once
+ start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
+ stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
- custom_data = json.loads(prog.custom_properties)
+ program_xml = [f' ']
+ program_xml.append(f' {html.escape(prog.title)}')
- # Add categories if available
- if "categories" in custom_data and custom_data["categories"]:
- for category in custom_data["categories"]:
- xml_lines.append(
- f" {html.escape(category)}"
+ # Add subtitle if available
+ if prog.sub_title:
+ program_xml.append(f" {html.escape(prog.sub_title)}")
+
+ # Add description if available
+ if prog.description:
+ program_xml.append(f" {html.escape(prog.description)}")
+
+ # Process custom properties if available
+ if prog.custom_properties:
+ try:
+ custom_data = json.loads(prog.custom_properties)
+
+ # Add categories if available
+ if "categories" in custom_data and custom_data["categories"]:
+ for category in custom_data["categories"]:
+ program_xml.append(f" {html.escape(category)}")
+
+ # Handle episode numbering - multiple formats supported
+ # Standard episode number if available
+ if "episode" in custom_data:
+ program_xml.append(f' E{custom_data["episode"]}')
+
+ # Handle onscreen episode format (like S06E128)
+ if "onscreen_episode" in custom_data:
+ program_xml.append(f' {html.escape(custom_data["onscreen_episode"])}')
+
+ # Handle dd_progid format
+ if 'dd_progid' in custom_data:
+ program_xml.append(f' {html.escape(custom_data["dd_progid"])}')
+
+ # Add season and episode numbers in xmltv_ns format if available
+ if "season" in custom_data and "episode" in custom_data:
+ season = (
+ int(custom_data["season"]) - 1
+ if str(custom_data["season"]).isdigit()
+ else 0
)
+ episode = (
+ int(custom_data["episode"]) - 1
+ if str(custom_data["episode"]).isdigit()
+ else 0
+ )
+ program_xml.append(f' {season}.{episode}.')
- # Handle episode numbering - multiple formats supported
- # Standard episode number if available
- if "episode" in custom_data:
- xml_lines.append(
- f' E{custom_data["episode"]}'
- )
+ # Add rating if available
+ if "rating" in custom_data:
+ rating_system = custom_data.get("rating_system", "TV Parental Guidelines")
+ program_xml.append(f' ')
+ program_xml.append(f' {html.escape(custom_data["rating"])}')
+ program_xml.append(f" ")
- # Handle onscreen episode format (like S06E128)
- if "onscreen_episode" in custom_data:
- xml_lines.append(
- f' {html.escape(custom_data["onscreen_episode"])}'
- )
+ # Add actors/directors/writers if available
+ if "credits" in custom_data:
+ program_xml.append(f" ")
+ for role, people in custom_data["credits"].items():
+ if isinstance(people, list):
+ for person in people:
+ program_xml.append(f" <{role}>{html.escape(person)}{role}>")
+ else:
+ program_xml.append(f" <{role}>{html.escape(people)}{role}>")
+ program_xml.append(f" ")
- # Handle dd_progid format
- if 'dd_progid' in custom_data:
- xml_lines.append(f' {html.escape(custom_data["dd_progid"])}')
+ # Add program date/year if available
+ if "year" in custom_data:
+ program_xml.append(f' {html.escape(custom_data["year"])}')
- # Add season and episode numbers in xmltv_ns format if available
- if "season" in custom_data and "episode" in custom_data:
- season = (
- int(custom_data["season"]) - 1
- if str(custom_data["season"]).isdigit()
- else 0
- )
- episode = (
- int(custom_data["episode"]) - 1
- if str(custom_data["episode"]).isdigit()
- else 0
- )
- xml_lines.append(
- f' {season}.{episode}.'
- )
+ # Add country if available
+ if "country" in custom_data:
+ program_xml.append(f' {html.escape(custom_data["country"])}')
- # Add rating if available
- if "rating" in custom_data:
- rating_system = custom_data.get(
- "rating_system", "TV Parental Guidelines"
- )
- xml_lines.append(
- f' '
- )
- xml_lines.append(
- f' {html.escape(custom_data["rating"])}'
- )
- xml_lines.append(f" ")
+ # Add icon if available
+ if "icon" in custom_data:
+ program_xml.append(f' ')
- # Add actors/directors/writers if available
- if "credits" in custom_data:
- xml_lines.append(f" ")
- for role, people in custom_data["credits"].items():
- if isinstance(people, list):
- for person in people:
- xml_lines.append(
- f" <{role}>{html.escape(person)}{role}>"
- )
- else:
- xml_lines.append(
- f" <{role}>{html.escape(people)}{role}>"
- )
- xml_lines.append(f" ")
+ # Add special flags as proper tags
+ if custom_data.get("previously_shown", False):
+ program_xml.append(f" ")
- # Add program date/year if available
- if "year" in custom_data:
- xml_lines.append(
- f' {html.escape(custom_data["year"])}'
- )
+ if custom_data.get("premiere", False):
+ program_xml.append(f" ")
- # Add country if available
- if "country" in custom_data:
- xml_lines.append(
- f' {html.escape(custom_data["country"])}'
- )
+ if custom_data.get("new", False):
+ program_xml.append(f" ")
- # Add icon if available
- if "icon" in custom_data:
- xml_lines.append(
- f' '
- )
+ if custom_data.get('live', False):
+ program_xml.append(f' ')
- # Add special flags as proper tags
- if custom_data.get("previously_shown", False):
- xml_lines.append(f" ")
+ except Exception as e:
+ program_xml.append(f" ")
- if custom_data.get("premiere", False):
- xml_lines.append(f" ")
+ program_xml.append(" ")
- if custom_data.get("new", False):
- xml_lines.append(f" ")
+ # Add to batch
+ program_batch.extend(program_xml)
- if custom_data.get('live', False):
- xml_lines.append(f' ')
+ # Send batch when full or send keep-alive
+ if len(program_batch) >= batch_size:
+ yield '\n'.join(program_batch) + '\n'
+ program_batch = [] # Send keep-alive every batch
- except Exception as e:
- xml_lines.append(
- f" "
- )
+ # Send remaining programs in batch
+ if program_batch:
+ yield '\n'.join(program_batch) + '\n'
- xml_lines.append(" ")
-
- xml_lines.append("")
- xml_content = "\n".join(xml_lines)
-
- response = HttpResponse(xml_content, content_type="application/xml")
- response["Content-Disposition"] = 'attachment; filename="epg.xml"'
+ # Send final closing tag and completion message
+ yield "\n" # Return streaming response
+ response = StreamingHttpResponse(
+ streaming_content=epg_generator(),
+ content_type="application/xml"
+ )
+ response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"'
+ response["Cache-Control"] = "no-cache"
return response
diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py
index bf5d4981..4699091a 100644
--- a/apps/proxy/ts_proxy/server.py
+++ b/apps/proxy/ts_proxy/server.py
@@ -472,7 +472,7 @@ class ProxyServer:
if b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
active_states = [ChannelState.INITIALIZING, ChannelState.CONNECTING,
- ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE]
+ ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE, ChannelState.BUFFERING]
if state in active_states:
logger.info(f"Channel {channel_id} already being initialized with state {state}")
# Create buffer and client manager only if we don't have them
@@ -689,7 +689,8 @@ class ProxyServer:
owner = metadata.get(b'owner', b'').decode('utf-8')
# States that indicate the channel is running properly
- valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING]
+ valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS,
+ ChannelState.CONNECTING, ChannelState.BUFFERING, ChannelState.INITIALIZING]
# If the channel is in a valid state, check if the owner is still active
if state in valid_states:
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 17c38b90..391eaae9 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -390,9 +390,9 @@ export default class API {
static async updateChannels(ids, values) {
const body = [];
- for (const id in ids) {
+ for (const id of ids) {
body.push({
- id,
+ id: id,
...values,
});
}
@@ -406,7 +406,7 @@ export default class API {
}
);
- useChannelsStore.getState().updateChannels(response);
+ // Don't automatically update the store here - let the caller handle it
return response;
} catch (e) {
errorNotification('Failed to update channels', e);
diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx
index 12ee52b8..2ba3245c 100644
--- a/frontend/src/components/forms/ChannelBatch.jsx
+++ b/frontend/src/components/forms/ChannelBatch.jsx
@@ -35,7 +35,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
- const [selectedChannelGroup, setSelectedChannelGroup] = useState('');
+ const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1');
+ const [isSubmitting, setIsSubmitting] = useState(false);
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
const [groupFilter, setGroupFilter] = useState('');
@@ -44,34 +45,51 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
const form = useForm({
mode: 'uncontrolled',
initialValues: {
- channel_group: '',
- stream_profile_id: '0',
+ channel_group: '(no change)',
+ stream_profile_id: '-1',
user_level: '-1',
},
});
const onSubmit = async () => {
+ setIsSubmitting(true);
+
const values = {
...form.getValues(),
- channel_group_id: selectedChannelGroup,
- };
-
- if (!values.stream_profile_id || values.stream_profile_id === '0') {
- values.stream_profile_id = null;
+ }; // Handle channel group ID - convert to integer if it exists
+ if (selectedChannelGroup && selectedChannelGroup !== '-1') {
+ values.channel_group_id = parseInt(selectedChannelGroup);
+ } else {
+ delete values.channel_group_id;
}
- if (!values.channel_group_id) {
- delete values.channel_group_id;
+ // Handle stream profile ID - convert special values
+ if (!values.stream_profile_id || values.stream_profile_id === '-1') {
+ delete values.stream_profile_id;
+ } else if (values.stream_profile_id === '0' || values.stream_profile_id === 0) {
+ values.stream_profile_id = null; // Convert "use default" to null
}
if (values.user_level == '-1') {
delete values.user_level;
}
- await API.batchUpdateChannels({
- ids: channelIds,
- values,
- });
+ // Remove the channel_group field from form values as we use channel_group_id
+ delete values.channel_group;
+
+ try {
+ await API.updateChannels(channelIds, values);
+ // Refresh both the channels table data and the main channels store
+ await Promise.all([
+ API.requeryChannels(),
+ useChannelsStore.getState().fetchChannels()
+ ]);
+ onClose();
+ } catch (error) {
+ console.error('Failed to update channels:', error);
+ } finally {
+ setIsSubmitting(false);
+ }
};
// useEffect(() => {
@@ -107,10 +125,12 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
});
}
};
-
- const filteredGroups = groupOptions.filter((group) =>
- group.name.toLowerCase().includes(groupFilter.toLowerCase())
- );
+ const filteredGroups = [
+ { id: '-1', name: '(no change)' },
+ ...groupOptions.filter((group) =>
+ group.name.toLowerCase().includes(groupFilter.toLowerCase())
+ )
+ ];
if (!isOpen) {
return <>>;
@@ -150,7 +170,21 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
key={form.key('channel_group')}
onClick={() => setGroupPopoverOpened(true)}
size="xs"
- style={{ flex: 1 }}
+ style={{ flex: 1 }} rightSection={
+ form.getValues().channel_group && form.getValues().channel_group !== '(no change)' && (
+ {
+ e.stopPropagation();
+ setSelectedChannelGroup('-1');
+ form.setValues({ channel_group: '(no change)' });
+ }}
+ >
+
+
+ )
+ }
/>
{
name="stream_profile_id"
{...form.getInputProps('stream_profile_id')}
key={form.key('stream_profile_id')}
- data={[{ value: '0', label: '(use default)' }].concat(
+ data={[
+ { value: '-1', label: '(no change)' },
+ { value: '0', label: '(use default)' }
+ ].concat(
streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
@@ -264,7 +301,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
label: '(no change)',
},
].concat(
- Object.entries(USER_LEVELS).map(([label, value]) => {
+ Object.entries(USER_LEVELS).map(([, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
@@ -274,9 +311,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
/>
-
-
diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx
index f4cdbe62..163f981b 100644
--- a/frontend/src/components/forms/M3UProfiles.jsx
+++ b/frontend/src/components/forms/M3UProfiles.jsx
@@ -1,7 +1,9 @@
-import React, { useState, useMemo, useEffect } from 'react';
+import React, { useState, useEffect } from 'react';
import API from '../../api';
import M3UProfile from './M3UProfile';
import usePlaylistsStore from '../../store/playlists';
+import ConfirmationDialog from '../ConfirmationDialog';
+import useWarningsStore from '../../store/warnings';
import {
Card,
Checkbox,
@@ -23,10 +25,15 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const theme = useMantineTheme();
const allProfiles = usePlaylistsStore((s) => s.profiles);
+ const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
+ const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
const [profile, setProfile] = useState(null);
const [profiles, setProfiles] = useState([]);
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [profileToDelete, setProfileToDelete] = useState(null);
useEffect(() => {
try {
@@ -50,13 +57,30 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
setProfileEditorOpen(true);
};
-
const deleteProfile = async (id) => {
if (!playlist || !playlist.id) return;
+
+ // Get profile details for the confirmation dialog
+ const profileObj = profiles.find(p => p.id === id);
+ setProfileToDelete(profileObj);
+ setDeleteTarget(id);
+
+ // Skip warning if it's been suppressed
+ if (isWarningSuppressed('delete-profile')) {
+ return executeDeleteProfile(id);
+ }
+
+ setConfirmDeleteOpen(true);
+ };
+
+ const executeDeleteProfile = async (id) => {
+ if (!playlist || !playlist.id) return;
try {
await API.deleteM3UProfile(playlist.id, id);
+ setConfirmDeleteOpen(false);
} catch (error) {
console.error('Error deleting profile:', error);
+ setConfirmDeleteOpen(false);
}
};
@@ -171,14 +195,38 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
New
-
-
-
+
+ setConfirmDeleteOpen(false)}
+ onConfirm={() => executeDeleteProfile(deleteTarget)}
+ title="Confirm Profile Deletion"
+ message={
+ profileToDelete ? (
+
+ {`Are you sure you want to delete the following profile?
+
+Name: ${profileToDelete.name}
+Max Streams: ${profileToDelete.max_streams}
+
+This action cannot be undone.`}
+
+ ) : (
+ 'Are you sure you want to delete this profile? This action cannot be undone.'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ actionKey="delete-profile"
+ onSuppressChange={suppressWarning}
+ size="md"
+ />
>
);
};
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index 3bf71d00..8c19671a 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -41,6 +41,9 @@ import {
Pagination,
NativeSelect,
UnstyledButton,
+ Stack,
+ Select,
+ NumberInput,
} from '@mantine/core';
import { getCoreRowModel, flexRender } from '@tanstack/react-table';
import './table.css';
@@ -211,7 +214,7 @@ const ChannelRowActions = React.memo(
}
);
-const ChannelsTable = ({}) => {
+const ChannelsTable = ({ }) => {
const theme = useMantineTheme();
/**
@@ -283,14 +286,25 @@ const ChannelsTable = ({}) => {
const [isLoading, setIsLoading] = useState(true);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
- const [epgUrl, setEPGUrl] = useState(epgUrlBase);
- const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
+ const [epgUrl, setEPGUrl] = useState(epgUrlBase); const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [channelToDelete, setChannelToDelete] = useState(null);
+ // M3U and EPG URL configuration state
+ const [m3uParams, setM3uParams] = useState({
+ cachedlogos: true,
+ direct: false,
+ tvg_id_source: 'channel_number'
+ });
+ const [epgParams, setEpgParams] = useState({
+ cachedlogos: true,
+ tvg_id_source: 'channel_number',
+ days: 0
+ });
+
/**
* Dereived variables
*/
@@ -361,10 +375,22 @@ const ChannelsTable = ({}) => {
};
const editChannel = async (ch = null) => {
- if (selectedChannelIds.length > 0) {
+ // Use table's selected state instead of store state to avoid stale selections
+ const currentSelection = table ? table.getState().selectedTableIds : [];
+ console.log('editChannel called with:', { ch, currentSelection, tableExists: !!table });
+
+ if (currentSelection.length > 1) {
setChannelBatchModalOpen(true);
} else {
- setChannel(ch);
+ // If no channel object is passed but we have a selection, get the selected channel
+ let channelToEdit = ch;
+ if (!channelToEdit && currentSelection.length === 1) {
+ const selectedId = currentSelection[0];
+
+ // Use table data since that's what's currently displayed
+ channelToEdit = data.find(d => d.id === selectedId);
+ }
+ setChannel(channelToEdit);
setChannelModalOpen(true);
}
};
@@ -514,16 +540,47 @@ const ChannelsTable = ({}) => {
}
}
};
+ // Build URLs with parameters
+ const buildM3UUrl = () => {
+ const params = new URLSearchParams();
+ if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false');
+ if (m3uParams.direct) params.append('direct', 'true');
+ if (m3uParams.tvg_id_source !== 'channel_number') params.append('tvg_id_source', m3uParams.tvg_id_source);
+ const baseUrl = m3uUrl;
+ return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
+ };
+
+ const buildEPGUrl = () => {
+ const params = new URLSearchParams();
+ if (!epgParams.cachedlogos) params.append('cachedlogos', 'false');
+ if (epgParams.tvg_id_source !== 'channel_number') params.append('tvg_id_source', epgParams.tvg_id_source);
+ if (epgParams.days > 0) params.append('days', epgParams.days.toString());
+
+ const baseUrl = epgUrl;
+ return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
+ };
// Example copy URLs
const copyM3UUrl = () => {
- copyToClipboard(m3uUrl);
+ copyToClipboard(buildM3UUrl());
+ notifications.show({
+ title: 'M3U URL Copied!',
+ message: 'The M3U URL has been copied to your clipboard.',
+ });
};
const copyEPGUrl = () => {
- copyToClipboard(epgUrl);
+ copyToClipboard(buildEPGUrl());
+ notifications.show({
+ title: 'EPG URL Copied!',
+ message: 'The EPG URL has been copied to your clipboard.',
+ });
};
const copyHDHRUrl = () => {
copyToClipboard(hdhrUrl);
+ notifications.show({
+ title: 'HDHR URL Copied!',
+ message: 'The HDHR URL has been copied to your clipboard.',
+ });
};
const onSortingChange = (column) => {
@@ -566,7 +623,7 @@ const ChannelsTable = ({}) => {
setHDHRUrl(`${hdhrUrlBase}${profileString}`);
setEPGUrl(`${epgUrlBase}${profileString}`);
setM3UUrl(`${m3uUrlBase}${profileString}`);
- }, [selectedProfileId]);
+ }, [selectedProfileId, profiles]);
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
@@ -575,7 +632,19 @@ const ChannelsTable = ({}) => {
totalCount
);
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
- }, [data]);
+ }, [pagination.pageIndex, pagination.pageSize, totalCount]);
+
+ // Clear selection when data changes (e.g., when navigating back to the page)
+ useEffect(() => {
+ setSelectedChannelIds([]);
+ }, [data, setSelectedChannelIds]);
+
+ // Clear selection when component unmounts
+ useEffect(() => {
+ return () => {
+ setSelectedChannelIds([]);
+ };
+ }, [setSelectedChannelIds]);
const columns = useMemo(
() => [
@@ -812,8 +881,8 @@ const ChannelsTable = ({}) => {
return hasStreams
? {} // Default style for channels with streams
: {
- className: 'no-streams-row', // Add a class instead of background color
- };
+ className: 'no-streams-row', // Add a class instead of background color
+ };
},
});
@@ -860,9 +929,8 @@ const ChannelsTable = ({}) => {
>
Links:
-
-
+ }
@@ -879,7 +947,14 @@ const ChannelsTable = ({}) => {
-
+ {
-
-
+ }
@@ -909,21 +983,72 @@ const ChannelsTable = ({}) => {
-
-
-
-
-
-
+
+
+
+
+ }
+ />
+ Use cached logos
+ setM3uParams(prev => ({
+ ...prev,
+ cachedlogos: event.target.checked
+ }))}
+ />
+
+
+
+ Direct stream URLs
+ setM3uParams(prev => ({
+ ...prev,
+ direct: event.target.checked
+ }))}
+ />
+