Merge pull request #273 from Dispatcharr/dev

Release Notes – Version 0.7.1
This commit is contained in:
SergeantPanda 2025-07-29 16:05:23 -05:00 committed by GitHub
commit 38813e6c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 571 additions and 129 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

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