diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 4246373e..469ec773 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -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//streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'), path('profiles//channels//', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'), path('profiles//channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'), diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b651081e..0973dce0 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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") diff --git a/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py b/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py new file mode 100644 index 00000000..b1450c09 --- /dev/null +++ b/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py @@ -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), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 1bcbcc41..f53a9875 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -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") diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index cdc6ef60..82b5f808 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -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): diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index d3062171..4fcf5706 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -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: diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 0ef42272..d3739f19 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -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""" diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index d6e0755b..e3893dc1 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -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 diff --git a/apps/output/views.py b/apps/output/views.py index 4ef9f4f2..67d72bd2 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -467,19 +467,27 @@ def generate_epg(request, profile_name=None, user=None): for category in custom_data["categories"]: program_xml.append(f" {html.escape(category)}") - # Handle episode numbering - multiple formats supported - # Standard episode number if available - if "episode" in custom_data: - program_xml.append(f' E{custom_data["episode"]}') + # Add keywords if available + if "keywords" in custom_data and custom_data["keywords"]: + for keyword in custom_data["keywords"]: + program_xml.append(f" {html.escape(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' {html.escape(custom_data["onscreen_episode"])}') + elif "episode" in custom_data: + program_xml.append(f' E{custom_data["episode"]}') # Handle dd_progid format if 'dd_progid' in custom_data: program_xml.append(f' {html.escape(custom_data["dd_progid"])}') + # Handle external database IDs + for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']: + if f'{system}_id' in custom_data: + program_xml.append(f' {html.escape(custom_data[f"{system}_id"])}') + # 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' {season}.{episode}.') + # Add language information + if "language" in custom_data: + program_xml.append(f' {html.escape(custom_data["language"])}') + + if "original_language" in custom_data: + program_xml.append(f' {html.escape(custom_data["original_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' {html.escape(str(length_value))}') + + # Add video information + if "video" in custom_data and isinstance(custom_data["video"], dict): + program_xml.append(" ") + + # Add audio information + if "audio" in custom_data and isinstance(custom_data["audio"], dict): + program_xml.append(" ") + + # 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" ") + if "language" in subtitle: + program_xml.append(f" {html.escape(subtitle['language'])}") + program_xml.append(" ") + # 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' {html.escape(custom_data["rating"])}') program_xml.append(f" ") - # Add actors/directors/writers if available - if "credits" in custom_data: - program_xml.append(f" ") - for role, people in custom_data["credits"].items(): - if isinstance(people, list): - for person in people: - program_xml.append(f" <{role}>{html.escape(person)}") - else: - program_xml.append(f" <{role}>{html.escape(people)}") - program_xml.append(f" ") + # 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" ") + program_xml.append(f" {html.escape(star_rating['value'])}") + program_xml.append(" ") - # Add program date/year if available - if "year" in custom_data: - program_xml.append(f' {html.escape(custom_data["year"])}') + # 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' {html.escape(review["content"])}') + + # 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' {html.escape(image["url"])}') + + # Add enhanced credits handling + if "credits" in custom_data: + program_xml.append(" ") + 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)}") + else: + program_xml.append(f" <{role}>{html.escape(people)}") + + # 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" {html.escape(name)}") + else: + program_xml.append(f" {html.escape(actor)}") + else: + program_xml.append(f" {html.escape(actors)}") + + program_xml.append(" ") + + # Add program date if available (full date, not just year) + if "date" in custom_data: + program_xml.append(f' {html.escape(custom_data["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' ') - # 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" ") + 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" ") if custom_data.get("premiere", False): - program_xml.append(f" ") + premiere_text = custom_data.get("premiere_text", "") + if premiere_text: + program_xml.append(f" {html.escape(premiere_text)}") + else: + program_xml.append(" ") + + if custom_data.get("last_chance", False): + last_chance_text = custom_data.get("last_chance_text", "") + if last_chance_text: + program_xml.append(f" {html.escape(last_chance_text)}") + else: + program_xml.append(" ") if custom_data.get("new", False): - program_xml.append(f" ") + program_xml.append(" ") if custom_data.get('live', False): - program_xml.append(f' ') + program_xml.append(' ') except Exception as e: program_xml.append(f" ") diff --git a/core/api_urls.py b/core/api_urls.py index e30eb698..30714d44 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -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)), ] diff --git a/core/api_views.py b/core/api_views.py index b416cf92..6b9743f6 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -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) diff --git a/core/tasks.py b/core/tasks.py index 0fdaedf7..47bc8cf0 100644 --- a/core/tasks.py +++ b/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") diff --git a/core/xtream_codes.py b/core/xtream_codes.py index 17f3eaad..d068bacb 100644 --- a/core/xtream_codes.py +++ b/core/xtream_codes.py @@ -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() diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 8856d330..98c6210b 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -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', diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index 767913c6..5e1ad087 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -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): diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a057be50..4467759e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 = () => { } /> } /> } /> + } /> ) : ( } /> diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index aeed8826..2e210461 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -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]; diff --git a/frontend/src/api.js b/frontend/src/api.js index 391eaae9..7cbb6214 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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); + } + } } diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 822b46f1..1cfbe84d 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -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 ( - + {message} {actionKey && ( @@ -62,8 +83,17 @@ const ConfirmationDialog = ({ /> )} + {showDeleteFileOption && ( + setDeleteFiles(event.currentTarget.checked)} + label={deleteFileLabel} + mb="md" + /> + )} + - @@ -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(); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 6d69e9e7..08114d99 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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: , path: '/users', }, + { + label: 'Logo Manager', + icon: , + path: '/logos', + }, { label: 'Settings', icon: , diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 452db052..c7d8ed6c 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -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); } diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 2ba3245c..693ebb11 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -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); diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx index 18ed31c1..46641fb1 100644 --- a/frontend/src/components/forms/ChannelGroup.jsx +++ b/frontend/src/components/forms/ChannelGroup.jsx @@ -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 ( + {channelGroup && !canEdit && ( + + This group cannot be edited because it has M3U account associations. + + )}
@@ -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 diff --git a/frontend/src/components/forms/Channels.jsx b/frontend/src/components/forms/Channels.jsx index dbce5cf3..e67d9419 100644 --- a/frontend/src/components/forms/Channels.jsx +++ b/frontend/src/components/forms/Channels.jsx @@ -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); } diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx new file mode 100644 index 00000000..1e52d2c8 --- /dev/null +++ b/frontend/src/components/forms/GroupManager.jsx @@ -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( + }> + Channels + + ); + } + + if (usage?.hasM3UAccounts) { + badges.push( + }> + M3U + + ); + } + + return badges; + }; + + return ( + + + + {editingGroup === group.id ? ( + e.key === 'Enter' && onSaveEdit()} + autoFocus + /> + ) : ( + <> + {group.name} + + {getGroupBadges(group)} + + + )} + + + + {editingGroup === group.id ? ( + <> + + + + + + + + ) : ( + <> + onEdit(group)} + disabled={!canEditGroup(group)} + > + + + onDelete(group)} + disabled={!canDeleteGroup(group)} + > + + + + )} + + + + ); +}); + +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 ( + <> + + + } color="blue" variant="light"> + Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted. + + + {/* Create new group section */} + + {isCreating ? ( + + e.key === 'Enter' && handleCreate()} + autoFocus + /> + + + + { + setIsCreating(false); + setNewGroupName(''); + }}> + + + + ) : ( + + )} + + {!isCreating && ( + + )} + + + + + {/* Filter Controls */} + + + + + Filter Groups + + setSearchTerm('')} + > + + + )} + /> + + + + Show: + + + + Channel Groups ({filterCounts.channels}) + + + + + + M3U Groups ({filterCounts.m3u}) + + + + Unused Groups ({filterCounts.unused}) + + + + + + + {/* Existing groups */} + + + Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`}) + + + {loading ? ( + Loading group information... + ) : filteredGroups.length === 0 ? ( + + {searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'} + + ) : ( + + {filteredGroups.map((group) => ( + + ))} + + )} + + + + + + + + + + + setConfirmDeleteOpen(false)} + onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)} + title="Confirm Group Deletion" + message={ + groupToDelete ? ( +
+ {`Are you sure you want to delete the following group? + +Name: ${groupToDelete.name} + +This action cannot be undone.`} +
+ ) : ( + '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} + /> + + setConfirmCleanupOpen(false)} + onConfirm={executeCleanup} + title="Confirm Group Cleanup" + message={ +
+ {`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.`} +
+ } + confirmLabel="Cleanup" + cancelLabel="Cancel" + actionKey="cleanup-groups" + onSuppressChange={suppressWarning} + size="md" + zIndex={2100} + /> + + ); +}); + +export default GroupManager; diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx new file mode 100644 index 00000000..e209659c --- /dev/null +++ b/frontend/src/components/forms/Logo.jsx @@ -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 ( + + + + {/* Logo Preview */} + {logoPreview && ( +
+ + + Preview + + Logo preview { + e.target.style.transform = 'scale(1.5)'; + }} + onMouseLeave={(e) => { + e.target.style.transform = 'scale(1)'; + }} + /> + +
+ )} + + {/* File Upload */} + + + Upload Logo File + + + + + + + + + + + + + +
+ + Drag image here or click to select + + + Supports PNG, JPEG, GIF, WebP files + +
+
+
+
+ + + + {/* Manual URL Input */} + + + + + + + + +
+ +
+ ); +}; + +export default LogoForm; diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 24ddd377..0e4d5643 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -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 diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 7ca0fa96..df947d56 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -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) => ( + +
+ {label} +
+
+)); 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 }) => { + } color="blue" variant="light"> + + Auto Channel Sync: 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. + + + setGroupFilter(event.currentTarget.value)} style={{ flex: 1 }} + size="xs" /> - - - - {groupStates - .filter((group) => - group.name.toLowerCase().includes(groupFilter.toLowerCase()) - ) - .sort((a, b) => a.name > b.name) - .map((group) => ( - - ))} - + + + + + + {groupStates + .filter((group) => + group.name.toLowerCase().includes(groupFilter.toLowerCase()) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((group) => ( + + {/* Group Enable/Disable Button */} + + + {/* Auto Sync Controls */} + + + toggleAutoSync(group.channel_group)} + size="xs" + /> + + + {group.auto_channel_sync && group.enabled && ( + <> + updateChannelStart(group.channel_group, value)} + min={1} + step={1} + size="xs" + precision={1} + /> + + {/* Auto Channel Sync Options Multi-Select */} + { + 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 && ( + + + 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 }} + /> + + + + + + + + + + + + {/* Table container */} + + +
+ + +
+
+ + {/* Pagination Controls */} + + + Page Size + + + {paginationString} + + +
+ +
+ + + + + setConfirmDeleteOpen(false)} + onConfirm={(deleteFiles) => { + if (isBulkDelete) { + executeBulkDelete(deleteFiles); + } else { + executeDeleteLogo(deleteTarget, deleteFiles); + } + }} + title={isBulkDelete ? "Delete Multiple Logos" : "Delete Logo"} + message={ + isBulkDelete ? ( +
+ Are you sure you want to delete {selectedRows.size} selected logos? + + Any channels using these logos will have their logo removed. + + + This action cannot be undone. + +
+ ) : logoToDelete ? ( +
+ Are you sure you want to delete the logo "{logoToDelete.name}"? + {logoToDelete.channel_count > 0 && ( + + This logo is currently used by {logoToDelete.channel_count} channel{logoToDelete.channel_count !== 1 ? 's' : ''}. They will have their logo removed. + + )} + + This action cannot be undone. + +
+ ) : ( + '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" + } + /> + + setConfirmCleanupOpen(false)} + onConfirm={executeCleanupUnused} + title="Cleanup Unused Logos" + message={ +
+ Are you sure you want to cleanup {unusedLogosCount} unused logo{unusedLogosCount !== 1 ? 's' : ''}? + + This will permanently delete all logos that are not currently used by any channels. + + + This action cannot be undone. + +
+ } + confirmLabel="Cleanup" + cancelLabel="Cancel" + size="md" + showDeleteFileOption={true} + deleteFileLabel="Also delete local logo files from disk" + /> + + ); +}; + +export default LogosTable; diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 64f9bbef..28380e7b 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -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, }); }; diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx new file mode 100644 index 00000000..ee26c51e --- /dev/null +++ b/frontend/src/pages/Logos.jsx @@ -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 ( + + + + ); +}; + +export default LogosPage; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index a5b07fa2..7533637b 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 (
{ key={form.key('m3u-hash-key')} /> + {rehashSuccess && ( + + )} + +