mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-28 12:36:42 +00:00
commit
da90ae5436
37 changed files with 4408 additions and 288 deletions
|
|
@ -6,6 +6,8 @@ from .api_views import (
|
|||
ChannelGroupViewSet,
|
||||
BulkDeleteStreamsAPIView,
|
||||
BulkDeleteChannelsAPIView,
|
||||
BulkDeleteLogosAPIView,
|
||||
CleanupUnusedLogosAPIView,
|
||||
LogoViewSet,
|
||||
ChannelProfileViewSet,
|
||||
UpdateChannelMembershipAPIView,
|
||||
|
|
@ -28,6 +30,8 @@ urlpatterns = [
|
|||
# Bulk delete is a single APIView, not a ViewSet
|
||||
path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'),
|
||||
path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'),
|
||||
path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'),
|
||||
path('logos/cleanup/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'),
|
||||
path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'),
|
||||
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
|
||||
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ from apps.accounts.permissions import (
|
|||
permission_classes_by_method,
|
||||
)
|
||||
|
||||
from core.models import UserAgent, CoreSettings
|
||||
|
||||
from .models import (
|
||||
Stream,
|
||||
Channel,
|
||||
|
|
@ -67,7 +69,7 @@ class OrInFilter(django_filters.Filter):
|
|||
|
||||
|
||||
class StreamPagination(PageNumberPagination):
|
||||
page_size = 25 # Default page size
|
||||
page_size = 50 # Default page size to match frontend default
|
||||
page_size_query_param = "page_size" # Allow clients to specify page size
|
||||
max_page_size = 10000 # Prevent excessive page sizes
|
||||
|
||||
|
|
@ -187,12 +189,97 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Add annotation for association counts"""
|
||||
from django.db.models import Count
|
||||
return ChannelGroup.objects.annotate(
|
||||
channel_count=Count('channels', distinct=True),
|
||||
m3u_account_count=Count('m3u_account', distinct=True)
|
||||
)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Override update to check M3U associations"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if group has M3U account associations
|
||||
if hasattr(instance, 'm3u_account') and instance.m3u_account.exists():
|
||||
return Response(
|
||||
{"error": "Cannot edit group with M3U account associations"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
"""Override partial_update to check M3U associations"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if group has M3U account associations
|
||||
if hasattr(instance, 'm3u_account') and instance.m3u_account.exists():
|
||||
return Response(
|
||||
{"error": "Cannot edit group with M3U account associations"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)",
|
||||
responses={200: "Cleanup completed"},
|
||||
)
|
||||
@action(detail=False, methods=["post"], url_path="cleanup")
|
||||
def cleanup_unused_groups(self, request):
|
||||
"""Delete all channel groups with no channels or M3U account associations"""
|
||||
from django.db.models import Count
|
||||
|
||||
# Find groups with no channels and no M3U account associations
|
||||
unused_groups = ChannelGroup.objects.annotate(
|
||||
channel_count=Count('channels', distinct=True),
|
||||
m3u_account_count=Count('m3u_account', distinct=True)
|
||||
).filter(
|
||||
channel_count=0,
|
||||
m3u_account_count=0
|
||||
)
|
||||
|
||||
deleted_count = unused_groups.count()
|
||||
group_names = list(unused_groups.values_list('name', flat=True))
|
||||
|
||||
# Delete the unused groups
|
||||
unused_groups.delete()
|
||||
|
||||
return Response({
|
||||
"message": f"Successfully deleted {deleted_count} unused channel groups",
|
||||
"deleted_count": deleted_count,
|
||||
"deleted_groups": group_names
|
||||
})
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Override destroy to check for associations before deletion"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if group has associated channels
|
||||
if instance.channels.exists():
|
||||
return Response(
|
||||
{"error": "Cannot delete group with associated channels"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if group has M3U account associations
|
||||
if hasattr(instance, 'm3u_account') and instance.m3u_account.exists():
|
||||
return Response(
|
||||
{"error": "Cannot delete group with M3U account associations"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 3) Channel Management (CRUD)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
class ChannelPagination(PageNumberPagination):
|
||||
page_size = 25 # Default page size
|
||||
page_size = 50 # Default page size to match frontend default
|
||||
page_size_query_param = "page_size" # Allow clients to specify page size
|
||||
max_page_size = 10000 # Prevent excessive page sizes
|
||||
|
||||
|
|
@ -936,6 +1023,142 @@ class BulkDeleteChannelsAPIView(APIView):
|
|||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 6) Bulk Delete Logos
|
||||
# ─────────────────────────────────────────────────────────
|
||||
class BulkDeleteLogosAPIView(APIView):
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [
|
||||
perm() for perm in permission_classes_by_method[self.request.method]
|
||||
]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Bulk delete logos by ID",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["logo_ids"],
|
||||
properties={
|
||||
"logo_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="Logo IDs to delete",
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={204: "Logos deleted"},
|
||||
)
|
||||
def delete(self, request):
|
||||
logo_ids = request.data.get("logo_ids", [])
|
||||
delete_files = request.data.get("delete_files", False)
|
||||
|
||||
# Get logos and their usage info before deletion
|
||||
logos_to_delete = Logo.objects.filter(id__in=logo_ids)
|
||||
total_channels_affected = 0
|
||||
local_files_deleted = 0
|
||||
|
||||
for logo in logos_to_delete:
|
||||
# Handle file deletion for local files
|
||||
if delete_files and logo.url and logo.url.startswith('/data/logos'):
|
||||
try:
|
||||
if os.path.exists(logo.url):
|
||||
os.remove(logo.url)
|
||||
local_files_deleted += 1
|
||||
logger.info(f"Deleted local logo file: {logo.url}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete logo file {logo.url}: {str(e)}")
|
||||
return Response(
|
||||
{"error": f"Failed to delete logo file {logo.url}: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
if logo.channels.exists():
|
||||
channel_count = logo.channels.count()
|
||||
total_channels_affected += channel_count
|
||||
# Remove logo from channels
|
||||
logo.channels.update(logo=None)
|
||||
logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion")
|
||||
|
||||
# Delete logos
|
||||
deleted_count = logos_to_delete.delete()[0]
|
||||
|
||||
message = f"Successfully deleted {deleted_count} logos"
|
||||
if total_channels_affected > 0:
|
||||
message += f" and removed them from {total_channels_affected} channels"
|
||||
if local_files_deleted > 0:
|
||||
message += f" and deleted {local_files_deleted} local files"
|
||||
|
||||
return Response(
|
||||
{"message": message},
|
||||
status=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
|
||||
|
||||
class CleanupUnusedLogosAPIView(APIView):
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [
|
||||
perm() for perm in permission_classes_by_method[self.request.method]
|
||||
]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Delete all logos that are not used by any channels",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
"delete_files": openapi.Schema(
|
||||
type=openapi.TYPE_BOOLEAN,
|
||||
description="Whether to delete local logo files from disk",
|
||||
default=False
|
||||
)
|
||||
},
|
||||
),
|
||||
responses={200: "Cleanup completed"},
|
||||
)
|
||||
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))
|
||||
local_files_deleted = 0
|
||||
|
||||
# Handle file deletion for local files if requested
|
||||
if delete_files:
|
||||
for logo in unused_logos:
|
||||
if logo.url and logo.url.startswith('/data/logos'):
|
||||
try:
|
||||
if os.path.exists(logo.url):
|
||||
os.remove(logo.url)
|
||||
local_files_deleted += 1
|
||||
logger.info(f"Deleted local logo file: {logo.url}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete logo file {logo.url}: {str(e)}")
|
||||
return Response(
|
||||
{"error": f"Failed to delete logo file {logo.url}: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# Delete the unused logos
|
||||
unused_logos.delete()
|
||||
|
||||
message = f"Successfully deleted {deleted_count} unused logos"
|
||||
if local_files_deleted > 0:
|
||||
message += f" and deleted {local_files_deleted} local files"
|
||||
|
||||
return Response({
|
||||
"message": message,
|
||||
"deleted_count": deleted_count,
|
||||
"deleted_logos": logo_names,
|
||||
"local_files_deleted": local_files_deleted
|
||||
})
|
||||
|
||||
|
||||
class LogoViewSet(viewsets.ModelViewSet):
|
||||
queryset = Logo.objects.all()
|
||||
serializer_class = LogoSerializer
|
||||
|
|
@ -953,6 +1176,62 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optimize queryset with prefetch and add filtering"""
|
||||
queryset = Logo.objects.prefetch_related('channels').order_by('name')
|
||||
|
||||
# Filter by usage
|
||||
used_filter = self.request.query_params.get('used', None)
|
||||
if used_filter == 'true':
|
||||
queryset = queryset.filter(channels__isnull=False).distinct()
|
||||
elif used_filter == 'false':
|
||||
queryset = queryset.filter(channels__isnull=True)
|
||||
|
||||
# Filter by name
|
||||
name_filter = self.request.query_params.get('name', None)
|
||||
if name_filter:
|
||||
queryset = queryset.filter(name__icontains=name_filter)
|
||||
|
||||
return queryset
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Create a new logo entry"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
logo = serializer.save()
|
||||
return Response(self.get_serializer(logo).data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Update an existing logo"""
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete a logo and remove it from any channels using it"""
|
||||
logo = self.get_object()
|
||||
delete_file = request.query_params.get('delete_file', 'false').lower() == 'true'
|
||||
|
||||
# Check if it's a local file that should be deleted
|
||||
if delete_file and logo.url and logo.url.startswith('/data/logos'):
|
||||
try:
|
||||
if os.path.exists(logo.url):
|
||||
os.remove(logo.url)
|
||||
logger.info(f"Deleted local logo file: {logo.url}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete logo file {logo.url}: {str(e)}")
|
||||
return Response(
|
||||
{"error": f"Failed to delete logo file: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
# Instead of preventing deletion, remove the logo from channels
|
||||
if logo.channels.exists():
|
||||
channel_count = logo.channels.count()
|
||||
logo.channels.update(logo=None)
|
||||
logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion")
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def upload(self, request):
|
||||
if "file" not in request.FILES:
|
||||
|
|
@ -961,6 +1240,16 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
)
|
||||
|
||||
file = request.FILES["file"]
|
||||
|
||||
# Validate file
|
||||
try:
|
||||
from dispatcharr.utils import validate_logo_file
|
||||
validate_logo_file(file)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
file_name = file.name
|
||||
file_path = os.path.join("/data/logos", file_name)
|
||||
|
||||
|
|
@ -976,8 +1265,10 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
},
|
||||
)
|
||||
|
||||
# Use get_serializer to ensure proper context
|
||||
serializer = self.get_serializer(logo)
|
||||
return Response(
|
||||
{"id": logo.id, "name": logo.name, "url": logo.url},
|
||||
serializer.data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
|
@ -1007,7 +1298,22 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
|
||||
else: # Remote image
|
||||
try:
|
||||
remote_response = requests.get(logo_url, stream=True)
|
||||
# Get the default user agent
|
||||
try:
|
||||
default_user_agent_id = CoreSettings.get_default_user_agent_id()
|
||||
user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id))
|
||||
user_agent = user_agent_obj.user_agent
|
||||
except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError):
|
||||
# Fallback to hardcoded if default not found
|
||||
user_agent = 'Dispatcharr/1.0'
|
||||
|
||||
# Add proper timeouts to prevent hanging
|
||||
remote_response = requests.get(
|
||||
logo_url,
|
||||
stream=True,
|
||||
timeout=(3, 5), # (connect_timeout, read_timeout)
|
||||
headers={'User-Agent': user_agent}
|
||||
)
|
||||
if remote_response.status_code == 200:
|
||||
# Try to get content type from response headers first
|
||||
content_type = remote_response.headers.get("Content-Type")
|
||||
|
|
@ -1029,7 +1335,14 @@ class LogoViewSet(viewsets.ModelViewSet):
|
|||
)
|
||||
return response
|
||||
raise Http404("Remote image not found")
|
||||
except requests.RequestException:
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"Timeout fetching logo from {logo_url}")
|
||||
raise Http404("Logo request timed out")
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.warning(f"Connection error fetching logo from {logo_url}")
|
||||
raise Http404("Unable to connect to logo server")
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Error fetching logo from {logo_url}: {e}")
|
||||
raise Http404("Error fetching remote image")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 5.1.6 on 2025-07-13 23:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0021_channel_user_level'),
|
||||
('m3u', '0012_alter_m3uaccount_refresh_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='auto_created',
|
||||
field=models.BooleanField(default=False, help_text='Whether this channel was automatically created via M3U auto channel sync'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='auto_created_by',
|
||||
field=models.ForeignKey(blank=True, help_text='The M3U account that auto-created this channel', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='auto_created_channels', to='m3u.m3uaccount'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='channelgroupm3uaccount',
|
||||
name='auto_channel_sync',
|
||||
field=models.BooleanField(default=False, help_text='Automatically create/delete channels to match streams in this group'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='channelgroupm3uaccount',
|
||||
name='auto_sync_channel_start',
|
||||
field=models.FloatField(blank=True, help_text='Starting channel number for auto-created channels in this group', null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -277,6 +277,19 @@ class Channel(models.Model):
|
|||
|
||||
user_level = models.IntegerField(default=0)
|
||||
|
||||
auto_created = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this channel was automatically created via M3U auto channel sync"
|
||||
)
|
||||
auto_created_by = models.ForeignKey(
|
||||
"m3u.M3UAccount",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="auto_created_channels",
|
||||
help_text="The M3U account that auto-created this channel"
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
# Enforce unique channel_number within a given group
|
||||
existing = Channel.objects.filter(
|
||||
|
|
@ -541,6 +554,15 @@ class ChannelGroupM3UAccount(models.Model):
|
|||
)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
auto_channel_sync = models.BooleanField(
|
||||
default=False,
|
||||
help_text='Automatically create/delete channels to match streams in this group'
|
||||
)
|
||||
auto_sync_channel_start = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Starting channel number for auto-created channels in this group'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("channel_group", "m3u_account")
|
||||
|
|
|
|||
|
|
@ -20,10 +20,23 @@ from django.utils import timezone
|
|||
|
||||
class LogoSerializer(serializers.ModelSerializer):
|
||||
cache_url = serializers.SerializerMethodField()
|
||||
channel_count = serializers.SerializerMethodField()
|
||||
is_used = serializers.SerializerMethodField()
|
||||
channel_names = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Logo
|
||||
fields = ["id", "name", "url", "cache_url"]
|
||||
fields = ["id", "name", "url", "cache_url", "channel_count", "is_used", "channel_names"]
|
||||
|
||||
def validate_url(self, value):
|
||||
"""Validate that the URL is unique for creation or update"""
|
||||
if self.instance and self.instance.url == value:
|
||||
return value
|
||||
|
||||
if Logo.objects.filter(url=value).exists():
|
||||
raise serializers.ValidationError("A logo with this URL already exists.")
|
||||
|
||||
return value
|
||||
|
||||
def get_cache_url(self, obj):
|
||||
# return f"/api/channels/logos/{obj.id}/cache/"
|
||||
|
|
@ -34,6 +47,22 @@ class LogoSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
return reverse("api:channels:logo-cache", args=[obj.id])
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
"""Get the number of channels using this logo"""
|
||||
return obj.channels.count()
|
||||
|
||||
def get_is_used(self, obj):
|
||||
"""Check if this logo is used by any channels"""
|
||||
return obj.channels.exists()
|
||||
|
||||
def get_channel_names(self, obj):
|
||||
"""Get the names of channels using this logo (limited to first 5)"""
|
||||
channels = obj.channels.all()[:5]
|
||||
names = [channel.name for channel in channels]
|
||||
if obj.channels.count() > 5:
|
||||
names.append(f"...and {obj.channels.count() - 5} more")
|
||||
return names
|
||||
|
||||
|
||||
#
|
||||
# Stream
|
||||
|
|
@ -89,9 +118,12 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
# Channel Group
|
||||
#
|
||||
class ChannelGroupSerializer(serializers.ModelSerializer):
|
||||
channel_count = serializers.IntegerField(read_only=True)
|
||||
m3u_account_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ChannelGroup
|
||||
fields = ["id", "name"]
|
||||
fields = ["id", "name", "channel_count", "m3u_account_count"]
|
||||
|
||||
|
||||
class ChannelProfileSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -170,6 +202,8 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
required=False,
|
||||
)
|
||||
|
||||
auto_created_by_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Channel
|
||||
fields = [
|
||||
|
|
@ -185,6 +219,9 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
"uuid",
|
||||
"logo_id",
|
||||
"user_level",
|
||||
"auto_created",
|
||||
"auto_created_by",
|
||||
"auto_created_by_name",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
|
@ -283,16 +320,45 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
return None
|
||||
return value # PrimaryKeyRelatedField will handle the conversion to object
|
||||
|
||||
def get_auto_created_by_name(self, obj):
|
||||
"""Get the name of the M3U account that auto-created this channel."""
|
||||
if obj.auto_created_by:
|
||||
return obj.auto_created_by.name
|
||||
return None
|
||||
|
||||
|
||||
class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
||||
enabled = serializers.BooleanField()
|
||||
auto_channel_sync = serializers.BooleanField(default=False)
|
||||
auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False)
|
||||
custom_properties = serializers.JSONField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = ChannelGroupM3UAccount
|
||||
fields = ["id", "channel_group", "enabled"]
|
||||
fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"]
|
||||
|
||||
# Optionally, if you only need the id of the ChannelGroup, you can customize it like this:
|
||||
# channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all())
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
# Ensure custom_properties is always a dict or None
|
||||
val = ret.get("custom_properties")
|
||||
if isinstance(val, str):
|
||||
import json
|
||||
try:
|
||||
ret["custom_properties"] = json.loads(val)
|
||||
except Exception:
|
||||
ret["custom_properties"] = None
|
||||
return ret
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# Accept both dict and JSON string for custom_properties
|
||||
val = data.get("custom_properties")
|
||||
if isinstance(val, str):
|
||||
import json
|
||||
try:
|
||||
data["custom_properties"] = json.loads(val)
|
||||
except Exception:
|
||||
pass
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
class RecordingSerializer(serializers.ModelSerializer):
|
||||
|
|
|
|||
|
|
@ -1612,6 +1612,11 @@ def extract_custom_properties(prog):
|
|||
if categories:
|
||||
custom_props['categories'] = categories
|
||||
|
||||
# Extract keywords (new)
|
||||
keywords = [kw.text.strip() for kw in prog.findall('keyword') if kw.text and kw.text.strip()]
|
||||
if keywords:
|
||||
custom_props['keywords'] = keywords
|
||||
|
||||
# Extract episode numbers
|
||||
for ep_num in prog.findall('episode-num'):
|
||||
system = ep_num.get('system', '')
|
||||
|
|
@ -1637,6 +1642,9 @@ def extract_custom_properties(prog):
|
|||
elif system == 'dd_progid' and ep_num.text:
|
||||
# Store the dd_progid format
|
||||
custom_props['dd_progid'] = ep_num.text.strip()
|
||||
# Add support for other systems like thetvdb.com, themoviedb.org, imdb.com
|
||||
elif system in ['thetvdb.com', 'themoviedb.org', 'imdb.com'] and ep_num.text:
|
||||
custom_props[f'{system}_id'] = ep_num.text.strip()
|
||||
|
||||
# Extract ratings more efficiently
|
||||
rating_elem = prog.find('rating')
|
||||
|
|
@ -1647,37 +1655,172 @@ def extract_custom_properties(prog):
|
|||
if rating_elem.get('system'):
|
||||
custom_props['rating_system'] = rating_elem.get('system')
|
||||
|
||||
# Extract star ratings (new)
|
||||
star_ratings = []
|
||||
for star_rating in prog.findall('star-rating'):
|
||||
value_elem = star_rating.find('value')
|
||||
if value_elem is not None and value_elem.text:
|
||||
rating_data = {'value': value_elem.text.strip()}
|
||||
if star_rating.get('system'):
|
||||
rating_data['system'] = star_rating.get('system')
|
||||
star_ratings.append(rating_data)
|
||||
if star_ratings:
|
||||
custom_props['star_ratings'] = star_ratings
|
||||
|
||||
# Extract credits more efficiently
|
||||
credits_elem = prog.find('credits')
|
||||
if credits_elem is not None:
|
||||
credits = {}
|
||||
for credit_type in ['director', 'actor', 'writer', 'presenter', 'producer']:
|
||||
names = [e.text.strip() for e in credits_elem.findall(credit_type) if e.text and e.text.strip()]
|
||||
if names:
|
||||
credits[credit_type] = names
|
||||
for credit_type in ['director', 'actor', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']:
|
||||
if credit_type == 'actor':
|
||||
# Handle actors with roles and guest status
|
||||
actors = []
|
||||
for actor_elem in credits_elem.findall('actor'):
|
||||
if actor_elem.text and actor_elem.text.strip():
|
||||
actor_data = {'name': actor_elem.text.strip()}
|
||||
if actor_elem.get('role'):
|
||||
actor_data['role'] = actor_elem.get('role')
|
||||
if actor_elem.get('guest') == 'yes':
|
||||
actor_data['guest'] = True
|
||||
actors.append(actor_data)
|
||||
if actors:
|
||||
credits['actor'] = actors
|
||||
else:
|
||||
names = [e.text.strip() for e in credits_elem.findall(credit_type) if e.text and e.text.strip()]
|
||||
if names:
|
||||
credits[credit_type] = names
|
||||
if credits:
|
||||
custom_props['credits'] = credits
|
||||
|
||||
# Extract other common program metadata
|
||||
date_elem = prog.find('date')
|
||||
if date_elem is not None and date_elem.text:
|
||||
custom_props['year'] = date_elem.text.strip()[:4] # Just the year part
|
||||
custom_props['date'] = date_elem.text.strip()
|
||||
|
||||
country_elem = prog.find('country')
|
||||
if country_elem is not None and country_elem.text:
|
||||
custom_props['country'] = country_elem.text.strip()
|
||||
|
||||
# Extract language information (new)
|
||||
language_elem = prog.find('language')
|
||||
if language_elem is not None and language_elem.text:
|
||||
custom_props['language'] = language_elem.text.strip()
|
||||
|
||||
orig_language_elem = prog.find('orig-language')
|
||||
if orig_language_elem is not None and orig_language_elem.text:
|
||||
custom_props['original_language'] = orig_language_elem.text.strip()
|
||||
|
||||
# Extract length (new)
|
||||
length_elem = prog.find('length')
|
||||
if length_elem is not None and length_elem.text:
|
||||
try:
|
||||
length_value = int(length_elem.text.strip())
|
||||
length_units = length_elem.get('units', 'minutes')
|
||||
custom_props['length'] = {'value': length_value, 'units': length_units}
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Extract video information (new)
|
||||
video_elem = prog.find('video')
|
||||
if video_elem is not None:
|
||||
video_info = {}
|
||||
for video_attr in ['present', 'colour', 'aspect', 'quality']:
|
||||
attr_elem = video_elem.find(video_attr)
|
||||
if attr_elem is not None and attr_elem.text:
|
||||
video_info[video_attr] = attr_elem.text.strip()
|
||||
if video_info:
|
||||
custom_props['video'] = video_info
|
||||
|
||||
# Extract audio information (new)
|
||||
audio_elem = prog.find('audio')
|
||||
if audio_elem is not None:
|
||||
audio_info = {}
|
||||
for audio_attr in ['present', 'stereo']:
|
||||
attr_elem = audio_elem.find(audio_attr)
|
||||
if attr_elem is not None and attr_elem.text:
|
||||
audio_info[audio_attr] = attr_elem.text.strip()
|
||||
if audio_info:
|
||||
custom_props['audio'] = audio_info
|
||||
|
||||
# Extract subtitles information (new)
|
||||
subtitles = []
|
||||
for subtitle_elem in prog.findall('subtitles'):
|
||||
subtitle_data = {}
|
||||
if subtitle_elem.get('type'):
|
||||
subtitle_data['type'] = subtitle_elem.get('type')
|
||||
lang_elem = subtitle_elem.find('language')
|
||||
if lang_elem is not None and lang_elem.text:
|
||||
subtitle_data['language'] = lang_elem.text.strip()
|
||||
if subtitle_data:
|
||||
subtitles.append(subtitle_data)
|
||||
|
||||
if subtitles:
|
||||
custom_props['subtitles'] = subtitles
|
||||
|
||||
# Extract reviews (new)
|
||||
reviews = []
|
||||
for review_elem in prog.findall('review'):
|
||||
if review_elem.text and review_elem.text.strip():
|
||||
review_data = {'content': review_elem.text.strip()}
|
||||
if review_elem.get('type'):
|
||||
review_data['type'] = review_elem.get('type')
|
||||
if review_elem.get('source'):
|
||||
review_data['source'] = review_elem.get('source')
|
||||
if review_elem.get('reviewer'):
|
||||
review_data['reviewer'] = review_elem.get('reviewer')
|
||||
reviews.append(review_data)
|
||||
if reviews:
|
||||
custom_props['reviews'] = reviews
|
||||
|
||||
# Extract images (new)
|
||||
images = []
|
||||
for image_elem in prog.findall('image'):
|
||||
if image_elem.text and image_elem.text.strip():
|
||||
image_data = {'url': image_elem.text.strip()}
|
||||
for attr in ['type', 'size', 'orient', 'system']:
|
||||
if image_elem.get(attr):
|
||||
image_data[attr] = image_elem.get(attr)
|
||||
images.append(image_data)
|
||||
if images:
|
||||
custom_props['images'] = images
|
||||
|
||||
icon_elem = prog.find('icon')
|
||||
if icon_elem is not None and icon_elem.get('src'):
|
||||
custom_props['icon'] = icon_elem.get('src')
|
||||
|
||||
# Simpler approach for boolean flags
|
||||
for kw in ['previously-shown', 'premiere', 'new', 'live']:
|
||||
# Simpler approach for boolean flags - expanded list
|
||||
for kw in ['previously-shown', 'premiere', 'new', 'live', 'last-chance']:
|
||||
if prog.find(kw) is not None:
|
||||
custom_props[kw.replace('-', '_')] = True
|
||||
|
||||
# Extract premiere and last-chance text content if available
|
||||
premiere_elem = prog.find('premiere')
|
||||
if premiere_elem is not None:
|
||||
custom_props['premiere'] = True
|
||||
if premiere_elem.text and premiere_elem.text.strip():
|
||||
custom_props['premiere_text'] = premiere_elem.text.strip()
|
||||
|
||||
last_chance_elem = prog.find('last-chance')
|
||||
if last_chance_elem is not None:
|
||||
custom_props['last_chance'] = True
|
||||
if last_chance_elem.text and last_chance_elem.text.strip():
|
||||
custom_props['last_chance_text'] = last_chance_elem.text.strip()
|
||||
|
||||
# Extract previously-shown details
|
||||
prev_shown_elem = prog.find('previously-shown')
|
||||
if prev_shown_elem is not None:
|
||||
custom_props['previously_shown'] = True
|
||||
prev_shown_data = {}
|
||||
if prev_shown_elem.get('start'):
|
||||
prev_shown_data['start'] = prev_shown_elem.get('start')
|
||||
if prev_shown_elem.get('channel'):
|
||||
prev_shown_data['channel'] = prev_shown_elem.get('channel')
|
||||
if prev_shown_data:
|
||||
custom_props['previously_shown_details'] = prev_shown_data
|
||||
|
||||
return custom_props
|
||||
|
||||
|
||||
def clear_element(elem):
|
||||
"""Clear an XML element and its parent to free memory."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -15,14 +15,13 @@ import os
|
|||
from rest_framework.decorators import action
|
||||
from django.conf import settings
|
||||
from .tasks import refresh_m3u_groups
|
||||
import json
|
||||
|
||||
# Import all models, including UserAgent.
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
from core.models import UserAgent
|
||||
from apps.channels.models import ChannelGroupM3UAccount
|
||||
from core.serializers import UserAgentSerializer
|
||||
|
||||
# Import all serializers, including the UserAgentSerializer.
|
||||
from .serializers import (
|
||||
M3UAccountSerializer,
|
||||
M3UFilterSerializer,
|
||||
|
|
@ -144,6 +143,44 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
# Continue with regular partial update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=["patch"], url_path="group-settings")
|
||||
def update_group_settings(self, request, pk=None):
|
||||
"""Update auto channel sync settings for M3U account groups"""
|
||||
account = self.get_object()
|
||||
group_settings = request.data.get("group_settings", [])
|
||||
|
||||
try:
|
||||
for setting in group_settings:
|
||||
group_id = setting.get("channel_group")
|
||||
enabled = setting.get("enabled", True)
|
||||
auto_sync = setting.get("auto_channel_sync", False)
|
||||
sync_start = setting.get("auto_sync_channel_start")
|
||||
custom_properties = setting.get("custom_properties", {})
|
||||
|
||||
if group_id:
|
||||
ChannelGroupM3UAccount.objects.update_or_create(
|
||||
channel_group_id=group_id,
|
||||
m3u_account=account,
|
||||
defaults={
|
||||
"enabled": enabled,
|
||||
"auto_channel_sync": auto_sync,
|
||||
"auto_sync_channel_start": sync_start,
|
||||
"custom_properties": (
|
||||
custom_properties
|
||||
if isinstance(custom_properties, str)
|
||||
else json.dumps(custom_properties)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return Response({"message": "Group settings updated successfully"})
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": f"Failed to update group settings: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class M3UFilterViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U filters"""
|
||||
|
|
|
|||
|
|
@ -285,57 +285,56 @@ def process_xc_category(account_id, batch, groups, hash_keys):
|
|||
stream_hashes = {}
|
||||
|
||||
try:
|
||||
xc_client = XCClient(account.server_url, account.username, account.password, account.get_user_agent())
|
||||
with XCClient(account.server_url, account.username, account.password, account.get_user_agent()) as xc_client:
|
||||
# Log the batch details to help with debugging
|
||||
logger.debug(f"Processing XC batch: {batch}")
|
||||
|
||||
# Log the batch details to help with debugging
|
||||
logger.debug(f"Processing XC batch: {batch}")
|
||||
|
||||
for group_name, props in batch.items():
|
||||
# Check if we have a valid xc_id for this group
|
||||
if 'xc_id' not in props:
|
||||
logger.error(f"Missing xc_id for group {group_name} in batch {batch}")
|
||||
continue
|
||||
|
||||
# Get actual group ID from the mapping
|
||||
group_id = groups.get(group_name)
|
||||
if not group_id:
|
||||
logger.error(f"Group {group_name} not found in enabled groups")
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching streams for XC category: {group_name} (ID: {props['xc_id']})")
|
||||
streams = xc_client.get_live_category_streams(props['xc_id'])
|
||||
|
||||
if not streams:
|
||||
logger.warning(f"No streams found for XC category {group_name} (ID: {props['xc_id']})")
|
||||
for group_name, props in batch.items():
|
||||
# Check if we have a valid xc_id for this group
|
||||
if 'xc_id' not in props:
|
||||
logger.error(f"Missing xc_id for group {group_name} in batch {batch}")
|
||||
continue
|
||||
|
||||
logger.debug(f"Found {len(streams)} streams for category {group_name}")
|
||||
# Get actual group ID from the mapping
|
||||
group_id = groups.get(group_name)
|
||||
if not group_id:
|
||||
logger.error(f"Group {group_name} not found in enabled groups")
|
||||
continue
|
||||
|
||||
for stream in streams:
|
||||
name = stream["name"]
|
||||
url = xc_client.get_stream_url(stream["stream_id"])
|
||||
tvg_id = stream.get("epg_channel_id", "")
|
||||
tvg_logo = stream.get("stream_icon", "")
|
||||
group_title = group_name
|
||||
try:
|
||||
logger.debug(f"Fetching streams for XC category: {group_name} (ID: {props['xc_id']})")
|
||||
streams = xc_client.get_live_category_streams(props['xc_id'])
|
||||
|
||||
stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys)
|
||||
stream_props = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
"logo_url": tvg_logo,
|
||||
"tvg_id": tvg_id,
|
||||
"m3u_account": account,
|
||||
"channel_group_id": int(group_id),
|
||||
"stream_hash": stream_hash,
|
||||
"custom_properties": json.dumps(stream),
|
||||
}
|
||||
if not streams:
|
||||
logger.warning(f"No streams found for XC category {group_name} (ID: {props['xc_id']})")
|
||||
continue
|
||||
|
||||
if stream_hash not in stream_hashes:
|
||||
stream_hashes[stream_hash] = stream_props
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing XC category {group_name} (ID: {props['xc_id']}): {str(e)}")
|
||||
continue
|
||||
logger.debug(f"Found {len(streams)} streams for category {group_name}")
|
||||
|
||||
for stream in streams:
|
||||
name = stream["name"]
|
||||
url = xc_client.get_stream_url(stream["stream_id"])
|
||||
tvg_id = stream.get("epg_channel_id", "")
|
||||
tvg_logo = stream.get("stream_icon", "")
|
||||
group_title = group_name
|
||||
|
||||
stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys)
|
||||
stream_props = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
"logo_url": tvg_logo,
|
||||
"tvg_id": tvg_id,
|
||||
"m3u_account": account,
|
||||
"channel_group_id": int(group_id),
|
||||
"stream_hash": stream_hash,
|
||||
"custom_properties": json.dumps(stream),
|
||||
}
|
||||
|
||||
if stream_hash not in stream_hashes:
|
||||
stream_hashes[stream_hash] = stream_props
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing XC category {group_name} (ID: {props['xc_id']}): {str(e)}")
|
||||
continue
|
||||
|
||||
# Process all found streams
|
||||
existing_streams = {s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys())}
|
||||
|
|
@ -622,62 +621,63 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
|
|||
|
||||
# Create XCClient with explicit error handling
|
||||
try:
|
||||
xc_client = XCClient(server_url, account.username, account.password, user_agent_string)
|
||||
logger.info(f"XCClient instance created successfully")
|
||||
with XCClient(server_url, account.username, account.password, user_agent_string) as xc_client:
|
||||
logger.info(f"XCClient instance created successfully")
|
||||
|
||||
# Authenticate with detailed error handling
|
||||
try:
|
||||
logger.debug(f"Authenticating with XC server {server_url}")
|
||||
auth_result = xc_client.authenticate()
|
||||
logger.debug(f"Authentication response: {auth_result}")
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to authenticate with XC server: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg)
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return error_msg, None
|
||||
|
||||
# Get categories with detailed error handling
|
||||
try:
|
||||
logger.info(f"Getting live categories from XC server")
|
||||
xc_categories = xc_client.get_live_categories()
|
||||
logger.info(f"Found {len(xc_categories)} categories: {xc_categories}")
|
||||
|
||||
# Validate response
|
||||
if not isinstance(xc_categories, list):
|
||||
error_msg = f"Unexpected response from XC server: {xc_categories}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg)
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return error_msg, None
|
||||
|
||||
if len(xc_categories) == 0:
|
||||
logger.warning("No categories found in XC server response")
|
||||
|
||||
for category in xc_categories:
|
||||
cat_name = category.get("category_name", "Unknown Category")
|
||||
cat_id = category.get("category_id", "0")
|
||||
logger.info(f"Adding category: {cat_name} (ID: {cat_id})")
|
||||
groups[cat_name] = {
|
||||
"xc_id": cat_id,
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to get categories from XC server: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg)
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return error_msg, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to create XCClient: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg)
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return error_msg, None
|
||||
|
||||
# Authenticate with detailed error handling
|
||||
try:
|
||||
logger.debug(f"Authenticating with XC server {server_url}")
|
||||
auth_result = xc_client.authenticate()
|
||||
logger.debug(f"Authentication response: {auth_result}")
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to authenticate with XC server: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg)
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return error_msg, None
|
||||
|
||||
# Get categories with detailed error handling
|
||||
try:
|
||||
logger.info(f"Getting live categories from XC server")
|
||||
xc_categories = xc_client.get_live_categories()
|
||||
logger.info(f"Found {len(xc_categories)} categories: {xc_categories}")
|
||||
|
||||
# Validate response
|
||||
if not isinstance(xc_categories, list):
|
||||
error_msg = f"Unexpected response from XC server: {xc_categories}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "processing_groups", 100, status="error", error=error_msg)
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return error_msg, None
|
||||
|
||||
if len(xc_categories) == 0:
|
||||
logger.warning("No categories found in XC server response")
|
||||
|
||||
for category in xc_categories:
|
||||
cat_name = category.get("category_name", "Unknown Category")
|
||||
cat_id = category.get("category_id", "0")
|
||||
logger.info(f"Adding category: {cat_name} (ID: {cat_id})")
|
||||
groups[cat_name] = {
|
||||
"xc_id": cat_id,
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to get categories from XC server: {str(e)}"
|
||||
error_msg = f"Failed to create XC Client: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
|
|
@ -686,7 +686,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
|
|||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return error_msg, None
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error in XC processing: {str(e)}"
|
||||
error_msg = f"Unexpected error occurred in XC Client: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
|
|
@ -838,6 +838,281 @@ def delete_m3u_refresh_task_by_id(account_id):
|
|||
logger.error(f"Error deleting periodic task for M3UAccount {account_id}: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
@shared_task
|
||||
def sync_auto_channels(account_id, scan_start_time=None):
|
||||
"""
|
||||
Automatically create/update/delete channels to match streams in groups with auto_channel_sync enabled.
|
||||
Preserves existing channel UUIDs to maintain M3U link integrity.
|
||||
Called after M3U refresh completes successfully.
|
||||
"""
|
||||
from apps.channels.models import Channel, ChannelGroup, ChannelGroupM3UAccount, Stream, ChannelStream
|
||||
from apps.epg.models import EPGData
|
||||
from django.utils import timezone
|
||||
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
logger.info(f"Starting auto channel sync for M3U account {account.name}")
|
||||
|
||||
# Always use scan_start_time as the cutoff for last_seen
|
||||
if scan_start_time is not None:
|
||||
if isinstance(scan_start_time, str):
|
||||
scan_start_time = timezone.datetime.fromisoformat(scan_start_time)
|
||||
else:
|
||||
scan_start_time = timezone.now()
|
||||
|
||||
# Get groups with auto sync enabled for this account
|
||||
auto_sync_groups = ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account=account,
|
||||
enabled=True,
|
||||
auto_channel_sync=True
|
||||
).select_related('channel_group')
|
||||
|
||||
channels_created = 0
|
||||
channels_updated = 0
|
||||
channels_deleted = 0
|
||||
|
||||
for group_relation in auto_sync_groups:
|
||||
channel_group = group_relation.channel_group
|
||||
start_number = group_relation.auto_sync_channel_start or 1.0
|
||||
|
||||
# Get force_dummy_epg, group_override, and regex patterns from group custom_properties
|
||||
group_custom_props = {}
|
||||
force_dummy_epg = False
|
||||
override_group_id = None
|
||||
name_regex_pattern = None
|
||||
name_replace_pattern = None
|
||||
name_match_regex = None
|
||||
if group_relation.custom_properties:
|
||||
try:
|
||||
group_custom_props = json.loads(group_relation.custom_properties)
|
||||
force_dummy_epg = group_custom_props.get("force_dummy_epg", False)
|
||||
override_group_id = group_custom_props.get("group_override")
|
||||
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")
|
||||
except Exception:
|
||||
force_dummy_epg = False
|
||||
override_group_id = None
|
||||
name_regex_pattern = None
|
||||
name_replace_pattern = None
|
||||
name_match_regex = None
|
||||
|
||||
# Determine which group to use for created channels
|
||||
target_group = channel_group
|
||||
if override_group_id:
|
||||
try:
|
||||
target_group = ChannelGroup.objects.get(id=override_group_id)
|
||||
logger.info(f"Using override group '{target_group.name}' instead of '{channel_group.name}' for auto-created channels")
|
||||
except ChannelGroup.DoesNotExist:
|
||||
logger.warning(f"Override group with ID {override_group_id} not found, using original group '{channel_group.name}'")
|
||||
|
||||
logger.info(f"Processing auto sync for group: {channel_group.name} (start: {start_number})")
|
||||
|
||||
# Get all current streams in this group for this M3U account, filter out stale streams
|
||||
current_streams = Stream.objects.filter(
|
||||
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.")
|
||||
|
||||
# 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(
|
||||
auto_created=True,
|
||||
auto_created_by=account
|
||||
).select_related('logo', 'epg_data')
|
||||
|
||||
# Create mapping of existing channels by their associated stream
|
||||
# This approach finds channels even if they've been moved to different groups
|
||||
existing_channel_map = {}
|
||||
for channel in existing_channels:
|
||||
# Get streams associated with this channel that belong to our M3U account and original group
|
||||
channel_streams = ChannelStream.objects.filter(
|
||||
channel=channel,
|
||||
stream__m3u_account=account,
|
||||
stream__channel_group=channel_group # Match streams from the original group
|
||||
).select_related('stream')
|
||||
|
||||
# Map each of our M3U account's streams to this channel
|
||||
for channel_stream in channel_streams:
|
||||
if channel_stream.stream:
|
||||
existing_channel_map[channel_stream.stream.id] = channel
|
||||
|
||||
# Track which streams we've processed
|
||||
processed_stream_ids = set()
|
||||
|
||||
if not current_streams.exists():
|
||||
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()]
|
||||
if channels_to_delete:
|
||||
deleted_count = len(channels_to_delete)
|
||||
Channel.objects.filter(id__in=[ch.id for ch in channels_to_delete]).delete()
|
||||
channels_deleted += deleted_count
|
||||
logger.debug(f"Deleted {deleted_count} auto channels (no streams remaining)")
|
||||
continue
|
||||
|
||||
# Process each current stream
|
||||
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 {}
|
||||
tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid")
|
||||
|
||||
# --- REGEX FIND/REPLACE LOGIC ---
|
||||
original_name = stream.name
|
||||
new_name = original_name
|
||||
if name_regex_pattern is not None:
|
||||
# If replace is None, treat as empty string (remove match)
|
||||
replace = name_replace_pattern if name_replace_pattern is not None else ''
|
||||
try:
|
||||
new_name = re.sub(name_regex_pattern, replace, original_name)
|
||||
except re.error as e:
|
||||
logger.warning(f"Regex error for group '{channel_group.name}': {e}. Using original name.")
|
||||
new_name = original_name
|
||||
|
||||
# Check if we already have a channel for this stream
|
||||
existing_channel = existing_channel_map.get(stream.id)
|
||||
|
||||
if existing_channel:
|
||||
# Update existing channel if needed
|
||||
channel_updated = False
|
||||
|
||||
# Use new_name instead of stream.name
|
||||
if existing_channel.name != new_name:
|
||||
existing_channel.name = new_name
|
||||
channel_updated = True
|
||||
|
||||
if existing_channel.tvg_id != stream.tvg_id:
|
||||
existing_channel.tvg_id = stream.tvg_id
|
||||
channel_updated = True
|
||||
|
||||
if existing_channel.tvc_guide_stationid != tvc_guide_stationid:
|
||||
existing_channel.tvc_guide_stationid = tvc_guide_stationid
|
||||
channel_updated = True
|
||||
|
||||
# Check if channel group needs to be updated (in case override was added/changed)
|
||||
if existing_channel.channel_group != target_group:
|
||||
existing_channel.channel_group = target_group
|
||||
channel_updated = True
|
||||
logger.info(f"Moved auto channel '{existing_channel.name}' from '{existing_channel.channel_group.name if existing_channel.channel_group else 'None'}' to '{target_group.name}'")
|
||||
|
||||
# Handle logo updates
|
||||
current_logo = None
|
||||
if stream.logo_url:
|
||||
from apps.channels.models import Logo
|
||||
current_logo, _ = Logo.objects.get_or_create(
|
||||
url=stream.logo_url,
|
||||
defaults={"name": stream.name or stream.tvg_id or "Unknown"}
|
||||
)
|
||||
|
||||
if existing_channel.logo != current_logo:
|
||||
existing_channel.logo = current_logo
|
||||
channel_updated = True
|
||||
|
||||
# Handle EPG data updates
|
||||
current_epg_data = None
|
||||
if stream.tvg_id and not force_dummy_epg:
|
||||
current_epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first()
|
||||
|
||||
if existing_channel.epg_data != current_epg_data:
|
||||
existing_channel.epg_data = current_epg_data
|
||||
channel_updated = True
|
||||
|
||||
if channel_updated:
|
||||
existing_channel.save()
|
||||
channels_updated += 1
|
||||
logger.debug(f"Updated auto channel: {existing_channel.channel_number} - {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
|
||||
|
||||
# Create the channel with auto-created tracking in the target group
|
||||
channel = Channel.objects.create(
|
||||
channel_number=current_channel_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
|
||||
)
|
||||
|
||||
# Associate the stream with the channel
|
||||
ChannelStream.objects.create(
|
||||
channel=channel,
|
||||
stream=stream,
|
||||
order=0
|
||||
)
|
||||
|
||||
# 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()
|
||||
if epg_data:
|
||||
channel.epg_data = epg_data
|
||||
channel.save(update_fields=['epg_data'])
|
||||
elif stream.tvg_id and force_dummy_epg:
|
||||
channel.epg_data = None
|
||||
channel.save(update_fields=['epg_data'])
|
||||
|
||||
# Handle logo
|
||||
if stream.logo_url:
|
||||
from apps.channels.models import Logo
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=stream.logo_url,
|
||||
defaults={"name": stream.name or stream.tvg_id or "Unknown"}
|
||||
)
|
||||
channel.logo = logo
|
||||
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}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing auto channel for stream {stream.name}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Delete channels for streams that no longer exist
|
||||
channels_to_delete = []
|
||||
for stream_id, channel in existing_channel_map.items():
|
||||
if stream_id not in processed_stream_ids:
|
||||
channels_to_delete.append(channel)
|
||||
|
||||
if channels_to_delete:
|
||||
deleted_count = len(channels_to_delete)
|
||||
Channel.objects.filter(id__in=[ch.id for ch in channels_to_delete]).delete()
|
||||
channels_deleted += deleted_count
|
||||
logger.debug(f"Deleted {deleted_count} auto channels for removed streams")
|
||||
|
||||
logger.info(f"Auto channel sync complete for account {account.name}: {channels_created} created, {channels_updated} updated, {channels_deleted} deleted")
|
||||
return f"Auto sync: {channels_created} channels created, {channels_updated} updated, {channels_deleted} deleted"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in auto channel sync for account {account_id}: {str(e)}")
|
||||
return f"Auto sync error: {str(e)}"
|
||||
|
||||
@shared_task
|
||||
def refresh_single_m3u_account(account_id):
|
||||
"""Splits M3U processing into chunks and dispatches them as parallel tasks."""
|
||||
|
|
@ -1092,6 +1367,16 @@ def refresh_single_m3u_account(account_id):
|
|||
# Now run cleanup
|
||||
streams_deleted = cleanup_streams(account_id, refresh_start_timestamp)
|
||||
|
||||
# Run auto channel sync after successful refresh
|
||||
auto_sync_message = ""
|
||||
try:
|
||||
sync_result = sync_auto_channels(account_id, scan_start_time=str(refresh_start_timestamp))
|
||||
logger.info(f"Auto channel sync result for account {account_id}: {sync_result}")
|
||||
if sync_result and "created" in sync_result:
|
||||
auto_sync_message = f" {sync_result}."
|
||||
except Exception as e:
|
||||
logger.error(f"Error running auto channel sync for account {account_id}: {str(e)}")
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
|
|
@ -1100,7 +1385,7 @@ def refresh_single_m3u_account(account_id):
|
|||
account.last_message = (
|
||||
f"Processing completed in {elapsed_time:.1f} seconds. "
|
||||
f"Streams: {streams_created} created, {streams_updated} updated, {streams_deleted} removed. "
|
||||
f"Total processed: {streams_processed}."
|
||||
f"Total processed: {streams_processed}.{auto_sync_message}"
|
||||
)
|
||||
account.updated_at = timezone.now()
|
||||
account.save(update_fields=['status', 'last_message', 'updated_at'])
|
||||
|
|
@ -1162,11 +1447,7 @@ def send_m3u_update(account_id, action, progress, **kwargs):
|
|||
|
||||
# Add the additional key-value pairs from kwargs
|
||||
data.update(kwargs)
|
||||
|
||||
# Use the standardized function with memory management
|
||||
# Enable garbage collection for certain operations
|
||||
collect_garbage = action == "parsing" and progress % 25 == 0
|
||||
send_websocket_update('updates', 'update', data, collect_garbage=collect_garbage)
|
||||
send_websocket_update('updates', 'update', data, collect_garbage=False)
|
||||
|
||||
# Explicitly clear data reference to help garbage collection
|
||||
data = None
|
||||
|
|
|
|||
|
|
@ -467,19 +467,27 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
for category in custom_data["categories"]:
|
||||
program_xml.append(f" <category>{html.escape(category)}</category>")
|
||||
|
||||
# Handle episode numbering - multiple formats supported
|
||||
# Standard episode number if available
|
||||
if "episode" in custom_data:
|
||||
program_xml.append(f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>')
|
||||
# Add keywords if available
|
||||
if "keywords" in custom_data and custom_data["keywords"]:
|
||||
for keyword in custom_data["keywords"]:
|
||||
program_xml.append(f" <keyword>{html.escape(keyword)}</keyword>")
|
||||
|
||||
# Handle onscreen episode format (like S06E128)
|
||||
# Handle episode numbering - multiple formats supported
|
||||
# Prioritize onscreen_episode over standalone episode for onscreen system
|
||||
if "onscreen_episode" in custom_data:
|
||||
program_xml.append(f' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>')
|
||||
elif "episode" in custom_data:
|
||||
program_xml.append(f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>')
|
||||
|
||||
# Handle dd_progid format
|
||||
if 'dd_progid' in custom_data:
|
||||
program_xml.append(f' <episode-num system="dd_progid">{html.escape(custom_data["dd_progid"])}</episode-num>')
|
||||
|
||||
# Handle external database IDs
|
||||
for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']:
|
||||
if f'{system}_id' in custom_data:
|
||||
program_xml.append(f' <episode-num system="{system}">{html.escape(custom_data[f"{system}_id"])}</episode-num>')
|
||||
|
||||
# Add season and episode numbers in xmltv_ns format if available
|
||||
if "season" in custom_data and "episode" in custom_data:
|
||||
season = (
|
||||
|
|
@ -494,6 +502,46 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
)
|
||||
program_xml.append(f' <episode-num system="xmltv_ns">{season}.{episode}.</episode-num>')
|
||||
|
||||
# Add language information
|
||||
if "language" in custom_data:
|
||||
program_xml.append(f' <language>{html.escape(custom_data["language"])}</language>')
|
||||
|
||||
if "original_language" in custom_data:
|
||||
program_xml.append(f' <orig-language>{html.escape(custom_data["original_language"])}</orig-language>')
|
||||
|
||||
# Add length information
|
||||
if "length" in custom_data and isinstance(custom_data["length"], dict):
|
||||
length_value = custom_data["length"].get("value", "")
|
||||
length_units = custom_data["length"].get("units", "minutes")
|
||||
program_xml.append(f' <length units="{html.escape(length_units)}">{html.escape(str(length_value))}</length>')
|
||||
|
||||
# Add video information
|
||||
if "video" in custom_data and isinstance(custom_data["video"], dict):
|
||||
program_xml.append(" <video>")
|
||||
for attr in ['present', 'colour', 'aspect', 'quality']:
|
||||
if attr in custom_data["video"]:
|
||||
program_xml.append(f" <{attr}>{html.escape(custom_data['video'][attr])}</{attr}>")
|
||||
program_xml.append(" </video>")
|
||||
|
||||
# Add audio information
|
||||
if "audio" in custom_data and isinstance(custom_data["audio"], dict):
|
||||
program_xml.append(" <audio>")
|
||||
for attr in ['present', 'stereo']:
|
||||
if attr in custom_data["audio"]:
|
||||
program_xml.append(f" <{attr}>{html.escape(custom_data['audio'][attr])}</{attr}>")
|
||||
program_xml.append(" </audio>")
|
||||
|
||||
# Add subtitles information
|
||||
if "subtitles" in custom_data and isinstance(custom_data["subtitles"], list):
|
||||
for subtitle in custom_data["subtitles"]:
|
||||
if isinstance(subtitle, dict):
|
||||
subtitle_type = subtitle.get("type", "")
|
||||
type_attr = f' type="{html.escape(subtitle_type)}"' if subtitle_type else ""
|
||||
program_xml.append(f" <subtitles{type_attr}>")
|
||||
if "language" in subtitle:
|
||||
program_xml.append(f" <language>{html.escape(subtitle['language'])}</language>")
|
||||
program_xml.append(" </subtitles>")
|
||||
|
||||
# Add rating if available
|
||||
if "rating" in custom_data:
|
||||
rating_system = custom_data.get("rating_system", "TV Parental Guidelines")
|
||||
|
|
@ -501,20 +549,74 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
program_xml.append(f' <value>{html.escape(custom_data["rating"])}</value>')
|
||||
program_xml.append(f" </rating>")
|
||||
|
||||
# Add actors/directors/writers if available
|
||||
if "credits" in custom_data:
|
||||
program_xml.append(f" <credits>")
|
||||
for role, people in custom_data["credits"].items():
|
||||
if isinstance(people, list):
|
||||
for person in people:
|
||||
program_xml.append(f" <{role}>{html.escape(person)}</{role}>")
|
||||
else:
|
||||
program_xml.append(f" <{role}>{html.escape(people)}</{role}>")
|
||||
program_xml.append(f" </credits>")
|
||||
# Add star ratings
|
||||
if "star_ratings" in custom_data and isinstance(custom_data["star_ratings"], list):
|
||||
for star_rating in custom_data["star_ratings"]:
|
||||
if isinstance(star_rating, dict) and "value" in star_rating:
|
||||
system_attr = f' system="{html.escape(star_rating["system"])}"' if "system" in star_rating else ""
|
||||
program_xml.append(f" <star-rating{system_attr}>")
|
||||
program_xml.append(f" <value>{html.escape(star_rating['value'])}</value>")
|
||||
program_xml.append(" </star-rating>")
|
||||
|
||||
# Add program date/year if available
|
||||
if "year" in custom_data:
|
||||
program_xml.append(f' <date>{html.escape(custom_data["year"])}</date>')
|
||||
# Add reviews
|
||||
if "reviews" in custom_data and isinstance(custom_data["reviews"], list):
|
||||
for review in custom_data["reviews"]:
|
||||
if isinstance(review, dict) and "content" in review:
|
||||
review_type = review.get("type", "text")
|
||||
attrs = [f'type="{html.escape(review_type)}"']
|
||||
if "source" in review:
|
||||
attrs.append(f'source="{html.escape(review["source"])}"')
|
||||
if "reviewer" in review:
|
||||
attrs.append(f'reviewer="{html.escape(review["reviewer"])}"')
|
||||
attr_str = " ".join(attrs)
|
||||
program_xml.append(f' <review {attr_str}>{html.escape(review["content"])}</review>')
|
||||
|
||||
# Add images
|
||||
if "images" in custom_data and isinstance(custom_data["images"], list):
|
||||
for image in custom_data["images"]:
|
||||
if isinstance(image, dict) and "url" in image:
|
||||
attrs = []
|
||||
for attr in ['type', 'size', 'orient', 'system']:
|
||||
if attr in image:
|
||||
attrs.append(f'{attr}="{html.escape(image[attr])}"')
|
||||
attr_str = " " + " ".join(attrs) if attrs else ""
|
||||
program_xml.append(f' <image{attr_str}>{html.escape(image["url"])}</image>')
|
||||
|
||||
# Add enhanced credits handling
|
||||
if "credits" in custom_data:
|
||||
program_xml.append(" <credits>")
|
||||
credits = custom_data["credits"]
|
||||
|
||||
# Handle different credit types
|
||||
for role in ['director', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']:
|
||||
if role in credits:
|
||||
people = credits[role]
|
||||
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}>")
|
||||
|
||||
# Handle actors separately to include role and guest attributes
|
||||
if "actor" in credits:
|
||||
actors = credits["actor"]
|
||||
if isinstance(actors, list):
|
||||
for actor in actors:
|
||||
if isinstance(actor, dict):
|
||||
name = actor.get("name", "")
|
||||
role_attr = f' role="{html.escape(actor["role"])}"' if "role" in actor else ""
|
||||
guest_attr = ' guest="yes"' if actor.get("guest") else ""
|
||||
program_xml.append(f" <actor{role_attr}{guest_attr}>{html.escape(name)}</actor>")
|
||||
else:
|
||||
program_xml.append(f" <actor>{html.escape(actor)}</actor>")
|
||||
else:
|
||||
program_xml.append(f" <actor>{html.escape(actors)}</actor>")
|
||||
|
||||
program_xml.append(" </credits>")
|
||||
|
||||
# Add program date if available (full date, not just year)
|
||||
if "date" in custom_data:
|
||||
program_xml.append(f' <date>{html.escape(custom_data["date"])}</date>')
|
||||
|
||||
# Add country if available
|
||||
if "country" in custom_data:
|
||||
|
|
@ -524,18 +626,36 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
if "icon" in custom_data:
|
||||
program_xml.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
|
||||
|
||||
# Add special flags as proper tags
|
||||
# Add special flags as proper tags with enhanced handling
|
||||
if custom_data.get("previously_shown", False):
|
||||
program_xml.append(f" <previously-shown />")
|
||||
prev_shown_details = custom_data.get("previously_shown_details", {})
|
||||
attrs = []
|
||||
if "start" in prev_shown_details:
|
||||
attrs.append(f'start="{html.escape(prev_shown_details["start"])}"')
|
||||
if "channel" in prev_shown_details:
|
||||
attrs.append(f'channel="{html.escape(prev_shown_details["channel"])}"')
|
||||
attr_str = " " + " ".join(attrs) if attrs else ""
|
||||
program_xml.append(f" <previously-shown{attr_str} />")
|
||||
|
||||
if custom_data.get("premiere", False):
|
||||
program_xml.append(f" <premiere />")
|
||||
premiere_text = custom_data.get("premiere_text", "")
|
||||
if premiere_text:
|
||||
program_xml.append(f" <premiere>{html.escape(premiere_text)}</premiere>")
|
||||
else:
|
||||
program_xml.append(" <premiere />")
|
||||
|
||||
if custom_data.get("last_chance", False):
|
||||
last_chance_text = custom_data.get("last_chance_text", "")
|
||||
if last_chance_text:
|
||||
program_xml.append(f" <last-chance>{html.escape(last_chance_text)}</last-chance>")
|
||||
else:
|
||||
program_xml.append(" <last-chance />")
|
||||
|
||||
if custom_data.get("new", False):
|
||||
program_xml.append(f" <new />")
|
||||
program_xml.append(" <new />")
|
||||
|
||||
if custom_data.get('live', False):
|
||||
program_xml.append(f' <live />')
|
||||
program_xml.append(' <live />')
|
||||
|
||||
except Exception as e:
|
||||
program_xml.append(f" <!-- Error parsing custom properties: {html.escape(str(e))} -->")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version
|
||||
from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version, rehash_streams_endpoint
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'useragents', UserAgentViewSet, basename='useragent')
|
||||
|
|
@ -12,5 +12,6 @@ router.register(r'settings', CoreSettingsViewSet, basename='settings')
|
|||
urlpatterns = [
|
||||
path('settings/env/', environment, name='token_refresh'),
|
||||
path('version/', version, name='version'),
|
||||
path('rehash-streams/', rehash_streams_endpoint, name='rehash_streams'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -280,3 +280,40 @@ def version(request):
|
|||
"timestamp": __timestamp__,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method="post",
|
||||
operation_description="Trigger rehashing of all streams",
|
||||
responses={200: "Rehash task started"},
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([Authenticated])
|
||||
def rehash_streams_endpoint(request):
|
||||
"""Trigger the rehash streams task"""
|
||||
try:
|
||||
# Get the current hash keys from settings
|
||||
hash_key_setting = CoreSettings.objects.get(key=STREAM_HASH_KEY)
|
||||
hash_keys = hash_key_setting.value.split(",")
|
||||
|
||||
# Queue the rehash task
|
||||
task = rehash_streams.delay(hash_keys)
|
||||
|
||||
return Response({
|
||||
"success": True,
|
||||
"message": "Stream rehashing task has been queued",
|
||||
"task_id": task.id
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except CoreSettings.DoesNotExist:
|
||||
return Response({
|
||||
"success": False,
|
||||
"message": "Hash key settings not found"
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering rehash streams: {e}")
|
||||
return Response({
|
||||
"success": False,
|
||||
"message": "Failed to trigger rehash task"
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
|
|
|||
342
core/tasks.py
342
core/tasks.py
|
|
@ -7,7 +7,7 @@ import logging
|
|||
import re
|
||||
import time
|
||||
import os
|
||||
from core.utils import RedisClient, send_websocket_update
|
||||
from core.utils import RedisClient, send_websocket_update, acquire_task_lock, release_task_lock
|
||||
from apps.proxy.ts_proxy.channel_status import ChannelStatus
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.epg.models import EPGSource
|
||||
|
|
@ -21,10 +21,12 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
EPG_WATCH_DIR = '/data/epgs'
|
||||
M3U_WATCH_DIR = '/data/m3us'
|
||||
LOGO_WATCH_DIR = '/data/logos'
|
||||
MIN_AGE_SECONDS = 6
|
||||
STARTUP_SKIP_AGE = 30
|
||||
REDIS_PREFIX = "processed_file:"
|
||||
REDIS_TTL = 60 * 60 * 24 * 3 # expire keys after 3 days (optional)
|
||||
SUPPORTED_LOGO_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']
|
||||
|
||||
# Store the last known value to compare with new data
|
||||
last_known_data = {}
|
||||
|
|
@ -56,10 +58,11 @@ def scan_and_process_files():
|
|||
global _first_scan_completed
|
||||
redis_client = RedisClient.get_client()
|
||||
now = time.time()
|
||||
|
||||
# Check if directories exist
|
||||
dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR])
|
||||
dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR, LOGO_WATCH_DIR])
|
||||
if not dirs_exist:
|
||||
throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)})", "watch_dirs_missing")
|
||||
throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)}), LOGO ({os.path.exists(LOGO_WATCH_DIR)})", "watch_dirs_missing")
|
||||
|
||||
# Process M3U files
|
||||
m3u_files = [f for f in os.listdir(M3U_WATCH_DIR)
|
||||
|
|
@ -266,6 +269,126 @@ def scan_and_process_files():
|
|||
|
||||
logger.trace(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors")
|
||||
|
||||
# Process Logo files (including subdirectories)
|
||||
try:
|
||||
logo_files = []
|
||||
if os.path.exists(LOGO_WATCH_DIR):
|
||||
for root, dirs, files in os.walk(LOGO_WATCH_DIR):
|
||||
for filename in files:
|
||||
logo_files.append(os.path.join(root, filename))
|
||||
logger.trace(f"Found {len(logo_files)} files in LOGO directory (including subdirectories)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing LOGO directory: {e}")
|
||||
logo_files = []
|
||||
|
||||
logo_processed = 0
|
||||
logo_skipped = 0
|
||||
logo_errors = 0
|
||||
|
||||
for filepath in logo_files:
|
||||
filename = os.path.basename(filepath)
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Not a file")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Not a file")
|
||||
logo_skipped += 1
|
||||
continue
|
||||
|
||||
# Check if file has supported logo extension
|
||||
file_ext = os.path.splitext(filename)[1].lower()
|
||||
if file_ext not in SUPPORTED_LOGO_FORMATS:
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Not a supported logo format")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Not a supported logo format")
|
||||
logo_skipped += 1
|
||||
continue
|
||||
|
||||
mtime = os.path.getmtime(filepath)
|
||||
age = now - mtime
|
||||
redis_key = REDIS_PREFIX + filepath
|
||||
stored_mtime = redis_client.get(redis_key)
|
||||
|
||||
# Check if logo already exists in database
|
||||
if not stored_mtime and age > STARTUP_SKIP_AGE:
|
||||
from apps.channels.models import Logo
|
||||
existing_logo = Logo.objects.filter(url=filepath).exists()
|
||||
if existing_logo:
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Already exists in database")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Already exists in database")
|
||||
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
|
||||
logo_skipped += 1
|
||||
continue
|
||||
else:
|
||||
logger.debug(f"Processing {filename} despite age: Not found in database")
|
||||
|
||||
# File too new — probably still being written
|
||||
if age < MIN_AGE_SECONDS:
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
|
||||
logo_skipped += 1
|
||||
continue
|
||||
|
||||
# Skip if we've already processed this mtime
|
||||
if stored_mtime and float(stored_mtime) >= mtime:
|
||||
if _first_scan_completed:
|
||||
logger.trace(f"Skipping {filename}: Already processed this version")
|
||||
else:
|
||||
logger.debug(f"Skipping {filename}: Already processed this version")
|
||||
logo_skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
from apps.channels.models import Logo
|
||||
|
||||
# Create logo entry with just the filename (without extension) as name
|
||||
logo_name = os.path.splitext(filename)[0]
|
||||
|
||||
logo, created = Logo.objects.get_or_create(
|
||||
url=filepath,
|
||||
defaults={
|
||||
"name": logo_name,
|
||||
}
|
||||
)
|
||||
|
||||
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
|
||||
|
||||
if created:
|
||||
logger.info(f"Created new logo entry: {logo_name}")
|
||||
else:
|
||||
logger.debug(f"Logo entry already exists: {logo_name}")
|
||||
|
||||
logo_processed += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing logo file {filename}: {str(e)}", exc_info=True)
|
||||
logo_errors += 1
|
||||
continue
|
||||
|
||||
logger.trace(f"LOGO processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors")
|
||||
|
||||
# Send summary websocket update for logo processing
|
||||
if logo_processed > 0 or logo_errors > 0:
|
||||
send_websocket_update(
|
||||
"updates",
|
||||
"update",
|
||||
{
|
||||
"success": True,
|
||||
"type": "logo_processing_summary",
|
||||
"processed": logo_processed,
|
||||
"skipped": logo_skipped,
|
||||
"errors": logo_errors,
|
||||
"total_files": len(logo_files),
|
||||
"message": f"Logo processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors"
|
||||
}
|
||||
)
|
||||
|
||||
# Mark that the first scan is complete
|
||||
_first_scan_completed = True
|
||||
|
||||
|
|
@ -312,32 +435,201 @@ def fetch_channel_stats():
|
|||
|
||||
@shared_task
|
||||
def rehash_streams(keys):
|
||||
batch_size = 1000
|
||||
queryset = Stream.objects.all()
|
||||
"""
|
||||
Regenerate stream hashes for all streams based on current hash key configuration.
|
||||
This task checks for and blocks M3U refresh tasks to prevent conflicts.
|
||||
"""
|
||||
from apps.channels.models import Stream
|
||||
from apps.m3u.models import M3UAccount
|
||||
|
||||
hash_keys = {}
|
||||
total_records = queryset.count()
|
||||
for start in range(0, total_records, batch_size):
|
||||
with transaction.atomic():
|
||||
batch = queryset[start:start + batch_size]
|
||||
for obj in batch:
|
||||
stream_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys)
|
||||
if stream_hash in hash_keys:
|
||||
# Handle duplicate keys and remove any without channels
|
||||
stream_channels = ChannelStream.objects.filter(stream_id=obj.id).count()
|
||||
if stream_channels == 0:
|
||||
logger.info("Starting stream rehash process")
|
||||
|
||||
# Get all M3U account IDs for locking
|
||||
m3u_account_ids = list(M3UAccount.objects.filter(is_active=True).values_list('id', flat=True))
|
||||
|
||||
# Check if any M3U refresh tasks are currently running
|
||||
blocked_accounts = []
|
||||
for account_id in m3u_account_ids:
|
||||
if not acquire_task_lock('refresh_single_m3u_account', account_id):
|
||||
blocked_accounts.append(account_id)
|
||||
|
||||
if blocked_accounts:
|
||||
# Release any locks we did acquire
|
||||
for account_id in m3u_account_ids:
|
||||
if account_id not in blocked_accounts:
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
|
||||
logger.warning(f"Rehash blocked: M3U refresh tasks running for accounts: {blocked_accounts}")
|
||||
|
||||
# Send WebSocket notification to inform user
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
"success": False,
|
||||
"type": "stream_rehash",
|
||||
"action": "blocked",
|
||||
"blocked_accounts": len(blocked_accounts),
|
||||
"total_accounts": len(m3u_account_ids),
|
||||
"message": f"Stream rehash blocked: M3U refresh tasks are currently running for {len(blocked_accounts)} accounts. Please try again later."
|
||||
}
|
||||
)
|
||||
|
||||
return f"Rehash blocked: M3U refresh tasks running for {len(blocked_accounts)} accounts"
|
||||
|
||||
acquired_locks = m3u_account_ids.copy()
|
||||
|
||||
try:
|
||||
batch_size = 1000
|
||||
queryset = Stream.objects.all()
|
||||
|
||||
# Track statistics
|
||||
total_processed = 0
|
||||
duplicates_merged = 0
|
||||
hash_keys = {}
|
||||
|
||||
total_records = queryset.count()
|
||||
logger.info(f"Starting rehash of {total_records} streams with keys: {keys}")
|
||||
|
||||
# Send initial WebSocket update
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
"success": True,
|
||||
"type": "stream_rehash",
|
||||
"action": "starting",
|
||||
"progress": 0,
|
||||
"total_records": total_records,
|
||||
"message": f"Starting rehash of {total_records} streams"
|
||||
}
|
||||
)
|
||||
|
||||
for start in range(0, total_records, batch_size):
|
||||
batch_processed = 0
|
||||
batch_duplicates = 0
|
||||
|
||||
with transaction.atomic():
|
||||
batch = queryset[start:start + batch_size]
|
||||
|
||||
for obj in batch:
|
||||
# Generate new hash
|
||||
new_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys)
|
||||
|
||||
# Check if this hash already exists in our tracking dict or in database
|
||||
if new_hash in hash_keys:
|
||||
# Found duplicate in current batch - merge the streams
|
||||
existing_stream_id = hash_keys[new_hash]
|
||||
existing_stream = Stream.objects.get(id=existing_stream_id)
|
||||
|
||||
# Move any channel relationships from duplicate to existing stream
|
||||
ChannelStream.objects.filter(stream_id=obj.id).update(stream_id=existing_stream_id)
|
||||
|
||||
# Update the existing stream with the most recent data
|
||||
if obj.updated_at > existing_stream.updated_at:
|
||||
existing_stream.name = obj.name
|
||||
existing_stream.url = obj.url
|
||||
existing_stream.logo_url = obj.logo_url
|
||||
existing_stream.tvg_id = obj.tvg_id
|
||||
existing_stream.m3u_account = obj.m3u_account
|
||||
existing_stream.channel_group = obj.channel_group
|
||||
existing_stream.custom_properties = obj.custom_properties
|
||||
existing_stream.last_seen = obj.last_seen
|
||||
existing_stream.updated_at = obj.updated_at
|
||||
existing_stream.save()
|
||||
|
||||
# Delete the duplicate
|
||||
obj.delete()
|
||||
continue
|
||||
batch_duplicates += 1
|
||||
else:
|
||||
# Check if hash already exists in database (from previous batches or existing data)
|
||||
existing_stream = Stream.objects.filter(stream_hash=new_hash).exclude(id=obj.id).first()
|
||||
if existing_stream:
|
||||
# Found duplicate in database - merge the streams
|
||||
# Move any channel relationships from duplicate to existing stream
|
||||
ChannelStream.objects.filter(stream_id=obj.id).update(stream_id=existing_stream.id)
|
||||
|
||||
# Update the existing stream with the most recent data
|
||||
if obj.updated_at > existing_stream.updated_at:
|
||||
existing_stream.name = obj.name
|
||||
existing_stream.url = obj.url
|
||||
existing_stream.logo_url = obj.logo_url
|
||||
existing_stream.tvg_id = obj.tvg_id
|
||||
existing_stream.m3u_account = obj.m3u_account
|
||||
existing_stream.channel_group = obj.channel_group
|
||||
existing_stream.custom_properties = obj.custom_properties
|
||||
existing_stream.last_seen = obj.last_seen
|
||||
existing_stream.updated_at = obj.updated_at
|
||||
existing_stream.save()
|
||||
|
||||
existing_stream_channels = ChannelStream.objects.filter(stream_id=hash_keys[stream_hash]).count()
|
||||
if existing_stream_channels == 0:
|
||||
Stream.objects.filter(id=hash_keys[stream_hash]).delete()
|
||||
# Delete the duplicate
|
||||
obj.delete()
|
||||
batch_duplicates += 1
|
||||
hash_keys[new_hash] = existing_stream.id
|
||||
else:
|
||||
# Update hash for this stream
|
||||
obj.stream_hash = new_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[new_hash] = obj.id
|
||||
|
||||
obj.stream_hash = stream_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[stream_hash] = obj.id
|
||||
batch_processed += 1
|
||||
|
||||
logger.debug(f"Re-hashed {batch_size} streams")
|
||||
total_processed += batch_processed
|
||||
duplicates_merged += batch_duplicates
|
||||
|
||||
logger.debug(f"Re-hashing complete")
|
||||
# Calculate progress percentage
|
||||
progress_percent = int((total_processed / total_records) * 100)
|
||||
current_batch = start // batch_size + 1
|
||||
total_batches = (total_records // batch_size) + 1
|
||||
|
||||
# Send progress update via WebSocket
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
"success": True,
|
||||
"type": "stream_rehash",
|
||||
"action": "processing",
|
||||
"progress": progress_percent,
|
||||
"batch": current_batch,
|
||||
"total_batches": total_batches,
|
||||
"processed": total_processed,
|
||||
"duplicates_merged": duplicates_merged,
|
||||
"message": f"Processed batch {current_batch}/{total_batches}: {batch_processed} streams, {batch_duplicates} duplicates merged"
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Rehashed batch {current_batch}/{total_batches}: "
|
||||
f"{batch_processed} processed, {batch_duplicates} duplicates merged")
|
||||
|
||||
logger.info(f"Rehashing complete: {total_processed} streams processed, "
|
||||
f"{duplicates_merged} duplicates merged")
|
||||
|
||||
# Send completion update via WebSocket
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
"success": True,
|
||||
"type": "stream_rehash",
|
||||
"action": "completed",
|
||||
"progress": 100,
|
||||
"total_processed": total_processed,
|
||||
"duplicates_merged": duplicates_merged,
|
||||
"final_count": total_processed - duplicates_merged,
|
||||
"message": f"Rehashing complete: {total_processed} streams processed, {duplicates_merged} duplicates merged"
|
||||
},
|
||||
collect_garbage=True # Force garbage collection after completion
|
||||
)
|
||||
|
||||
logger.info("Stream rehash completed successfully")
|
||||
return f"Successfully rehashed {total_processed} streams"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during stream rehash: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Always release all acquired M3U locks
|
||||
for account_id in acquired_locks:
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
logger.info(f"Released M3U task locks for {len(acquired_locks)} accounts")
|
||||
|
|
|
|||
|
|
@ -17,20 +17,29 @@ class Client:
|
|||
# Fix: Properly handle all possible user_agent input types
|
||||
if user_agent:
|
||||
if isinstance(user_agent, str):
|
||||
# Direct string user agent
|
||||
user_agent_string = user_agent
|
||||
elif hasattr(user_agent, 'user_agent'):
|
||||
# UserAgent model object
|
||||
user_agent_string = user_agent.user_agent
|
||||
else:
|
||||
# Fallback for any other type
|
||||
logger.warning(f"Unexpected user_agent type: {type(user_agent)}, using default")
|
||||
user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
else:
|
||||
# No user agent provided
|
||||
user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
|
||||
self.headers = {'User-Agent': user_agent_string}
|
||||
# Create persistent session
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({'User-Agent': user_agent_string})
|
||||
|
||||
# Configure connection pooling
|
||||
adapter = requests.adapters.HTTPAdapter(
|
||||
pool_connections=1,
|
||||
pool_maxsize=2,
|
||||
max_retries=3,
|
||||
pool_block=False
|
||||
)
|
||||
self.session.mount('http://', adapter)
|
||||
self.session.mount('https://', adapter)
|
||||
|
||||
self.server_info = None
|
||||
|
||||
def _normalize_url(self, url):
|
||||
|
|
@ -53,11 +62,35 @@ class Client:
|
|||
url = f"{self.server_url}/{endpoint}"
|
||||
logger.debug(f"XC API Request: {url} with params: {params}")
|
||||
|
||||
response = requests.get(url, params=params, headers=self.headers, timeout=30)
|
||||
response = self.session.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
logger.debug(f"XC API Response: {url} status code: {response.status_code}")
|
||||
# Check if response is empty
|
||||
if not response.content:
|
||||
error_msg = f"XC API returned empty response from {url}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Check for common blocking responses before trying to parse JSON
|
||||
response_text = response.text.strip()
|
||||
if response_text.lower() in ['blocked', 'forbidden', 'access denied', 'unauthorized']:
|
||||
error_msg = f"XC API request blocked by server from {url}. Response: {response_text}"
|
||||
logger.error(error_msg)
|
||||
logger.error(f"This may indicate IP blocking, User-Agent filtering, or rate limiting")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except requests.exceptions.JSONDecodeError as json_err:
|
||||
error_msg = f"XC API returned invalid JSON from {url}. Response: {response.text[:1000]}"
|
||||
logger.error(error_msg)
|
||||
logger.error(f"JSON decode error: {str(json_err)}")
|
||||
|
||||
# Check if it looks like an HTML error page
|
||||
if response_text.startswith('<'):
|
||||
logger.error("Response appears to be HTML - server may be returning an error page")
|
||||
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Check for XC-specific error responses
|
||||
if isinstance(data, dict) and data.get('user_info') is None and 'error' in data:
|
||||
|
|
@ -162,3 +195,24 @@ class Client:
|
|||
def get_stream_url(self, stream_id):
|
||||
"""Get the playback URL for a stream"""
|
||||
return f"{self.server_url}/live/{self.username}/{self.password}/{stream_id}.ts"
|
||||
|
||||
def close(self):
|
||||
"""Close the session and cleanup resources"""
|
||||
if hasattr(self, 'session') and self.session:
|
||||
try:
|
||||
self.session.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error closing XC session: {e}")
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter the context manager"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Exit the context manager and cleanup resources"""
|
||||
self.close()
|
||||
return False # Don't suppress exceptions
|
||||
|
||||
def __del__(self):
|
||||
"""Ensure session is closed when object is destroyed"""
|
||||
self.close()
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ def cleanup_task_memory(**kwargs):
|
|||
'apps.m3u.tasks.refresh_m3u_accounts',
|
||||
'apps.m3u.tasks.process_m3u_batch',
|
||||
'apps.m3u.tasks.process_xc_category',
|
||||
'apps.m3u.tasks.sync_auto_channels',
|
||||
'apps.epg.tasks.refresh_epg_data',
|
||||
'apps.epg.tasks.refresh_all_epg_data',
|
||||
'apps.epg.tasks.parse_programs_for_source',
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ 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"]
|
||||
valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
if file.content_type not in valid_mime_types:
|
||||
raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF.")
|
||||
if file.size > 2 * 1024 * 1024:
|
||||
raise ValidationError("File too large. Max 2MB.")
|
||||
raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP.")
|
||||
if file.size > 5 * 1024 * 1024: # Increased to 5MB
|
||||
raise ValidationError("File too large. Max 5MB.")
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import Stats from './pages/Stats';
|
|||
import DVR from './pages/DVR';
|
||||
import Settings from './pages/Settings';
|
||||
import Users from './pages/Users';
|
||||
import LogosPage from './pages/Logos';
|
||||
import useAuthStore from './store/auth';
|
||||
import FloatingVideo from './components/FloatingVideo';
|
||||
import { WebsocketProvider } from './WebSocket';
|
||||
|
|
@ -133,6 +134,7 @@ const App = () => {
|
|||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/logos" element={<LogosPage />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/login" element={<Login needsSuperuser />} />
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ export const WebsocketProvider = ({ children }) => {
|
|||
}
|
||||
|
||||
updatePlaylist(updateData);
|
||||
fetchPlaylists(); // Refresh playlists to ensure UI is up-to-date
|
||||
} else {
|
||||
// Log when playlist can't be found for debugging purposes
|
||||
console.warn(
|
||||
|
|
@ -372,6 +373,61 @@ export const WebsocketProvider = ({ children }) => {
|
|||
}
|
||||
break;
|
||||
|
||||
case 'stream_rehash':
|
||||
// Handle stream rehash progress updates
|
||||
if (parsedEvent.data.action === 'starting') {
|
||||
notifications.show({
|
||||
id: 'stream-rehash-progress', // Persistent ID
|
||||
title: 'Stream Rehash Started',
|
||||
message: parsedEvent.data.message,
|
||||
color: 'blue.5',
|
||||
autoClose: false, // Don't auto-close
|
||||
withCloseButton: false, // No close button during processing
|
||||
loading: true, // Show loading indicator
|
||||
});
|
||||
} else if (parsedEvent.data.action === 'processing') {
|
||||
// Update the existing notification
|
||||
notifications.update({
|
||||
id: 'stream-rehash-progress',
|
||||
title: 'Stream Rehash in Progress',
|
||||
message: `${parsedEvent.data.progress}% complete - ${parsedEvent.data.processed} streams processed, ${parsedEvent.data.duplicates_merged} duplicates merged`,
|
||||
color: 'blue.5',
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
loading: true,
|
||||
});
|
||||
} else if (parsedEvent.data.action === 'completed') {
|
||||
// Update to completion state
|
||||
notifications.update({
|
||||
id: 'stream-rehash-progress',
|
||||
title: 'Stream Rehash Complete',
|
||||
message: `Processed ${parsedEvent.data.total_processed} streams, merged ${parsedEvent.data.duplicates_merged} duplicates. Final count: ${parsedEvent.data.final_count}`,
|
||||
color: 'green.5',
|
||||
autoClose: 8000, // Auto-close after completion
|
||||
withCloseButton: true, // Allow manual close
|
||||
loading: false, // Remove loading indicator
|
||||
});
|
||||
} else if (parsedEvent.data.action === 'blocked') {
|
||||
// Handle blocked rehash attempt
|
||||
notifications.show({
|
||||
title: 'Stream Rehash Blocked',
|
||||
message: parsedEvent.data.message,
|
||||
color: 'orange.5',
|
||||
autoClose: 8000,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'logo_processing_summary':
|
||||
notifications.show({
|
||||
title: 'Logo Processing Summary',
|
||||
message: `${parsedEvent.data.message}`,
|
||||
color: 'blue',
|
||||
autoClose: 5000,
|
||||
});
|
||||
fetchLogos();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(
|
||||
`Unknown websocket event type: ${parsedEvent.data?.type}`
|
||||
|
|
@ -442,6 +498,7 @@ export const WebsocketProvider = ({ children }) => {
|
|||
const setProfilePreview = usePlaylistsStore((s) => s.setProfilePreview);
|
||||
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
|
||||
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
|
||||
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
|
||||
|
||||
const ret = useMemo(() => {
|
||||
return [isReady, ws.current?.send.bind(ws.current), val];
|
||||
|
|
|
|||
|
|
@ -250,7 +250,17 @@ export default class API {
|
|||
});
|
||||
|
||||
if (response.id) {
|
||||
useChannelsStore.getState().addChannelGroup(response);
|
||||
// Add association flags for new groups
|
||||
const processedGroup = {
|
||||
...response,
|
||||
hasChannels: false,
|
||||
hasM3UAccounts: false,
|
||||
canEdit: true,
|
||||
canDelete: true
|
||||
};
|
||||
useChannelsStore.getState().addChannelGroup(processedGroup);
|
||||
// Refresh channel groups to update the UI
|
||||
useChannelsStore.getState().fetchChannelGroups();
|
||||
}
|
||||
|
||||
return response;
|
||||
|
|
@ -277,6 +287,38 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async deleteChannelGroup(id) {
|
||||
try {
|
||||
await request(`${host}/api/channels/groups/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
// Remove from store after successful deletion
|
||||
useChannelsStore.getState().removeChannelGroup(id);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete channel group', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async cleanupUnusedChannelGroups() {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/groups/cleanup/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
// Refresh channel groups to update the UI
|
||||
useChannelsStore.getState().fetchChannelGroups();
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to cleanup unused channel groups', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async addChannel(channel) {
|
||||
try {
|
||||
let body = null;
|
||||
|
|
@ -691,6 +733,21 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async updateM3UGroupSettings(playlistId, groupSettings) {
|
||||
try {
|
||||
const response = await request(`${host}/api/m3u/accounts/${playlistId}/group-settings/`, {
|
||||
method: 'PATCH',
|
||||
body: { group_settings: groupSettings },
|
||||
});
|
||||
// Fetch the updated playlist and update the store
|
||||
const updatedPlaylist = await API.getPlaylist(playlistId);
|
||||
usePlaylistsStore.getState().updatePlaylist(updatedPlaylist);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update M3U group settings', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async addPlaylist(values) {
|
||||
if (values.custom_properties) {
|
||||
values.custom_properties = JSON.stringify(values.custom_properties);
|
||||
|
|
@ -726,7 +783,6 @@ export default class API {
|
|||
const response = await request(`${host}/api/m3u/refresh/${id}/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to refresh M3U account', e);
|
||||
|
|
@ -1170,9 +1226,10 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getLogos() {
|
||||
static async getLogos(params = {}) {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/logos/`);
|
||||
const queryParams = new URLSearchParams(params);
|
||||
const response = await request(`${host}/api/channels/logos/?${queryParams.toString()}`);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -1180,21 +1237,165 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async fetchLogos() {
|
||||
try {
|
||||
const response = await this.getLogos();
|
||||
useChannelsStore.getState().setLogos(response);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch logos', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async uploadLogo(file) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await request(`${host}/api/channels/logos/upload/`, {
|
||||
// Add timeout handling for file uploads
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
const response = await fetch(`${host}/api/channels/logos/upload/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP error! Status: ${response.status}`);
|
||||
let errorBody = await response.text();
|
||||
|
||||
try {
|
||||
errorBody = JSON.parse(errorBody);
|
||||
} catch (e) {
|
||||
// If parsing fails, leave errorBody as the raw text
|
||||
}
|
||||
|
||||
error.status = response.status;
|
||||
error.response = response;
|
||||
error.body = errorBody;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
useChannelsStore.getState().addLogo(result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
const timeoutError = new Error('Upload timed out. Please try again.');
|
||||
timeoutError.code = 'NETWORK_ERROR';
|
||||
throw timeoutError;
|
||||
}
|
||||
errorNotification('Failed to upload logo', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async createLogo(values) {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/logos/`, {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
});
|
||||
|
||||
useChannelsStore.getState().addLogo(response);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to upload logo', e);
|
||||
errorNotification('Failed to create logo', e);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
useChannelsStore.getState().updateLogo(response);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update logo', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteLogo(id, deleteFile = false) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (deleteFile) {
|
||||
params.append('delete_file', 'true');
|
||||
}
|
||||
|
||||
const url = `${host}/api/channels/logos/${id}/?${params.toString()}`;
|
||||
await request(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
useChannelsStore.getState().removeLogo(id);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete logo', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteLogos(ids, deleteFiles = false) {
|
||||
try {
|
||||
const body = { logo_ids: ids };
|
||||
if (deleteFiles) {
|
||||
body.delete_files = true;
|
||||
}
|
||||
|
||||
await request(`${host}/api/channels/logos/bulk-delete/`, {
|
||||
method: 'DELETE',
|
||||
body: body,
|
||||
});
|
||||
|
||||
// Remove multiple logos from store
|
||||
ids.forEach(id => {
|
||||
useChannelsStore.getState().removeLogo(id);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete logos', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async cleanupUnusedLogos(deleteFiles = false) {
|
||||
try {
|
||||
const body = {};
|
||||
if (deleteFiles) {
|
||||
body.delete_files = true;
|
||||
}
|
||||
|
||||
const response = await request(`${host}/api/channels/logos/cleanup/`, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to cleanup unused logos', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1463,4 +1664,16 @@ export default class API {
|
|||
errorNotification('Failed to delete user', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async rehashStreams() {
|
||||
try {
|
||||
const response = await request(`${host}/api/core/rehash-streams/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to trigger stream rehash', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,13 +27,17 @@ const ConfirmationDialog = ({
|
|||
cancelLabel = 'Cancel',
|
||||
actionKey,
|
||||
onSuppressChange,
|
||||
size = 'md', // Add default size parameter - md is a medium width
|
||||
size = 'md',
|
||||
zIndex = 1000,
|
||||
showDeleteFileOption = false,
|
||||
deleteFileLabel = "Also delete files from disk",
|
||||
}) => {
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const [suppressChecked, setSuppressChecked] = useState(
|
||||
isWarningSuppressed(actionKey)
|
||||
);
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
|
||||
const handleToggleSuppress = (e) => {
|
||||
setSuppressChecked(e.currentTarget.checked);
|
||||
|
|
@ -46,11 +50,28 @@ const ConfirmationDialog = ({
|
|||
if (suppressChecked) {
|
||||
suppressWarning(actionKey);
|
||||
}
|
||||
onConfirm();
|
||||
if (showDeleteFileOption) {
|
||||
onConfirm(deleteFiles);
|
||||
} else {
|
||||
onConfirm();
|
||||
}
|
||||
setDeleteFiles(false); // Reset for next time
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setDeleteFiles(false); // Reset for next time
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} size={size} centered>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={title}
|
||||
size={size}
|
||||
centered
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<Box mb={20}>{message}</Box>
|
||||
|
||||
{actionKey && (
|
||||
|
|
@ -62,8 +83,17 @@ const ConfirmationDialog = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{showDeleteFileOption && (
|
||||
<Checkbox
|
||||
checked={deleteFiles}
|
||||
onChange={(event) => setDeleteFiles(event.currentTarget.checked)}
|
||||
label={deleteFileLabel}
|
||||
mb="md"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleConfirm}>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default function M3URefreshNotification() {
|
|||
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
|
||||
const fetchStreams = useStreamsStore((s) => s.fetchStreams);
|
||||
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
|
||||
const fetchChannels = useChannelsStore((s) => s.fetchChannels);
|
||||
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
|
||||
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ export default function M3URefreshNotification() {
|
|||
message: (
|
||||
<Stack>
|
||||
{data.message ||
|
||||
'M3U groups loaded. Please select groups or refresh M3U to complete setup.'}
|
||||
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
|
||||
<Group grow>
|
||||
<Button
|
||||
size="xs"
|
||||
|
|
@ -72,7 +73,7 @@ export default function M3URefreshNotification() {
|
|||
navigate('/sources');
|
||||
}}
|
||||
>
|
||||
Edit Groups
|
||||
Configure Groups
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
|
@ -135,6 +136,8 @@ export default function M3URefreshNotification() {
|
|||
// Only trigger additional fetches on successful completion
|
||||
if (data.action == 'parsing') {
|
||||
fetchStreams();
|
||||
API.requeryChannels();
|
||||
fetchChannels();
|
||||
} else if (data.action == 'processing_groups') {
|
||||
fetchStreams();
|
||||
fetchChannelGroups();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Ellipsis,
|
||||
LogOut,
|
||||
User,
|
||||
FileImage,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar,
|
||||
|
|
@ -109,6 +110,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
icon: <User size={20} />,
|
||||
path: '/users',
|
||||
},
|
||||
{
|
||||
label: 'Logo Manager',
|
||||
icon: <FileImage size={20} />,
|
||||
path: '/logos',
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <LucideSettings size={20} />,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
Image,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
|
|
@ -45,6 +46,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
const groupListRef = useRef(null);
|
||||
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
|
||||
const logos = useChannelsStore((s) => s.logos);
|
||||
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
|
||||
const streams = useStreamsStore((state) => state.streams);
|
||||
|
|
@ -82,10 +85,28 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
const handleLogoChange = async (files) => {
|
||||
if (files.length === 1) {
|
||||
const retval = await API.uploadLogo(files[0]);
|
||||
await fetchLogos();
|
||||
setLogoPreview(retval.cache_url);
|
||||
formik.setFieldValue('logo_id', retval.id);
|
||||
const file = files[0];
|
||||
|
||||
// Validate file size on frontend first
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
// 5MB
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'File too large. Maximum size is 5MB.',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const retval = await API.uploadLogo(file);
|
||||
await fetchLogos();
|
||||
setLogoPreview(retval.cache_url);
|
||||
formik.setFieldValue('logo_id', retval.id);
|
||||
} catch (error) {
|
||||
console.error('Logo upload failed:', error);
|
||||
// Error notification is already handled in API.uploadLogo
|
||||
}
|
||||
} else {
|
||||
setLogoPreview(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
|
|||
const groupListRef = useRef(null);
|
||||
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
|
||||
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
|
||||
|
||||
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
// Modal.js
|
||||
import React from 'react';
|
||||
import API from '../../api';
|
||||
import { Flex, TextInput, Button, Modal } from '@mantine/core';
|
||||
import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
||||
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
|
||||
// Check if editing is allowed
|
||||
const canEdit = !channelGroup || canEditChannelGroup(channelGroup.id);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: {
|
||||
|
|
@ -17,6 +24,16 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
|
|||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
// Prevent submission if editing is not allowed
|
||||
if (channelGroup && !canEdit) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Cannot edit group with M3U account associations',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const values = form.getValues();
|
||||
let newGroup;
|
||||
|
||||
|
|
@ -36,11 +53,17 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
|
|||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Channel Group">
|
||||
{channelGroup && !canEdit && (
|
||||
<Alert color="yellow" mb="md">
|
||||
This group cannot be edited because it has M3U account associations.
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
disabled={channelGroup && !canEdit}
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
|
|
@ -50,7 +73,7 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
|
|||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={form.submitting}
|
||||
disabled={form.submitting || (channelGroup && !canEdit)}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
const ChannelsForm = ({ channel = null, isOpen, onClose }) => {
|
||||
|
|
@ -81,10 +82,28 @@ const ChannelsForm = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
const handleLogoChange = async (files) => {
|
||||
if (files.length === 1) {
|
||||
const retval = await API.uploadLogo(files[0]);
|
||||
await fetchLogos();
|
||||
setLogoPreview(retval.cache_url);
|
||||
formik.setFieldValue('logo_id', retval.id);
|
||||
const file = files[0];
|
||||
|
||||
// Validate file size on frontend first
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
// 5MB
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'File too large. Maximum size is 5MB.',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const retval = await API.uploadLogo(file);
|
||||
await fetchLogos();
|
||||
setLogoPreview(retval.cache_url);
|
||||
formik.setFieldValue('logo_id', retval.id);
|
||||
} catch (error) {
|
||||
console.error('Logo upload failed:', error);
|
||||
// Error notification is already handled in API.uploadLogo
|
||||
}
|
||||
} else {
|
||||
setLogoPreview(null);
|
||||
}
|
||||
|
|
|
|||
671
frontend/src/components/forms/GroupManager.jsx
Normal file
671
frontend/src/components/forms/GroupManager.jsx
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Flex,
|
||||
Badge,
|
||||
Alert,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
useMantineTheme,
|
||||
Chip,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
SquarePlus,
|
||||
SquarePen,
|
||||
SquareMinus,
|
||||
Check,
|
||||
X,
|
||||
AlertCircle,
|
||||
Database,
|
||||
Tv,
|
||||
Trash,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import API from '../../api';
|
||||
|
||||
// Move GroupItem outside to prevent recreation on every render
|
||||
const GroupItem = React.memo(({
|
||||
group,
|
||||
editingGroup,
|
||||
editName,
|
||||
onEditNameChange,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
groupUsage,
|
||||
canEditGroup,
|
||||
canDeleteGroup
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const getGroupBadges = (group) => {
|
||||
const usage = groupUsage[group.id];
|
||||
const badges = [];
|
||||
|
||||
if (usage?.hasChannels) {
|
||||
badges.push(
|
||||
<Badge key="channels" size="xs" color="blue" leftSection={<Tv size={10} />}>
|
||||
Channels
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (usage?.hasM3UAccounts) {
|
||||
badges.push(
|
||||
<Badge key="m3u" size="xs" color="purple" leftSection={<Database size={10} />}>
|
||||
M3U
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
border: '1px solid #444',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: editingGroup === group.id ? '#3f3f46' : (group.enabled ? '#2A2A2E' : '#1E1E22'),
|
||||
padding: '8px',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" gap="xs" align="flex-start">
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
{editingGroup === group.id ? (
|
||||
<TextInput
|
||||
value={editName}
|
||||
onChange={onEditNameChange}
|
||||
size="sm"
|
||||
onKeyPress={(e) => e.key === 'Enter' && onSaveEdit()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" fw={500}>{group.name}</Text>
|
||||
<Group gap={4}>
|
||||
{getGroupBadges(group)}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs">
|
||||
{editingGroup === group.id ? (
|
||||
<>
|
||||
<ActionIcon color="green" size="sm" onClick={onSaveEdit}>
|
||||
<Check size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon color="gray" size="sm" onClick={onCancelEdit}>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
size="sm"
|
||||
onClick={() => onEdit(group)}
|
||||
disabled={!canEditGroup(group)}
|
||||
>
|
||||
<SquarePen size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
size="sm"
|
||||
onClick={() => onDelete(group)}
|
||||
disabled={!canDeleteGroup(group)}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const GroupManager = React.memo(({ isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [groupUsage, setGroupUsage] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isCleaningUp, setIsCleaningUp] = useState(false);
|
||||
const [showChannelGroups, setShowChannelGroups] = useState(true);
|
||||
const [showM3UGroups, setShowM3UGroups] = useState(true);
|
||||
const [showUnusedGroups, setShowUnusedGroups] = useState(true);
|
||||
|
||||
// Confirmation dialog states
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [groupToDelete, setGroupToDelete] = useState(null);
|
||||
const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
|
||||
|
||||
// Memoize the channel groups array to prevent unnecessary re-renders
|
||||
const channelGroupsArray = useMemo(() =>
|
||||
Object.values(channelGroups),
|
||||
[channelGroups]
|
||||
);
|
||||
|
||||
// Memoize sorted groups to prevent re-sorting on every render
|
||||
const sortedGroups = useMemo(() =>
|
||||
channelGroupsArray.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[channelGroupsArray]
|
||||
);
|
||||
|
||||
// Filter groups based on search term and chip filters
|
||||
const filteredGroups = useMemo(() => {
|
||||
let filtered = sortedGroups;
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm.trim()) {
|
||||
filtered = filtered.filter(group =>
|
||||
group.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Apply chip filters
|
||||
filtered = filtered.filter(group => {
|
||||
const usage = groupUsage[group.id];
|
||||
if (!usage) return false;
|
||||
|
||||
const hasChannels = usage.hasChannels;
|
||||
const hasM3U = usage.hasM3UAccounts;
|
||||
const isUnused = !hasChannels && !hasM3U;
|
||||
|
||||
// If group is unused, only show if unused groups are enabled
|
||||
if (isUnused) {
|
||||
return showUnusedGroups;
|
||||
}
|
||||
|
||||
// For groups with channels and/or M3U, show if either filter is enabled
|
||||
let shouldShow = false;
|
||||
if (hasChannels && showChannelGroups) shouldShow = true;
|
||||
if (hasM3U && showM3UGroups) shouldShow = true;
|
||||
|
||||
return shouldShow;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [sortedGroups, searchTerm, showChannelGroups, showM3UGroups, showUnusedGroups, groupUsage]);
|
||||
|
||||
// Calculate filter counts
|
||||
const filterCounts = useMemo(() => {
|
||||
const counts = {
|
||||
channels: 0,
|
||||
m3u: 0,
|
||||
unused: 0
|
||||
};
|
||||
|
||||
sortedGroups.forEach(group => {
|
||||
const usage = groupUsage[group.id];
|
||||
if (usage) {
|
||||
const hasChannels = usage.hasChannels;
|
||||
const hasM3U = usage.hasM3UAccounts;
|
||||
|
||||
// Count groups with channels (including those with both)
|
||||
if (hasChannels) {
|
||||
counts.channels++;
|
||||
}
|
||||
|
||||
// Count groups with M3U (including those with both)
|
||||
if (hasM3U) {
|
||||
counts.m3u++;
|
||||
}
|
||||
|
||||
// Count truly unused groups
|
||||
if (!hasChannels && !hasM3U) {
|
||||
counts.unused++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [sortedGroups, groupUsage]);
|
||||
|
||||
const fetchGroupUsage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Use the actual channel group data that already has the flags
|
||||
const usage = {};
|
||||
|
||||
Object.values(channelGroups).forEach(group => {
|
||||
usage[group.id] = {
|
||||
hasChannels: group.hasChannels ?? false,
|
||||
hasM3UAccounts: group.hasM3UAccounts ?? false,
|
||||
canEdit: group.canEdit ?? true,
|
||||
canDelete: group.canDelete ?? true
|
||||
};
|
||||
});
|
||||
|
||||
setGroupUsage(usage);
|
||||
} catch (error) {
|
||||
console.error('Error fetching group usage:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [channelGroups]);
|
||||
|
||||
// Fetch group usage information when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchGroupUsage();
|
||||
}
|
||||
}, [isOpen, fetchGroupUsage]);
|
||||
|
||||
const handleEdit = useCallback((group) => {
|
||||
setEditingGroup(group.id);
|
||||
setEditName(group.name);
|
||||
}, []);
|
||||
|
||||
const handleSaveEdit = useCallback(async () => {
|
||||
if (!editName.trim()) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Group name cannot be empty',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.updateChannelGroup({
|
||||
id: editingGroup,
|
||||
name: editName.trim(),
|
||||
});
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Group updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
setEditingGroup(null);
|
||||
setEditName('');
|
||||
await fetchGroupUsage(); // Refresh usage data
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to update group',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [editName, editingGroup, fetchGroupUsage]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingGroup(null);
|
||||
setEditName('');
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!newGroupName.trim()) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Group name cannot be empty',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.addChannelGroup({
|
||||
name: newGroupName.trim(),
|
||||
});
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Group created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
setNewGroupName('');
|
||||
setIsCreating(false);
|
||||
await fetchGroupUsage(); // Refresh usage data
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create group',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [newGroupName, fetchGroupUsage]);
|
||||
|
||||
const executeDeleteGroup = useCallback(async (group) => {
|
||||
try {
|
||||
await API.deleteChannelGroup(group.id);
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Group deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
await fetchGroupUsage(); // Refresh usage data
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete group',
|
||||
color: 'red',
|
||||
});
|
||||
setConfirmDeleteOpen(false);
|
||||
}
|
||||
}, [fetchGroupUsage]);
|
||||
|
||||
const handleDelete = useCallback(async (group) => {
|
||||
const usage = groupUsage[group.id];
|
||||
|
||||
if (usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts)) {
|
||||
notifications.show({
|
||||
title: 'Cannot Delete',
|
||||
message: 'This group is associated with channels or M3U accounts and cannot be deleted',
|
||||
color: 'orange',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store group for confirmation dialog
|
||||
setGroupToDelete(group);
|
||||
|
||||
// Skip warning if it's been suppressed
|
||||
if (isWarningSuppressed('delete-group')) {
|
||||
return executeDeleteGroup(group);
|
||||
}
|
||||
|
||||
setConfirmDeleteOpen(true);
|
||||
}, [groupUsage, isWarningSuppressed, executeDeleteGroup]);
|
||||
|
||||
const executeCleanup = useCallback(async () => {
|
||||
setIsCleaningUp(true);
|
||||
try {
|
||||
const result = await API.cleanupUnusedChannelGroups();
|
||||
|
||||
notifications.show({
|
||||
title: 'Cleanup Complete',
|
||||
message: `Successfully deleted ${result.deleted_count} unused groups`,
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
await fetchGroupUsage(); // Refresh usage data
|
||||
setConfirmCleanupOpen(false);
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Cleanup Failed',
|
||||
message: 'Failed to cleanup unused groups',
|
||||
color: 'red',
|
||||
});
|
||||
setConfirmCleanupOpen(false);
|
||||
} finally {
|
||||
setIsCleaningUp(false);
|
||||
}
|
||||
}, [fetchGroupUsage]);
|
||||
|
||||
const handleCleanup = useCallback(async () => {
|
||||
// Skip warning if it's been suppressed
|
||||
if (isWarningSuppressed('cleanup-groups')) {
|
||||
return executeCleanup();
|
||||
}
|
||||
|
||||
setConfirmCleanupOpen(true);
|
||||
}, [isWarningSuppressed, executeCleanup]);
|
||||
|
||||
const handleNewGroupNameChange = useCallback((e) => {
|
||||
setNewGroupName(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleEditNameChange = useCallback((e) => {
|
||||
setEditName(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title="Group Manager"
|
||||
size="lg"
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
zIndex={2000}
|
||||
>
|
||||
<Stack>
|
||||
<Alert icon={<AlertCircle size={16} />} color="blue" variant="light">
|
||||
Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted.
|
||||
</Alert>
|
||||
|
||||
{/* Create new group section */}
|
||||
<Group justify="space-between">
|
||||
{isCreating ? (
|
||||
<Group style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
placeholder="Enter group name"
|
||||
value={newGroupName}
|
||||
onChange={handleNewGroupNameChange}
|
||||
style={{ flex: 1 }}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
autoFocus
|
||||
/>
|
||||
<ActionIcon color="green" onClick={handleCreate}>
|
||||
<Check size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon color="gray" onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewGroupName('');
|
||||
}}>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
leftSection={<SquarePlus size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => setIsCreating(true)}
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isCreating && (
|
||||
<Button
|
||||
leftSection={<Trash size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="orange"
|
||||
onClick={handleCleanup}
|
||||
loading={isCleaningUp}
|
||||
>
|
||||
Cleanup Unused
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Filter Controls */}
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group align="center" gap="sm">
|
||||
<Filter size={16} />
|
||||
<Text size="sm" fw={600}>Filter Groups</Text>
|
||||
</Group>
|
||||
<TextInput
|
||||
placeholder="Search groups..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
size="sm"
|
||||
style={{ width: '200px' }}
|
||||
rightSection={searchTerm && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => setSearchTerm('')}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">Show:</Text>
|
||||
<Chip
|
||||
checked={showChannelGroups}
|
||||
onChange={setShowChannelGroups}
|
||||
size="sm"
|
||||
color="blue"
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Tv size={10} />
|
||||
Channel Groups ({filterCounts.channels})
|
||||
</Group>
|
||||
</Chip>
|
||||
<Chip
|
||||
checked={showM3UGroups}
|
||||
onChange={setShowM3UGroups}
|
||||
size="sm"
|
||||
color="purple"
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Database size={10} />
|
||||
M3U Groups ({filterCounts.m3u})
|
||||
</Group>
|
||||
</Chip>
|
||||
<Chip
|
||||
checked={showUnusedGroups}
|
||||
onChange={setShowUnusedGroups}
|
||||
size="sm"
|
||||
color="gray"
|
||||
>
|
||||
Unused Groups ({filterCounts.unused})
|
||||
</Chip>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Existing groups */}
|
||||
<Stack>
|
||||
<Text size="sm" fw={600}>
|
||||
Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`})
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<Text size="sm" c="dimmed">Loading group information...</Text>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{filteredGroups.map((group) => (
|
||||
<GroupItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
editingGroup={editingGroup}
|
||||
editName={editName}
|
||||
onEditNameChange={handleEditNameChange}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
groupUsage={groupUsage}
|
||||
canEditGroup={canEditChannelGroup}
|
||||
canDeleteGroup={canDeleteChannelGroup}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)}
|
||||
title="Confirm Group Deletion"
|
||||
message={
|
||||
groupToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to delete the following group?
|
||||
|
||||
Name: ${groupToDelete.name}
|
||||
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
) : (
|
||||
'Are you sure you want to delete this group? This action cannot be undone.'
|
||||
)
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
actionKey="delete-group"
|
||||
onSuppressChange={suppressWarning}
|
||||
size="md"
|
||||
zIndex={2100}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmCleanupOpen}
|
||||
onClose={() => setConfirmCleanupOpen(false)}
|
||||
onConfirm={executeCleanup}
|
||||
title="Confirm Group Cleanup"
|
||||
message={
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to cleanup all unused groups?
|
||||
|
||||
This will permanently delete all groups that are not associated with any channels or M3U accounts.
|
||||
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
}
|
||||
confirmLabel="Cleanup"
|
||||
cancelLabel="Cancel"
|
||||
actionKey="cleanup-groups"
|
||||
onSuppressChange={suppressWarning}
|
||||
size="md"
|
||||
zIndex={2100}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default GroupManager;
|
||||
268
frontend/src/components/forms/Logo.jsx
Normal file
268
frontend/src/components/forms/Logo.jsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Image,
|
||||
Text,
|
||||
Center,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { Upload, FileImage, X } from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
|
||||
const LogoForm = ({ logo = null, isOpen, onClose }) => {
|
||||
const [logoPreview, setLogoPreview] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string()
|
||||
.required('URL is required')
|
||||
.test('valid-url-or-path', 'Must be a valid URL or local file path', (value) => {
|
||||
if (!value) return false;
|
||||
// Allow local file paths starting with /data/logos/
|
||||
if (value.startsWith('/data/logos/')) return true;
|
||||
// Allow valid URLs
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
if (logo) {
|
||||
await API.updateLogo(logo.id, values);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
await API.createLogo(values);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
let errorMessage = logo ? 'Failed to update logo' : 'Failed to create logo';
|
||||
|
||||
// Handle specific timeout errors
|
||||
if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) {
|
||||
errorMessage = 'Request timed out. Please try again.';
|
||||
} else if (error.response?.data?.error) {
|
||||
errorMessage = error.response.data.error;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: errorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (logo) {
|
||||
formik.setValues({
|
||||
name: logo.name || '',
|
||||
url: logo.url || '',
|
||||
});
|
||||
setLogoPreview(logo.cache_url);
|
||||
} else {
|
||||
formik.resetForm();
|
||||
setLogoPreview(null);
|
||||
}
|
||||
}, [logo, isOpen]);
|
||||
|
||||
const handleFileUpload = async (files) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// Validate file size on frontend first
|
||||
if (file.size > 5 * 1024 * 1024) { // 5MB
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'File too large. Maximum size is 5MB.',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const response = await API.uploadLogo(file);
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (event) => {
|
||||
const url = event.target.value;
|
||||
formik.setFieldValue('url', url);
|
||||
|
||||
// Update preview for remote URLs
|
||||
if (url && url.startsWith('http')) {
|
||||
setLogoPreview(url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={logo ? 'Edit Logo' : 'Add Logo'}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<Stack spacing="md">
|
||||
{/* Logo Preview */}
|
||||
{logoPreview && (
|
||||
<Center>
|
||||
<Box>
|
||||
<Text size="sm" color="dimmed" mb="xs" ta="center">
|
||||
Preview
|
||||
</Text>
|
||||
<Image
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
width={100}
|
||||
height={75}
|
||||
fit="contain"
|
||||
fallbackSrc="/logo.png"
|
||||
style={{
|
||||
transition: 'transform 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
transform: 'scale(1.5)'
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'scale(1.5)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'scale(1)';
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* File Upload */}
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Upload Logo File
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={['image/png', 'image/jpeg', 'image/gif', 'image/webp']}
|
||||
maxFiles={1}
|
||||
loading={uploading}
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
|
||||
<Dropzone.Accept>
|
||||
<Upload size={50} color="green" />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<X size={50} color="red" />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<FileImage size={50} />
|
||||
</Dropzone.Idle>
|
||||
|
||||
<div>
|
||||
<Text size="xl" inline>
|
||||
Drag image here or click to select
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed" inline mt={7}>
|
||||
Supports PNG, JPEG, GIF, WebP files
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
</Box>
|
||||
|
||||
<Divider label="OR" labelPosition="center" />
|
||||
|
||||
{/* Manual URL Input */}
|
||||
<TextInput
|
||||
label="Logo URL"
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...formik.getFieldProps('url')}
|
||||
onChange={handleUrlChange}
|
||||
error={formik.touched.url && formik.errors.url}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Enter logo name"
|
||||
{...formik.getFieldProps('name')}
|
||||
error={formik.touched.name && formik.errors.name}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={formik.isSubmitting || uploading}>
|
||||
{logo ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoForm;
|
||||
|
|
@ -145,8 +145,7 @@ const M3U = ({
|
|||
if (values.account_type != 'XC') {
|
||||
notifications.show({
|
||||
title: 'Fetching M3U Groups',
|
||||
message: 'Filter out groups or refresh M3U once complete.',
|
||||
// color: 'green.5',
|
||||
message: 'Configure group filters and auto sync settings once complete.',
|
||||
});
|
||||
|
||||
// Don't prompt for group filters, but keeping this here
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, forwardRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
|
|
@ -21,9 +21,26 @@ import {
|
|||
Center,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
NumberInput,
|
||||
Divider,
|
||||
Alert,
|
||||
Box,
|
||||
MultiSelect,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { CircleCheck, CircleX } from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
// Custom item component for MultiSelect with tooltip
|
||||
const OptionWithTooltip = forwardRef(({ label, description, ...others }, ref) => (
|
||||
<Tooltip label={description} withArrow>
|
||||
<div ref={ref} {...others}>
|
||||
{label}
|
||||
</div>
|
||||
</Tooltip>
|
||||
));
|
||||
|
||||
const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
|
|
@ -37,10 +54,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
}
|
||||
|
||||
setGroupStates(
|
||||
playlist.channel_groups.map((group) => ({
|
||||
...group,
|
||||
name: channelGroups[group.channel_group].name,
|
||||
}))
|
||||
playlist.channel_groups.map((group) => {
|
||||
// Parse custom_properties if present
|
||||
let customProps = {};
|
||||
if (group.custom_properties) {
|
||||
try {
|
||||
customProps = typeof group.custom_properties === 'string'
|
||||
? JSON.parse(group.custom_properties)
|
||||
: group.custom_properties;
|
||||
} catch (e) {
|
||||
customProps = {};
|
||||
}
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
name: channelGroups[group.channel_group].name,
|
||||
auto_channel_sync: group.auto_channel_sync || false,
|
||||
auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
|
||||
custom_properties: customProps,
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [playlist, channelGroups]);
|
||||
|
||||
|
|
@ -53,15 +86,79 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const toggleAutoSync = (id) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => ({
|
||||
...state,
|
||||
auto_channel_sync: state.channel_group == id ? !state.auto_channel_sync : state.auto_channel_sync,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const updateChannelStart = (id, value) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => ({
|
||||
...state,
|
||||
auto_sync_channel_start: state.channel_group == id ? value : state.auto_sync_channel_start,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
// Toggle force_dummy_epg in custom_properties for a group
|
||||
const toggleForceDummyEPG = (id) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (state.channel_group == id) {
|
||||
const customProps = { ...(state.custom_properties || {}) };
|
||||
customProps.force_dummy_epg = !customProps.force_dummy_epg;
|
||||
return {
|
||||
...state,
|
||||
custom_properties: customProps,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setIsLoading(true);
|
||||
await API.updatePlaylist({
|
||||
...playlist,
|
||||
channel_groups: groupStates,
|
||||
});
|
||||
setIsLoading(false);
|
||||
API.refreshPlaylist(playlist.id);
|
||||
onClose();
|
||||
try {
|
||||
// Prepare groupStates for API: custom_properties must be stringified
|
||||
const payload = groupStates.map((state) => ({
|
||||
...state,
|
||||
custom_properties: state.custom_properties
|
||||
? JSON.stringify(state.custom_properties)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
// Update group settings via API endpoint
|
||||
await API.updateM3UGroupSettings(playlist.id, payload);
|
||||
|
||||
// Show notification about the refresh process
|
||||
notifications.show({
|
||||
title: 'Group Settings Updated',
|
||||
message: 'Settings saved. Starting M3U refresh to apply changes...',
|
||||
color: 'green',
|
||||
autoClose: 3000,
|
||||
});
|
||||
|
||||
// Refresh the playlist - this will handle channel sync automatically at the end
|
||||
await API.refreshPlaylist(playlist.id);
|
||||
|
||||
notifications.show({
|
||||
title: 'M3U Refresh Started',
|
||||
message: 'The M3U account is being refreshed. Channel sync will occur automatically after parsing completes.',
|
||||
color: 'blue',
|
||||
autoClose: 5000,
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error updating group settings:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
|
|
@ -94,60 +191,354 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title="M3U Group Filter"
|
||||
title="M3U Group Filter & Auto Channel Sync"
|
||||
size={1000}
|
||||
styles={{ content: { '--mantine-color-body': '#27272A' } }}
|
||||
>
|
||||
<LoadingOverlay visible={isLoading} overlayBlur={2} />
|
||||
<Stack>
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
<strong>Auto Channel Sync:</strong> When enabled, channels will be automatically created for all streams in the group during M3U updates,
|
||||
and removed when streams are no longer present. Set a starting channel number for each group to organize your channels.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Flex gap="sm">
|
||||
<TextInput
|
||||
placeholder="Filter"
|
||||
placeholder="Filter groups..."
|
||||
value={groupFilter}
|
||||
onChange={(event) => setGroupFilter(event.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
/>
|
||||
<Button variant="default" size="sm" onClick={selectAll}>
|
||||
<Button variant="default" size="xs" onClick={selectAll}>
|
||||
Select Visible
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={deselectAll}>
|
||||
<Button variant="default" size="xs" onClick={deselectAll}>
|
||||
Deselect Visible
|
||||
</Button>
|
||||
</Flex>
|
||||
<SimpleGrid cols={4}>
|
||||
{groupStates
|
||||
.filter((group) =>
|
||||
group.name.toLowerCase().includes(groupFilter.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => a.name > b.name)
|
||||
.map((group) => (
|
||||
<Button
|
||||
key={group.channel_group}
|
||||
color={group.enabled ? 'green' : 'gray'}
|
||||
variant="filled"
|
||||
checked={group.enabled}
|
||||
onClick={() => toggleGroupEnabled(group.channel_group)}
|
||||
radius="xl"
|
||||
leftSection={
|
||||
group.enabled ? (
|
||||
<CircleCheck size={20} />
|
||||
) : (
|
||||
<CircleX size={20} />
|
||||
)
|
||||
}
|
||||
justify="left"
|
||||
>
|
||||
<Text size="xs">{group.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Groups & Auto Sync Settings" labelPosition="center" />
|
||||
|
||||
<Box style={{ maxHeight: '50vh', overflowY: 'auto' }}>
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, md: 3 }}
|
||||
spacing="xs"
|
||||
verticalSpacing="xs"
|
||||
>
|
||||
{groupStates
|
||||
.filter((group) =>
|
||||
group.name.toLowerCase().includes(groupFilter.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((group) => (
|
||||
<Group key={group.channel_group} spacing="xs" style={{
|
||||
padding: '8px',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: group.enabled ? '#2A2A2E' : '#1E1E22',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
{/* Group Enable/Disable Button */}
|
||||
<Button
|
||||
color={group.enabled ? 'green' : 'gray'}
|
||||
variant="filled"
|
||||
onClick={() => toggleGroupEnabled(group.channel_group)}
|
||||
radius="md"
|
||||
size="xs"
|
||||
leftSection={
|
||||
group.enabled ? (
|
||||
<CircleCheck size={14} />
|
||||
) : (
|
||||
<CircleX size={14} />
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<Text size="xs" truncate>
|
||||
{group.name}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{/* Auto Sync Controls */}
|
||||
<Stack spacing="xs" style={{ '--stack-gap': '4px' }}>
|
||||
<Flex align="center" gap="xs">
|
||||
<Checkbox
|
||||
label="Auto Channel Sync"
|
||||
checked={group.auto_channel_sync && group.enabled}
|
||||
disabled={!group.enabled}
|
||||
onChange={() => toggleAutoSync(group.channel_group)}
|
||||
size="xs"
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{group.auto_channel_sync && group.enabled && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Start Channel #"
|
||||
value={group.auto_sync_channel_start}
|
||||
onChange={(value) => updateChannelStart(group.channel_group, value)}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={1}
|
||||
/>
|
||||
|
||||
{/* Auto Channel Sync Options Multi-Select */}
|
||||
<MultiSelect
|
||||
label="Advanced Options"
|
||||
placeholder="Select options..."
|
||||
data={[
|
||||
{
|
||||
value: 'force_dummy_epg',
|
||||
label: 'Force Dummy EPG',
|
||||
description: 'Assign a dummy EPG to all channels in this group if no EPG is matched',
|
||||
},
|
||||
{
|
||||
value: 'group_override',
|
||||
label: 'Override Channel Group',
|
||||
description: 'Override the group assignment for all channels in this group',
|
||||
},
|
||||
{
|
||||
value: 'name_regex',
|
||||
label: 'Channel Name Find & Replace (Regex)',
|
||||
description: 'Find and replace part of the channel name using a regex pattern',
|
||||
},
|
||||
{
|
||||
value: 'name_match_regex',
|
||||
label: 'Channel Name Filter (Regex)',
|
||||
description: 'Only include channels whose names match this regex pattern',
|
||||
},
|
||||
]}
|
||||
itemComponent={OptionWithTooltip}
|
||||
value={(() => {
|
||||
const selectedValues = [];
|
||||
if (group.custom_properties?.force_dummy_epg) {
|
||||
selectedValues.push('force_dummy_epg');
|
||||
}
|
||||
if (group.custom_properties?.group_override !== undefined) {
|
||||
selectedValues.push('group_override');
|
||||
}
|
||||
if (
|
||||
group.custom_properties?.name_regex_pattern !== undefined ||
|
||||
group.custom_properties?.name_replace_pattern !== undefined
|
||||
) {
|
||||
selectedValues.push('name_regex');
|
||||
}
|
||||
if (group.custom_properties?.name_match_regex !== undefined) {
|
||||
selectedValues.push('name_match_regex');
|
||||
}
|
||||
return selectedValues;
|
||||
})()}
|
||||
onChange={(values) => {
|
||||
// MultiSelect always returns an array
|
||||
const selectedOptions = values || [];
|
||||
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (state.channel_group === group.channel_group) {
|
||||
let newCustomProps = { ...(state.custom_properties || {}) };
|
||||
|
||||
// Handle force_dummy_epg
|
||||
if (selectedOptions.includes('force_dummy_epg')) {
|
||||
newCustomProps.force_dummy_epg = true;
|
||||
} else {
|
||||
delete newCustomProps.force_dummy_epg;
|
||||
}
|
||||
|
||||
// Handle group_override
|
||||
if (selectedOptions.includes('group_override')) {
|
||||
if (newCustomProps.group_override === undefined) {
|
||||
newCustomProps.group_override = null;
|
||||
}
|
||||
} else {
|
||||
delete newCustomProps.group_override;
|
||||
}
|
||||
|
||||
// Handle name_regex
|
||||
if (selectedOptions.includes('name_regex')) {
|
||||
if (newCustomProps.name_regex_pattern === undefined) {
|
||||
newCustomProps.name_regex_pattern = '';
|
||||
}
|
||||
if (newCustomProps.name_replace_pattern === undefined) {
|
||||
newCustomProps.name_replace_pattern = '';
|
||||
}
|
||||
} else {
|
||||
delete newCustomProps.name_regex_pattern;
|
||||
delete newCustomProps.name_replace_pattern;
|
||||
}
|
||||
|
||||
// Handle name_match_regex
|
||||
if (selectedOptions.includes('name_match_regex')) {
|
||||
if (newCustomProps.name_match_regex === undefined) {
|
||||
newCustomProps.name_match_regex = '';
|
||||
}
|
||||
} else {
|
||||
delete newCustomProps.name_match_regex;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
custom_properties: newCustomProps,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}}
|
||||
clearable
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
{/* Show group select only if group_override is selected */}
|
||||
{group.custom_properties?.group_override !== undefined && (
|
||||
<Tooltip
|
||||
label="Select a group to override the assignment for all channels in this group."
|
||||
withArrow
|
||||
>
|
||||
<Select
|
||||
label="Override Channel Group"
|
||||
placeholder="Choose group..."
|
||||
value={group.custom_properties?.group_override?.toString() || null}
|
||||
onChange={(value) => {
|
||||
const newValue = value ? parseInt(value) : null;
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (state.channel_group === group.channel_group) {
|
||||
return {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
group_override: newValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}}
|
||||
data={Object.values(channelGroups).map((g) => ({
|
||||
value: g.id.toString(),
|
||||
label: g.name,
|
||||
}))}
|
||||
clearable
|
||||
searchable
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Show regex fields only if name_regex is selected */}
|
||||
{(group.custom_properties?.name_regex_pattern !== undefined ||
|
||||
group.custom_properties?.name_replace_pattern !== undefined) && (
|
||||
<>
|
||||
<Tooltip
|
||||
label="Regex pattern to find in the channel name. Example: ^.*? - PPV\\d+ - (.+)$"
|
||||
withArrow
|
||||
>
|
||||
<TextInput
|
||||
label="Channel Name Find (Regex)"
|
||||
placeholder="e.g. ^.*? - PPV\\d+ - (.+)$"
|
||||
value={group.custom_properties?.name_regex_pattern || ''}
|
||||
onChange={e => {
|
||||
const val = e.currentTarget.value;
|
||||
setGroupStates(
|
||||
groupStates.map(state =>
|
||||
state.channel_group === group.channel_group
|
||||
? {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
name_regex_pattern: val,
|
||||
},
|
||||
}
|
||||
: state
|
||||
)
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Replacement pattern for the channel name. Example: $1"
|
||||
withArrow
|
||||
>
|
||||
<TextInput
|
||||
label="Channel Name Replace"
|
||||
placeholder="e.g. $1"
|
||||
value={group.custom_properties?.name_replace_pattern || ''}
|
||||
onChange={e => {
|
||||
const val = e.currentTarget.value;
|
||||
setGroupStates(
|
||||
groupStates.map(state =>
|
||||
state.channel_group === group.channel_group
|
||||
? {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
name_replace_pattern: val,
|
||||
},
|
||||
}
|
||||
: state
|
||||
)
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show name_match_regex field only if selected */}
|
||||
{group.custom_properties?.name_match_regex !== undefined && (
|
||||
<Tooltip
|
||||
label="Only channels whose names match this regex will be included. Example: ^Sports.*"
|
||||
withArrow
|
||||
>
|
||||
<TextInput
|
||||
label="Channel Name Filter (Regex)"
|
||||
placeholder="e.g. ^Sports.*"
|
||||
value={group.custom_properties?.name_match_regex || ''}
|
||||
onChange={e => {
|
||||
const val = e.currentTarget.value;
|
||||
setGroupStates(
|
||||
groupStates.map(state =>
|
||||
state.channel_group === group.channel_group
|
||||
? {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
name_match_regex: val,
|
||||
},
|
||||
}
|
||||
: state
|
||||
)
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button variant="default" onClick={onClose} size="xs">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
disabled={isLoading}
|
||||
size="small"
|
||||
onClick={submit}
|
||||
>
|
||||
Save and Refresh
|
||||
|
|
@ -158,4 +549,4 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default M3UGroupFilter;
|
||||
export default M3UGroupFilter;
|
||||
|
|
@ -216,6 +216,9 @@ const ChannelRowActions = React.memo(
|
|||
|
||||
const ChannelsTable = ({ }) => {
|
||||
const theme = useMantineTheme();
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup);
|
||||
|
||||
/**
|
||||
* STORES
|
||||
|
|
@ -241,7 +244,6 @@ const ChannelsTable = ({ }) => {
|
|||
const channels = useChannelsStore((s) => s.channels);
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const logos = useChannelsStore((s) => s.logos);
|
||||
const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
|
||||
pageSize: 50,
|
||||
|
|
@ -286,7 +288,8 @@ 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);
|
||||
|
|
@ -306,7 +309,7 @@ const ChannelsTable = ({ }) => {
|
|||
});
|
||||
|
||||
/**
|
||||
* Dereived variables
|
||||
* Derived variables
|
||||
*/
|
||||
const activeGroupIds = new Set(
|
||||
Object.values(channels).map((channel) => channel.channel_group_id)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
SquareMinus,
|
||||
SquarePen,
|
||||
SquarePlus,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import API from '../../../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
|
@ -32,6 +33,7 @@ import useChannelsStore from '../../../store/channels';
|
|||
import useAuthStore from '../../../store/auth';
|
||||
import { USER_LEVELS } from '../../../constants';
|
||||
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
|
||||
import GroupManager from '../../forms/GroupManager';
|
||||
import ConfirmationDialog from '../../ConfirmationDialog';
|
||||
import useWarningsStore from '../../../store/warnings';
|
||||
|
||||
|
|
@ -105,6 +107,7 @@ const ChannelTableHeader = ({
|
|||
|
||||
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
|
||||
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
|
||||
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
|
||||
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false);
|
||||
const [profileToDelete, setProfileToDelete] = useState(null);
|
||||
|
||||
|
|
@ -284,22 +287,25 @@ const ChannelTableHeader = ({
|
|||
selectedTableIds.length == 0 ||
|
||||
authUser.user_level != USER_LEVELS.ADMIN
|
||||
}
|
||||
onClick={() => setAssignNumbersModalOpen(true)}
|
||||
>
|
||||
<UnstyledButton
|
||||
size="xs"
|
||||
onClick={() => setAssignNumbersModalOpen(true)}
|
||||
>
|
||||
<Text size="xs">Assign #s</Text>
|
||||
</UnstyledButton>
|
||||
<Text size="xs">Assign #s</Text>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<Binary size={18} />}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
onClick={matchEpg}
|
||||
>
|
||||
<UnstyledButton size="xs" onClick={matchEpg}>
|
||||
<Text size="xs">Auto-Match</Text>
|
||||
</UnstyledButton>
|
||||
<Text size="xs">Auto-Match</Text>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<Settings size={18} />}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
onClick={() => setGroupManagerOpen(true)}
|
||||
>
|
||||
<Text size="xs">Edit Groups</Text>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
|
@ -312,6 +318,11 @@ const ChannelTableHeader = ({
|
|||
onClose={closeAssignChannelNumbersModal}
|
||||
/>
|
||||
|
||||
<GroupManager
|
||||
isOpen={groupManagerOpen}
|
||||
onClose={() => setGroupManagerOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteProfileOpen}
|
||||
onClose={() => setConfirmDeleteProfileOpen(false)}
|
||||
|
|
|
|||
793
frontend/src/components/tables/LogosTable.jsx
Normal file
793
frontend/src/components/tables/LogosTable.jsx
Normal file
|
|
@ -0,0 +1,793 @@
|
|||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import LogoForm from '../forms/Logo';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import {
|
||||
SquarePlus,
|
||||
SquareMinus,
|
||||
SquarePen,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
Trash2,
|
||||
Trash,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Text,
|
||||
Paper,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
useMantineTheme,
|
||||
LoadingOverlay,
|
||||
Stack,
|
||||
Image,
|
||||
Center,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Select,
|
||||
TextInput,
|
||||
Menu,
|
||||
Checkbox,
|
||||
Pagination,
|
||||
NativeSelect,
|
||||
} from '@mantine/core';
|
||||
import { CustomTable, useTable } from './CustomTable';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => {
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const onEdit = useCallback(() => {
|
||||
editLogo(row.original);
|
||||
}, [row.original, editLogo]);
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
deleteLogo(row.original.id);
|
||||
}, [row.original.id, deleteLogo]);
|
||||
|
||||
const iconSize =
|
||||
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
|
||||
|
||||
return (
|
||||
<Box style={{ width: '100%', justifyContent: 'left' }}>
|
||||
<Group gap={2} justify="center">
|
||||
<ActionIcon
|
||||
size={iconSize}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<SquarePen size="18" />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
size={iconSize}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const LogosTable = () => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
/**
|
||||
* STORES
|
||||
*/
|
||||
const { logos, fetchLogos } = useChannelsStore();
|
||||
|
||||
/**
|
||||
* useState
|
||||
*/
|
||||
const [selectedLogo, setSelectedLogo] = useState(null);
|
||||
const [logoModalOpen, setLogoModalOpen] = useState(false);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [logoToDelete, setLogoToDelete] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
|
||||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||
const [isCleaningUp, setIsCleaningUp] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
name: '',
|
||||
used: 'all'
|
||||
});
|
||||
const [debouncedNameFilter, setDebouncedNameFilter] = useState('');
|
||||
const [selectedRows, setSelectedRows] = useState(new Set());
|
||||
const [pageSize, setPageSize] = useLocalStorage('logos-page-size', 25);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: pageSize,
|
||||
});
|
||||
const [paginationString, setPaginationString] = useState('');
|
||||
|
||||
// Debounce the name filter
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedNameFilter(filters.name);
|
||||
}, 300); // 300ms delay
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [filters.name]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
const logosArray = Object.values(logos || {});
|
||||
|
||||
// Apply filters
|
||||
let filteredLogos = logosArray;
|
||||
|
||||
if (debouncedNameFilter) {
|
||||
filteredLogos = filteredLogos.filter(logo =>
|
||||
logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.used === 'used') {
|
||||
filteredLogos = filteredLogos.filter(logo => logo.is_used);
|
||||
} else if (filters.used === 'unused') {
|
||||
filteredLogos = filteredLogos.filter(logo => !logo.is_used);
|
||||
}
|
||||
|
||||
return filteredLogos.sort((a, b) => a.id - b.id);
|
||||
}, [logos, debouncedNameFilter, filters.used]);
|
||||
|
||||
// Get paginated data
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = pagination.pageIndex * pagination.pageSize;
|
||||
const endIndex = startIndex + pagination.pageSize;
|
||||
return data.slice(startIndex, endIndex);
|
||||
}, [data, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
// Calculate unused logos count
|
||||
const unusedLogosCount = useMemo(() => {
|
||||
const allLogos = Object.values(logos || {});
|
||||
return allLogos.filter(logo => !logo.is_used).length;
|
||||
}, [logos]);
|
||||
|
||||
/**
|
||||
* Functions
|
||||
*/
|
||||
const executeDeleteLogo = useCallback(async (id, deleteFile = false) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await API.deleteLogo(id, deleteFile);
|
||||
await fetchLogos();
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete logo',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
setLogoToDelete(null);
|
||||
setIsBulkDelete(false);
|
||||
setSelectedRows(new Set()); // Clear selections
|
||||
}
|
||||
}, [fetchLogos]);
|
||||
|
||||
const executeBulkDelete = useCallback(async (deleteFiles = false) => {
|
||||
if (selectedRows.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await API.deleteLogos(Array.from(selectedRows), deleteFiles);
|
||||
await fetchLogos();
|
||||
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `${selectedRows.size} logos deleted successfully`,
|
||||
color: 'green',
|
||||
});
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete logos',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
setIsBulkDelete(false);
|
||||
setSelectedRows(new Set()); // Clear selections
|
||||
}
|
||||
}, [selectedRows, fetchLogos]);
|
||||
|
||||
const executeCleanupUnused = useCallback(async (deleteFiles = false) => {
|
||||
setIsCleaningUp(true);
|
||||
try {
|
||||
const result = await API.cleanupUnusedLogos(deleteFiles);
|
||||
await fetchLogos(); // Refresh the logos list
|
||||
|
||||
let message = `Successfully deleted ${result.deleted_count} unused logos`;
|
||||
if (result.local_files_deleted > 0) {
|
||||
message += ` and deleted ${result.local_files_deleted} local files`;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Cleanup Complete',
|
||||
message: message,
|
||||
color: 'green',
|
||||
});
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Cleanup Failed',
|
||||
message: 'Failed to cleanup unused logos',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsCleaningUp(false);
|
||||
setConfirmCleanupOpen(false);
|
||||
setSelectedRows(new Set()); // Clear selections after cleanup
|
||||
}
|
||||
}, [fetchLogos]);
|
||||
|
||||
const editLogo = useCallback(async (logo = null) => {
|
||||
setSelectedLogo(logo);
|
||||
setLogoModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const deleteLogo = useCallback(async (id) => {
|
||||
const logosArray = Object.values(logos || {});
|
||||
const logo = logosArray.find((l) => l.id === id);
|
||||
setLogoToDelete(logo);
|
||||
setDeleteTarget(id);
|
||||
setIsBulkDelete(false);
|
||||
setConfirmDeleteOpen(true);
|
||||
}, [logos]);
|
||||
|
||||
const handleSelectRow = useCallback((id, checked) => {
|
||||
setSelectedRows(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (checked) {
|
||||
newSet.add(id);
|
||||
} else {
|
||||
newSet.delete(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback((checked) => {
|
||||
if (checked) {
|
||||
setSelectedRows(new Set(data.map(logo => logo.id)));
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const deleteBulkLogos = useCallback(() => {
|
||||
if (selectedRows.size === 0) return;
|
||||
|
||||
setIsBulkDelete(true);
|
||||
setLogoToDelete(null);
|
||||
setDeleteTarget(Array.from(selectedRows));
|
||||
setConfirmDeleteOpen(true);
|
||||
}, [selectedRows]);
|
||||
|
||||
const handleCleanupUnused = useCallback(() => {
|
||||
setConfirmCleanupOpen(true);
|
||||
}, []);
|
||||
|
||||
// Clear selections when logos data changes (e.g., after filtering)
|
||||
useEffect(() => {
|
||||
setSelectedRows(new Set());
|
||||
}, [data.length]);
|
||||
|
||||
// Update pagination when pageSize changes
|
||||
useEffect(() => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
pageSize: pageSize,
|
||||
}));
|
||||
}, [pageSize]);
|
||||
|
||||
// Calculate pagination string
|
||||
useEffect(() => {
|
||||
const startItem = pagination.pageIndex * pagination.pageSize + 1;
|
||||
const endItem = Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
data.length
|
||||
);
|
||||
setPaginationString(`${startItem} to ${endItem} of ${data.length}`);
|
||||
}, [pagination.pageIndex, pagination.pageSize, data.length]);
|
||||
|
||||
// Calculate page count
|
||||
const pageCount = useMemo(() => {
|
||||
return Math.ceil(data.length / pagination.pageSize);
|
||||
}, [data.length, pagination.pageSize]);
|
||||
|
||||
/**
|
||||
* useMemo
|
||||
*/
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={selectedRows.size > 0 && selectedRows.size === data.length}
|
||||
indeterminate={selectedRows.size > 0 && selectedRows.size < data.length}
|
||||
onChange={(event) => handleSelectAll(event.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={selectedRows.has(row.original.id)}
|
||||
onChange={(event) => handleSelectRow(row.original.id, event.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
size: 50,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
header: 'Preview',
|
||||
accessorKey: 'cache_url',
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
cell: ({ getValue, row }) => (
|
||||
<Center style={{ width: '100%', padding: '4px' }}>
|
||||
<Image
|
||||
src={getValue()}
|
||||
alt={row.original.name}
|
||||
width={40}
|
||||
height={30}
|
||||
fit="contain"
|
||||
fallbackSrc="/logo.png"
|
||||
style={{
|
||||
transition: 'transform 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.transform = 'scale(1.5)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.transform = 'scale(1)';
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
size: 200,
|
||||
cell: ({ getValue }) => (
|
||||
<Text fw={500} size="sm">
|
||||
{getValue()}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Usage',
|
||||
accessorKey: 'channel_count',
|
||||
size: 120,
|
||||
cell: ({ getValue, row }) => {
|
||||
const count = getValue();
|
||||
const channelNames = row.original.channel_names || [];
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
Unused
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={600}>Used by {count} channel{count !== 1 ? 's' : ''}:</Text>
|
||||
{channelNames.map((name, index) => (
|
||||
<Text key={index} size="xs">• {name}</Text>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
multiline
|
||||
width={220}
|
||||
>
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{count} channel{count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'URL',
|
||||
accessorKey: 'url',
|
||||
cell: ({ getValue }) => (
|
||||
<Group gap={4} style={{ alignItems: 'center' }}>
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{getValue()}
|
||||
</Text>
|
||||
</Box>
|
||||
{getValue()?.startsWith('http') && (
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => window.open(getValue(), '_blank')}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: 80,
|
||||
header: 'Actions',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<LogoRowActions
|
||||
theme={theme}
|
||||
row={row}
|
||||
editLogo={editLogo}
|
||||
deleteLogo={deleteLogo}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[theme, editLogo, deleteLogo, selectedRows, handleSelectRow, handleSelectAll, data.length]
|
||||
);
|
||||
|
||||
const closeLogoForm = () => {
|
||||
setSelectedLogo(null);
|
||||
setLogoModalOpen(false);
|
||||
fetchLogos(); // Refresh the logos list
|
||||
};
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
return (
|
||||
<Text size="sm" name={header.id}>
|
||||
{header.column.columnDef.header}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const onRowSelectionChange = useCallback((newSelection) => {
|
||||
setSelectedRows(new Set(newSelection));
|
||||
}, []);
|
||||
|
||||
const onPageSizeChange = (e) => {
|
||||
const newPageSize = parseInt(e.target.value);
|
||||
setPageSize(newPageSize);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
pageSize: newPageSize,
|
||||
pageIndex: 0, // Reset to first page
|
||||
}));
|
||||
};
|
||||
|
||||
const onPageIndexChange = (pageIndex) => {
|
||||
if (!pageIndex || pageIndex > pageCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
pageIndex: pageIndex - 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const table = useTable({
|
||||
columns,
|
||||
data: paginatedData,
|
||||
allRowIds: paginatedData.map((logo) => logo.id),
|
||||
enablePagination: false, // Disable internal pagination since we're handling it manually
|
||||
enableRowSelection: true,
|
||||
enableRowVirtualization: false,
|
||||
renderTopToolbar: false,
|
||||
manualSorting: false,
|
||||
manualFiltering: false,
|
||||
manualPagination: true, // Enable manual pagination
|
||||
onRowSelectionChange: onRowSelectionChange,
|
||||
headerCellRenderFns: {
|
||||
actions: renderHeaderCell,
|
||||
cache_url: renderHeaderCell,
|
||||
name: renderHeaderCell,
|
||||
url: renderHeaderCell,
|
||||
channel_count: renderHeaderCell,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '0px',
|
||||
minHeight: 'calc(100vh - 200px)',
|
||||
minWidth: '900px',
|
||||
}}
|
||||
>
|
||||
<Stack gap="md" style={{ maxWidth: '1200px', width: '100%' }}>
|
||||
<Flex style={{ alignItems: 'center', paddingBottom: 10 }} gap={15}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6',
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Logos
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
({data.length} logo{data.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
}}
|
||||
>
|
||||
{/* Top toolbar */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #3f3f46',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Filter by name..."
|
||||
value={filters.name}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
name: value
|
||||
}));
|
||||
}}
|
||||
size="xs"
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Usage filter"
|
||||
value={filters.used}
|
||||
onChange={(value) =>
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
used: value
|
||||
}))
|
||||
}
|
||||
data={[
|
||||
{ value: 'all', label: 'All logos' },
|
||||
{ value: 'used', label: 'Used only' },
|
||||
{ value: 'unused', label: 'Unused only' },
|
||||
]}
|
||||
size="xs"
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
leftSection={<Trash size={16} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
color="orange"
|
||||
onClick={handleCleanupUnused}
|
||||
loading={isCleaningUp}
|
||||
disabled={unusedLogosCount === 0}
|
||||
>
|
||||
Cleanup Unused {unusedLogosCount > 0 ? `(${unusedLogosCount})` : ''}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<SquareMinus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={deleteBulkLogos}
|
||||
disabled={selectedRows.size === 0}
|
||||
>
|
||||
Delete {selectedRows.size > 0 ? `(${selectedRows.size})` : ''}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editLogo()}
|
||||
p={5}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add Logo
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Table container */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: '0 0 var(--mantine-radius-md) var(--mantine-radius-md)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: 'calc(100vh - 200px)',
|
||||
}}
|
||||
>
|
||||
<div >
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
<CustomTable table={table} />
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<Box
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 3,
|
||||
backgroundColor: '#27272A',
|
||||
borderTop: '1px solid #3f3f46',
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap={5}
|
||||
justify="center"
|
||||
style={{
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<Text size="xs">Page Size</Text>
|
||||
<NativeSelect
|
||||
size="xxs"
|
||||
value={pagination.pageSize}
|
||||
data={['25', '50', '100', '250']}
|
||||
onChange={onPageSizeChange}
|
||||
style={{ paddingRight: 20 }}
|
||||
/>
|
||||
<Pagination
|
||||
total={pageCount}
|
||||
value={pagination.pageIndex + 1}
|
||||
onChange={onPageIndexChange}
|
||||
size="xs"
|
||||
withEdges
|
||||
style={{ paddingRight: 20 }}
|
||||
/>
|
||||
<Text size="xs">{paginationString}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<LogoForm
|
||||
logo={selectedLogo}
|
||||
isOpen={logoModalOpen}
|
||||
onClose={closeLogoForm}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={(deleteFiles) => {
|
||||
if (isBulkDelete) {
|
||||
executeBulkDelete(deleteFiles);
|
||||
} else {
|
||||
executeDeleteLogo(deleteTarget, deleteFiles);
|
||||
}
|
||||
}}
|
||||
title={isBulkDelete ? "Delete Multiple Logos" : "Delete Logo"}
|
||||
message={
|
||||
isBulkDelete ? (
|
||||
<div>
|
||||
Are you sure you want to delete {selectedRows.size} selected logos?
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
Any channels using these logos will have their logo removed.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
This action cannot be undone.
|
||||
</Text>
|
||||
</div>
|
||||
) : logoToDelete ? (
|
||||
<div>
|
||||
Are you sure you want to delete the logo "{logoToDelete.name}"?
|
||||
{logoToDelete.channel_count > 0 && (
|
||||
<Text size="sm" c="orange" mt="xs">
|
||||
This logo is currently used by {logoToDelete.channel_count} channel{logoToDelete.channel_count !== 1 ? 's' : ''}. They will have their logo removed.
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
This action cannot be undone.
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
'Are you sure you want to delete this logo?'
|
||||
)
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
size="md"
|
||||
showDeleteFileOption={
|
||||
isBulkDelete
|
||||
? Array.from(selectedRows).some(id => {
|
||||
const logo = Object.values(logos).find(l => l.id === id);
|
||||
return logo && logo.url && logo.url.startsWith('/data/logos');
|
||||
})
|
||||
: logoToDelete && logoToDelete.url && logoToDelete.url.startsWith('/data/logos')
|
||||
}
|
||||
deleteFileLabel={
|
||||
isBulkDelete
|
||||
? "Also delete local logo files from disk"
|
||||
: "Also delete logo file from disk"
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmCleanupOpen}
|
||||
onClose={() => setConfirmCleanupOpen(false)}
|
||||
onConfirm={executeCleanupUnused}
|
||||
title="Cleanup Unused Logos"
|
||||
message={
|
||||
<div>
|
||||
Are you sure you want to cleanup {unusedLogosCount} unused logo{unusedLogosCount !== 1 ? 's' : ''}?
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
This will permanently delete all logos that are not currently used by any channels.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
This action cannot be undone.
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
confirmLabel="Cleanup"
|
||||
cancelLabel="Cancel"
|
||||
size="md"
|
||||
showDeleteFileOption={true}
|
||||
deleteFileLabel="Also delete local logo files from disk"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogosTable;
|
||||
|
|
@ -189,9 +189,12 @@ const StreamsTable = ({ }) => {
|
|||
const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]);
|
||||
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
|
||||
// const [allRowsSelected, setAllRowsSelected] = useState(false);
|
||||
|
||||
// Add local storage for page size
|
||||
const [storedPageSize, setStoredPageSize] = useLocalStorage('streams-page-size', 50);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
pageSize: storedPageSize,
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
name: '',
|
||||
|
|
@ -448,9 +451,11 @@ const StreamsTable = ({ }) => {
|
|||
};
|
||||
|
||||
const onPageSizeChange = (e) => {
|
||||
const newPageSize = parseInt(e.target.value);
|
||||
setStoredPageSize(newPageSize);
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageSize: e.target.value,
|
||||
pageSize: newPageSize,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
33
frontend/src/pages/Logos.jsx
Normal file
33
frontend/src/pages/Logos.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import LogosTable from '../components/tables/LogosTable';
|
||||
|
||||
const LogosPage = () => {
|
||||
const { fetchLogos } = useChannelsStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadLogos();
|
||||
}, []);
|
||||
|
||||
const loadLogos = async () => {
|
||||
try {
|
||||
await fetchLogos();
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load logos',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box style={{ padding: 10 }}>
|
||||
<LogosTable />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogosPage;
|
||||
|
|
@ -31,12 +31,15 @@ import {
|
|||
REGION_CHOICES,
|
||||
} from '../constants';
|
||||
import ConfirmationDialog from '../components/ConfirmationDialog';
|
||||
import useWarningsStore from '../store/warnings';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const userAgents = useUserAgentsStore((s) => s.userAgents);
|
||||
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
|
||||
const [accordianValue, setAccordianValue] = useState(null);
|
||||
const [networkAccessSaved, setNetworkAccessSaved] = useState(false);
|
||||
|
|
@ -47,6 +50,12 @@ const SettingsPage = () => {
|
|||
useState([]);
|
||||
|
||||
const [proxySettingsSaved, setProxySettingsSaved] = useState(false);
|
||||
const [rehashingStreams, setRehashingStreams] = useState(false);
|
||||
const [rehashSuccess, setRehashSuccess] = useState(false);
|
||||
const [rehashConfirmOpen, setRehashConfirmOpen] = useState(false);
|
||||
|
||||
// Add a new state to track the dialog type
|
||||
const [rehashDialogType, setRehashDialogType] = useState(null); // 'save' or 'rehash'
|
||||
|
||||
// UI / local storage settings
|
||||
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
|
||||
|
|
@ -157,13 +166,27 @@ const SettingsPage = () => {
|
|||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const changedSettings = {};
|
||||
let m3uHashKeyChanged = false;
|
||||
|
||||
for (const settingKey in values) {
|
||||
// If the user changed the setting’s value from what’s in the DB:
|
||||
// If the user changed the setting's value from what's in the DB:
|
||||
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
|
||||
changedSettings[settingKey] = `${values[settingKey]}`;
|
||||
|
||||
// Check if M3U hash key was changed
|
||||
if (settingKey === 'm3u-hash-key') {
|
||||
m3uHashKeyChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If M3U hash key changed, show warning (unless suppressed)
|
||||
if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
|
||||
setRehashDialogType('save'); // Set dialog type to save
|
||||
setRehashConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update each changed setting in the backend
|
||||
for (const updatedKey in changedSettings) {
|
||||
await API.updateSetting({
|
||||
|
|
@ -245,6 +268,63 @@ const SettingsPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const executeSettingsSaveAndRehash = async () => {
|
||||
setRehashConfirmOpen(false);
|
||||
|
||||
// First save the settings
|
||||
const values = form.getValues();
|
||||
const changedSettings = {};
|
||||
|
||||
for (const settingKey in values) {
|
||||
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
|
||||
changedSettings[settingKey] = `${values[settingKey]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update each changed setting in the backend
|
||||
for (const updatedKey in changedSettings) {
|
||||
await API.updateSetting({
|
||||
...settings[updatedKey],
|
||||
value: changedSettings[updatedKey],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const executeRehashStreamsOnly = async () => {
|
||||
setRehashingStreams(true);
|
||||
setRehashSuccess(false);
|
||||
setRehashConfirmOpen(false);
|
||||
|
||||
try {
|
||||
await API.rehashStreams();
|
||||
setRehashSuccess(true);
|
||||
setTimeout(() => setRehashSuccess(false), 5000);
|
||||
} catch (error) {
|
||||
console.error('Error rehashing streams:', error);
|
||||
} finally {
|
||||
setRehashingStreams(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRehashStreams = async () => {
|
||||
// Skip warning if it's been suppressed
|
||||
if (isWarningSuppressed('rehash-streams')) {
|
||||
return executeRehashStreamsOnly();
|
||||
}
|
||||
|
||||
setRehashDialogType('rehash'); // Set dialog type to rehash
|
||||
setRehashConfirmOpen(true);
|
||||
};
|
||||
|
||||
// Create a function to handle the confirmation based on dialog type
|
||||
const handleRehashConfirm = () => {
|
||||
if (rehashDialogType === 'save') {
|
||||
executeSettingsSaveAndRehash();
|
||||
} else {
|
||||
executeRehashStreamsOnly();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
|
|
@ -395,12 +475,28 @@ const SettingsPage = () => {
|
|||
key={form.key('m3u-hash-key')}
|
||||
/>
|
||||
|
||||
{rehashSuccess && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="green"
|
||||
title="Rehash task queued successfully"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
mih={50}
|
||||
gap="xs"
|
||||
justify="flex-end"
|
||||
justify="space-between"
|
||||
align="flex-end"
|
||||
>
|
||||
<Button
|
||||
onClick={onRehashStreams}
|
||||
loading={rehashingStreams}
|
||||
variant="outline"
|
||||
color="blue"
|
||||
>
|
||||
Rehash Streams
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.submitting}
|
||||
|
|
@ -588,6 +684,32 @@ const SettingsPage = () => {
|
|||
</Accordion>
|
||||
</Box>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={rehashConfirmOpen}
|
||||
onClose={() => {
|
||||
setRehashConfirmOpen(false);
|
||||
setRehashDialogType(null);
|
||||
}}
|
||||
onConfirm={handleRehashConfirm}
|
||||
title={rehashDialogType === 'save' ? 'Save Settings and Rehash Streams' : 'Confirm Stream Rehash'}
|
||||
message={
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to rehash all streams?
|
||||
|
||||
This process may take a while depending on the number of streams.
|
||||
Do not shut down Dispatcharr until the rehashing is complete.
|
||||
M3U refreshes will be blocked until this process finishes.
|
||||
|
||||
Please ensure you have time to let this complete before proceeding.`}
|
||||
</div>
|
||||
}
|
||||
confirmLabel={rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'}
|
||||
cancelLabel="Cancel"
|
||||
actionKey="rehash-streams"
|
||||
onSuppressChange={suppressWarning}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={networkAccessConfirmOpen}
|
||||
onClose={() => setNetworkAccessConfirmOpen(false)}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const useChannelsStore = create((set, get) => ({
|
|||
forceUpdate: 0,
|
||||
|
||||
triggerUpdate: () => {
|
||||
set({ forecUpdate: new Date() });
|
||||
set({ forceUpdate: new Date() });
|
||||
},
|
||||
|
||||
fetchChannels: async () => {
|
||||
|
|
@ -46,16 +46,24 @@ const useChannelsStore = create((set, get) => ({
|
|||
},
|
||||
|
||||
fetchChannelGroups: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const channelGroups = await api.getChannelGroups();
|
||||
set({
|
||||
channelGroups: channelGroups.reduce((acc, group) => {
|
||||
acc[group.id] = group;
|
||||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Process groups to add association flags
|
||||
const processedGroups = channelGroups.reduce((acc, group) => {
|
||||
acc[group.id] = {
|
||||
...group,
|
||||
hasChannels: group.channel_count > 0,
|
||||
hasM3UAccounts: group.m3u_account_count > 0,
|
||||
canEdit: group.m3u_account_count === 0,
|
||||
canDelete: group.channel_count === 0 && group.m3u_account_count === 0
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
set((state) => ({
|
||||
channelGroups: processedGroups,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channel groups:', error);
|
||||
set({ error: 'Failed to load channel groups.', isLoading: false });
|
||||
|
|
@ -204,10 +212,18 @@ const useChannelsStore = create((set, get) => ({
|
|||
|
||||
updateChannelGroup: (channelGroup) =>
|
||||
set((state) => ({
|
||||
...state.channelGroups,
|
||||
[channelGroup.id]: channelGroup,
|
||||
channelGroups: {
|
||||
...state.channelGroups,
|
||||
[channelGroup.id]: channelGroup,
|
||||
},
|
||||
})),
|
||||
|
||||
removeChannelGroup: (groupId) =>
|
||||
set((state) => {
|
||||
const { [groupId]: removed, ...remainingGroups } = state.channelGroups;
|
||||
return { channelGroups: remainingGroups };
|
||||
}),
|
||||
|
||||
fetchLogos: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
|
|
@ -216,7 +232,6 @@ const useChannelsStore = create((set, get) => ({
|
|||
logos: logos.reduce((acc, logo) => {
|
||||
acc[logo.id] = {
|
||||
...logo,
|
||||
url: logo.url.replace(/^\/data/, ''),
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
|
|
@ -234,11 +249,27 @@ const useChannelsStore = create((set, get) => ({
|
|||
...state.logos,
|
||||
[newLogo.id]: {
|
||||
...newLogo,
|
||||
url: newLogo.url.replace(/^\/data/, ''),
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
updateLogo: (logo) =>
|
||||
set((state) => ({
|
||||
logos: {
|
||||
...state.logos,
|
||||
[logo.id]: {
|
||||
...logo,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
removeLogo: (logoId) =>
|
||||
set((state) => {
|
||||
const newLogos = { ...state.logos };
|
||||
delete newLogos[logoId];
|
||||
return { logos: newLogos };
|
||||
}),
|
||||
|
||||
addProfile: (profile) =>
|
||||
set((state) => ({
|
||||
profiles: {
|
||||
|
|
@ -427,6 +458,17 @@ const useChannelsStore = create((set, get) => ({
|
|||
set({ error: 'Failed to load recordings.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Add helper methods for validation
|
||||
canEditChannelGroup: (groupIdOrGroup) => {
|
||||
const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
|
||||
return get().channelGroups[groupId]?.canEdit ?? true;
|
||||
},
|
||||
|
||||
canDeleteChannelGroup: (groupIdOrGroup) => {
|
||||
const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
|
||||
return get().channelGroups[groupId]?.canDelete ?? true;
|
||||
},
|
||||
}));
|
||||
|
||||
export default useChannelsStore;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const useChannelsTableStore = create((set, get) => ({
|
|||
sorting: [{ id: 'channel_number', desc: false }],
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
pageSize: JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50,
|
||||
},
|
||||
selectedChannelIds: [],
|
||||
allQueryIds: [],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue