Merge pull request #253 from Dispatcharr/dev

Dispatcharr v0.7.0
This commit is contained in:
SergeantPanda 2025-07-19 15:42:34 -05:00 committed by GitHub
commit da90ae5436
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 4408 additions and 288 deletions

View file

@ -6,6 +6,8 @@ from .api_views import (
ChannelGroupViewSet,
BulkDeleteStreamsAPIView,
BulkDeleteChannelsAPIView,
BulkDeleteLogosAPIView,
CleanupUnusedLogosAPIView,
LogoViewSet,
ChannelProfileViewSet,
UpdateChannelMembershipAPIView,
@ -28,6 +30,8 @@ urlpatterns = [
# Bulk delete is a single APIView, not a ViewSet
path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'),
path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'),
path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'),
path('logos/cleanup/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'),
path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'),
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),

View file

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

View file

@ -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),
),
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -467,19 +467,27 @@ def generate_epg(request, profile_name=None, user=None):
for category in custom_data["categories"]:
program_xml.append(f" <category>{html.escape(category)}</category>")
# Handle episode numbering - multiple formats supported
# Standard episode number if available
if "episode" in custom_data:
program_xml.append(f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>')
# Add keywords if available
if "keywords" in custom_data and custom_data["keywords"]:
for keyword in custom_data["keywords"]:
program_xml.append(f" <keyword>{html.escape(keyword)}</keyword>")
# Handle onscreen episode format (like S06E128)
# Handle episode numbering - multiple formats supported
# Prioritize onscreen_episode over standalone episode for onscreen system
if "onscreen_episode" in custom_data:
program_xml.append(f' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>')
elif "episode" in custom_data:
program_xml.append(f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>')
# Handle dd_progid format
if 'dd_progid' in custom_data:
program_xml.append(f' <episode-num system="dd_progid">{html.escape(custom_data["dd_progid"])}</episode-num>')
# Handle external database IDs
for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']:
if f'{system}_id' in custom_data:
program_xml.append(f' <episode-num system="{system}">{html.escape(custom_data[f"{system}_id"])}</episode-num>')
# Add season and episode numbers in xmltv_ns format if available
if "season" in custom_data and "episode" in custom_data:
season = (
@ -494,6 +502,46 @@ def generate_epg(request, profile_name=None, user=None):
)
program_xml.append(f' <episode-num system="xmltv_ns">{season}.{episode}.</episode-num>')
# Add language information
if "language" in custom_data:
program_xml.append(f' <language>{html.escape(custom_data["language"])}</language>')
if "original_language" in custom_data:
program_xml.append(f' <orig-language>{html.escape(custom_data["original_language"])}</orig-language>')
# Add length information
if "length" in custom_data and isinstance(custom_data["length"], dict):
length_value = custom_data["length"].get("value", "")
length_units = custom_data["length"].get("units", "minutes")
program_xml.append(f' <length units="{html.escape(length_units)}">{html.escape(str(length_value))}</length>')
# Add video information
if "video" in custom_data and isinstance(custom_data["video"], dict):
program_xml.append(" <video>")
for attr in ['present', 'colour', 'aspect', 'quality']:
if attr in custom_data["video"]:
program_xml.append(f" <{attr}>{html.escape(custom_data['video'][attr])}</{attr}>")
program_xml.append(" </video>")
# Add audio information
if "audio" in custom_data and isinstance(custom_data["audio"], dict):
program_xml.append(" <audio>")
for attr in ['present', 'stereo']:
if attr in custom_data["audio"]:
program_xml.append(f" <{attr}>{html.escape(custom_data['audio'][attr])}</{attr}>")
program_xml.append(" </audio>")
# Add subtitles information
if "subtitles" in custom_data and isinstance(custom_data["subtitles"], list):
for subtitle in custom_data["subtitles"]:
if isinstance(subtitle, dict):
subtitle_type = subtitle.get("type", "")
type_attr = f' type="{html.escape(subtitle_type)}"' if subtitle_type else ""
program_xml.append(f" <subtitles{type_attr}>")
if "language" in subtitle:
program_xml.append(f" <language>{html.escape(subtitle['language'])}</language>")
program_xml.append(" </subtitles>")
# Add rating if available
if "rating" in custom_data:
rating_system = custom_data.get("rating_system", "TV Parental Guidelines")
@ -501,20 +549,74 @@ def generate_epg(request, profile_name=None, user=None):
program_xml.append(f' <value>{html.escape(custom_data["rating"])}</value>')
program_xml.append(f" </rating>")
# Add actors/directors/writers if available
if "credits" in custom_data:
program_xml.append(f" <credits>")
for role, people in custom_data["credits"].items():
if isinstance(people, list):
for person in people:
program_xml.append(f" <{role}>{html.escape(person)}</{role}>")
else:
program_xml.append(f" <{role}>{html.escape(people)}</{role}>")
program_xml.append(f" </credits>")
# Add star ratings
if "star_ratings" in custom_data and isinstance(custom_data["star_ratings"], list):
for star_rating in custom_data["star_ratings"]:
if isinstance(star_rating, dict) and "value" in star_rating:
system_attr = f' system="{html.escape(star_rating["system"])}"' if "system" in star_rating else ""
program_xml.append(f" <star-rating{system_attr}>")
program_xml.append(f" <value>{html.escape(star_rating['value'])}</value>")
program_xml.append(" </star-rating>")
# Add program date/year if available
if "year" in custom_data:
program_xml.append(f' <date>{html.escape(custom_data["year"])}</date>')
# Add reviews
if "reviews" in custom_data and isinstance(custom_data["reviews"], list):
for review in custom_data["reviews"]:
if isinstance(review, dict) and "content" in review:
review_type = review.get("type", "text")
attrs = [f'type="{html.escape(review_type)}"']
if "source" in review:
attrs.append(f'source="{html.escape(review["source"])}"')
if "reviewer" in review:
attrs.append(f'reviewer="{html.escape(review["reviewer"])}"')
attr_str = " ".join(attrs)
program_xml.append(f' <review {attr_str}>{html.escape(review["content"])}</review>')
# Add images
if "images" in custom_data and isinstance(custom_data["images"], list):
for image in custom_data["images"]:
if isinstance(image, dict) and "url" in image:
attrs = []
for attr in ['type', 'size', 'orient', 'system']:
if attr in image:
attrs.append(f'{attr}="{html.escape(image[attr])}"')
attr_str = " " + " ".join(attrs) if attrs else ""
program_xml.append(f' <image{attr_str}>{html.escape(image["url"])}</image>')
# Add enhanced credits handling
if "credits" in custom_data:
program_xml.append(" <credits>")
credits = custom_data["credits"]
# Handle different credit types
for role in ['director', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']:
if role in credits:
people = credits[role]
if isinstance(people, list):
for person in people:
program_xml.append(f" <{role}>{html.escape(person)}</{role}>")
else:
program_xml.append(f" <{role}>{html.escape(people)}</{role}>")
# Handle actors separately to include role and guest attributes
if "actor" in credits:
actors = credits["actor"]
if isinstance(actors, list):
for actor in actors:
if isinstance(actor, dict):
name = actor.get("name", "")
role_attr = f' role="{html.escape(actor["role"])}"' if "role" in actor else ""
guest_attr = ' guest="yes"' if actor.get("guest") else ""
program_xml.append(f" <actor{role_attr}{guest_attr}>{html.escape(name)}</actor>")
else:
program_xml.append(f" <actor>{html.escape(actor)}</actor>")
else:
program_xml.append(f" <actor>{html.escape(actors)}</actor>")
program_xml.append(" </credits>")
# Add program date if available (full date, not just year)
if "date" in custom_data:
program_xml.append(f' <date>{html.escape(custom_data["date"])}</date>')
# Add country if available
if "country" in custom_data:
@ -524,18 +626,36 @@ def generate_epg(request, profile_name=None, user=None):
if "icon" in custom_data:
program_xml.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
# Add special flags as proper tags
# Add special flags as proper tags with enhanced handling
if custom_data.get("previously_shown", False):
program_xml.append(f" <previously-shown />")
prev_shown_details = custom_data.get("previously_shown_details", {})
attrs = []
if "start" in prev_shown_details:
attrs.append(f'start="{html.escape(prev_shown_details["start"])}"')
if "channel" in prev_shown_details:
attrs.append(f'channel="{html.escape(prev_shown_details["channel"])}"')
attr_str = " " + " ".join(attrs) if attrs else ""
program_xml.append(f" <previously-shown{attr_str} />")
if custom_data.get("premiere", False):
program_xml.append(f" <premiere />")
premiere_text = custom_data.get("premiere_text", "")
if premiere_text:
program_xml.append(f" <premiere>{html.escape(premiere_text)}</premiere>")
else:
program_xml.append(" <premiere />")
if custom_data.get("last_chance", False):
last_chance_text = custom_data.get("last_chance_text", "")
if last_chance_text:
program_xml.append(f" <last-chance>{html.escape(last_chance_text)}</last-chance>")
else:
program_xml.append(" <last-chance />")
if custom_data.get("new", False):
program_xml.append(f" <new />")
program_xml.append(" <new />")
if custom_data.get('live', False):
program_xml.append(f' <live />')
program_xml.append(' <live />')
except Exception as e:
program_xml.append(f" <!-- Error parsing custom properties: {html.escape(str(e))} -->")

View file

@ -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)),
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ import Stats from './pages/Stats';
import DVR from './pages/DVR';
import Settings from './pages/Settings';
import Users from './pages/Users';
import LogosPage from './pages/Logos';
import useAuthStore from './store/auth';
import FloatingVideo from './components/FloatingVideo';
import { WebsocketProvider } from './WebSocket';
@ -133,6 +134,7 @@ const App = () => {
<Route path="/stats" element={<Stats />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
<Route path="/logos" element={<LogosPage />} />
</>
) : (
<Route path="/login" element={<Login needsSuperuser />} />

View file

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

View file

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

View file

@ -27,13 +27,17 @@ const ConfirmationDialog = ({
cancelLabel = 'Cancel',
actionKey,
onSuppressChange,
size = 'md', // Add default size parameter - md is a medium width
size = 'md',
zIndex = 1000,
showDeleteFileOption = false,
deleteFileLabel = "Also delete files from disk",
}) => {
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const [suppressChecked, setSuppressChecked] = useState(
isWarningSuppressed(actionKey)
);
const [deleteFiles, setDeleteFiles] = useState(false);
const handleToggleSuppress = (e) => {
setSuppressChecked(e.currentTarget.checked);
@ -46,11 +50,28 @@ const ConfirmationDialog = ({
if (suppressChecked) {
suppressWarning(actionKey);
}
onConfirm();
if (showDeleteFileOption) {
onConfirm(deleteFiles);
} else {
onConfirm();
}
setDeleteFiles(false); // Reset for next time
};
const handleClose = () => {
setDeleteFiles(false); // Reset for next time
onClose();
};
return (
<Modal opened={opened} onClose={onClose} title={title} size={size} centered>
<Modal
opened={opened}
onClose={handleClose}
title={title}
size={size}
centered
zIndex={zIndex}
>
<Box mb={20}>{message}</Box>
{actionKey && (
@ -62,8 +83,17 @@ const ConfirmationDialog = ({
/>
)}
{showDeleteFileOption && (
<Checkbox
checked={deleteFiles}
onChange={(event) => setDeleteFiles(event.currentTarget.checked)}
label={deleteFileLabel}
mb="md"
/>
)}
<Group justify="flex-end">
<Button variant="outline" onClick={onClose}>
<Button variant="outline" onClick={handleClose}>
{cancelLabel}
</Button>
<Button color="red" onClick={handleConfirm}>

View file

@ -15,6 +15,7 @@ export default function M3URefreshNotification() {
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
const fetchStreams = useStreamsStore((s) => s.fetchStreams);
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
const fetchChannels = useChannelsStore((s) => s.fetchChannels);
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
@ -49,7 +50,7 @@ export default function M3URefreshNotification() {
message: (
<Stack>
{data.message ||
'M3U groups loaded. Please select groups or refresh M3U to complete setup.'}
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
<Group grow>
<Button
size="xs"
@ -72,7 +73,7 @@ export default function M3URefreshNotification() {
navigate('/sources');
}}
>
Edit Groups
Configure Groups
</Button>
</Group>
</Stack>
@ -135,6 +136,8 @@ export default function M3URefreshNotification() {
// Only trigger additional fetches on successful completion
if (data.action == 'parsing') {
fetchStreams();
API.requeryChannels();
fetchChannels();
} else if (data.action == 'processing_groups') {
fetchStreams();
fetchChannelGroups();

View file

@ -13,6 +13,7 @@ import {
Ellipsis,
LogOut,
User,
FileImage,
} from 'lucide-react';
import {
Avatar,
@ -109,6 +110,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
icon: <User size={20} />,
path: '/users',
},
{
label: 'Logo Manager',
icon: <FileImage size={20} />,
path: '/logos',
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,

View file

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

View file

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

View file

@ -1,10 +1,17 @@
// Modal.js
import React from 'react';
import API from '../../api';
import { Flex, TextInput, Button, Modal } from '@mantine/core';
import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { isNotEmpty, useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
// Check if editing is allowed
const canEdit = !channelGroup || canEditChannelGroup(channelGroup.id);
const form = useForm({
mode: 'uncontrolled',
initialValues: {
@ -17,6 +24,16 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
});
const onSubmit = async () => {
// Prevent submission if editing is not allowed
if (channelGroup && !canEdit) {
notifications.show({
title: 'Error',
message: 'Cannot edit group with M3U account associations',
color: 'red',
});
return;
}
const values = form.getValues();
let newGroup;
@ -36,11 +53,17 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
return (
<Modal opened={isOpen} onClose={onClose} title="Channel Group">
{channelGroup && !canEdit && (
<Alert color="yellow" mb="md">
This group cannot be edited because it has M3U account associations.
</Alert>
)}
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
name="name"
label="Name"
disabled={channelGroup && !canEdit}
{...form.getInputProps('name')}
key={form.key('name')}
/>
@ -50,7 +73,7 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
type="submit"
variant="contained"
color="primary"
disabled={form.submitting}
disabled={form.submitting || (channelGroup && !canEdit)}
size="small"
>
Submit

View file

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

View file

@ -0,0 +1,671 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Modal,
Stack,
Group,
Text,
TextInput,
Button,
ActionIcon,
Flex,
Badge,
Alert,
Divider,
ScrollArea,
useMantineTheme,
Chip,
Box,
} from '@mantine/core';
import {
SquarePlus,
SquarePen,
SquareMinus,
Check,
X,
AlertCircle,
Database,
Tv,
Trash,
Filter
} from 'lucide-react';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../store/channels';
import useWarningsStore from '../../store/warnings';
import ConfirmationDialog from '../ConfirmationDialog';
import API from '../../api';
// Move GroupItem outside to prevent recreation on every render
const GroupItem = React.memo(({
group,
editingGroup,
editName,
onEditNameChange,
onSaveEdit,
onCancelEdit,
onEdit,
onDelete,
groupUsage,
canEditGroup,
canDeleteGroup
}) => {
const theme = useMantineTheme();
const getGroupBadges = (group) => {
const usage = groupUsage[group.id];
const badges = [];
if (usage?.hasChannels) {
badges.push(
<Badge key="channels" size="xs" color="blue" leftSection={<Tv size={10} />}>
Channels
</Badge>
);
}
if (usage?.hasM3UAccounts) {
badges.push(
<Badge key="m3u" size="xs" color="purple" leftSection={<Database size={10} />}>
M3U
</Badge>
);
}
return badges;
};
return (
<Box
style={{
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: editingGroup === group.id ? '#3f3f46' : (group.enabled ? '#2A2A2E' : '#1E1E22'),
padding: '8px',
marginBottom: '2px',
}}
>
<Group justify="space-between" gap="xs" align="flex-start">
<Stack gap={4} style={{ flex: 1 }}>
{editingGroup === group.id ? (
<TextInput
value={editName}
onChange={onEditNameChange}
size="sm"
onKeyPress={(e) => e.key === 'Enter' && onSaveEdit()}
autoFocus
/>
) : (
<>
<Text size="sm" fw={500}>{group.name}</Text>
<Group gap={4}>
{getGroupBadges(group)}
</Group>
</>
)}
</Stack>
<Group gap="xs">
{editingGroup === group.id ? (
<>
<ActionIcon color="green" size="sm" onClick={onSaveEdit}>
<Check size={14} />
</ActionIcon>
<ActionIcon color="gray" size="sm" onClick={onCancelEdit}>
<X size={14} />
</ActionIcon>
</>
) : (
<>
<ActionIcon
variant="transparent"
color={theme.tailwind.yellow[3]}
size="sm"
onClick={() => onEdit(group)}
disabled={!canEditGroup(group)}
>
<SquarePen size={18} />
</ActionIcon>
<ActionIcon
variant="transparent"
color={theme.tailwind.red[6]}
size="sm"
onClick={() => onDelete(group)}
disabled={!canDeleteGroup(group)}
>
<SquareMinus size="18" />
</ActionIcon>
</>
)}
</Group>
</Group>
</Box>
);
});
const GroupManager = React.memo(({ isOpen, onClose }) => {
const channelGroups = useChannelsStore((s) => s.channelGroups);
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const [editingGroup, setEditingGroup] = useState(null);
const [editName, setEditName] = useState('');
const [newGroupName, setNewGroupName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [groupUsage, setGroupUsage] = useState({});
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [isCleaningUp, setIsCleaningUp] = useState(false);
const [showChannelGroups, setShowChannelGroups] = useState(true);
const [showM3UGroups, setShowM3UGroups] = useState(true);
const [showUnusedGroups, setShowUnusedGroups] = useState(true);
// Confirmation dialog states
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [groupToDelete, setGroupToDelete] = useState(null);
const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
// Memoize the channel groups array to prevent unnecessary re-renders
const channelGroupsArray = useMemo(() =>
Object.values(channelGroups),
[channelGroups]
);
// Memoize sorted groups to prevent re-sorting on every render
const sortedGroups = useMemo(() =>
channelGroupsArray.sort((a, b) => a.name.localeCompare(b.name)),
[channelGroupsArray]
);
// Filter groups based on search term and chip filters
const filteredGroups = useMemo(() => {
let filtered = sortedGroups;
// Apply search filter
if (searchTerm.trim()) {
filtered = filtered.filter(group =>
group.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Apply chip filters
filtered = filtered.filter(group => {
const usage = groupUsage[group.id];
if (!usage) return false;
const hasChannels = usage.hasChannels;
const hasM3U = usage.hasM3UAccounts;
const isUnused = !hasChannels && !hasM3U;
// If group is unused, only show if unused groups are enabled
if (isUnused) {
return showUnusedGroups;
}
// For groups with channels and/or M3U, show if either filter is enabled
let shouldShow = false;
if (hasChannels && showChannelGroups) shouldShow = true;
if (hasM3U && showM3UGroups) shouldShow = true;
return shouldShow;
});
return filtered;
}, [sortedGroups, searchTerm, showChannelGroups, showM3UGroups, showUnusedGroups, groupUsage]);
// Calculate filter counts
const filterCounts = useMemo(() => {
const counts = {
channels: 0,
m3u: 0,
unused: 0
};
sortedGroups.forEach(group => {
const usage = groupUsage[group.id];
if (usage) {
const hasChannels = usage.hasChannels;
const hasM3U = usage.hasM3UAccounts;
// Count groups with channels (including those with both)
if (hasChannels) {
counts.channels++;
}
// Count groups with M3U (including those with both)
if (hasM3U) {
counts.m3u++;
}
// Count truly unused groups
if (!hasChannels && !hasM3U) {
counts.unused++;
}
}
});
return counts;
}, [sortedGroups, groupUsage]);
const fetchGroupUsage = useCallback(async () => {
setLoading(true);
try {
// Use the actual channel group data that already has the flags
const usage = {};
Object.values(channelGroups).forEach(group => {
usage[group.id] = {
hasChannels: group.hasChannels ?? false,
hasM3UAccounts: group.hasM3UAccounts ?? false,
canEdit: group.canEdit ?? true,
canDelete: group.canDelete ?? true
};
});
setGroupUsage(usage);
} catch (error) {
console.error('Error fetching group usage:', error);
} finally {
setLoading(false);
}
}, [channelGroups]);
// Fetch group usage information when modal opens
useEffect(() => {
if (isOpen) {
fetchGroupUsage();
}
}, [isOpen, fetchGroupUsage]);
const handleEdit = useCallback((group) => {
setEditingGroup(group.id);
setEditName(group.name);
}, []);
const handleSaveEdit = useCallback(async () => {
if (!editName.trim()) {
notifications.show({
title: 'Error',
message: 'Group name cannot be empty',
color: 'red',
});
return;
}
try {
await API.updateChannelGroup({
id: editingGroup,
name: editName.trim(),
});
notifications.show({
title: 'Success',
message: 'Group updated successfully',
color: 'green',
});
setEditingGroup(null);
setEditName('');
await fetchGroupUsage(); // Refresh usage data
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to update group',
color: 'red',
});
}
}, [editName, editingGroup, fetchGroupUsage]);
const handleCancelEdit = useCallback(() => {
setEditingGroup(null);
setEditName('');
}, []);
const handleCreate = useCallback(async () => {
if (!newGroupName.trim()) {
notifications.show({
title: 'Error',
message: 'Group name cannot be empty',
color: 'red',
});
return;
}
try {
await API.addChannelGroup({
name: newGroupName.trim(),
});
notifications.show({
title: 'Success',
message: 'Group created successfully',
color: 'green',
});
setNewGroupName('');
setIsCreating(false);
await fetchGroupUsage(); // Refresh usage data
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to create group',
color: 'red',
});
}
}, [newGroupName, fetchGroupUsage]);
const executeDeleteGroup = useCallback(async (group) => {
try {
await API.deleteChannelGroup(group.id);
notifications.show({
title: 'Success',
message: 'Group deleted successfully',
color: 'green',
});
await fetchGroupUsage(); // Refresh usage data
setConfirmDeleteOpen(false);
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to delete group',
color: 'red',
});
setConfirmDeleteOpen(false);
}
}, [fetchGroupUsage]);
const handleDelete = useCallback(async (group) => {
const usage = groupUsage[group.id];
if (usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts)) {
notifications.show({
title: 'Cannot Delete',
message: 'This group is associated with channels or M3U accounts and cannot be deleted',
color: 'orange',
});
return;
}
// Store group for confirmation dialog
setGroupToDelete(group);
// Skip warning if it's been suppressed
if (isWarningSuppressed('delete-group')) {
return executeDeleteGroup(group);
}
setConfirmDeleteOpen(true);
}, [groupUsage, isWarningSuppressed, executeDeleteGroup]);
const executeCleanup = useCallback(async () => {
setIsCleaningUp(true);
try {
const result = await API.cleanupUnusedChannelGroups();
notifications.show({
title: 'Cleanup Complete',
message: `Successfully deleted ${result.deleted_count} unused groups`,
color: 'green',
});
await fetchGroupUsage(); // Refresh usage data
setConfirmCleanupOpen(false);
} catch (error) {
notifications.show({
title: 'Cleanup Failed',
message: 'Failed to cleanup unused groups',
color: 'red',
});
setConfirmCleanupOpen(false);
} finally {
setIsCleaningUp(false);
}
}, [fetchGroupUsage]);
const handleCleanup = useCallback(async () => {
// Skip warning if it's been suppressed
if (isWarningSuppressed('cleanup-groups')) {
return executeCleanup();
}
setConfirmCleanupOpen(true);
}, [isWarningSuppressed, executeCleanup]);
const handleNewGroupNameChange = useCallback((e) => {
setNewGroupName(e.target.value);
}, []);
const handleEditNameChange = useCallback((e) => {
setEditName(e.target.value);
}, []);
const handleSearchChange = useCallback((e) => {
setSearchTerm(e.target.value);
}, []);
if (!isOpen) return null;
return (
<>
<Modal
opened={isOpen}
onClose={onClose}
title="Group Manager"
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
zIndex={2000}
>
<Stack>
<Alert icon={<AlertCircle size={16} />} color="blue" variant="light">
Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted.
</Alert>
{/* Create new group section */}
<Group justify="space-between">
{isCreating ? (
<Group style={{ flex: 1 }}>
<TextInput
placeholder="Enter group name"
value={newGroupName}
onChange={handleNewGroupNameChange}
style={{ flex: 1 }}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
autoFocus
/>
<ActionIcon color="green" onClick={handleCreate}>
<Check size={16} />
</ActionIcon>
<ActionIcon color="gray" onClick={() => {
setIsCreating(false);
setNewGroupName('');
}}>
<X size={16} />
</ActionIcon>
</Group>
) : (
<Button
leftSection={<SquarePlus size={16} />}
variant="light"
size="sm"
onClick={() => setIsCreating(true)}
>
Add Group
</Button>
)}
{!isCreating && (
<Button
leftSection={<Trash size={16} />}
variant="light"
size="sm"
color="orange"
onClick={handleCleanup}
loading={isCleaningUp}
>
Cleanup Unused
</Button>
)}
</Group>
<Divider />
{/* Filter Controls */}
<Stack gap="sm">
<Group justify="space-between" align="center">
<Group align="center" gap="sm">
<Filter size={16} />
<Text size="sm" fw={600}>Filter Groups</Text>
</Group>
<TextInput
placeholder="Search groups..."
value={searchTerm}
onChange={handleSearchChange}
size="sm"
style={{ width: '200px' }}
rightSection={searchTerm && (
<ActionIcon
size="sm"
variant="subtle"
onClick={() => setSearchTerm('')}
>
<X size={14} />
</ActionIcon>
)}
/>
</Group>
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">Show:</Text>
<Chip
checked={showChannelGroups}
onChange={setShowChannelGroups}
size="sm"
color="blue"
>
<Group gap={4}>
<Tv size={10} />
Channel Groups ({filterCounts.channels})
</Group>
</Chip>
<Chip
checked={showM3UGroups}
onChange={setShowM3UGroups}
size="sm"
color="purple"
>
<Group gap={4}>
<Database size={10} />
M3U Groups ({filterCounts.m3u})
</Group>
</Chip>
<Chip
checked={showUnusedGroups}
onChange={setShowUnusedGroups}
size="sm"
color="gray"
>
Unused Groups ({filterCounts.unused})
</Chip>
</Group>
</Stack>
<Divider />
{/* Existing groups */}
<Stack>
<Text size="sm" fw={600}>
Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`})
</Text>
{loading ? (
<Text size="sm" c="dimmed">Loading group information...</Text>
) : filteredGroups.length === 0 ? (
<Text size="sm" c="dimmed">
{searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'}
</Text>
) : (
<Stack gap="xs">
{filteredGroups.map((group) => (
<GroupItem
key={group.id}
group={group}
editingGroup={editingGroup}
editName={editName}
onEditNameChange={handleEditNameChange}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onEdit={handleEdit}
onDelete={handleDelete}
groupUsage={groupUsage}
canEditGroup={canEditChannelGroup}
canDeleteGroup={canDeleteChannelGroup}
/>
))}
</Stack>
)}
</Stack>
<Divider />
<Flex justify="flex-end">
<Button variant="default" onClick={onClose}>
Close
</Button>
</Flex>
</Stack>
</Modal>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)}
title="Confirm Group Deletion"
message={
groupToDelete ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to delete the following group?
Name: ${groupToDelete.name}
This action cannot be undone.`}
</div>
) : (
'Are you sure you want to delete this group? This action cannot be undone.'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
actionKey="delete-group"
onSuppressChange={suppressWarning}
size="md"
zIndex={2100}
/>
<ConfirmationDialog
opened={confirmCleanupOpen}
onClose={() => setConfirmCleanupOpen(false)}
onConfirm={executeCleanup}
title="Confirm Group Cleanup"
message={
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to cleanup all unused groups?
This will permanently delete all groups that are not associated with any channels or M3U accounts.
This action cannot be undone.`}
</div>
}
confirmLabel="Cleanup"
cancelLabel="Cancel"
actionKey="cleanup-groups"
onSuppressChange={suppressWarning}
size="md"
zIndex={2100}
/>
</>
);
});
export default GroupManager;

View file

@ -0,0 +1,268 @@
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import {
Modal,
TextInput,
Button,
Group,
Stack,
Image,
Text,
Center,
Box,
Divider,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { Upload, FileImage, X } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import API from '../../api';
const LogoForm = ({ logo = null, isOpen, onClose }) => {
const [logoPreview, setLogoPreview] = useState(null);
const [uploading, setUploading] = useState(false);
const formik = useFormik({
initialValues: {
name: '',
url: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string()
.required('URL is required')
.test('valid-url-or-path', 'Must be a valid URL or local file path', (value) => {
if (!value) return false;
// Allow local file paths starting with /data/logos/
if (value.startsWith('/data/logos/')) return true;
// Allow valid URLs
try {
new URL(value);
return true;
} catch {
return false;
}
}),
}),
onSubmit: async (values, { setSubmitting }) => {
try {
if (logo) {
await API.updateLogo(logo.id, values);
notifications.show({
title: 'Success',
message: 'Logo updated successfully',
color: 'green',
});
} else {
await API.createLogo(values);
notifications.show({
title: 'Success',
message: 'Logo created successfully',
color: 'green',
});
}
onClose();
} catch (error) {
let errorMessage = logo ? 'Failed to update logo' : 'Failed to create logo';
// Handle specific timeout errors
if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) {
errorMessage = 'Request timed out. Please try again.';
} else if (error.response?.data?.error) {
errorMessage = error.response.data.error;
}
notifications.show({
title: 'Error',
message: errorMessage,
color: 'red',
});
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
if (logo) {
formik.setValues({
name: logo.name || '',
url: logo.url || '',
});
setLogoPreview(logo.cache_url);
} else {
formik.resetForm();
setLogoPreview(null);
}
}, [logo, isOpen]);
const handleFileUpload = async (files) => {
if (files.length === 0) return;
const file = files[0];
// Validate file size on frontend first
if (file.size > 5 * 1024 * 1024) { // 5MB
notifications.show({
title: 'Error',
message: 'File too large. Maximum size is 5MB.',
color: 'red',
});
return;
}
setUploading(true);
try {
const response = await API.uploadLogo(file);
// Update form with uploaded file info
formik.setFieldValue('name', response.name);
formik.setFieldValue('url', response.url);
setLogoPreview(response.cache_url);
notifications.show({
title: 'Success',
message: 'Logo uploaded successfully',
color: 'green',
});
} catch (error) {
let errorMessage = 'Failed to upload logo';
// Handle specific timeout errors
if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) {
errorMessage = 'Upload timed out. Please try again.';
} else if (error.status === 413) {
errorMessage = 'File too large. Please choose a smaller file.';
} else if (error.body?.error) {
errorMessage = error.body.error;
}
notifications.show({
title: 'Error',
message: errorMessage,
color: 'red',
});
} finally {
setUploading(false);
}
};
const handleUrlChange = (event) => {
const url = event.target.value;
formik.setFieldValue('url', url);
// Update preview for remote URLs
if (url && url.startsWith('http')) {
setLogoPreview(url);
}
};
return (
<Modal
opened={isOpen}
onClose={onClose}
title={logo ? 'Edit Logo' : 'Add Logo'}
size="md"
>
<form onSubmit={formik.handleSubmit}>
<Stack spacing="md">
{/* Logo Preview */}
{logoPreview && (
<Center>
<Box>
<Text size="sm" color="dimmed" mb="xs" ta="center">
Preview
</Text>
<Image
src={logoPreview}
alt="Logo preview"
width={100}
height={75}
fit="contain"
fallbackSrc="/logo.png"
style={{
transition: 'transform 0.3s ease',
cursor: 'pointer',
':hover': {
transform: 'scale(1.5)'
}
}}
onMouseEnter={(e) => {
e.target.style.transform = 'scale(1.5)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'scale(1)';
}}
/>
</Box>
</Center>
)}
{/* File Upload */}
<Box>
<Text size="sm" fw={500} mb="xs">
Upload Logo File
</Text>
<Dropzone
onDrop={handleFileUpload}
accept={['image/png', 'image/jpeg', 'image/gif', 'image/webp']}
maxFiles={1}
loading={uploading}
>
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept>
<Upload size={50} color="green" />
</Dropzone.Accept>
<Dropzone.Reject>
<X size={50} color="red" />
</Dropzone.Reject>
<Dropzone.Idle>
<FileImage size={50} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag image here or click to select
</Text>
<Text size="sm" color="dimmed" inline mt={7}>
Supports PNG, JPEG, GIF, WebP files
</Text>
</div>
</Group>
</Dropzone>
</Box>
<Divider label="OR" labelPosition="center" />
{/* Manual URL Input */}
<TextInput
label="Logo URL"
placeholder="https://example.com/logo.png"
{...formik.getFieldProps('url')}
onChange={handleUrlChange}
error={formik.touched.url && formik.errors.url}
/>
<TextInput
label="Name"
placeholder="Enter logo name"
{...formik.getFieldProps('name')}
error={formik.touched.name && formik.errors.name}
/>
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit" loading={formik.isSubmitting || uploading}>
{logo ? 'Update' : 'Create'}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
};
export default LogoForm;

View file

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

View file

@ -1,5 +1,5 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, forwardRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
@ -21,9 +21,26 @@ import {
Center,
SimpleGrid,
Text,
NumberInput,
Divider,
Alert,
Box,
MultiSelect,
Tooltip,
} from '@mantine/core';
import { Info } from 'lucide-react';
import useChannelsStore from '../../store/channels';
import { CircleCheck, CircleX } from 'lucide-react';
import { notifications } from '@mantine/notifications';
// Custom item component for MultiSelect with tooltip
const OptionWithTooltip = forwardRef(({ label, description, ...others }, ref) => (
<Tooltip label={description} withArrow>
<div ref={ref} {...others}>
{label}
</div>
</Tooltip>
));
const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((s) => s.channelGroups);
@ -37,10 +54,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
}
setGroupStates(
playlist.channel_groups.map((group) => ({
...group,
name: channelGroups[group.channel_group].name,
}))
playlist.channel_groups.map((group) => {
// Parse custom_properties if present
let customProps = {};
if (group.custom_properties) {
try {
customProps = typeof group.custom_properties === 'string'
? JSON.parse(group.custom_properties)
: group.custom_properties;
} catch (e) {
customProps = {};
}
}
return {
...group,
name: channelGroups[group.channel_group].name,
auto_channel_sync: group.auto_channel_sync || false,
auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
custom_properties: customProps,
};
})
);
}, [playlist, channelGroups]);
@ -53,15 +86,79 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
);
};
const toggleAutoSync = (id) => {
setGroupStates(
groupStates.map((state) => ({
...state,
auto_channel_sync: state.channel_group == id ? !state.auto_channel_sync : state.auto_channel_sync,
}))
);
};
const updateChannelStart = (id, value) => {
setGroupStates(
groupStates.map((state) => ({
...state,
auto_sync_channel_start: state.channel_group == id ? value : state.auto_sync_channel_start,
}))
);
};
// Toggle force_dummy_epg in custom_properties for a group
const toggleForceDummyEPG = (id) => {
setGroupStates(
groupStates.map((state) => {
if (state.channel_group == id) {
const customProps = { ...(state.custom_properties || {}) };
customProps.force_dummy_epg = !customProps.force_dummy_epg;
return {
...state,
custom_properties: customProps,
};
}
return state;
})
);
};
const submit = async () => {
setIsLoading(true);
await API.updatePlaylist({
...playlist,
channel_groups: groupStates,
});
setIsLoading(false);
API.refreshPlaylist(playlist.id);
onClose();
try {
// Prepare groupStates for API: custom_properties must be stringified
const payload = groupStates.map((state) => ({
...state,
custom_properties: state.custom_properties
? JSON.stringify(state.custom_properties)
: undefined,
}));
// Update group settings via API endpoint
await API.updateM3UGroupSettings(playlist.id, payload);
// Show notification about the refresh process
notifications.show({
title: 'Group Settings Updated',
message: 'Settings saved. Starting M3U refresh to apply changes...',
color: 'green',
autoClose: 3000,
});
// Refresh the playlist - this will handle channel sync automatically at the end
await API.refreshPlaylist(playlist.id);
notifications.show({
title: 'M3U Refresh Started',
message: 'The M3U account is being refreshed. Channel sync will occur automatically after parsing completes.',
color: 'blue',
autoClose: 5000,
});
onClose();
} catch (error) {
console.error('Error updating group settings:', error);
} finally {
setIsLoading(false);
}
};
const selectAll = () => {
@ -94,60 +191,354 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
<Modal
opened={isOpen}
onClose={onClose}
title="M3U Group Filter"
title="M3U Group Filter & Auto Channel Sync"
size={1000}
styles={{ content: { '--mantine-color-body': '#27272A' } }}
>
<LoadingOverlay visible={isLoading} overlayBlur={2} />
<Stack>
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
<strong>Auto Channel Sync:</strong> When enabled, channels will be automatically created for all streams in the group during M3U updates,
and removed when streams are no longer present. Set a starting channel number for each group to organize your channels.
</Text>
</Alert>
<Flex gap="sm">
<TextInput
placeholder="Filter"
placeholder="Filter groups..."
value={groupFilter}
onChange={(event) => setGroupFilter(event.currentTarget.value)}
style={{ flex: 1 }}
size="xs"
/>
<Button variant="default" size="sm" onClick={selectAll}>
<Button variant="default" size="xs" onClick={selectAll}>
Select Visible
</Button>
<Button variant="default" size="sm" onClick={deselectAll}>
<Button variant="default" size="xs" onClick={deselectAll}>
Deselect Visible
</Button>
</Flex>
<SimpleGrid cols={4}>
{groupStates
.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
)
.sort((a, b) => a.name > b.name)
.map((group) => (
<Button
key={group.channel_group}
color={group.enabled ? 'green' : 'gray'}
variant="filled"
checked={group.enabled}
onClick={() => toggleGroupEnabled(group.channel_group)}
radius="xl"
leftSection={
group.enabled ? (
<CircleCheck size={20} />
) : (
<CircleX size={20} />
)
}
justify="left"
>
<Text size="xs">{group.name}</Text>
</Button>
))}
</SimpleGrid>
<Divider label="Groups & Auto Sync Settings" labelPosition="center" />
<Box style={{ maxHeight: '50vh', overflowY: 'auto' }}>
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing="xs"
verticalSpacing="xs"
>
{groupStates
.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => (
<Group key={group.channel_group} spacing="xs" style={{
padding: '8px',
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: group.enabled ? '#2A2A2E' : '#1E1E22',
flexDirection: 'column',
alignItems: 'stretch'
}}>
{/* Group Enable/Disable Button */}
<Button
color={group.enabled ? 'green' : 'gray'}
variant="filled"
onClick={() => toggleGroupEnabled(group.channel_group)}
radius="md"
size="xs"
leftSection={
group.enabled ? (
<CircleCheck size={14} />
) : (
<CircleX size={14} />
)
}
fullWidth
>
<Text size="xs" truncate>
{group.name}
</Text>
</Button>
{/* Auto Sync Controls */}
<Stack spacing="xs" style={{ '--stack-gap': '4px' }}>
<Flex align="center" gap="xs">
<Checkbox
label="Auto Channel Sync"
checked={group.auto_channel_sync && group.enabled}
disabled={!group.enabled}
onChange={() => toggleAutoSync(group.channel_group)}
size="xs"
/>
</Flex>
{group.auto_channel_sync && group.enabled && (
<>
<NumberInput
label="Start Channel #"
value={group.auto_sync_channel_start}
onChange={(value) => updateChannelStart(group.channel_group, value)}
min={1}
step={1}
size="xs"
precision={1}
/>
{/* Auto Channel Sync Options Multi-Select */}
<MultiSelect
label="Advanced Options"
placeholder="Select options..."
data={[
{
value: 'force_dummy_epg',
label: 'Force Dummy EPG',
description: 'Assign a dummy EPG to all channels in this group if no EPG is matched',
},
{
value: 'group_override',
label: 'Override Channel Group',
description: 'Override the group assignment for all channels in this group',
},
{
value: 'name_regex',
label: 'Channel Name Find & Replace (Regex)',
description: 'Find and replace part of the channel name using a regex pattern',
},
{
value: 'name_match_regex',
label: 'Channel Name Filter (Regex)',
description: 'Only include channels whose names match this regex pattern',
},
]}
itemComponent={OptionWithTooltip}
value={(() => {
const selectedValues = [];
if (group.custom_properties?.force_dummy_epg) {
selectedValues.push('force_dummy_epg');
}
if (group.custom_properties?.group_override !== undefined) {
selectedValues.push('group_override');
}
if (
group.custom_properties?.name_regex_pattern !== undefined ||
group.custom_properties?.name_replace_pattern !== undefined
) {
selectedValues.push('name_regex');
}
if (group.custom_properties?.name_match_regex !== undefined) {
selectedValues.push('name_match_regex');
}
return selectedValues;
})()}
onChange={(values) => {
// MultiSelect always returns an array
const selectedOptions = values || [];
setGroupStates(
groupStates.map((state) => {
if (state.channel_group === group.channel_group) {
let newCustomProps = { ...(state.custom_properties || {}) };
// Handle force_dummy_epg
if (selectedOptions.includes('force_dummy_epg')) {
newCustomProps.force_dummy_epg = true;
} else {
delete newCustomProps.force_dummy_epg;
}
// Handle group_override
if (selectedOptions.includes('group_override')) {
if (newCustomProps.group_override === undefined) {
newCustomProps.group_override = null;
}
} else {
delete newCustomProps.group_override;
}
// Handle name_regex
if (selectedOptions.includes('name_regex')) {
if (newCustomProps.name_regex_pattern === undefined) {
newCustomProps.name_regex_pattern = '';
}
if (newCustomProps.name_replace_pattern === undefined) {
newCustomProps.name_replace_pattern = '';
}
} else {
delete newCustomProps.name_regex_pattern;
delete newCustomProps.name_replace_pattern;
}
// Handle name_match_regex
if (selectedOptions.includes('name_match_regex')) {
if (newCustomProps.name_match_regex === undefined) {
newCustomProps.name_match_regex = '';
}
} else {
delete newCustomProps.name_match_regex;
}
return {
...state,
custom_properties: newCustomProps,
};
}
return state;
})
);
}}
clearable
size="xs"
/>
{/* Show group select only if group_override is selected */}
{group.custom_properties?.group_override !== undefined && (
<Tooltip
label="Select a group to override the assignment for all channels in this group."
withArrow
>
<Select
label="Override Channel Group"
placeholder="Choose group..."
value={group.custom_properties?.group_override?.toString() || null}
onChange={(value) => {
const newValue = value ? parseInt(value) : null;
setGroupStates(
groupStates.map((state) => {
if (state.channel_group === group.channel_group) {
return {
...state,
custom_properties: {
...state.custom_properties,
group_override: newValue,
},
};
}
return state;
})
);
}}
data={Object.values(channelGroups).map((g) => ({
value: g.id.toString(),
label: g.name,
}))}
clearable
searchable
size="xs"
/>
</Tooltip>
)}
{/* Show regex fields only if name_regex is selected */}
{(group.custom_properties?.name_regex_pattern !== undefined ||
group.custom_properties?.name_replace_pattern !== undefined) && (
<>
<Tooltip
label="Regex pattern to find in the channel name. Example: ^.*? - PPV\\d+ - (.+)$"
withArrow
>
<TextInput
label="Channel Name Find (Regex)"
placeholder="e.g. ^.*? - PPV\\d+ - (.+)$"
value={group.custom_properties?.name_regex_pattern || ''}
onChange={e => {
const val = e.currentTarget.value;
setGroupStates(
groupStates.map(state =>
state.channel_group === group.channel_group
? {
...state,
custom_properties: {
...state.custom_properties,
name_regex_pattern: val,
},
}
: state
)
);
}}
size="xs"
/>
</Tooltip>
<Tooltip
label="Replacement pattern for the channel name. Example: $1"
withArrow
>
<TextInput
label="Channel Name Replace"
placeholder="e.g. $1"
value={group.custom_properties?.name_replace_pattern || ''}
onChange={e => {
const val = e.currentTarget.value;
setGroupStates(
groupStates.map(state =>
state.channel_group === group.channel_group
? {
...state,
custom_properties: {
...state.custom_properties,
name_replace_pattern: val,
},
}
: state
)
);
}}
size="xs"
/>
</Tooltip>
</>
)}
{/* Show name_match_regex field only if selected */}
{group.custom_properties?.name_match_regex !== undefined && (
<Tooltip
label="Only channels whose names match this regex will be included. Example: ^Sports.*"
withArrow
>
<TextInput
label="Channel Name Filter (Regex)"
placeholder="e.g. ^Sports.*"
value={group.custom_properties?.name_match_regex || ''}
onChange={e => {
const val = e.currentTarget.value;
setGroupStates(
groupStates.map(state =>
state.channel_group === group.channel_group
? {
...state,
custom_properties: {
...state.custom_properties,
name_match_regex: val,
},
}
: state
)
);
}}
size="xs"
/>
</Tooltip>
)}
</>
)}
</Stack>
</Group>
))}
</SimpleGrid>
</Box>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button variant="default" onClick={onClose} size="xs">
Cancel
</Button>
<Button
type="submit"
variant="contained"
color="primary"
variant="filled"
color="blue"
disabled={isLoading}
size="small"
onClick={submit}
>
Save and Refresh
@ -158,4 +549,4 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
);
};
export default M3UGroupFilter;
export default M3UGroupFilter;

View file

@ -216,6 +216,9 @@ const ChannelRowActions = React.memo(
const ChannelsTable = ({ }) => {
const theme = useMantineTheme();
const channelGroups = useChannelsStore((s) => s.channelGroups);
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup);
/**
* STORES
@ -241,7 +244,6 @@ const ChannelsTable = ({ }) => {
const channels = useChannelsStore((s) => s.channels);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const logos = useChannelsStore((s) => s.logos);
const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
pageSize: 50,
@ -286,7 +288,8 @@ const ChannelsTable = ({ }) => {
const [isLoading, setIsLoading] = useState(true);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase); const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
@ -306,7 +309,7 @@ const ChannelsTable = ({ }) => {
});
/**
* Dereived variables
* Derived variables
*/
const activeGroupIds = new Set(
Object.values(channels).map((channel) => channel.channel_group_id)

View file

@ -25,6 +25,7 @@ import {
SquareMinus,
SquarePen,
SquarePlus,
Settings,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
@ -32,6 +33,7 @@ import useChannelsStore from '../../../store/channels';
import useAuthStore from '../../../store/auth';
import { USER_LEVELS } from '../../../constants';
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
import GroupManager from '../../forms/GroupManager';
import ConfirmationDialog from '../../ConfirmationDialog';
import useWarningsStore from '../../../store/warnings';
@ -105,6 +107,7 @@ const ChannelTableHeader = ({
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false);
const [profileToDelete, setProfileToDelete] = useState(null);
@ -284,22 +287,25 @@ const ChannelTableHeader = ({
selectedTableIds.length == 0 ||
authUser.user_level != USER_LEVELS.ADMIN
}
onClick={() => setAssignNumbersModalOpen(true)}
>
<UnstyledButton
size="xs"
onClick={() => setAssignNumbersModalOpen(true)}
>
<Text size="xs">Assign #s</Text>
</UnstyledButton>
<Text size="xs">Assign #s</Text>
</Menu.Item>
<Menu.Item
leftSection={<Binary size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={matchEpg}
>
<UnstyledButton size="xs" onClick={matchEpg}>
<Text size="xs">Auto-Match</Text>
</UnstyledButton>
<Text size="xs">Auto-Match</Text>
</Menu.Item>
<Menu.Item
leftSection={<Settings size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={() => setGroupManagerOpen(true)}
>
<Text size="xs">Edit Groups</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
@ -312,6 +318,11 @@ const ChannelTableHeader = ({
onClose={closeAssignChannelNumbersModal}
/>
<GroupManager
isOpen={groupManagerOpen}
onClose={() => setGroupManagerOpen(false)}
/>
<ConfirmationDialog
opened={confirmDeleteProfileOpen}
onClose={() => setConfirmDeleteProfileOpen(false)}

View file

@ -0,0 +1,793 @@
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import API from '../../api';
import LogoForm from '../forms/Logo';
import useChannelsStore from '../../store/channels';
import useLocalStorage from '../../hooks/useLocalStorage';
import {
SquarePlus,
SquareMinus,
SquarePen,
ExternalLink,
Filter,
Trash2,
Trash,
} from 'lucide-react';
import {
ActionIcon,
Box,
Text,
Paper,
Button,
Flex,
Group,
useMantineTheme,
LoadingOverlay,
Stack,
Image,
Center,
Badge,
Tooltip,
Select,
TextInput,
Menu,
Checkbox,
Pagination,
NativeSelect,
} from '@mantine/core';
import { CustomTable, useTable } from './CustomTable';
import ConfirmationDialog from '../ConfirmationDialog';
import { notifications } from '@mantine/notifications';
const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const onEdit = useCallback(() => {
editLogo(row.original);
}, [row.original, editLogo]);
const onDelete = useCallback(() => {
deleteLogo(row.original.id);
}, [row.original.id, deleteLogo]);
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
return (
<Box style={{ width: '100%', justifyContent: 'left' }}>
<Group gap={2} justify="center">
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onEdit}
>
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
>
<SquareMinus size="18" />
</ActionIcon>
</Group>
</Box>
);
};
const LogosTable = () => {
const theme = useMantineTheme();
/**
* STORES
*/
const { logos, fetchLogos } = useChannelsStore();
/**
* useState
*/
const [selectedLogo, setSelectedLogo] = useState(null);
const [logoModalOpen, setLogoModalOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [logoToDelete, setLogoToDelete] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false);
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [isCleaningUp, setIsCleaningUp] = useState(false);
const [filters, setFilters] = useState({
name: '',
used: 'all'
});
const [debouncedNameFilter, setDebouncedNameFilter] = useState('');
const [selectedRows, setSelectedRows] = useState(new Set());
const [pageSize, setPageSize] = useLocalStorage('logos-page-size', 25);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: pageSize,
});
const [paginationString, setPaginationString] = useState('');
// Debounce the name filter
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedNameFilter(filters.name);
}, 300); // 300ms delay
return () => clearTimeout(timer);
}, [filters.name]);
const data = useMemo(() => {
const logosArray = Object.values(logos || {});
// Apply filters
let filteredLogos = logosArray;
if (debouncedNameFilter) {
filteredLogos = filteredLogos.filter(logo =>
logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase())
);
}
if (filters.used === 'used') {
filteredLogos = filteredLogos.filter(logo => logo.is_used);
} else if (filters.used === 'unused') {
filteredLogos = filteredLogos.filter(logo => !logo.is_used);
}
return filteredLogos.sort((a, b) => a.id - b.id);
}, [logos, debouncedNameFilter, filters.used]);
// Get paginated data
const paginatedData = useMemo(() => {
const startIndex = pagination.pageIndex * pagination.pageSize;
const endIndex = startIndex + pagination.pageSize;
return data.slice(startIndex, endIndex);
}, [data, pagination.pageIndex, pagination.pageSize]);
// Calculate unused logos count
const unusedLogosCount = useMemo(() => {
const allLogos = Object.values(logos || {});
return allLogos.filter(logo => !logo.is_used).length;
}, [logos]);
/**
* Functions
*/
const executeDeleteLogo = useCallback(async (id, deleteFile = false) => {
setIsLoading(true);
try {
await API.deleteLogo(id, deleteFile);
await fetchLogos();
notifications.show({
title: 'Success',
message: 'Logo deleted successfully',
color: 'green',
});
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to delete logo',
color: 'red',
});
} finally {
setIsLoading(false);
setConfirmDeleteOpen(false);
setDeleteTarget(null);
setLogoToDelete(null);
setIsBulkDelete(false);
setSelectedRows(new Set()); // Clear selections
}
}, [fetchLogos]);
const executeBulkDelete = useCallback(async (deleteFiles = false) => {
if (selectedRows.size === 0) return;
setIsLoading(true);
try {
await API.deleteLogos(Array.from(selectedRows), deleteFiles);
await fetchLogos();
notifications.show({
title: 'Success',
message: `${selectedRows.size} logos deleted successfully`,
color: 'green',
});
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to delete logos',
color: 'red',
});
} finally {
setIsLoading(false);
setConfirmDeleteOpen(false);
setIsBulkDelete(false);
setSelectedRows(new Set()); // Clear selections
}
}, [selectedRows, fetchLogos]);
const executeCleanupUnused = useCallback(async (deleteFiles = false) => {
setIsCleaningUp(true);
try {
const result = await API.cleanupUnusedLogos(deleteFiles);
await fetchLogos(); // Refresh the logos list
let message = `Successfully deleted ${result.deleted_count} unused logos`;
if (result.local_files_deleted > 0) {
message += ` and deleted ${result.local_files_deleted} local files`;
}
notifications.show({
title: 'Cleanup Complete',
message: message,
color: 'green',
});
} catch (error) {
notifications.show({
title: 'Cleanup Failed',
message: 'Failed to cleanup unused logos',
color: 'red',
});
} finally {
setIsCleaningUp(false);
setConfirmCleanupOpen(false);
setSelectedRows(new Set()); // Clear selections after cleanup
}
}, [fetchLogos]);
const editLogo = useCallback(async (logo = null) => {
setSelectedLogo(logo);
setLogoModalOpen(true);
}, []);
const deleteLogo = useCallback(async (id) => {
const logosArray = Object.values(logos || {});
const logo = logosArray.find((l) => l.id === id);
setLogoToDelete(logo);
setDeleteTarget(id);
setIsBulkDelete(false);
setConfirmDeleteOpen(true);
}, [logos]);
const handleSelectRow = useCallback((id, checked) => {
setSelectedRows(prev => {
const newSet = new Set(prev);
if (checked) {
newSet.add(id);
} else {
newSet.delete(id);
}
return newSet;
});
}, []);
const handleSelectAll = useCallback((checked) => {
if (checked) {
setSelectedRows(new Set(data.map(logo => logo.id)));
} else {
setSelectedRows(new Set());
}
}, [data]);
const deleteBulkLogos = useCallback(() => {
if (selectedRows.size === 0) return;
setIsBulkDelete(true);
setLogoToDelete(null);
setDeleteTarget(Array.from(selectedRows));
setConfirmDeleteOpen(true);
}, [selectedRows]);
const handleCleanupUnused = useCallback(() => {
setConfirmCleanupOpen(true);
}, []);
// Clear selections when logos data changes (e.g., after filtering)
useEffect(() => {
setSelectedRows(new Set());
}, [data.length]);
// Update pagination when pageSize changes
useEffect(() => {
setPagination(prev => ({
...prev,
pageSize: pageSize,
}));
}, [pageSize]);
// Calculate pagination string
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1;
const endItem = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
data.length
);
setPaginationString(`${startItem} to ${endItem} of ${data.length}`);
}, [pagination.pageIndex, pagination.pageSize, data.length]);
// Calculate page count
const pageCount = useMemo(() => {
return Math.ceil(data.length / pagination.pageSize);
}, [data.length, pagination.pageSize]);
/**
* useMemo
*/
const columns = useMemo(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={selectedRows.size > 0 && selectedRows.size === data.length}
indeterminate={selectedRows.size > 0 && selectedRows.size < data.length}
onChange={(event) => handleSelectAll(event.currentTarget.checked)}
size="sm"
/>
),
cell: ({ row }) => (
<Checkbox
checked={selectedRows.has(row.original.id)}
onChange={(event) => handleSelectRow(row.original.id, event.currentTarget.checked)}
size="sm"
/>
),
size: 50,
enableSorting: false,
},
{
header: 'Preview',
accessorKey: 'cache_url',
size: 80,
enableSorting: false,
cell: ({ getValue, row }) => (
<Center style={{ width: '100%', padding: '4px' }}>
<Image
src={getValue()}
alt={row.original.name}
width={40}
height={30}
fit="contain"
fallbackSrc="/logo.png"
style={{
transition: 'transform 0.3s ease',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.target.style.transform = 'scale(1.5)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'scale(1)';
}}
/>
</Center>
),
},
{
header: 'Name',
accessorKey: 'name',
size: 200,
cell: ({ getValue }) => (
<Text fw={500} size="sm">
{getValue()}
</Text>
),
},
{
header: 'Usage',
accessorKey: 'channel_count',
size: 120,
cell: ({ getValue, row }) => {
const count = getValue();
const channelNames = row.original.channel_names || [];
if (count === 0) {
return (
<Badge size="sm" variant="light" color="gray">
Unused
</Badge>
);
}
return (
<Tooltip
label={
<div>
<Text size="xs" fw={600}>Used by {count} channel{count !== 1 ? 's' : ''}:</Text>
{channelNames.map((name, index) => (
<Text key={index} size="xs"> {name}</Text>
))}
</div>
}
multiline
width={220}
>
<Badge size="sm" variant="light" color="blue">
{count} channel{count !== 1 ? 's' : ''}
</Badge>
</Tooltip>
);
},
},
{
header: 'URL',
accessorKey: 'url',
cell: ({ getValue }) => (
<Group gap={4} style={{ alignItems: 'center' }}>
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 300,
}}
>
<Text size="sm" c="dimmed">
{getValue()}
</Text>
</Box>
{getValue()?.startsWith('http') && (
<ActionIcon
size="xs"
variant="transparent"
color="gray"
onClick={() => window.open(getValue(), '_blank')}
>
<ExternalLink size={12} />
</ActionIcon>
)}
</Group>
),
},
{
id: 'actions',
size: 80,
header: 'Actions',
enableSorting: false,
cell: ({ row }) => (
<LogoRowActions
theme={theme}
row={row}
editLogo={editLogo}
deleteLogo={deleteLogo}
/>
),
},
],
[theme, editLogo, deleteLogo, selectedRows, handleSelectRow, handleSelectAll, data.length]
);
const closeLogoForm = () => {
setSelectedLogo(null);
setLogoModalOpen(false);
fetchLogos(); // Refresh the logos list
};
const renderHeaderCell = (header) => {
return (
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
);
};
const onRowSelectionChange = useCallback((newSelection) => {
setSelectedRows(new Set(newSelection));
}, []);
const onPageSizeChange = (e) => {
const newPageSize = parseInt(e.target.value);
setPageSize(newPageSize);
setPagination(prev => ({
...prev,
pageSize: newPageSize,
pageIndex: 0, // Reset to first page
}));
};
const onPageIndexChange = (pageIndex) => {
if (!pageIndex || pageIndex > pageCount) {
return;
}
setPagination(prev => ({
...prev,
pageIndex: pageIndex - 1,
}));
};
const table = useTable({
columns,
data: paginatedData,
allRowIds: paginatedData.map((logo) => logo.id),
enablePagination: false, // Disable internal pagination since we're handling it manually
enableRowSelection: true,
enableRowVirtualization: false,
renderTopToolbar: false,
manualSorting: false,
manualFiltering: false,
manualPagination: true, // Enable manual pagination
onRowSelectionChange: onRowSelectionChange,
headerCellRenderFns: {
actions: renderHeaderCell,
cache_url: renderHeaderCell,
name: renderHeaderCell,
url: renderHeaderCell,
channel_count: renderHeaderCell,
},
});
return (
<>
<Box
style={{
display: 'flex',
justifyContent: 'center',
padding: '0px',
minHeight: 'calc(100vh - 200px)',
minWidth: '900px',
}}
>
<Stack gap="md" style={{ maxWidth: '1200px', width: '100%' }}>
<Flex style={{ alignItems: 'center', paddingBottom: 10 }} gap={15}>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6',
marginBottom: 0,
}}
>
Logos
</Text>
<Text size="sm" c="dimmed">
({data.length} logo{data.length !== 1 ? 's' : ''})
</Text>
</Flex>
<Paper
style={{
backgroundColor: '#27272A',
border: '1px solid #3f3f46',
borderRadius: 'var(--mantine-radius-md)',
}}
>
{/* Top toolbar */}
<Box
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
borderBottom: '1px solid #3f3f46',
}}
>
<Group gap="sm">
<TextInput
placeholder="Filter by name..."
value={filters.name}
onChange={(event) => {
const value = event.target.value;
setFilters(prev => ({
...prev,
name: value
}));
}}
size="xs"
style={{ width: 200 }}
/>
<Select
placeholder="Usage filter"
value={filters.used}
onChange={(value) =>
setFilters(prev => ({
...prev,
used: value
}))
}
data={[
{ value: 'all', label: 'All logos' },
{ value: 'used', label: 'Used only' },
{ value: 'unused', label: 'Unused only' },
]}
size="xs"
style={{ width: 140 }}
/>
</Group>
<Group gap="sm">
<Button
leftSection={<Trash size={16} />}
variant="light"
size="xs"
color="orange"
onClick={handleCleanupUnused}
loading={isCleaningUp}
disabled={unusedLogosCount === 0}
>
Cleanup Unused {unusedLogosCount > 0 ? `(${unusedLogosCount})` : ''}
</Button>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteBulkLogos}
disabled={selectedRows.size === 0}
>
Delete {selectedRows.size > 0 ? `(${selectedRows.size})` : ''}
</Button>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editLogo()}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
Add Logo
</Button>
</Group>
</Box>
{/* Table container */}
<Box
style={{
position: 'relative',
borderRadius: '0 0 var(--mantine-radius-md) var(--mantine-radius-md)',
}}
>
<Box
style={{
overflow: 'auto',
height: 'calc(100vh - 200px)',
}}
>
<div >
<LoadingOverlay visible={isLoading} />
<CustomTable table={table} />
</div>
</Box>
{/* Pagination Controls */}
<Box
style={{
position: 'sticky',
bottom: 0,
zIndex: 3,
backgroundColor: '#27272A',
borderTop: '1px solid #3f3f46',
}}
>
<Group
gap={5}
justify="center"
style={{
padding: 8,
}}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={pagination.pageSize}
data={['25', '50', '100', '250']}
onChange={onPageSizeChange}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={pagination.pageIndex + 1}
onChange={onPageIndexChange}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
</Box>
</Box>
</Paper>
</Stack>
</Box>
<LogoForm
logo={selectedLogo}
isOpen={logoModalOpen}
onClose={closeLogoForm}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={(deleteFiles) => {
if (isBulkDelete) {
executeBulkDelete(deleteFiles);
} else {
executeDeleteLogo(deleteTarget, deleteFiles);
}
}}
title={isBulkDelete ? "Delete Multiple Logos" : "Delete Logo"}
message={
isBulkDelete ? (
<div>
Are you sure you want to delete {selectedRows.size} selected logos?
<Text size="sm" c="dimmed" mt="xs">
Any channels using these logos will have their logo removed.
</Text>
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
) : logoToDelete ? (
<div>
Are you sure you want to delete the logo "{logoToDelete.name}"?
{logoToDelete.channel_count > 0 && (
<Text size="sm" c="orange" mt="xs">
This logo is currently used by {logoToDelete.channel_count} channel{logoToDelete.channel_count !== 1 ? 's' : ''}. They will have their logo removed.
</Text>
)}
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
) : (
'Are you sure you want to delete this logo?'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
size="md"
showDeleteFileOption={
isBulkDelete
? Array.from(selectedRows).some(id => {
const logo = Object.values(logos).find(l => l.id === id);
return logo && logo.url && logo.url.startsWith('/data/logos');
})
: logoToDelete && logoToDelete.url && logoToDelete.url.startsWith('/data/logos')
}
deleteFileLabel={
isBulkDelete
? "Also delete local logo files from disk"
: "Also delete logo file from disk"
}
/>
<ConfirmationDialog
opened={confirmCleanupOpen}
onClose={() => setConfirmCleanupOpen(false)}
onConfirm={executeCleanupUnused}
title="Cleanup Unused Logos"
message={
<div>
Are you sure you want to cleanup {unusedLogosCount} unused logo{unusedLogosCount !== 1 ? 's' : ''}?
<Text size="sm" c="dimmed" mt="xs">
This will permanently delete all logos that are not currently used by any channels.
</Text>
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
}
confirmLabel="Cleanup"
cancelLabel="Cancel"
size="md"
showDeleteFileOption={true}
deleteFileLabel="Also delete local logo files from disk"
/>
</>
);
};
export default LogosTable;

View file

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

View file

@ -0,0 +1,33 @@
import React, { useEffect } from 'react';
import { Box } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../store/channels';
import LogosTable from '../components/tables/LogosTable';
const LogosPage = () => {
const { fetchLogos } = useChannelsStore();
useEffect(() => {
loadLogos();
}, []);
const loadLogos = async () => {
try {
await fetchLogos();
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to load logos',
color: 'red',
});
}
};
return (
<Box style={{ padding: 10 }}>
<LogosTable />
</Box>
);
};
export default LogosPage;

View file

@ -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 settings value from whats in the DB:
// If the user changed the setting's value from what's in the DB:
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
changedSettings[settingKey] = `${values[settingKey]}`;
// Check if M3U hash key was changed
if (settingKey === 'm3u-hash-key') {
m3uHashKeyChanged = true;
}
}
}
// If M3U hash key changed, show warning (unless suppressed)
if (m3uHashKeyChanged && !isWarningSuppressed('rehash-streams')) {
setRehashDialogType('save'); // Set dialog type to save
setRehashConfirmOpen(true);
return;
}
// Update each changed setting in the backend
for (const updatedKey in changedSettings) {
await API.updateSetting({
@ -245,6 +268,63 @@ const SettingsPage = () => {
}
};
const executeSettingsSaveAndRehash = async () => {
setRehashConfirmOpen(false);
// First save the settings
const values = form.getValues();
const changedSettings = {};
for (const settingKey in values) {
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
changedSettings[settingKey] = `${values[settingKey]}`;
}
}
// Update each changed setting in the backend
for (const updatedKey in changedSettings) {
await API.updateSetting({
...settings[updatedKey],
value: changedSettings[updatedKey],
});
}
};
const executeRehashStreamsOnly = async () => {
setRehashingStreams(true);
setRehashSuccess(false);
setRehashConfirmOpen(false);
try {
await API.rehashStreams();
setRehashSuccess(true);
setTimeout(() => setRehashSuccess(false), 5000);
} catch (error) {
console.error('Error rehashing streams:', error);
} finally {
setRehashingStreams(false);
}
};
const onRehashStreams = async () => {
// Skip warning if it's been suppressed
if (isWarningSuppressed('rehash-streams')) {
return executeRehashStreamsOnly();
}
setRehashDialogType('rehash'); // Set dialog type to rehash
setRehashConfirmOpen(true);
};
// Create a function to handle the confirmation based on dialog type
const handleRehashConfirm = () => {
if (rehashDialogType === 'save') {
executeSettingsSaveAndRehash();
} else {
executeRehashStreamsOnly();
}
};
return (
<Center
style={{
@ -395,12 +475,28 @@ const SettingsPage = () => {
key={form.key('m3u-hash-key')}
/>
{rehashSuccess && (
<Alert
variant="light"
color="green"
title="Rehash task queued successfully"
/>
)}
<Flex
mih={50}
gap="xs"
justify="flex-end"
justify="space-between"
align="flex-end"
>
<Button
onClick={onRehashStreams}
loading={rehashingStreams}
variant="outline"
color="blue"
>
Rehash Streams
</Button>
<Button
type="submit"
disabled={form.submitting}
@ -588,6 +684,32 @@ const SettingsPage = () => {
</Accordion>
</Box>
<ConfirmationDialog
opened={rehashConfirmOpen}
onClose={() => {
setRehashConfirmOpen(false);
setRehashDialogType(null);
}}
onConfirm={handleRehashConfirm}
title={rehashDialogType === 'save' ? 'Save Settings and Rehash Streams' : 'Confirm Stream Rehash'}
message={
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to rehash all streams?
This process may take a while depending on the number of streams.
Do not shut down Dispatcharr until the rehashing is complete.
M3U refreshes will be blocked until this process finishes.
Please ensure you have time to let this complete before proceeding.`}
</div>
}
confirmLabel={rehashDialogType === 'save' ? 'Save and Rehash' : 'Start Rehash'}
cancelLabel="Cancel"
actionKey="rehash-streams"
onSuppressChange={suppressWarning}
size="md"
/>
<ConfirmationDialog
opened={networkAccessConfirmOpen}
onClose={() => setNetworkAccessConfirmOpen(false)}

View file

@ -21,7 +21,7 @@ const useChannelsStore = create((set, get) => ({
forceUpdate: 0,
triggerUpdate: () => {
set({ forecUpdate: new Date() });
set({ forceUpdate: new Date() });
},
fetchChannels: async () => {
@ -46,16 +46,24 @@ const useChannelsStore = create((set, get) => ({
},
fetchChannelGroups: async () => {
set({ isLoading: true, error: null });
try {
const channelGroups = await api.getChannelGroups();
set({
channelGroups: channelGroups.reduce((acc, group) => {
acc[group.id] = group;
return acc;
}, {}),
isLoading: false,
});
// Process groups to add association flags
const processedGroups = channelGroups.reduce((acc, group) => {
acc[group.id] = {
...group,
hasChannels: group.channel_count > 0,
hasM3UAccounts: group.m3u_account_count > 0,
canEdit: group.m3u_account_count === 0,
canDelete: group.channel_count === 0 && group.m3u_account_count === 0
};
return acc;
}, {});
set((state) => ({
channelGroups: processedGroups,
}));
} catch (error) {
console.error('Failed to fetch channel groups:', error);
set({ error: 'Failed to load channel groups.', isLoading: false });
@ -204,10 +212,18 @@ const useChannelsStore = create((set, get) => ({
updateChannelGroup: (channelGroup) =>
set((state) => ({
...state.channelGroups,
[channelGroup.id]: channelGroup,
channelGroups: {
...state.channelGroups,
[channelGroup.id]: channelGroup,
},
})),
removeChannelGroup: (groupId) =>
set((state) => {
const { [groupId]: removed, ...remainingGroups } = state.channelGroups;
return { channelGroups: remainingGroups };
}),
fetchLogos: async () => {
set({ isLoading: true, error: null });
try {
@ -216,7 +232,6 @@ const useChannelsStore = create((set, get) => ({
logos: logos.reduce((acc, logo) => {
acc[logo.id] = {
...logo,
url: logo.url.replace(/^\/data/, ''),
};
return acc;
}, {}),
@ -234,11 +249,27 @@ const useChannelsStore = create((set, get) => ({
...state.logos,
[newLogo.id]: {
...newLogo,
url: newLogo.url.replace(/^\/data/, ''),
},
},
})),
updateLogo: (logo) =>
set((state) => ({
logos: {
...state.logos,
[logo.id]: {
...logo,
},
},
})),
removeLogo: (logoId) =>
set((state) => {
const newLogos = { ...state.logos };
delete newLogos[logoId];
return { logos: newLogos };
}),
addProfile: (profile) =>
set((state) => ({
profiles: {
@ -427,6 +458,17 @@ const useChannelsStore = create((set, get) => ({
set({ error: 'Failed to load recordings.', isLoading: false });
}
},
// Add helper methods for validation
canEditChannelGroup: (groupIdOrGroup) => {
const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
return get().channelGroups[groupId]?.canEdit ?? true;
},
canDeleteChannelGroup: (groupIdOrGroup) => {
const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup;
return get().channelGroups[groupId]?.canDelete ?? true;
},
}));
export default useChannelsStore;

View file

@ -10,7 +10,7 @@ const useChannelsTableStore = create((set, get) => ({
sorting: [{ id: 'channel_number', desc: false }],
pagination: {
pageIndex: 0,
pageSize: 50,
pageSize: JSON.parse(localStorage.getItem('channel-table-prefs'))?.pageSize || 50,
},
selectedChannelIds: [],
allQueryIds: [],