mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
Merge pull request #273 from Dispatcharr/dev
Release Notes – Version 0.7.1
This commit is contained in:
commit
38813e6c27
15 changed files with 571 additions and 129 deletions
|
|
@ -18,6 +18,7 @@ from apps.accounts.permissions import (
|
|||
)
|
||||
|
||||
from core.models import UserAgent, CoreSettings
|
||||
from core.utils import RedisClient
|
||||
|
||||
from .models import (
|
||||
Stream,
|
||||
|
|
@ -493,7 +494,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
operation_description=(
|
||||
"Create a new channel from an existing stream. "
|
||||
"If 'channel_number' is provided, it will be used (if available); "
|
||||
"otherwise, the next available channel number is assigned."
|
||||
"otherwise, the next available channel number is assigned. "
|
||||
"If 'channel_profile_ids' is provided, the channel will only be added to those profiles. "
|
||||
"Accepts either a single ID or an array of IDs."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
|
|
@ -509,6 +512,11 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
"name": openapi.Schema(
|
||||
type=openapi.TYPE_STRING, description="Desired channel name"
|
||||
),
|
||||
"channel_profile_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="(Optional) Channel profile ID(s) to add the channel to. Can be a single ID or array of IDs. If not provided, channel is added to all profiles."
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={201: ChannelSerializer()},
|
||||
|
|
@ -590,8 +598,50 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
|
||||
serializer = self.get_serializer(data=channel_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
channel = serializer.save()
|
||||
channel.streams.add(stream)
|
||||
|
||||
with transaction.atomic():
|
||||
channel = serializer.save()
|
||||
channel.streams.add(stream)
|
||||
|
||||
# Handle channel profile membership
|
||||
channel_profile_ids = request.data.get("channel_profile_ids")
|
||||
if channel_profile_ids is not None:
|
||||
# Normalize single ID to array
|
||||
if not isinstance(channel_profile_ids, list):
|
||||
channel_profile_ids = [channel_profile_ids]
|
||||
|
||||
if channel_profile_ids:
|
||||
# Add channel only to the specified profiles
|
||||
try:
|
||||
channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids)
|
||||
if len(channel_profiles) != len(channel_profile_ids):
|
||||
missing_ids = set(channel_profile_ids) - set(channel_profiles.values_list('id', flat=True))
|
||||
return Response(
|
||||
{"error": f"Channel profiles with IDs {list(missing_ids)} not found"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
ChannelProfileMembership.objects.bulk_create([
|
||||
ChannelProfileMembership(
|
||||
channel_profile=profile,
|
||||
channel=channel,
|
||||
enabled=True
|
||||
)
|
||||
for profile in channel_profiles
|
||||
])
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": f"Error creating profile memberships: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
# Default behavior: add to all profiles
|
||||
profiles = ChannelProfile.objects.all()
|
||||
ChannelProfileMembership.objects.bulk_create([
|
||||
ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True)
|
||||
for profile in profiles
|
||||
])
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@swagger_auto_schema(
|
||||
|
|
@ -599,7 +649,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
operation_description=(
|
||||
"Bulk create channels from existing streams. For each object, if 'channel_number' is provided, "
|
||||
"it is used (if available); otherwise, the next available number is auto-assigned. "
|
||||
"Each object must include 'stream_id' and 'name'."
|
||||
"Each object must include 'stream_id' and 'name'. "
|
||||
"Supports single profile ID or array of profile IDs in 'channel_profile_ids'."
|
||||
),
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
|
|
@ -618,6 +669,11 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
"name": openapi.Schema(
|
||||
type=openapi.TYPE_STRING, description="Desired channel name"
|
||||
),
|
||||
"channel_profile_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="(Optional) Channel profile ID(s) to add the channel to. Can be a single ID or array of IDs."
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -652,13 +708,15 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
channels_to_create = []
|
||||
streams_map = []
|
||||
logo_map = []
|
||||
profile_map = [] # Track which profiles each channel should be added to
|
||||
|
||||
for item in data_list:
|
||||
stream_id = item.get("stream_id")
|
||||
if not all([stream_id]):
|
||||
if not stream_id:
|
||||
errors.append(
|
||||
{
|
||||
"item": item,
|
||||
"error": "Missing required fields: stream_id and name are required.",
|
||||
"error": "Missing required field: stream_id is required.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
|
@ -703,7 +761,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
errors.append(
|
||||
{
|
||||
"item": item,
|
||||
"error": "channel_number must be an integer.",
|
||||
"error": "channel_number must be a number.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
|
@ -745,6 +803,15 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
channels_to_create.append(channel)
|
||||
|
||||
streams_map.append([stream_id])
|
||||
# Store which profiles this channel should be added to - normalize to array
|
||||
channel_profile_ids = item.get("channel_profile_ids")
|
||||
if channel_profile_ids is not None:
|
||||
# Normalize single ID to array
|
||||
if not isinstance(channel_profile_ids, list):
|
||||
channel_profile_ids = [channel_profile_ids]
|
||||
|
||||
profile_map.append(channel_profile_ids)
|
||||
|
||||
if stream.logo_url:
|
||||
logos_to_create.append(
|
||||
Logo(
|
||||
|
|
@ -756,9 +823,6 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
else:
|
||||
logo_map.append(None)
|
||||
|
||||
# channel = serializer.save()
|
||||
# channel.streams.add(stream)
|
||||
# created_channels.append(serializer.data)
|
||||
else:
|
||||
errors.append({"item": item, "error": serializer.errors})
|
||||
|
||||
|
|
@ -772,31 +836,67 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
)
|
||||
}
|
||||
|
||||
profiles = ChannelProfile.objects.all()
|
||||
# Get all profiles for default assignment
|
||||
all_profiles = ChannelProfile.objects.all()
|
||||
channel_profile_memberships = []
|
||||
|
||||
if channels_to_create:
|
||||
with transaction.atomic():
|
||||
created_channels = Channel.objects.bulk_create(channels_to_create)
|
||||
|
||||
update = []
|
||||
for channel, stream_ids, logo_url in zip(
|
||||
created_channels, streams_map, logo_map
|
||||
for channel, stream_ids, logo_url, channel_profile_ids in zip(
|
||||
created_channels, streams_map, logo_map, profile_map
|
||||
):
|
||||
if logo_url:
|
||||
channel.logo = channel_logos[logo_url]
|
||||
update.append(channel)
|
||||
channel_profile_memberships = channel_profile_memberships + [
|
||||
ChannelProfileMembership(
|
||||
channel_profile=profile, channel=channel
|
||||
)
|
||||
for profile in profiles
|
||||
]
|
||||
|
||||
ChannelProfileMembership.objects.bulk_create(
|
||||
channel_profile_memberships
|
||||
)
|
||||
Channel.objects.bulk_update(update, ["logo"])
|
||||
# Handle channel profile membership based on channel_profile_ids
|
||||
if channel_profile_ids:
|
||||
# Add channel only to the specified profiles
|
||||
try:
|
||||
specific_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids)
|
||||
channel_profile_memberships.extend([
|
||||
ChannelProfileMembership(
|
||||
channel_profile=profile,
|
||||
channel=channel,
|
||||
enabled=True
|
||||
)
|
||||
for profile in specific_profiles
|
||||
])
|
||||
except Exception:
|
||||
# If profiles don't exist, add to all profiles as fallback
|
||||
channel_profile_memberships.extend([
|
||||
ChannelProfileMembership(
|
||||
channel_profile=profile,
|
||||
channel=channel,
|
||||
enabled=True
|
||||
)
|
||||
for profile in all_profiles
|
||||
])
|
||||
else:
|
||||
# Default behavior: add to all profiles
|
||||
channel_profile_memberships.extend([
|
||||
ChannelProfileMembership(
|
||||
channel_profile=profile,
|
||||
channel=channel,
|
||||
enabled=True
|
||||
)
|
||||
for profile in all_profiles
|
||||
])
|
||||
|
||||
# Bulk create profile memberships
|
||||
if channel_profile_memberships:
|
||||
ChannelProfileMembership.objects.bulk_create(
|
||||
channel_profile_memberships
|
||||
)
|
||||
|
||||
# Update logos
|
||||
if update:
|
||||
Channel.objects.bulk_update(update, ["logo"])
|
||||
|
||||
# Set stream relationships
|
||||
for channel, stream_ids in zip(created_channels, streams_map):
|
||||
channel.streams.set(stream_ids)
|
||||
|
||||
|
|
@ -1122,7 +1222,7 @@ class CleanupUnusedLogosAPIView(APIView):
|
|||
def post(self, request):
|
||||
"""Delete all logos with no channel associations"""
|
||||
delete_files = request.data.get("delete_files", False)
|
||||
|
||||
|
||||
unused_logos = Logo.objects.filter(channels__isnull=True)
|
||||
deleted_count = unused_logos.count()
|
||||
logo_names = list(unused_logos.values_list('name', flat=True))
|
||||
|
|
@ -1204,7 +1304,13 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Update an existing logo"""
|
||||
return super().update(request, *args, **kwargs)
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
if serializer.is_valid():
|
||||
logo = serializer.save()
|
||||
return Response(self.get_serializer(logo).data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete a logo and remove it from any channels using it"""
|
||||
|
|
@ -1258,6 +1364,17 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
for chunk in file.chunks():
|
||||
destination.write(chunk)
|
||||
|
||||
# Mark file as processed in Redis to prevent file scanner notifications
|
||||
try:
|
||||
redis_client = RedisClient.get_client()
|
||||
if redis_client:
|
||||
# Use the same key format as the file scanner
|
||||
redis_key = f"processed_file:{file_path}"
|
||||
redis_client.setex(redis_key, 60 * 60 * 24 * 3, "api_upload") # 3 day TTL
|
||||
logger.debug(f"Marked uploaded logo file as processed in Redis: {file_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to mark logo file as processed in Redis: {e}")
|
||||
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=file_path,
|
||||
defaults={
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ class LogoSerializer(serializers.ModelSerializer):
|
|||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Handle logo creation with proper URL validation"""
|
||||
return Logo.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Handle logo updates"""
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def get_cache_url(self, obj):
|
||||
# return f"/api/channels/logos/{obj.id}/cache/"
|
||||
request = self.context.get("request")
|
||||
|
|
|
|||
|
|
@ -62,15 +62,6 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
|
|||
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
|
||||
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
||||
|
||||
@receiver(post_save, sender=Channel)
|
||||
def add_new_channel_to_groups(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
profiles = ChannelProfile.objects.all()
|
||||
ChannelProfileMembership.objects.bulk_create([
|
||||
ChannelProfileMembership(channel_profile=profile, channel=instance)
|
||||
for profile in profiles
|
||||
])
|
||||
|
||||
@receiver(post_save, sender=ChannelProfile)
|
||||
def create_profile_memberships(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from channels.layers import get_channel_layer
|
|||
from django.utils import timezone
|
||||
import time
|
||||
import json
|
||||
from core.utils import RedisClient, acquire_task_lock, release_task_lock
|
||||
from core.utils import RedisClient, acquire_task_lock, release_task_lock, natural_sort_key
|
||||
from core.models import CoreSettings, UserAgent
|
||||
from asgiref.sync import async_to_sync
|
||||
from core.xtream_codes import Client as XCClient
|
||||
|
|
@ -882,6 +882,8 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
name_regex_pattern = None
|
||||
name_replace_pattern = None
|
||||
name_match_regex = None
|
||||
channel_profile_ids = None
|
||||
channel_sort_order = None
|
||||
if group_relation.custom_properties:
|
||||
try:
|
||||
group_custom_props = json.loads(group_relation.custom_properties)
|
||||
|
|
@ -890,12 +892,16 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
name_regex_pattern = group_custom_props.get("name_regex_pattern")
|
||||
name_replace_pattern = group_custom_props.get("name_replace_pattern")
|
||||
name_match_regex = group_custom_props.get("name_match_regex")
|
||||
channel_profile_ids = group_custom_props.get("channel_profile_ids")
|
||||
channel_sort_order = group_custom_props.get("channel_sort_order")
|
||||
except Exception:
|
||||
force_dummy_epg = False
|
||||
override_group_id = None
|
||||
name_regex_pattern = None
|
||||
name_replace_pattern = None
|
||||
name_match_regex = None
|
||||
channel_profile_ids = None
|
||||
channel_sort_order = None
|
||||
|
||||
# Determine which group to use for created channels
|
||||
target_group = channel_group
|
||||
|
|
@ -913,18 +919,36 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
m3u_account=account,
|
||||
channel_group=channel_group,
|
||||
last_seen__gte=scan_start_time
|
||||
).order_by('name')
|
||||
)
|
||||
|
||||
# --- FILTER STREAMS BY NAME MATCH REGEX IF SPECIFIED ---
|
||||
if name_match_regex:
|
||||
try:
|
||||
compiled_name_match_regex = re.compile(name_match_regex, re.IGNORECASE)
|
||||
current_streams = current_streams.filter(
|
||||
name__iregex=name_match_regex
|
||||
)
|
||||
except re.error as e:
|
||||
logger.warning(f"Invalid name_match_regex '{name_match_regex}' for group '{channel_group.name}': {e}. Skipping name filter.")
|
||||
|
||||
# --- APPLY CHANNEL SORT ORDER ---
|
||||
streams_is_list = False # Track if we converted to list
|
||||
if channel_sort_order and channel_sort_order != '':
|
||||
if channel_sort_order == 'name':
|
||||
# Use natural sorting for names to handle numbers correctly
|
||||
current_streams = list(current_streams)
|
||||
current_streams.sort(key=lambda stream: natural_sort_key(stream.name))
|
||||
streams_is_list = True
|
||||
elif channel_sort_order == 'tvg_id':
|
||||
current_streams = current_streams.order_by('tvg_id')
|
||||
elif channel_sort_order == 'updated_at':
|
||||
current_streams = current_streams.order_by('updated_at')
|
||||
else:
|
||||
logger.warning(f"Unknown channel_sort_order '{channel_sort_order}' for group '{channel_group.name}'. Using provider order.")
|
||||
current_streams = current_streams.order_by('id')
|
||||
else:
|
||||
current_streams = current_streams.order_by('id')
|
||||
# If channel_sort_order is empty or None, use provider order (no additional sorting)
|
||||
|
||||
# Get existing auto-created channels for this account (regardless of current group)
|
||||
# We'll find them by their stream associations instead of just group location
|
||||
existing_channels = Channel.objects.filter(
|
||||
|
|
@ -951,7 +975,10 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
# Track which streams we've processed
|
||||
processed_stream_ids = set()
|
||||
|
||||
if not current_streams.exists():
|
||||
# Check if we have streams - handle both QuerySet and list cases
|
||||
has_streams = len(current_streams) > 0 if streams_is_list else current_streams.exists()
|
||||
|
||||
if not has_streams:
|
||||
logger.debug(f"No streams found in group {channel_group.name}")
|
||||
# Delete all existing auto channels if no streams
|
||||
channels_to_delete = [ch for ch in existing_channel_map.values()]
|
||||
|
|
@ -962,12 +989,63 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
logger.debug(f"Deleted {deleted_count} auto channels (no streams remaining)")
|
||||
continue
|
||||
|
||||
# Prepare profiles to assign to new channels
|
||||
from apps.channels.models import ChannelProfile, ChannelProfileMembership
|
||||
if channel_profile_ids and isinstance(channel_profile_ids, list) and len(channel_profile_ids) > 0:
|
||||
# Convert all to int (in case they're strings)
|
||||
try:
|
||||
profile_ids = [int(pid) for pid in channel_profile_ids]
|
||||
except Exception:
|
||||
profile_ids = []
|
||||
profiles_to_assign = list(ChannelProfile.objects.filter(id__in=profile_ids))
|
||||
else:
|
||||
profiles_to_assign = list(ChannelProfile.objects.all())
|
||||
|
||||
# Process each current stream
|
||||
current_channel_number = start_number
|
||||
|
||||
# Always renumber all existing channels to match current sort order
|
||||
# This ensures channels are always in the correct sequence
|
||||
channels_to_renumber = []
|
||||
temp_channel_number = start_number
|
||||
|
||||
# Get all channel numbers that are already in use by other channels (not auto-created by this account)
|
||||
used_numbers = set(Channel.objects.exclude(
|
||||
auto_created=True,
|
||||
auto_created_by=account
|
||||
).values_list('channel_number', flat=True))
|
||||
|
||||
for stream in current_streams:
|
||||
if stream.id in existing_channel_map:
|
||||
channel = existing_channel_map[stream.id]
|
||||
|
||||
# Find next available number starting from temp_channel_number
|
||||
target_number = temp_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
|
||||
# Add this number to used_numbers so we don't reuse it in this batch
|
||||
used_numbers.add(target_number)
|
||||
|
||||
if channel.channel_number != target_number:
|
||||
channel.channel_number = target_number
|
||||
channels_to_renumber.append(channel)
|
||||
logger.debug(f"Will renumber channel '{channel.name}' to {target_number}")
|
||||
|
||||
temp_channel_number += 1.0
|
||||
if temp_channel_number % 1 != 0: # Has decimal
|
||||
temp_channel_number = int(temp_channel_number) + 1.0
|
||||
|
||||
# Bulk update channel numbers if any need renumbering
|
||||
if channels_to_renumber:
|
||||
Channel.objects.bulk_update(channels_to_renumber, ['channel_number'])
|
||||
logger.info(f"Renumbered {len(channels_to_renumber)} channels to maintain sort order")
|
||||
|
||||
# Reset channel number counter for processing new channels
|
||||
current_channel_number = start_number
|
||||
|
||||
for stream in current_streams:
|
||||
processed_stream_ids.add(stream.id)
|
||||
|
||||
try:
|
||||
# Parse custom properties for additional info
|
||||
stream_custom_props = json.loads(stream.custom_properties) if stream.custom_properties else {}
|
||||
|
|
@ -989,7 +1067,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
existing_channel = existing_channel_map.get(stream.id)
|
||||
|
||||
if existing_channel:
|
||||
# Update existing channel if needed
|
||||
# Update existing channel if needed (channel number already handled above)
|
||||
channel_updated = False
|
||||
|
||||
# Use new_name instead of stream.name
|
||||
|
|
@ -1038,22 +1116,55 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
channels_updated += 1
|
||||
logger.debug(f"Updated auto channel: {existing_channel.channel_number} - {existing_channel.name}")
|
||||
|
||||
# Update channel profile memberships for existing channels
|
||||
current_memberships = set(
|
||||
ChannelProfileMembership.objects.filter(
|
||||
channel=existing_channel,
|
||||
enabled=True
|
||||
).values_list('channel_profile_id', flat=True)
|
||||
)
|
||||
|
||||
target_profile_ids = set(profile.id for profile in profiles_to_assign)
|
||||
|
||||
# Only update if memberships have changed
|
||||
if current_memberships != target_profile_ids:
|
||||
# Disable all current memberships
|
||||
ChannelProfileMembership.objects.filter(
|
||||
channel=existing_channel
|
||||
).update(enabled=False)
|
||||
|
||||
# Enable/create memberships for target profiles
|
||||
for profile in profiles_to_assign:
|
||||
membership, created = ChannelProfileMembership.objects.get_or_create(
|
||||
channel_profile=profile,
|
||||
channel=existing_channel,
|
||||
defaults={'enabled': True}
|
||||
)
|
||||
if not created and not membership.enabled:
|
||||
membership.enabled = True
|
||||
membership.save()
|
||||
|
||||
logger.debug(f"Updated profile memberships for auto channel: {existing_channel.name}")
|
||||
|
||||
else:
|
||||
# Create new channel
|
||||
# Find next available channel number
|
||||
while Channel.objects.filter(channel_number=current_channel_number).exists():
|
||||
current_channel_number += 0.1
|
||||
target_number = current_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
|
||||
# Add this number to used_numbers
|
||||
used_numbers.add(target_number)
|
||||
|
||||
# Create the channel with auto-created tracking in the target group
|
||||
channel = Channel.objects.create(
|
||||
channel_number=current_channel_number,
|
||||
channel_number=target_number,
|
||||
name=new_name,
|
||||
tvg_id=stream.tvg_id,
|
||||
tvc_guide_stationid=tvc_guide_stationid,
|
||||
channel_group=target_group, # Use target group (could be override)
|
||||
user_level=0, # Default user level
|
||||
auto_created=True, # Mark as auto-created
|
||||
auto_created_by=account # Track which M3U account created it
|
||||
channel_group=target_group,
|
||||
user_level=0,
|
||||
auto_created=True,
|
||||
auto_created_by=account
|
||||
)
|
||||
|
||||
# Associate the stream with the channel
|
||||
|
|
@ -1063,6 +1174,14 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
order=0
|
||||
)
|
||||
|
||||
# Assign to correct profiles
|
||||
memberships = [
|
||||
ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True)
|
||||
for profile in profiles_to_assign
|
||||
]
|
||||
if memberships:
|
||||
ChannelProfileMembership.objects.bulk_create(memberships)
|
||||
|
||||
# Try to match EPG data
|
||||
if stream.tvg_id and not force_dummy_epg:
|
||||
epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first()
|
||||
|
|
@ -1084,12 +1203,13 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
channel.save(update_fields=['logo'])
|
||||
|
||||
channels_created += 1
|
||||
current_channel_number += 1.0
|
||||
if current_channel_number % 1 != 0: # Has decimal
|
||||
current_channel_number = int(current_channel_number) + 1.0
|
||||
|
||||
logger.debug(f"Created auto channel: {channel.channel_number} - {channel.name}")
|
||||
|
||||
# Increment channel number for next iteration
|
||||
current_channel_number += 1.0
|
||||
if current_channel_number % 1 != 0: # Has decimal
|
||||
current_channel_number = int(current_channel_number) + 1.0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing auto channel for stream {stream.name}: {str(e)}")
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -378,8 +378,7 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
tvg_logo = direct_logo
|
||||
else:
|
||||
tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
|
||||
|
||||
display_name = channel.epg_data.name if channel.epg_data else channel.name
|
||||
display_name = channel.name
|
||||
xml_lines.append(f' <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)}" />')
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import logging
|
|||
import time
|
||||
import os
|
||||
import threading
|
||||
import re
|
||||
from django.conf import settings
|
||||
from redis.exceptions import ConnectionError, TimeoutError
|
||||
from django.core.cache import cache
|
||||
|
|
@ -15,6 +16,29 @@ logger = logging.getLogger(__name__)
|
|||
# Import the command detector
|
||||
from .command_utils import is_management_command
|
||||
|
||||
def natural_sort_key(text):
|
||||
"""
|
||||
Convert a string into a list of string and number chunks for natural sorting.
|
||||
"PPV 10" becomes ['PPV ', 10] so it sorts correctly with "PPV 2".
|
||||
|
||||
This function enables natural/alphanumeric sorting where numbers within strings
|
||||
are treated as actual numbers rather than strings.
|
||||
|
||||
Args:
|
||||
text (str): The text to convert for sorting
|
||||
|
||||
Returns:
|
||||
list: A list of strings and integers for proper sorting
|
||||
|
||||
Example:
|
||||
>>> sorted(['PPV 1', 'PPV 10', 'PPV 2'], key=natural_sort_key)
|
||||
['PPV 1', 'PPV 2', 'PPV 10']
|
||||
"""
|
||||
def convert(chunk):
|
||||
return int(chunk) if chunk.isdigit() else chunk.lower()
|
||||
|
||||
return [convert(c) for c in re.split('([0-9]+)', text)]
|
||||
|
||||
class RedisClient:
|
||||
_client = None
|
||||
_pubsub_client = None
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ def json_success_response(data=None, status=200):
|
|||
|
||||
def validate_logo_file(file):
|
||||
"""Validate uploaded logo file size and MIME type."""
|
||||
valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"]
|
||||
if file.content_type not in valid_mime_types:
|
||||
raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP.")
|
||||
if file.size > 5 * 1024 * 1024: # Increased to 5MB
|
||||
raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, SVG.")
|
||||
if file.size > 5 * 1024 * 1024: # 5MB
|
||||
raise ValidationError("File too large. Max 5MB.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ export const WebsocketProvider = ({ children }) => {
|
|||
|
||||
updatePlaylist(updateData);
|
||||
fetchPlaylists(); // Refresh playlists to ensure UI is up-to-date
|
||||
fetchChannelProfiles(); // Ensure channel profiles are updated
|
||||
} else {
|
||||
// Log when playlist can't be found for debugging purposes
|
||||
console.warn(
|
||||
|
|
@ -499,6 +500,7 @@ export const WebsocketProvider = ({ children }) => {
|
|||
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
|
||||
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
|
||||
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
|
||||
const fetchChannelProfiles = useChannelsStore((s) => s.fetchChannelProfiles);
|
||||
|
||||
const ret = useMemo(() => {
|
||||
return [isReady, ws.current?.send.bind(ws.current), val];
|
||||
|
|
|
|||
|
|
@ -528,13 +528,14 @@ export default class API {
|
|||
}
|
||||
);
|
||||
|
||||
if (response.created.length > 0) {
|
||||
if (response.created && response.created.length > 0) {
|
||||
useChannelsStore.getState().addChannels(response.created);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create channels', e);
|
||||
throw e; // Re-throw to allow proper error handling in calling code
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1299,9 +1300,17 @@ export default class API {
|
|||
|
||||
static async createLogo(values) {
|
||||
try {
|
||||
// Use FormData for logo creation to match backend expectations
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await request(`${host}/api/channels/logos/`, {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
useChannelsStore.getState().addLogo(response);
|
||||
|
|
@ -1314,19 +1323,9 @@ export default class API {
|
|||
|
||||
static async updateLogo(id, values) {
|
||||
try {
|
||||
// Convert values to FormData for the multipart/form-data content type
|
||||
const formData = new FormData();
|
||||
|
||||
// Add each field to the form data
|
||||
Object.keys(values).forEach(key => {
|
||||
if (values[key] !== null && values[key] !== undefined) {
|
||||
formData.append(key, values[key]);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await request(`${host}/api/channels/logos/${id}/`, {
|
||||
method: 'PUT',
|
||||
body: formData, // Send as FormData instead of JSON
|
||||
body: values, // This will be converted to JSON in the request function
|
||||
});
|
||||
|
||||
useChannelsStore.getState().updateLogo(response);
|
||||
|
|
|
|||
|
|
@ -193,6 +193,10 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
formik.resetForm();
|
||||
API.requeryChannels();
|
||||
|
||||
// Refresh channel profiles to update the membership information
|
||||
useChannelsStore.getState().fetchChannelProfiles();
|
||||
|
||||
setSubmitting(false);
|
||||
setTvgFilter('');
|
||||
setLogoFilter('');
|
||||
|
|
|
|||
|
|
@ -189,6 +189,10 @@ const ChannelsForm = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
formik.resetForm();
|
||||
API.requeryChannels();
|
||||
|
||||
// Refresh channel profiles to update the membership information
|
||||
useChannelsStore.getState().fetchChannelProfiles();
|
||||
|
||||
setSubmitting(false);
|
||||
setTvgFilter('');
|
||||
setLogoFilter('');
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import API from '../../api';
|
|||
const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
||||
const [logoPreview, setLogoPreview] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null); // Store selected file
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
|
|
@ -46,6 +47,37 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
}),
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
// If we have a selected file, upload it first
|
||||
if (selectedFile) {
|
||||
try {
|
||||
const uploadResponse = await API.uploadLogo(selectedFile);
|
||||
// Use the uploaded file data instead of form values
|
||||
values.name = uploadResponse.name;
|
||||
values.url = uploadResponse.url;
|
||||
} catch (uploadError) {
|
||||
let errorMessage = 'Failed to upload logo file';
|
||||
|
||||
if (uploadError.code === 'NETWORK_ERROR' || uploadError.message?.includes('timeout')) {
|
||||
errorMessage = 'Upload timed out. Please try again.';
|
||||
} else if (uploadError.status === 413) {
|
||||
errorMessage = 'File too large. Please choose a smaller file.';
|
||||
} else if (uploadError.body?.error) {
|
||||
errorMessage = uploadError.body.error;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Upload Error',
|
||||
message: errorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
return; // Don't proceed with creation if upload fails
|
||||
}
|
||||
}
|
||||
|
||||
// Now create or update the logo with the final values
|
||||
// Only proceed if we don't already have a logo from file upload
|
||||
if (logo) {
|
||||
await API.updateLogo(logo.id, values);
|
||||
notifications.show({
|
||||
|
|
@ -53,13 +85,22 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
message: 'Logo updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
} else if (!selectedFile) {
|
||||
// Only create a new logo entry if we're not uploading a file
|
||||
// (file upload already created the logo entry)
|
||||
await API.createLogo(values);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
// File was uploaded and logo was already created
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo uploaded successfully',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
|
|
@ -79,6 +120,7 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -94,9 +136,11 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
formik.resetForm();
|
||||
setLogoPreview(null);
|
||||
}
|
||||
// Clear any selected file when logo changes
|
||||
setSelectedFile(null);
|
||||
}, [logo, isOpen]);
|
||||
|
||||
const handleFileUpload = async (files) => {
|
||||
const handleFileSelect = (files) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
|
@ -111,53 +155,53 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
// Store the file for later upload and create preview
|
||||
setSelectedFile(file);
|
||||
|
||||
try {
|
||||
const response = await API.uploadLogo(file);
|
||||
// Generate a local preview URL
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setLogoPreview(previewUrl);
|
||||
|
||||
// Update form with uploaded file info
|
||||
formik.setFieldValue('name', response.name);
|
||||
formik.setFieldValue('url', response.url);
|
||||
setLogoPreview(response.cache_url);
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo uploaded successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} catch (error) {
|
||||
let errorMessage = 'Failed to upload logo';
|
||||
|
||||
// Handle specific timeout errors
|
||||
if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) {
|
||||
errorMessage = 'Upload timed out. Please try again.';
|
||||
} else if (error.status === 413) {
|
||||
errorMessage = 'File too large. Please choose a smaller file.';
|
||||
} else if (error.body?.error) {
|
||||
errorMessage = error.body.error;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: errorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// Auto-fill the name field if empty
|
||||
if (!formik.values.name) {
|
||||
const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, "");
|
||||
formik.setFieldValue('name', nameWithoutExtension);
|
||||
}
|
||||
|
||||
// Set a placeholder URL (will be replaced after upload)
|
||||
formik.setFieldValue('url', 'file://pending-upload');
|
||||
};
|
||||
|
||||
const handleUrlChange = (event) => {
|
||||
const url = event.target.value;
|
||||
formik.setFieldValue('url', url);
|
||||
|
||||
// Clear any selected file when manually entering URL
|
||||
if (selectedFile) {
|
||||
setSelectedFile(null);
|
||||
// Revoke the object URL to free memory
|
||||
if (logoPreview && logoPreview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(logoPreview);
|
||||
}
|
||||
}
|
||||
|
||||
// Update preview for remote URLs
|
||||
if (url && url.startsWith('http')) {
|
||||
setLogoPreview(url);
|
||||
} else if (!url) {
|
||||
setLogoPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up object URLs when component unmounts or preview changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoPreview && logoPreview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(logoPreview);
|
||||
}
|
||||
};
|
||||
}, [logoPreview]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
|
|
@ -205,10 +249,11 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
Upload Logo File
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={['image/png', 'image/jpeg', 'image/gif', 'image/webp']}
|
||||
maxFiles={1}
|
||||
onDrop={handleFileSelect}
|
||||
loading={uploading}
|
||||
accept={{ "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"] }}
|
||||
multiple={false}
|
||||
maxSize={5 * 1024 * 1024} // 5MB limit
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
|
||||
<Dropzone.Accept>
|
||||
|
|
@ -223,10 +268,10 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
Drag image here or click to select
|
||||
{selectedFile ? `Selected: ${selectedFile.name}` : 'Drag image here or click to select'}
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed" inline mt={7}>
|
||||
Supports PNG, JPEG, GIF, WebP files
|
||||
{selectedFile ? 'File will be uploaded when you click Create/Update' : 'Supports PNG, JPEG, GIF, WebP, SVG files'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
|
@ -242,6 +287,7 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
{...formik.getFieldProps('url')}
|
||||
onChange={handleUrlChange}
|
||||
error={formik.touched.url && formik.errors.url}
|
||||
disabled={!!selectedFile} // Disable when file is selected
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
|
|
@ -251,6 +297,12 @@ const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
|||
error={formik.touched.name && formik.errors.name}
|
||||
/>
|
||||
|
||||
{selectedFile && (
|
||||
<Text size="sm" color="blue">
|
||||
Selected file: {selectedFile.name} - will be uploaded when you submit
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={onClose}>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const OptionWithTooltip = forwardRef(({ label, description, ...others }, ref) =>
|
|||
|
||||
const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const [groupStates, setGroupStates] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [groupFilter, setGroupFilter] = useState('');
|
||||
|
|
@ -312,6 +313,16 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
label: 'Channel Name Filter (Regex)',
|
||||
description: 'Only include channels whose names match this regex pattern',
|
||||
},
|
||||
{
|
||||
value: 'profile_assignment',
|
||||
label: 'Channel Profile Assignment',
|
||||
description: 'Specify which channel profiles the auto-synced channels should be added to',
|
||||
},
|
||||
{
|
||||
value: 'channel_sort_order',
|
||||
label: 'Channel Sort Order',
|
||||
description: 'Specify the order in which channels are created (name, tvg_id, updated_at)',
|
||||
},
|
||||
]}
|
||||
itemComponent={OptionWithTooltip}
|
||||
value={(() => {
|
||||
|
|
@ -331,6 +342,12 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
if (group.custom_properties?.name_match_regex !== undefined) {
|
||||
selectedValues.push('name_match_regex');
|
||||
}
|
||||
if (group.custom_properties?.channel_profile_ids !== undefined) {
|
||||
selectedValues.push('profile_assignment');
|
||||
}
|
||||
if (group.custom_properties?.channel_sort_order !== undefined) {
|
||||
selectedValues.push('channel_sort_order');
|
||||
}
|
||||
return selectedValues;
|
||||
})()}
|
||||
onChange={(values) => {
|
||||
|
|
@ -380,6 +397,23 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
delete newCustomProps.name_match_regex;
|
||||
}
|
||||
|
||||
// Handle profile_assignment
|
||||
if (selectedOptions.includes('profile_assignment')) {
|
||||
if (newCustomProps.channel_profile_ids === undefined) {
|
||||
newCustomProps.channel_profile_ids = [];
|
||||
}
|
||||
} else {
|
||||
delete newCustomProps.channel_profile_ids;
|
||||
}
|
||||
// Handle channel_sort_order
|
||||
if (selectedOptions.includes('channel_sort_order')) {
|
||||
if (newCustomProps.channel_sort_order === undefined) {
|
||||
newCustomProps.channel_sort_order = '';
|
||||
}
|
||||
} else {
|
||||
delete newCustomProps.channel_sort_order;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
custom_properties: newCustomProps,
|
||||
|
|
@ -392,6 +426,76 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
clearable
|
||||
size="xs"
|
||||
/>
|
||||
{/* Show only channel_sort_order if selected */}
|
||||
{group.custom_properties?.channel_sort_order !== undefined && (
|
||||
<Select
|
||||
label="Channel Sort Order"
|
||||
placeholder="Select sort order..."
|
||||
value={group.custom_properties?.channel_sort_order || ''}
|
||||
onChange={(value) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (state.channel_group === group.channel_group) {
|
||||
return {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
channel_sort_order: value || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{ value: '', label: 'Provider Order (Default)' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'tvg_id', label: 'TVG ID' },
|
||||
{ value: 'updated_at', label: 'Updated At' },
|
||||
]}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Show profile selection only if profile_assignment is selected */}
|
||||
{group.custom_properties?.channel_profile_ids !== undefined && (
|
||||
<Tooltip
|
||||
label="Select which channel profiles the auto-synced channels should be added to. Leave empty to add to all profiles."
|
||||
withArrow
|
||||
>
|
||||
<MultiSelect
|
||||
label="Channel Profiles"
|
||||
placeholder="Select profiles..."
|
||||
value={group.custom_properties?.channel_profile_ids || []}
|
||||
onChange={(value) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (state.channel_group === group.channel_group) {
|
||||
return {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
channel_profile_ids: value || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}}
|
||||
data={Object.values(profiles).map((profile) => ({
|
||||
value: profile.id.toString(),
|
||||
label: profile.name,
|
||||
}))}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Show group select only if group_override is selected */}
|
||||
{group.custom_properties?.group_override !== undefined && (
|
||||
|
|
|
|||
|
|
@ -62,10 +62,14 @@ const StreamRowActions = ({
|
|||
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
|
||||
|
||||
const createChannelFromStream = async () => {
|
||||
const selectedChannelProfileId = useChannelsStore.getState().selectedProfileId;
|
||||
|
||||
await API.createChannelFromStream({
|
||||
name: row.original.name,
|
||||
channel_number: null,
|
||||
stream_id: row.original.id,
|
||||
// Only pass channel_profile_ids if a specific profile is selected (not "All")
|
||||
...(selectedChannelProfileId !== '0' && { channel_profile_ids: selectedChannelProfileId }),
|
||||
});
|
||||
await API.requeryChannels();
|
||||
fetchLogos();
|
||||
|
|
@ -403,14 +407,34 @@ const StreamsTable = ({ }) => {
|
|||
// Bulk creation: create channels from selected streams in one API call
|
||||
const createChannelsFromStreams = async () => {
|
||||
setIsLoading(true);
|
||||
await API.createChannelsFromStreams(
|
||||
selectedStreamIds.map((stream_id) => ({
|
||||
stream_id,
|
||||
}))
|
||||
);
|
||||
await API.requeryChannels();
|
||||
fetchLogos();
|
||||
setIsLoading(false);
|
||||
try {
|
||||
const selectedChannelProfileId = useChannelsStore.getState().selectedProfileId;
|
||||
|
||||
const streamData = selectedStreamIds.map(streamId => {
|
||||
const stream = data.find(s => s.id === streamId);
|
||||
return {
|
||||
stream_id: streamId,
|
||||
name: stream?.name || `Stream ${streamId}`,
|
||||
...(selectedChannelProfileId !== '0' && { channel_profile_ids: selectedChannelProfileId }),
|
||||
};
|
||||
});
|
||||
|
||||
await API.createChannelsFromStreams(streamData);
|
||||
await API.requeryChannels();
|
||||
|
||||
// Refresh channel profiles to update the membership information
|
||||
await useChannelsStore.getState().fetchChannelProfiles();
|
||||
|
||||
fetchLogos();
|
||||
|
||||
// Clear selection and refresh data
|
||||
setSelectedStreamIds([]);
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error creating channels from streams:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editStream = async (stream = null) => {
|
||||
|
|
|
|||
|
|
@ -40,8 +40,7 @@ const useChannelsStore = create((set, get) => ({
|
|||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channels:', error);
|
||||
set({ error: 'Failed to load channels.', isLoading: false });
|
||||
set({ error: error.message, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -115,7 +114,6 @@ const useChannelsStore = create((set, get) => ({
|
|||
addChannels: (newChannels) =>
|
||||
set((state) => {
|
||||
const channelsByUUID = {};
|
||||
const logos = {};
|
||||
const profileChannels = new Set();
|
||||
|
||||
const channelsByID = newChannels.reduce((acc, channel) => {
|
||||
|
|
@ -126,14 +124,8 @@ const useChannelsStore = create((set, get) => ({
|
|||
return acc;
|
||||
}, {});
|
||||
|
||||
const newProfiles = { ...defaultProfiles };
|
||||
Object.entries(state.profiles).forEach(([id, profile]) => {
|
||||
newProfiles[id] = {
|
||||
...profile,
|
||||
channels: new Set([...profile.channels, ...profileChannels]),
|
||||
};
|
||||
});
|
||||
|
||||
// Don't automatically add to all profiles anymore - let the backend handle profile assignments
|
||||
// Just maintain the existing profile structure
|
||||
return {
|
||||
channels: {
|
||||
...state.channels,
|
||||
|
|
@ -143,7 +135,6 @@ const useChannelsStore = create((set, get) => ({
|
|||
...state.channelsByUUID,
|
||||
...channelsByUUID,
|
||||
},
|
||||
profiles: newProfiles,
|
||||
};
|
||||
}),
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue