mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-22 17:48:09 +00:00
Merge branch 'dev' into main
This commit is contained in:
commit
88a7312397
70 changed files with 4926 additions and 1263 deletions
|
|
@ -185,6 +185,30 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
context['include_streams'] = include_streams
|
||||
return context
|
||||
|
||||
@action(detail=False, methods=['patch'], url_path='edit/bulk')
|
||||
def edit_bulk(self, request):
|
||||
data_list = request.data
|
||||
if not isinstance(data_list, list):
|
||||
return Response({"error": "Expected a list of channel objects objects"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
updated_channels = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
for item in data_list:
|
||||
channel = Channel.objects.id(id=item.pop('id'))
|
||||
for key, value in item.items():
|
||||
setattr(channel, key, value)
|
||||
|
||||
channel.save(update_fields=item.keys())
|
||||
updated_channels.append(channel)
|
||||
except Exception as e:
|
||||
logger.error("Error during bulk channel edit", e)
|
||||
return Response({"error": e}, status=500)
|
||||
|
||||
response_data = ChannelSerializer(updated_channels, many=True).data
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='ids')
|
||||
def get_ids(self, request, *args, **kwargs):
|
||||
# Get the filtered queryset
|
||||
|
|
@ -204,12 +228,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
required=["channel_order"],
|
||||
required=["channel_ids"],
|
||||
properties={
|
||||
"channel_order": openapi.Schema(
|
||||
"starting_number": openapi.Schema(type=openapi.TYPE_STRING, description="Starting channel number to assign"),
|
||||
"channel_ids": openapi.Schema(
|
||||
type=openapi.TYPE_ARRAY,
|
||||
items=openapi.Items(type=openapi.TYPE_INTEGER),
|
||||
description="List of channel IDs in the new order"
|
||||
description="Channel IDs to assign"
|
||||
)
|
||||
}
|
||||
),
|
||||
|
|
@ -218,9 +243,11 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
@action(detail=False, methods=['post'], url_path='assign')
|
||||
def assign(self, request):
|
||||
with transaction.atomic():
|
||||
channel_order = request.data.get('channel_order', [])
|
||||
for order, channel_id in enumerate(channel_order, start=1):
|
||||
Channel.objects.filter(id=channel_id).update(channel_number=order)
|
||||
channel_ids = request.data.get('channel_ids', [])
|
||||
channel_num = request.data.get('starting_number', 1)
|
||||
for channel_id in channel_ids:
|
||||
Channel.objects.filter(id=channel_id).update(channel_number=channel_num)
|
||||
channel_num = channel_num + 1
|
||||
|
||||
return Response({"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
@ -285,6 +312,10 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
{"error": f"Channel number {channel_number} is already in use. Please choose a different number."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
#Get the tvc_guide_stationid from custom properties if it exists
|
||||
tvc_guide_stationid = None
|
||||
if 'tvc-guide-stationid' in stream_custom_props:
|
||||
tvc_guide_stationid = stream_custom_props['tvc-guide-stationid']
|
||||
|
||||
|
||||
|
||||
|
|
@ -292,6 +323,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
'channel_number': channel_number,
|
||||
'name': name,
|
||||
'tvg_id': stream.tvg_id,
|
||||
'tvc_guide_stationid': tvc_guide_stationid,
|
||||
'channel_group_id': channel_group.id,
|
||||
'streams': [stream_id],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.6 on 2025-04-27 14:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0017_alter_channelgroup_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channelgroupm3uaccount',
|
||||
name='custom_properties',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
18
apps/channels/migrations/0019_channel_tvc_guide_stationid.py
Normal file
18
apps/channels/migrations/0019_channel_tvc_guide_stationid.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.6 on 2025-05-04 00:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0018_channelgroupm3uaccount_custom_properties_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='tvc_guide_stationid',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from core.models import StreamProfile
|
||||
from django.conf import settings
|
||||
from core.models import StreamProfile, CoreSettings
|
||||
from core.utils import RedisClient
|
||||
|
|
@ -237,6 +236,8 @@ class Channel(models.Model):
|
|||
help_text="Channel group this channel belongs to."
|
||||
)
|
||||
tvg_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
tvc_guide_stationid = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
epg_data = models.ForeignKey(
|
||||
EPGData,
|
||||
on_delete=models.SET_NULL,
|
||||
|
|
@ -488,6 +489,7 @@ class ChannelGroupM3UAccount(models.Model):
|
|||
on_delete=models.CASCADE,
|
||||
related_name='channel_group'
|
||||
)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
'local_file',
|
||||
'current_viewers',
|
||||
'updated_at',
|
||||
'last_seen',
|
||||
'stream_profile_id',
|
||||
'is_custom',
|
||||
'channel_group',
|
||||
|
|
@ -151,6 +152,7 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
'name',
|
||||
'channel_group_id',
|
||||
'tvg_id',
|
||||
'tvc_guide_stationid',
|
||||
'epg_data_id',
|
||||
'streams',
|
||||
'stream_profile_id',
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ logger = logging.getLogger(__name__)
|
|||
# 1) EPG Source API (CRUD)
|
||||
# ─────────────────────────────
|
||||
class EPGSourceViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for EPG sources"""
|
||||
"""
|
||||
API endpoint that allows EPG sources to be viewed or edited.
|
||||
"""
|
||||
queryset = EPGSource.objects.all()
|
||||
serializer_class = EPGSourceSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
|
@ -50,6 +52,21 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
"""Handle partial updates with special logic for is_active field"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if we're toggling is_active
|
||||
if 'is_active' in request.data and instance.is_active != request.data['is_active']:
|
||||
# Set appropriate status based on new is_active value
|
||||
if request.data['is_active']:
|
||||
request.data['status'] = 'idle'
|
||||
else:
|
||||
request.data['status'] = 'disabled'
|
||||
|
||||
# Continue with regular partial update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
# ─────────────────────────────
|
||||
# 2) Program API (CRUD)
|
||||
# ─────────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0006_epgsource_refresh_interval_epgsource_refresh_task'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success')], default='idle', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='last_error',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
14
apps/epg/migrations/0010_merge_20250503_2147.py
Normal file
14
apps/epg/migrations/0010_merge_20250503_2147.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Generated by Django 5.1.6 on 2025-05-03 21:47
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0007_epgsource_status_epgsource_last_error'),
|
||||
('epg', '0009_alter_epgsource_created_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
42
apps/epg/migrations/0011_update_epgsource_fields.py
Normal file
42
apps/epg/migrations/0011_update_epgsource_fields.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Generated by Django 5.1.6 on 2025-05-04 21:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0010_merge_20250503_2147'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Change updated_at field
|
||||
migrations.AlterField(
|
||||
model_name='epgsource',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Time when this source was last successfully refreshed', null=True),
|
||||
),
|
||||
|
||||
# Add new last_message field
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='last_message',
|
||||
field=models.TextField(blank=True, help_text='Last status message, including success results or error information', null=True),
|
||||
),
|
||||
|
||||
# Copy data from last_error to last_message
|
||||
migrations.RunPython(
|
||||
code=lambda apps, schema_editor: apps.get_model('epg', 'EPGSource').objects.all().update(
|
||||
last_message=models.F('last_error')
|
||||
),
|
||||
reverse_code=lambda apps, schema_editor: apps.get_model('epg', 'EPGSource').objects.all().update(
|
||||
last_error=models.F('last_message')
|
||||
),
|
||||
),
|
||||
|
||||
# Remove the old field
|
||||
migrations.RemoveField(
|
||||
model_name='epgsource',
|
||||
name='last_error',
|
||||
),
|
||||
]
|
||||
|
|
@ -9,6 +9,23 @@ class EPGSource(models.Model):
|
|||
('xmltv', 'XMLTV URL'),
|
||||
('schedules_direct', 'Schedules Direct API'),
|
||||
]
|
||||
|
||||
STATUS_IDLE = 'idle'
|
||||
STATUS_FETCHING = 'fetching'
|
||||
STATUS_PARSING = 'parsing'
|
||||
STATUS_ERROR = 'error'
|
||||
STATUS_SUCCESS = 'success'
|
||||
STATUS_DISABLED = 'disabled'
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_IDLE, 'Idle'),
|
||||
(STATUS_FETCHING, 'Fetching'),
|
||||
(STATUS_PARSING, 'Parsing'),
|
||||
(STATUS_ERROR, 'Error'),
|
||||
(STATUS_SUCCESS, 'Success'),
|
||||
(STATUS_DISABLED, 'Disabled'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES)
|
||||
url = models.URLField(blank=True, null=True) # For XMLTV
|
||||
|
|
@ -19,13 +36,23 @@ class EPGSource(models.Model):
|
|||
refresh_task = models.ForeignKey(
|
||||
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_IDLE
|
||||
)
|
||||
last_message = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Last status message, including success results or error information"
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="Time when this source was created"
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="Time when this source was last updated"
|
||||
null=True, blank=True,
|
||||
help_text="Time when this source was last successfully refreshed"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
|
|
@ -46,6 +73,15 @@ class EPGSource(models.Model):
|
|||
|
||||
return cache
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Prevent auto_now behavior by handling updated_at manually
|
||||
if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']:
|
||||
# Don't modify updated_at for regular updates
|
||||
kwargs.setdefault('update_fields', [])
|
||||
if 'updated_at' in kwargs['update_fields']:
|
||||
kwargs['update_fields'].remove('updated_at')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class EPGData(models.Model):
|
||||
# Removed the Channel foreign key. We now just store the original tvg_id
|
||||
# and a name (which might simply be the tvg_id if no real channel exists).
|
||||
|
|
|
|||
|
|
@ -8,10 +8,24 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = EPGSource
|
||||
fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active', 'epg_data_ids', 'refresh_interval', 'created_at', 'updated_at']
|
||||
fields = [
|
||||
'id',
|
||||
'name',
|
||||
'source_type',
|
||||
'url',
|
||||
'api_key',
|
||||
'is_active',
|
||||
'file_path',
|
||||
'refresh_interval',
|
||||
'status',
|
||||
'last_message',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'epg_data_ids'
|
||||
]
|
||||
|
||||
def get_epg_data_ids(self, obj):
|
||||
return list(obj.epgs.values_list('id', flat=True))
|
||||
return list(obj.epgs.values_list('id', flat=True))
|
||||
|
||||
class ProgramDataSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from django.db.models.signals import post_save, post_delete
|
||||
from django.db.models.signals import post_save, post_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from .models import EPGSource
|
||||
from .tasks import refresh_epg_data
|
||||
|
|
@ -54,3 +54,25 @@ def delete_refresh_task(sender, instance, **kwargs):
|
|||
"""
|
||||
if instance.refresh_task:
|
||||
instance.refresh_task.delete()
|
||||
|
||||
@receiver(pre_save, sender=EPGSource)
|
||||
def update_status_on_active_change(sender, instance, **kwargs):
|
||||
"""
|
||||
When an EPGSource's is_active field changes, update the status accordingly.
|
||||
"""
|
||||
if instance.pk: # Only for existing records, not new ones
|
||||
try:
|
||||
# Get the current record from the database
|
||||
old_instance = EPGSource.objects.get(pk=instance.pk)
|
||||
|
||||
# If is_active changed, update the status
|
||||
if old_instance.is_active != instance.is_active:
|
||||
if instance.is_active:
|
||||
# When activating, set status to idle
|
||||
instance.status = 'idle'
|
||||
else:
|
||||
# When deactivating, set status to disabled
|
||||
instance.status = 'disabled'
|
||||
except EPGSource.DoesNotExist:
|
||||
# New record, will use default status
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import os
|
|||
import uuid
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
import time # Add import for tracking download progress
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
|
||||
from celery import shared_task
|
||||
|
|
@ -24,6 +25,30 @@ from core.utils import acquire_task_lock, release_task_lock
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_epg_update(source_id, action, progress, **kwargs):
|
||||
"""Send WebSocket update about EPG download/parsing progress"""
|
||||
# Start with the base data dictionary
|
||||
data = {
|
||||
"progress": progress,
|
||||
"type": "epg_refresh",
|
||||
"source": source_id,
|
||||
"action": action,
|
||||
}
|
||||
|
||||
# Add the additional key-value pairs from kwargs
|
||||
data.update(kwargs)
|
||||
|
||||
# Now, send the updated data dictionary
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': data
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def refresh_all_epg_data():
|
||||
logger.info("Starting refresh_epg_data task.")
|
||||
|
|
@ -36,32 +61,76 @@ def refresh_all_epg_data():
|
|||
logger.info("Finished refresh_epg_data task.")
|
||||
return "EPG data refreshed."
|
||||
|
||||
|
||||
@shared_task
|
||||
def refresh_epg_data(source_id):
|
||||
if not acquire_task_lock('refresh_epg_data', source_id):
|
||||
logger.debug(f"EPG refresh for {source_id} already running")
|
||||
return
|
||||
|
||||
source = EPGSource.objects.get(id=source_id)
|
||||
if not source.is_active:
|
||||
logger.info(f"EPG source {source_id} is not active. Skipping.")
|
||||
return
|
||||
try:
|
||||
source = EPGSource.objects.get(id=source_id)
|
||||
if not source.is_active:
|
||||
logger.info(f"EPG source {source_id} is not active. Skipping.")
|
||||
return
|
||||
|
||||
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
|
||||
if source.source_type == 'xmltv':
|
||||
fetch_xmltv(source)
|
||||
parse_channels_only(source)
|
||||
parse_programs_for_source(source)
|
||||
elif source.source_type == 'schedules_direct':
|
||||
fetch_schedules_direct(source)
|
||||
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
|
||||
if source.source_type == 'xmltv':
|
||||
fetch_success = fetch_xmltv(source)
|
||||
if not fetch_success:
|
||||
logger.error(f"Failed to fetch XMLTV for source {source.name}")
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
return
|
||||
|
||||
source.save(update_fields=['updated_at'])
|
||||
parse_channels_success = parse_channels_only(source)
|
||||
if not parse_channels_success:
|
||||
logger.error(f"Failed to parse channels for source {source.name}")
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
return
|
||||
|
||||
parse_programs_for_source(source)
|
||||
|
||||
elif source.source_type == 'schedules_direct':
|
||||
fetch_schedules_direct(source)
|
||||
|
||||
source.save(update_fields=['updated_at'])
|
||||
except Exception as e:
|
||||
logger.error(f"Error in refresh_epg_data for source {source_id}: {e}", exc_info=True)
|
||||
try:
|
||||
source = EPGSource.objects.get(id=source_id)
|
||||
source.status = 'error'
|
||||
source.last_message = f"Error refreshing EPG data: {str(e)}"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source_id, "refresh", 100, status="error", error=str(e))
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Error updating source status: {inner_e}")
|
||||
finally:
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
|
||||
def fetch_xmltv(source):
|
||||
# Handle cases with local file but no URL
|
||||
if not source.url and source.file_path and os.path.exists(source.file_path):
|
||||
logger.info(f"Using existing local file for EPG source: {source.name} at {source.file_path}")
|
||||
|
||||
# Set the status to success in the database
|
||||
source.status = 'success'
|
||||
source.save(update_fields=['status'])
|
||||
|
||||
# Send a download complete notification
|
||||
send_epg_update(source.id, "downloading", 100, status="success")
|
||||
|
||||
# Return True to indicate successful fetch, processing will continue with parse_channels_only
|
||||
return True
|
||||
|
||||
# Handle cases where no URL is provided and no valid file path exists
|
||||
if not source.url:
|
||||
return
|
||||
# Update source status for missing URL
|
||||
source.status = 'error'
|
||||
source.last_message = "No URL provided and no valid local file exists"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "downloading", 100, status="error", error="No URL provided and no valid local file exists")
|
||||
return False
|
||||
|
||||
if os.path.exists(source.get_cache_file()):
|
||||
os.remove(source.get_cache_file())
|
||||
|
|
@ -79,83 +148,369 @@ def fetch_xmltv(source):
|
|||
logger.debug(f"Using default user agent: {user_agent}")
|
||||
except (ValueError, Exception) as e:
|
||||
logger.warning(f"Error retrieving default user agent, using fallback: {e}")
|
||||
|
||||
headers = {
|
||||
'User-Agent': user_agent
|
||||
}
|
||||
|
||||
response = requests.get(source.url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
logger.debug("XMLTV data fetched successfully.")
|
||||
# Update status to fetching before starting download
|
||||
source.status = 'fetching'
|
||||
source.save(update_fields=['status'])
|
||||
|
||||
cache_file = source.get_cache_file()
|
||||
# Send initial download notification
|
||||
send_epg_update(source.id, "downloading", 0)
|
||||
|
||||
# Save raw data
|
||||
with open(cache_file, 'wb') as f:
|
||||
f.write(response.content)
|
||||
logger.info(f"Cached EPG file saved to {cache_file}")
|
||||
# Use streaming response to track download progress
|
||||
with requests.get(source.url, headers=headers, stream=True, timeout=30) as response:
|
||||
# Handle 404 specifically
|
||||
if response.status_code == 404:
|
||||
logger.error(f"EPG URL not found (404): {source.url}")
|
||||
# Update status to error in the database
|
||||
source.status = 'error'
|
||||
source.last_message = f"EPG source '{source.name}' returned 404 error - will retry on next scheduled run"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
|
||||
# Notify users through the WebSocket about the EPG fetch failure
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
"success": False,
|
||||
"type": "epg_fetch_error",
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"error_code": 404,
|
||||
"message": f"EPG source '{source.name}' returned 404 error - will retry on next scheduled run"
|
||||
}
|
||||
}
|
||||
)
|
||||
# Ensure we update the download progress to 100 with error status
|
||||
send_epg_update(source.id, "downloading", 100, status="error", error="URL not found (404)")
|
||||
return False
|
||||
|
||||
# For all other error status codes
|
||||
if response.status_code >= 400:
|
||||
error_message = f"HTTP error {response.status_code}"
|
||||
user_message = f"EPG source '{source.name}' encountered HTTP error {response.status_code}"
|
||||
|
||||
# Update status to error in the database
|
||||
source.status = 'error'
|
||||
source.last_message = user_message
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
|
||||
# Notify users through the WebSocket
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
"success": False,
|
||||
"type": "epg_fetch_error",
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"error_code": response.status_code,
|
||||
"message": user_message
|
||||
}
|
||||
}
|
||||
)
|
||||
# Update download progress
|
||||
send_epg_update(source.id, "downloading", 100, status="error", error=user_message)
|
||||
return False
|
||||
|
||||
response.raise_for_status()
|
||||
logger.debug("XMLTV data fetched successfully.")
|
||||
|
||||
cache_file = source.get_cache_file()
|
||||
|
||||
# Check if we have content length for progress tracking
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
last_update_time = start_time
|
||||
|
||||
with open(cache_file, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
downloaded += len(chunk)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# Calculate download speed in KB/s
|
||||
speed = downloaded / elapsed_time / 1024 if elapsed_time > 0 else 0
|
||||
|
||||
# Calculate progress percentage
|
||||
if total_size and total_size > 0:
|
||||
progress = min(100, int((downloaded / total_size) * 100))
|
||||
else:
|
||||
# If no content length header, estimate progress
|
||||
progress = min(95, int((downloaded / (10 * 1024 * 1024)) * 100)) # Assume 10MB if unknown
|
||||
|
||||
# Time remaining (in seconds)
|
||||
time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 and total_size > 0 else 0
|
||||
|
||||
# Only send updates every 0.5 seconds to avoid flooding
|
||||
current_time = time.time()
|
||||
if current_time - last_update_time >= 0.5 and progress > 0:
|
||||
last_update_time = current_time
|
||||
send_epg_update(
|
||||
source.id,
|
||||
"downloading",
|
||||
progress,
|
||||
speed=speed,
|
||||
elapsed_time=elapsed_time,
|
||||
time_remaining=time_remaining
|
||||
)
|
||||
|
||||
# Send completion notification
|
||||
send_epg_update(source.id, "downloading", 100)
|
||||
|
||||
# Update status to parsing
|
||||
source.status = 'parsing'
|
||||
source.save(update_fields=['status'])
|
||||
|
||||
logger.info(f"Cached EPG file saved to {cache_file}")
|
||||
return True
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"HTTP Error fetching XMLTV from {source.name}: {e}", exc_info=True)
|
||||
|
||||
# Get error details
|
||||
status_code = e.response.status_code if hasattr(e, 'response') and e.response else 'unknown'
|
||||
error_message = str(e)
|
||||
|
||||
# Create a user-friendly message
|
||||
user_message = f"EPG source '{source.name}' encountered HTTP error {status_code}"
|
||||
|
||||
# Add specific handling for common HTTP errors
|
||||
if status_code == 404:
|
||||
user_message = f"EPG source '{source.name}' URL not found (404) - will retry on next scheduled run"
|
||||
elif status_code == 401 or status_code == 403:
|
||||
user_message = f"EPG source '{source.name}' access denied (HTTP {status_code}) - check credentials"
|
||||
elif status_code == 429:
|
||||
user_message = f"EPG source '{source.name}' rate limited (429) - try again later"
|
||||
elif status_code >= 500:
|
||||
user_message = f"EPG source '{source.name}' server error (HTTP {status_code}) - will retry later"
|
||||
|
||||
# Update source status to error with the error message
|
||||
source.status = 'error'
|
||||
source.last_message = user_message
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
|
||||
# Notify users through the WebSocket about the EPG fetch failure
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
"success": False,
|
||||
"type": "epg_fetch_error",
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"error_code": status_code,
|
||||
"message": user_message,
|
||||
"details": error_message
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Ensure we update the download progress to 100 with error status
|
||||
send_epg_update(source.id, "downloading", 100, status="error", error=user_message)
|
||||
return False
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
# Handle connection errors separately
|
||||
error_message = str(e)
|
||||
user_message = f"Connection error: Unable to connect to EPG source '{source.name}'"
|
||||
logger.error(f"Connection error fetching XMLTV from {source.name}: {e}", exc_info=True)
|
||||
|
||||
# Update source status
|
||||
source.status = 'error'
|
||||
source.last_message = user_message
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
|
||||
# Send notifications
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
'data': {
|
||||
"success": False,
|
||||
"type": "epg_fetch_error",
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"error_code": "connection_error",
|
||||
"message": user_message
|
||||
}
|
||||
}
|
||||
)
|
||||
send_epg_update(source.id, "downloading", 100, status="error", error=user_message)
|
||||
return False
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True)
|
||||
|
||||
# Update source status for general exceptions too
|
||||
source.status = 'error'
|
||||
source.last_message = f"Error: {error_message}"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
|
||||
# Ensure we update the download progress to 100 with error status
|
||||
send_epg_update(source.id, "downloading", 100, status="error", error=f"Error: {error_message}")
|
||||
return False
|
||||
|
||||
|
||||
def parse_channels_only(source):
|
||||
file_path = source.file_path
|
||||
if not file_path:
|
||||
file_path = source.get_cache_file()
|
||||
|
||||
logger.info(f"Parsing channels from EPG file: {file_path}")
|
||||
existing_epgs = {e.tvg_id: e for e in EPGData.objects.filter(epg_source=source)}
|
||||
# Send initial parsing notification
|
||||
send_epg_update(source.id, "parsing_channels", 0)
|
||||
|
||||
# Read entire file (decompress if .gz)
|
||||
if file_path.endswith('.gz'):
|
||||
with open(file_path, 'rb') as gz_file:
|
||||
decompressed = gzip.decompress(gz_file.read())
|
||||
xml_data = decompressed.decode('utf-8')
|
||||
else:
|
||||
with open(file_path, 'r', encoding='utf-8') as xml_file:
|
||||
xml_data = xml_file.read()
|
||||
try:
|
||||
# Check if the file exists
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"EPG file does not exist at path: {file_path}")
|
||||
|
||||
root = ET.fromstring(xml_data)
|
||||
channels = root.findall('channel')
|
||||
# Update the source's file_path to the default cache location
|
||||
new_path = source.get_cache_file()
|
||||
logger.info(f"Updating file_path from '{file_path}' to '{new_path}'")
|
||||
source.file_path = new_path
|
||||
source.save(update_fields=['file_path'])
|
||||
|
||||
epgs_to_create = []
|
||||
epgs_to_update = []
|
||||
# If the source has a URL, fetch the data before continuing
|
||||
if source.url:
|
||||
logger.info(f"Fetching new EPG data from URL: {source.url}")
|
||||
fetch_success = fetch_xmltv(source) # Store the result
|
||||
|
||||
logger.info(f"Found {len(channels)} <channel> entries in {file_path}")
|
||||
for channel_elem in channels:
|
||||
tvg_id = channel_elem.get('id', '').strip()
|
||||
if not tvg_id:
|
||||
continue # skip blank/invalid IDs
|
||||
# Only proceed if fetch was successful AND file exists
|
||||
if not fetch_success:
|
||||
logger.error(f"Failed to fetch EPG data from URL: {source.url}")
|
||||
# Update status to error
|
||||
source.status = 'error'
|
||||
source.last_message = f"Failed to fetch EPG data from URL"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
# Send error notification
|
||||
send_epg_update(source.id, "parsing_channels", 100, status="error", error="Failed to fetch EPG data")
|
||||
return False
|
||||
|
||||
display_name = channel_elem.findtext('display-name', default=tvg_id).strip()
|
||||
# Verify the file was downloaded successfully
|
||||
if not os.path.exists(new_path):
|
||||
logger.error(f"Failed to fetch EPG data, file still missing at: {new_path}")
|
||||
# Update status to error
|
||||
source.status = 'error'
|
||||
source.last_message = f"Failed to fetch EPG data, file missing after download"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "parsing_channels", 100, status="error", error="File not found after download")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"No URL provided for EPG source {source.name}, cannot fetch new data")
|
||||
# Update status to error
|
||||
source.status = 'error'
|
||||
source.last_message = f"No URL provided, cannot fetch EPG data"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "parsing_channels", 100, status="error", error="No URL provided")
|
||||
return False
|
||||
|
||||
if tvg_id in existing_epgs:
|
||||
epg_obj = existing_epgs[tvg_id]
|
||||
if epg_obj.name != display_name:
|
||||
epg_obj.name = display_name
|
||||
epgs_to_update.append(epg_obj)
|
||||
file_path = new_path
|
||||
|
||||
logger.info(f"Parsing channels from EPG file: {file_path}")
|
||||
existing_epgs = {e.tvg_id: e for e in EPGData.objects.filter(epg_source=source)}
|
||||
|
||||
# Read entire file (decompress if .gz)
|
||||
if file_path.endswith('.gz'):
|
||||
with open(file_path, 'rb') as gz_file:
|
||||
decompressed = gzip.decompress(gz_file.read())
|
||||
xml_data = decompressed.decode('utf-8')
|
||||
else:
|
||||
epgs_to_create.append(EPGData(
|
||||
tvg_id=tvg_id,
|
||||
name=display_name,
|
||||
epg_source=source,
|
||||
))
|
||||
with open(file_path, 'r', encoding='utf-8') as xml_file:
|
||||
xml_data = xml_file.read()
|
||||
|
||||
if epgs_to_create:
|
||||
EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True)
|
||||
if epgs_to_update:
|
||||
EPGData.objects.bulk_update(epgs_to_update, ["name"])
|
||||
# Update progress to show file read completed
|
||||
send_epg_update(source.id, "parsing_channels", 25)
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
"data": {"success": True, "type": "epg_channels"}
|
||||
}
|
||||
)
|
||||
root = ET.fromstring(xml_data)
|
||||
channels = root.findall('channel')
|
||||
|
||||
epgs_to_create = []
|
||||
epgs_to_update = []
|
||||
|
||||
logger.info(f"Found {len(channels)} <channel> entries in {file_path}")
|
||||
|
||||
# Update progress to show parsing started
|
||||
send_epg_update(source.id, "parsing_channels", 50)
|
||||
|
||||
total_channels = len(channels)
|
||||
for i, channel_elem in enumerate(channels):
|
||||
tvg_id = channel_elem.get('id', '').strip()
|
||||
if not tvg_id:
|
||||
continue # skip blank/invalid IDs
|
||||
|
||||
display_name = channel_elem.findtext('display-name', default=tvg_id).strip()
|
||||
|
||||
if tvg_id in existing_epgs:
|
||||
epg_obj = existing_epgs[tvg_id]
|
||||
if epg_obj.name != display_name:
|
||||
epg_obj.name = display_name
|
||||
epgs_to_update.append(epg_obj)
|
||||
else:
|
||||
epgs_to_create.append(EPGData(
|
||||
tvg_id=tvg_id,
|
||||
name=display_name,
|
||||
epg_source=source,
|
||||
))
|
||||
|
||||
# Send occasional progress updates
|
||||
if i % 100 == 0 or i == total_channels - 1:
|
||||
progress = 50 + int((i / total_channels) * 40) # Scale to 50-90% range
|
||||
send_epg_update(source.id, "parsing_channels", progress)
|
||||
|
||||
# Update progress before database operations
|
||||
send_epg_update(source.id, "parsing_channels", 90)
|
||||
|
||||
if epgs_to_create:
|
||||
EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True)
|
||||
|
||||
if epgs_to_update:
|
||||
EPGData.objects.bulk_update(epgs_to_update, ["name"])
|
||||
|
||||
# Send completion notification
|
||||
send_epg_update(source.id, "parsing_channels", 100, status="success")
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
"data": {"success": True, "type": "epg_channels"}
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("Finished parsing channel info.")
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f"EPG file not found at: {file_path}")
|
||||
# Update status to error
|
||||
source.status = 'error'
|
||||
source.last_message = f"EPG file not found: {file_path}"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "parsing_channels", 100, status="error", error="File not found")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading EPG file {file_path}: {e}", exc_info=True)
|
||||
# Update status to error
|
||||
source.status = 'error'
|
||||
source.last_message = f"Error parsing EPG file: {str(e)}"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source.id, "parsing_channels", 100, status="error", error=str(e))
|
||||
return False
|
||||
|
||||
logger.info("Finished parsing channel info.")
|
||||
|
||||
@shared_task
|
||||
def parse_programs_for_tvg_id(epg_id):
|
||||
|
|
@ -179,17 +534,72 @@ def parse_programs_for_tvg_id(epg_id):
|
|||
file_path = epg_source.file_path
|
||||
if not file_path:
|
||||
file_path = epg_source.get_cache_file()
|
||||
if not os.path.exists(file_path):
|
||||
fetch_xmltv(epg_source)
|
||||
|
||||
# Check if the file exists
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"EPG file not found at: {file_path}")
|
||||
|
||||
# Update the file path in the database
|
||||
new_path = epg_source.get_cache_file()
|
||||
logger.info(f"Updating file_path from '{file_path}' to '{new_path}'")
|
||||
epg_source.file_path = new_path
|
||||
epg_source.save(update_fields=['file_path'])
|
||||
|
||||
# Fetch new data before continuing
|
||||
if epg_source.url:
|
||||
logger.info(f"Fetching new EPG data from URL: {epg_source.url}")
|
||||
# Properly check the return value from fetch_xmltv
|
||||
fetch_success = fetch_xmltv(epg_source)
|
||||
|
||||
# If fetch was not successful or the file still doesn't exist, abort
|
||||
if not fetch_success:
|
||||
logger.error(f"Failed to fetch EPG data, cannot parse programs for tvg_id: {epg.tvg_id}")
|
||||
# Update status to error if not already set
|
||||
epg_source.status = 'error'
|
||||
epg_source.last_message = f"Failed to download EPG data, cannot parse programs"
|
||||
epg_source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file")
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
|
||||
# Also check if the file exists after download
|
||||
if not os.path.exists(new_path):
|
||||
logger.error(f"Failed to fetch EPG data, file still missing at: {new_path}")
|
||||
epg_source.status = 'error'
|
||||
epg_source.last_message = f"Failed to download EPG data, file missing after download"
|
||||
epg_source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download")
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
else:
|
||||
logger.error(f"No URL provided for EPG source {epg_source.name}, cannot fetch new data")
|
||||
# Update status to error
|
||||
epg_source.status = 'error'
|
||||
epg_source.last_message = f"No URL provided, cannot fetch EPG data"
|
||||
epg_source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided")
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
|
||||
file_path = new_path
|
||||
|
||||
# Read entire file (decompress if .gz)
|
||||
if file_path.endswith('.gz'):
|
||||
with open(file_path, 'rb') as gz_file:
|
||||
decompressed = gzip.decompress(gz_file.read())
|
||||
xml_data = decompressed.decode('utf-8')
|
||||
else:
|
||||
with open(file_path, 'r', encoding='utf-8') as xml_file:
|
||||
xml_data = xml_file.read()
|
||||
try:
|
||||
if file_path.endswith('.gz'):
|
||||
with open(file_path, 'rb') as gz_file:
|
||||
decompressed = gzip.decompress(gz_file.read())
|
||||
xml_data = decompressed.decode('utf-8')
|
||||
else:
|
||||
with open(file_path, 'r', encoding='utf-8') as xml_file:
|
||||
xml_data = xml_file.read()
|
||||
except FileNotFoundError:
|
||||
logger.error(f"EPG file not found at: {file_path}")
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading EPG file {file_path}: {e}", exc_info=True)
|
||||
release_task_lock('parse_epg_programs', epg_id)
|
||||
return
|
||||
|
||||
root = ET.fromstring(xml_data)
|
||||
|
||||
|
|
@ -302,12 +712,85 @@ def parse_programs_for_tvg_id(epg_id):
|
|||
|
||||
logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.")
|
||||
|
||||
|
||||
def parse_programs_for_source(epg_source, tvg_id=None):
|
||||
file_path = epg_source.file_path
|
||||
epg_entries = EPGData.objects.filter(epg_source=epg_source)
|
||||
for epg in epg_entries:
|
||||
if epg.tvg_id:
|
||||
parse_programs_for_tvg_id(epg.id)
|
||||
# Send initial programs parsing notification
|
||||
send_epg_update(epg_source.id, "parsing_programs", 0)
|
||||
|
||||
try:
|
||||
epg_entries = EPGData.objects.filter(epg_source=epg_source)
|
||||
total_entries = epg_entries.count()
|
||||
processed = 0
|
||||
|
||||
if total_entries == 0:
|
||||
logger.info(f"No EPG entries found for source: {epg_source.name}")
|
||||
# Update status - this is not an error, just no entries
|
||||
epg_source.status = 'success'
|
||||
epg_source.save(update_fields=['status'])
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100, status="success")
|
||||
return True
|
||||
|
||||
logger.info(f"Parsing programs for {total_entries} EPG entries from source: {epg_source.name}")
|
||||
|
||||
failed_entries = []
|
||||
program_count = 0
|
||||
channel_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for epg in epg_entries:
|
||||
if epg.tvg_id:
|
||||
try:
|
||||
result = parse_programs_for_tvg_id(epg.id)
|
||||
if result == "Task already running":
|
||||
logger.info(f"Program parse for {epg.id} already in progress, skipping")
|
||||
|
||||
processed += 1
|
||||
progress = min(95, int((processed / total_entries) * 100)) if total_entries > 0 else 50
|
||||
send_epg_update(epg_source.id, "parsing_programs", progress)
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing programs for tvg_id={epg.tvg_id}: {e}", exc_info=True)
|
||||
failed_entries.append(f"{epg.tvg_id}: {str(e)}")
|
||||
|
||||
# If there were failures, include them in the message but continue
|
||||
if failed_entries:
|
||||
epg_source.status = EPGSource.STATUS_SUCCESS # Still mark as success if some processed
|
||||
error_summary = f"Failed to parse {len(failed_entries)} of {total_entries} entries"
|
||||
stats_summary = f"Processed {program_count} programs across {channel_count} channels. Updated: {updated_count}."
|
||||
epg_source.last_message = f"{stats_summary} Warning: {error_summary}"
|
||||
epg_source.updated_at = timezone.now()
|
||||
epg_source.save(update_fields=['status', 'last_message', 'updated_at'])
|
||||
|
||||
# Send completion notification with mixed status
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100,
|
||||
status="success",
|
||||
message=epg_source.last_message)
|
||||
return True
|
||||
|
||||
# If all successful, set a comprehensive success message
|
||||
epg_source.status = EPGSource.STATUS_SUCCESS
|
||||
epg_source.last_message = f"Successfully processed {program_count} programs across {channel_count} channels. Updated: {updated_count}."
|
||||
epg_source.updated_at = timezone.now()
|
||||
epg_source.save(update_fields=['status', 'last_message', 'updated_at'])
|
||||
|
||||
# Send completion notification with status
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100,
|
||||
status="success",
|
||||
message=epg_source.last_message)
|
||||
|
||||
logger.info(f"Completed parsing all programs for source: {epg_source.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in parse_programs_for_source: {e}", exc_info=True)
|
||||
# Update status to error
|
||||
epg_source.status = EPGSource.STATUS_ERROR
|
||||
epg_source.last_message = f"Error parsing programs: {str(e)}"
|
||||
epg_source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(epg_source.id, "parsing_programs", 100,
|
||||
status="error",
|
||||
message=epg_source.last_message)
|
||||
return False
|
||||
|
||||
|
||||
def fetch_schedules_direct(source):
|
||||
logger.info(f"Fetching Schedules Direct data from source: {source.name}")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from django.core.cache import cache
|
|||
import os
|
||||
from rest_framework.decorators import action
|
||||
from django.conf import settings
|
||||
from .tasks import refresh_m3u_groups
|
||||
|
||||
# Import all models, including UserAgent.
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
|
|
@ -56,6 +57,10 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
# Now call super().create() to create the instance
|
||||
response = super().create(request, *args, **kwargs)
|
||||
|
||||
print(response.data.get('account_type'))
|
||||
if response.data.get('account_type') == M3UAccount.Types.XC:
|
||||
refresh_m3u_groups(response.data.get('id'))
|
||||
|
||||
# After the instance is created, return the response
|
||||
return response
|
||||
|
||||
|
|
@ -89,6 +94,21 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
# After the instance is created, return the response
|
||||
return response
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
"""Handle partial updates with special logic for is_active field"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if we're toggling is_active
|
||||
if 'is_active' in request.data and instance.is_active != request.data['is_active']:
|
||||
# Set appropriate status based on new is_active value
|
||||
if request.data['is_active']:
|
||||
request.data['status'] = M3UAccount.Status.IDLE
|
||||
else:
|
||||
request.data['status'] = M3UAccount.Status.DISABLED
|
||||
|
||||
# Continue with regular partial update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
class M3UFilterViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U filters"""
|
||||
queryset = M3UFilter.objects.all()
|
||||
|
|
|
|||
18
apps/m3u/migrations/0008_m3uaccount_stale_stream_days.py
Normal file
18
apps/m3u/migrations/0008_m3uaccount_stale_stream_days.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.6
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('m3u', '0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='stale_stream_days',
|
||||
field=models.PositiveIntegerField(default=7, help_text='Number of days after which a stream will be removed if not seen in the M3U source.'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 5.1.6 on 2025-04-27 12:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('m3u', '0008_m3uaccount_stale_stream_days'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='account_type',
|
||||
field=models.CharField(choices=[('STD', 'Standard'), ('XC', 'Xtream Codes')], default='STD'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='password',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='username',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 5.1.6 on 2025-05-04 21:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('m3u', '0009_m3uaccount_account_type_m3uaccount_password_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='last_message',
|
||||
field=models.TextField(blank=True, null=True, help_text="Last status message, including success results or error information"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success')], default='idle', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='m3uaccount',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Time when this account was last successfully refreshed', null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -10,6 +10,19 @@ from core.models import CoreSettings, UserAgent
|
|||
CUSTOM_M3U_ACCOUNT_NAME="custom"
|
||||
|
||||
class M3UAccount(models.Model):
|
||||
class Types(models.TextChoices):
|
||||
STADNARD = "STD", "Standard"
|
||||
XC = "XC", "Xtream Codes"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
IDLE = "idle", "Idle"
|
||||
FETCHING = "fetching", "Fetching"
|
||||
PARSING = "parsing", "Parsing"
|
||||
ERROR = "error", "Error"
|
||||
SUCCESS = "success", "Success"
|
||||
PENDING_SETUP = "pending_setup", "Pending Setup"
|
||||
DISABLED = "disabled", "Disabled"
|
||||
|
||||
"""Represents an M3U Account for IPTV streams."""
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
|
|
@ -47,8 +60,18 @@ class M3UAccount(models.Model):
|
|||
help_text="Time when this account was created"
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="Time when this account was last updated"
|
||||
null=True, blank=True,
|
||||
help_text="Time when this account was last successfully refreshed"
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.IDLE
|
||||
)
|
||||
last_message = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Last status message, including success results or error information"
|
||||
)
|
||||
user_agent = models.ForeignKey(
|
||||
'core.UserAgent',
|
||||
|
|
@ -69,11 +92,18 @@ class M3UAccount(models.Model):
|
|||
blank=True,
|
||||
related_name='m3u_accounts'
|
||||
)
|
||||
account_type = models.CharField(choices=Types.choices, default=Types.STADNARD)
|
||||
username = models.CharField(max_length=255, null=True, blank=True)
|
||||
password = models.CharField(max_length=255, null=True, blank=True)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
refresh_interval = models.IntegerField(default=24)
|
||||
refresh_task = models.ForeignKey(
|
||||
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
stale_stream_days = models.PositiveIntegerField(
|
||||
default=7,
|
||||
help_text="Number of days after which a stream will be removed if not seen in the M3U source."
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
@ -108,6 +138,15 @@ class M3UAccount(models.Model):
|
|||
|
||||
return user_agent
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Prevent auto_now behavior by handling updated_at manually
|
||||
if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']:
|
||||
# Don't modify updated_at for regular updates
|
||||
kwargs.setdefault('update_fields', [])
|
||||
if 'updated_at' in kwargs['update_fields']:
|
||||
kwargs['update_fields'].remove('updated_at')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# def get_channel_groups(self):
|
||||
# return ChannelGroup.objects.filter(m3u_account__m3u_account=self)
|
||||
|
||||
|
|
|
|||
|
|
@ -66,8 +66,15 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
fields = [
|
||||
'id', 'name', 'server_url', 'file_path', 'server_group',
|
||||
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent', 'profiles', 'locked',
|
||||
'channel_groups', 'refresh_interval'
|
||||
'channel_groups', 'refresh_interval', 'custom_properties', 'account_type', 'username', 'password', 'stale_stream_days',
|
||||
'status', 'last_message',
|
||||
]
|
||||
extra_kwargs = {
|
||||
'password': {
|
||||
'required': False,
|
||||
'allow_blank': True,
|
||||
},
|
||||
}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Pop out channel group memberships so we can handle them manually
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# apps/m3u/signals.py
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.db.models.signals import post_save, post_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from .models import M3UAccount
|
||||
from .tasks import refresh_single_m3u_account, refresh_m3u_groups
|
||||
|
|
@ -13,7 +13,7 @@ def refresh_account_on_save(sender, instance, created, **kwargs):
|
|||
call a Celery task that fetches & parses that single account
|
||||
if it is active or newly created.
|
||||
"""
|
||||
if created:
|
||||
if created and instance.account_type != M3UAccount.Types.XC:
|
||||
refresh_m3u_groups.delay(instance.id)
|
||||
|
||||
@receiver(post_save, sender=M3UAccount)
|
||||
|
|
@ -28,17 +28,10 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
|
|||
period=IntervalSchedule.HOURS
|
||||
)
|
||||
|
||||
if not instance.refresh_task:
|
||||
refresh_task = PeriodicTask.objects.create(
|
||||
name=task_name,
|
||||
interval=interval,
|
||||
task="apps.m3u.tasks.refresh_single_m3u_account",
|
||||
kwargs=json.dumps({"account_id": instance.id}),
|
||||
enabled=instance.refresh_interval != 0,
|
||||
)
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task)
|
||||
else:
|
||||
task = instance.refresh_task
|
||||
# First check if the task already exists to avoid validation errors
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
# Task exists, just update it
|
||||
updated_fields = []
|
||||
|
||||
if task.enabled != (instance.refresh_interval != 0):
|
||||
|
|
@ -52,6 +45,21 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
|
|||
if updated_fields:
|
||||
task.save(update_fields=updated_fields)
|
||||
|
||||
# Ensure instance has the task
|
||||
if instance.refresh_task_id != task.id:
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
|
||||
|
||||
except PeriodicTask.DoesNotExist:
|
||||
# Create new task if it doesn't exist
|
||||
refresh_task = PeriodicTask.objects.create(
|
||||
name=task_name,
|
||||
interval=interval,
|
||||
task="apps.m3u.tasks.refresh_single_m3u_account",
|
||||
kwargs=json.dumps({"account_id": instance.id}),
|
||||
enabled=instance.refresh_interval != 0,
|
||||
)
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task)
|
||||
|
||||
@receiver(post_delete, sender=M3UAccount)
|
||||
def delete_refresh_task(sender, instance, **kwargs):
|
||||
"""
|
||||
|
|
@ -60,3 +68,25 @@ def delete_refresh_task(sender, instance, **kwargs):
|
|||
if instance.refresh_task:
|
||||
instance.refresh_task.interval.delete()
|
||||
instance.refresh_task.delete()
|
||||
|
||||
@receiver(pre_save, sender=M3UAccount)
|
||||
def update_status_on_active_change(sender, instance, **kwargs):
|
||||
"""
|
||||
When an M3UAccount's is_active field changes, update the status accordingly.
|
||||
"""
|
||||
if instance.pk: # Only for existing records, not new ones
|
||||
try:
|
||||
# Get the current record from the database
|
||||
old_instance = M3UAccount.objects.get(pk=instance.pk)
|
||||
|
||||
# If is_active changed, update the status
|
||||
if old_instance.is_active != instance.is_active:
|
||||
if instance.is_active:
|
||||
# When activating, set status to idle
|
||||
instance.status = M3UAccount.Status.IDLE
|
||||
else:
|
||||
# When deactivating, set status to disabled
|
||||
instance.status = M3UAccount.Status.DISABLED
|
||||
except M3UAccount.DoesNotExist:
|
||||
# New record, will use default status
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ from django.utils import timezone
|
|||
import time
|
||||
import json
|
||||
from core.utils import RedisClient, acquire_task_lock, release_task_lock
|
||||
from core.models import CoreSettings
|
||||
from core.models import CoreSettings, UserAgent
|
||||
from asgiref.sync import async_to_sync
|
||||
from core.xtream_codes import Client as XCClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -44,6 +45,11 @@ def fetch_m3u_lines(account, use_cache=False):
|
|||
headers = {"User-Agent": user_agent}
|
||||
logger.info(f"Fetching from URL {account.server_url}")
|
||||
|
||||
# Set account status to FETCHING before starting download
|
||||
account.status = M3UAccount.Status.FETCHING
|
||||
account.last_message = "Starting download..."
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
|
||||
response = requests.get(account.server_url, headers=headers, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
|
|
@ -70,47 +76,101 @@ def fetch_m3u_lines(account, use_cache=False):
|
|||
progress = (downloaded / total_size) * 100
|
||||
|
||||
# Time remaining (in seconds)
|
||||
time_remaining = (total_size - downloaded) / (speed * 1024)
|
||||
time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 else 0
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - last_update_time >= 0.5:
|
||||
last_update_time = current_time
|
||||
if progress > 0:
|
||||
send_m3u_update(account.id, "downloading", progress, speed=speed, elapsed_time=elapsed_time, time_remaining=time_remaining)
|
||||
# Update the account's last_message with detailed progress info
|
||||
progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining"
|
||||
account.last_message = progress_msg
|
||||
account.save(update_fields=['last_message'])
|
||||
|
||||
send_m3u_update(account.id, "downloading", 100)
|
||||
send_m3u_update(account.id, "downloading", progress,
|
||||
speed=speed,
|
||||
elapsed_time=elapsed_time,
|
||||
time_remaining=time_remaining,
|
||||
message=progress_msg)
|
||||
|
||||
# Final update with 100% progress
|
||||
final_msg = f"Download complete. Size: {total_size/1024/1024:.2f} MB, Time: {time.time() - start_time:.1f}s"
|
||||
account.last_message = final_msg
|
||||
account.save(update_fields=['last_message'])
|
||||
send_m3u_update(account.id, "downloading", 100, message=final_msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching M3U from URL {account.server_url}: {e}")
|
||||
return []
|
||||
# Update account status and send error notification
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = f"Error downloading M3U file: {str(e)}"
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account.id, "downloading", 100, status="error", error=f"Error downloading M3U file: {str(e)}")
|
||||
return [], False # Return empty list and False for success
|
||||
|
||||
# Check if the file exists and is not empty
|
||||
if not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
|
||||
error_msg = f"M3U file not found or empty: {file_path}"
|
||||
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, "downloading", 100, status="error", error=error_msg)
|
||||
return [], False # Return empty list and False for success
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.readlines(), True
|
||||
except Exception as e:
|
||||
error_msg = f"Error reading M3U file: {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, "downloading", 100, status="error", error=error_msg)
|
||||
return [], False
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return f.readlines()
|
||||
elif account.file_path:
|
||||
try:
|
||||
if account.file_path.endswith('.gz'):
|
||||
with gzip.open(account.file_path, 'rt', encoding='utf-8') as f:
|
||||
return f.readlines()
|
||||
return f.readlines(), True
|
||||
|
||||
elif account.file_path.endswith('.zip'):
|
||||
with zipfile.ZipFile(account.file_path, 'r') as zip_file:
|
||||
for name in zip_file.namelist():
|
||||
if name.endswith('.m3u'):
|
||||
with zip_file.open(name) as f:
|
||||
return [line.decode('utf-8') for line in f.readlines()]
|
||||
logger.warning(f"No .m3u file found in ZIP archive: {account.file_path}")
|
||||
return []
|
||||
return [line.decode('utf-8') for line in f.readlines()], True
|
||||
|
||||
error_msg = f"No .m3u file found in ZIP archive: {account.file_path}"
|
||||
logger.warning(error_msg)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = error_msg
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg)
|
||||
return [], False
|
||||
|
||||
else:
|
||||
with open(account.file_path, 'r', encoding='utf-8') as f:
|
||||
return f.readlines()
|
||||
return f.readlines(), True
|
||||
|
||||
except (IOError, OSError, zipfile.BadZipFile, gzip.BadGzipFile) as e:
|
||||
logger.error(f"Error opening file {account.file_path}: {e}")
|
||||
return []
|
||||
error_msg = f"Error opening file {account.file_path}: {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, "downloading", 100, status="error", error=error_msg)
|
||||
return [], False
|
||||
|
||||
|
||||
# Return an empty list if neither server_url nor uploaded_file is available
|
||||
return []
|
||||
# Neither server_url nor uploaded_file is available
|
||||
error_msg = "No M3U source available (missing URL and file)"
|
||||
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, "downloading", 100, status="error", error=error_msg)
|
||||
return [], False
|
||||
|
||||
def parse_extinf_line(line: str) -> dict:
|
||||
"""
|
||||
|
|
@ -177,32 +237,36 @@ def check_field_lengths(streams_to_create):
|
|||
print("")
|
||||
|
||||
@shared_task
|
||||
def process_groups(account, group_names):
|
||||
existing_groups = {group.name: group for group in ChannelGroup.objects.filter(name__in=group_names)}
|
||||
def process_groups(account, groups):
|
||||
existing_groups = {group.name: group for group in ChannelGroup.objects.filter(name__in=groups.keys())}
|
||||
logger.info(f"Currently {len(existing_groups)} existing groups")
|
||||
|
||||
groups = []
|
||||
group_objs = []
|
||||
groups_to_create = []
|
||||
for group_name in group_names:
|
||||
for group_name, custom_props in groups.items():
|
||||
logger.info(f"Handling group: {group_name}")
|
||||
if group_name in existing_groups:
|
||||
groups.append(existing_groups[group_name])
|
||||
else:
|
||||
if (group_name not in existing_groups) and (group_name not in SKIP_EXTS):
|
||||
groups_to_create.append(ChannelGroup(
|
||||
name=group_name,
|
||||
))
|
||||
else:
|
||||
group_objs.append(existing_groups[group_name])
|
||||
|
||||
if groups_to_create:
|
||||
logger.info(f"Creating {len(groups_to_create)} groups")
|
||||
created = ChannelGroup.bulk_create_and_fetch(groups_to_create)
|
||||
logger.info(f"Created {len(created)} groups")
|
||||
groups.extend(created)
|
||||
group_objs.extend(created)
|
||||
|
||||
relations = []
|
||||
for group in groups:
|
||||
for group in group_objs:
|
||||
# Ensure we include the xc_id in the custom_properties
|
||||
custom_props = groups.get(group.name, {})
|
||||
relations.append(ChannelGroupM3UAccount(
|
||||
channel_group=group,
|
||||
m3u_account=account,
|
||||
custom_properties=json.dumps(custom_props),
|
||||
enabled=True, # Default to enabled
|
||||
))
|
||||
|
||||
ChannelGroupM3UAccount.objects.bulk_create(
|
||||
|
|
@ -210,6 +274,132 @@ def process_groups(account, group_names):
|
|||
ignore_conflicts=True
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def process_xc_category(account_id, batch, groups, hash_keys):
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
|
||||
streams_to_create = []
|
||||
streams_to_update = []
|
||||
stream_hashes = {}
|
||||
|
||||
try:
|
||||
xc_client = XCClient(account.server_url, account.username, account.password, account.get_user_agent())
|
||||
|
||||
# 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.info(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']})")
|
||||
continue
|
||||
|
||||
logger.info(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())}
|
||||
|
||||
for stream_hash, stream_props in stream_hashes.items():
|
||||
if stream_hash in existing_streams:
|
||||
obj = existing_streams[stream_hash]
|
||||
existing_attr = {field.name: getattr(obj, field.name) for field in Stream._meta.fields if field != 'channel_group_id'}
|
||||
changed = any(existing_attr[key] != value for key, value in stream_props.items() if key != 'channel_group_id')
|
||||
|
||||
if changed:
|
||||
for key, value in stream_props.items():
|
||||
setattr(obj, key, value)
|
||||
obj.last_seen = timezone.now()
|
||||
obj.updated_at = timezone.now() # Update timestamp only for changed streams
|
||||
streams_to_update.append(obj)
|
||||
del existing_streams[stream_hash]
|
||||
else:
|
||||
# Always update last_seen, even if nothing else changed
|
||||
obj.last_seen = timezone.now()
|
||||
# Don't update updated_at for unchanged streams
|
||||
streams_to_update.append(obj)
|
||||
existing_streams[stream_hash] = obj
|
||||
else:
|
||||
stream_props["last_seen"] = timezone.now()
|
||||
stream_props["updated_at"] = timezone.now() # Set initial updated_at for new streams
|
||||
streams_to_create.append(Stream(**stream_props))
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if streams_to_create:
|
||||
Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True)
|
||||
if streams_to_update:
|
||||
# We need to split the bulk update to correctly handle updated_at
|
||||
# First, get the subset of streams that have content changes
|
||||
changed_streams = [s for s in streams_to_update if hasattr(s, 'updated_at') and s.updated_at]
|
||||
unchanged_streams = [s for s in streams_to_update if not hasattr(s, 'updated_at') or not s.updated_at]
|
||||
|
||||
# Update changed streams with all fields including updated_at
|
||||
if changed_streams:
|
||||
Stream.objects.bulk_update(
|
||||
changed_streams,
|
||||
{key for key in stream_props.keys() if key not in ["m3u_account", "stream_hash"] and key not in hash_keys} | {"last_seen", "updated_at"}
|
||||
)
|
||||
|
||||
# Update unchanged streams with only last_seen
|
||||
if unchanged_streams:
|
||||
Stream.objects.bulk_update(unchanged_streams, ["last_seen"])
|
||||
|
||||
if len(existing_streams.keys()) > 0:
|
||||
Stream.objects.bulk_update(existing_streams.values(), ["last_seen"])
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk create failed for XC streams: {str(e)}")
|
||||
|
||||
retval = f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"XC category processing error: {str(e)}")
|
||||
retval = f"Error processing XC batch: {str(e)}"
|
||||
|
||||
# Aggressive garbage collection
|
||||
del streams_to_create, streams_to_update, stream_hashes, existing_streams
|
||||
gc.collect()
|
||||
|
||||
return retval
|
||||
|
||||
@shared_task
|
||||
def process_m3u_batch(account_id, batch, groups, hash_keys):
|
||||
"""Processes a batch of M3U streams using bulk operations."""
|
||||
|
|
@ -232,23 +422,7 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
|
|||
logger.debug(f"Skipping stream in disabled group: {group_title}")
|
||||
continue
|
||||
|
||||
# if any(url.lower().endswith(ext) for ext in SKIP_EXTS) or len(url) > 2000:
|
||||
# continue
|
||||
|
||||
# if _matches_filters(name, group_title, account.filters.all()):
|
||||
# continue
|
||||
|
||||
# if any(compiled_pattern.search(current_info['name']) for ftype, compiled_pattern in compiled_filters if ftype == 'name'):
|
||||
# excluded_count += 1
|
||||
# current_info = None
|
||||
# continue
|
||||
|
||||
stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys)
|
||||
# if redis_client.exists(f"m3u_refresh:{stream_hash}"):
|
||||
# # duplicate already processed by another batch
|
||||
# continue
|
||||
|
||||
# redis_client.set(f"m3u_refresh:{stream_hash}", "true")
|
||||
stream_props = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
|
|
@ -278,12 +452,18 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
|
|||
for key, value in stream_props.items():
|
||||
setattr(obj, key, value)
|
||||
obj.last_seen = timezone.now()
|
||||
obj.updated_at = timezone.now() # Update timestamp only for changed streams
|
||||
streams_to_update.append(obj)
|
||||
del existing_streams[stream_hash]
|
||||
else:
|
||||
# Always update last_seen, even if nothing else changed
|
||||
obj.last_seen = timezone.now()
|
||||
# Don't update updated_at for unchanged streams
|
||||
streams_to_update.append(obj)
|
||||
existing_streams[stream_hash] = obj
|
||||
else:
|
||||
stream_props["last_seen"] = timezone.now()
|
||||
stream_props["updated_at"] = timezone.now() # Set initial updated_at for new streams
|
||||
streams_to_create.append(Stream(**stream_props))
|
||||
|
||||
try:
|
||||
|
|
@ -291,9 +471,24 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
|
|||
if streams_to_create:
|
||||
Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True)
|
||||
if streams_to_update:
|
||||
Stream.objects.bulk_update(streams_to_update, { key for key in stream_props.keys() if key not in ["m3u_account", "stream_hash"] and key not in hash_keys})
|
||||
# if len(existing_streams.keys()) > 0:
|
||||
# Stream.objects.bulk_update(existing_streams.values(), ["last_seen"])
|
||||
# We need to split the bulk update to correctly handle updated_at
|
||||
# First, get the subset of streams that have content changes
|
||||
changed_streams = [s for s in streams_to_update if hasattr(s, 'updated_at') and s.updated_at]
|
||||
unchanged_streams = [s for s in streams_to_update if not hasattr(s, 'updated_at') or not s.updated_at]
|
||||
|
||||
# Update changed streams with all fields including updated_at
|
||||
if changed_streams:
|
||||
Stream.objects.bulk_update(
|
||||
changed_streams,
|
||||
{key for key in stream_props.keys() if key not in ["m3u_account", "stream_hash"] and key not in hash_keys} | {"last_seen", "updated_at"}
|
||||
)
|
||||
|
||||
# Update unchanged streams with only last_seen
|
||||
if unchanged_streams:
|
||||
Stream.objects.bulk_update(unchanged_streams, ["last_seen"])
|
||||
|
||||
if len(existing_streams.keys()) > 0:
|
||||
Stream.objects.bulk_update(existing_streams.values(), ["last_seen"])
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk create failed: {str(e)}")
|
||||
|
||||
|
|
@ -312,18 +507,31 @@ def cleanup_streams(account_id):
|
|||
m3u_account__enabled=True,
|
||||
).values_list('id', flat=True)
|
||||
logger.info(f"Found {len(existing_groups)} active groups")
|
||||
streams = Stream.objects.filter(m3u_account=account)
|
||||
|
||||
# Calculate cutoff date for stale streams
|
||||
stale_cutoff = timezone.now() - timezone.timedelta(days=account.stale_stream_days)
|
||||
logger.info(f"Removing streams not seen since {stale_cutoff}")
|
||||
|
||||
# Delete streams that are not in active groups
|
||||
streams_to_delete = Stream.objects.filter(
|
||||
m3u_account=account
|
||||
).exclude(
|
||||
channel_group__in=existing_groups # Exclude products having any of the excluded tags
|
||||
channel_group__in=existing_groups
|
||||
)
|
||||
|
||||
# Delete the filtered products
|
||||
streams_to_delete.delete()
|
||||
# Also delete streams that haven't been seen for longer than stale_stream_days
|
||||
stale_streams = Stream.objects.filter(
|
||||
m3u_account=account,
|
||||
last_seen__lt=stale_cutoff
|
||||
)
|
||||
|
||||
logger.info(f"Cleanup complete")
|
||||
deleted_count = streams_to_delete.count()
|
||||
stale_count = stale_streams.count()
|
||||
|
||||
streams_to_delete.delete()
|
||||
stale_streams.delete()
|
||||
|
||||
logger.info(f"Cleanup complete: {deleted_count} streams removed due to group filter, {stale_count} removed as stale")
|
||||
|
||||
@shared_task
|
||||
def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
|
||||
|
|
@ -337,46 +545,191 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
|
|||
return f"M3UAccount with ID={account_id} not found or inactive.", None
|
||||
|
||||
extinf_data = []
|
||||
groups = set(["Default Group"])
|
||||
groups = {"Default Group": {}}
|
||||
|
||||
for line in fetch_m3u_lines(account, use_cache):
|
||||
line = line.strip()
|
||||
if line.startswith("#EXTINF"):
|
||||
parsed = parse_extinf_line(line)
|
||||
if parsed:
|
||||
if "group-title" in parsed["attributes"]:
|
||||
groups.add(parsed["attributes"]["group-title"])
|
||||
if account.account_type == M3UAccount.Types.XC:
|
||||
# Log detailed information about the account
|
||||
logger.info(f"Processing XC account {account_id} with URL: {account.server_url}")
|
||||
logger.info(f"Username: {account.username}, Has password: {'Yes' if account.password else 'No'}")
|
||||
|
||||
extinf_data.append(parsed)
|
||||
elif extinf_data and line.startswith("http"):
|
||||
# Associate URL with the last EXTINF line
|
||||
extinf_data[-1]["url"] = line
|
||||
# Validate required fields
|
||||
if not account.server_url:
|
||||
error_msg = "Missing server URL for Xtream Codes account"
|
||||
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 not account.username or not account.password:
|
||||
error_msg = "Missing username or password for Xtream Codes account"
|
||||
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
|
||||
|
||||
try:
|
||||
# Ensure server URL is properly formatted
|
||||
server_url = account.server_url.rstrip('/')
|
||||
if not (server_url.startswith('http://') or server_url.startswith('https://')):
|
||||
server_url = f"http://{server_url}"
|
||||
|
||||
# User agent handling - completely rewritten
|
||||
try:
|
||||
# Debug the user agent issue
|
||||
logger.info(f"Getting user agent for account {account.id}")
|
||||
|
||||
# Use a hardcoded user agent string to avoid any issues with object structure
|
||||
user_agent_string = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
|
||||
try:
|
||||
# Try to get the user agent directly from the database
|
||||
if account.user_agent_id:
|
||||
ua_obj = UserAgent.objects.get(id=account.user_agent_id)
|
||||
if ua_obj and hasattr(ua_obj, 'user_agent') and ua_obj.user_agent:
|
||||
user_agent_string = ua_obj.user_agent
|
||||
logger.info(f"Using user agent from account: {user_agent_string}")
|
||||
else:
|
||||
# Get default user agent from CoreSettings
|
||||
default_ua_id = CoreSettings.get_default_user_agent_id()
|
||||
logger.info(f"Default user agent ID from settings: {default_ua_id}")
|
||||
if default_ua_id:
|
||||
ua_obj = UserAgent.objects.get(id=default_ua_id)
|
||||
if ua_obj and hasattr(ua_obj, 'user_agent') and ua_obj.user_agent:
|
||||
user_agent_string = ua_obj.user_agent
|
||||
logger.info(f"Using default user agent: {user_agent_string}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting user agent, using fallback: {str(e)}")
|
||||
|
||||
logger.info(f"Final user agent string: {user_agent_string}")
|
||||
except Exception as e:
|
||||
user_agent_string = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
logger.warning(f"Exception in user agent handling, using fallback: {str(e)}")
|
||||
|
||||
logger.info(f"Creating XCClient with URL: {server_url}, Username: {account.username}, User-Agent: {user_agent_string}")
|
||||
|
||||
# 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")
|
||||
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.info(f"Authenticating with XC server {server_url}")
|
||||
auth_result = xc_client.authenticate()
|
||||
logger.info(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"Unexpected error in XC processing: {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
|
||||
else:
|
||||
# Here's the key change - use the success flag from fetch_m3u_lines
|
||||
lines, success = fetch_m3u_lines(account, use_cache)
|
||||
if not success:
|
||||
# If fetch failed, don't continue processing
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return f"Failed to fetch M3U data for account_id={account_id}.", None
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith("#EXTINF"):
|
||||
parsed = parse_extinf_line(line)
|
||||
if parsed:
|
||||
if "group-title" in parsed["attributes"]:
|
||||
groups[parsed["attributes"]["group-title"]] = {}
|
||||
|
||||
extinf_data.append(parsed)
|
||||
elif extinf_data and line.startswith("http"):
|
||||
# Associate URL with the last EXTINF line
|
||||
extinf_data[-1]["url"] = line
|
||||
|
||||
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"extinf_data": extinf_data,
|
||||
"groups": groups,
|
||||
}, f)
|
||||
|
||||
send_m3u_update(account_id, "processing_groups", 0)
|
||||
|
||||
groups = list(groups)
|
||||
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"extinf_data": extinf_data,
|
||||
"groups": groups,
|
||||
}, f)
|
||||
|
||||
process_groups(account, groups)
|
||||
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
|
||||
send_m3u_update(account_id, "processing_groups", 100)
|
||||
|
||||
|
||||
if not full_refresh:
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
"data": {"success": True, "type": "m3u_group_refresh", "account": account_id}
|
||||
}
|
||||
# Use update() instead of save() to avoid triggering signals
|
||||
M3UAccount.objects.filter(id=account_id).update(
|
||||
status=M3UAccount.Status.PENDING_SETUP,
|
||||
last_message="M3U groups loaded. Please select groups or refresh M3U to complete setup."
|
||||
)
|
||||
send_m3u_update(account_id, "processing_groups", 100, status="pending_setup", message="M3U groups loaded. Please select groups or refresh M3U to complete setup.")
|
||||
|
||||
return extinf_data, groups
|
||||
|
||||
|
|
@ -386,23 +739,29 @@ def refresh_single_m3u_account(account_id):
|
|||
if not acquire_task_lock('refresh_single_m3u_account', account_id):
|
||||
return f"Task already running for account_id={account_id}."
|
||||
|
||||
# redis_client = RedisClient.get_client()
|
||||
# Record start time
|
||||
start_time = time.time()
|
||||
streams_created = 0
|
||||
streams_updated = 0
|
||||
streams_deleted = 0
|
||||
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id, is_active=True)
|
||||
if not account.is_active:
|
||||
logger.info(f"Account {account_id} is not active, skipping.")
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
return
|
||||
|
||||
# Set status to fetching
|
||||
account.status = M3UAccount.Status.FETCHING
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
filters = list(account.filters.all())
|
||||
except M3UAccount.DoesNotExist:
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
return f"M3UAccount with ID={account_id} not found or inactive."
|
||||
|
||||
# Fetch M3U lines and handle potential issues
|
||||
# lines = fetch_m3u_lines(account) # Extracted fetch logic into separate function
|
||||
extinf_data = []
|
||||
groups = None
|
||||
|
||||
|
|
@ -416,14 +775,58 @@ def refresh_single_m3u_account(account_id):
|
|||
|
||||
if not extinf_data:
|
||||
try:
|
||||
extinf_data, groups = refresh_m3u_groups(account_id, full_refresh=True)
|
||||
if not extinf_data or not groups:
|
||||
logger.info(f"Calling refresh_m3u_groups for account {account_id}")
|
||||
result = refresh_m3u_groups(account_id, full_refresh=True)
|
||||
logger.info(f"refresh_m3u_groups result: {result}")
|
||||
|
||||
# Check for completely empty result or missing groups
|
||||
if not result or result[1] is None:
|
||||
logger.error(f"Failed to refresh M3U groups for account {account_id}: {result}")
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
return "Failed to update m3u account, task may already be running"
|
||||
except:
|
||||
return "Failed to update m3u account - download failed or other error"
|
||||
|
||||
extinf_data, groups = result
|
||||
|
||||
# XC accounts can have empty extinf_data but valid groups
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
is_xc_account = account.account_type == M3UAccount.Types.XC
|
||||
except M3UAccount.DoesNotExist:
|
||||
is_xc_account = False
|
||||
|
||||
# For XC accounts, empty extinf_data is normal at this stage
|
||||
if not extinf_data and not is_xc_account:
|
||||
logger.error(f"No streams found for non-XC account {account_id}")
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = "No streams found in M3U source"
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "parsing", 100, status="error", error="No streams found")
|
||||
except Exception as e:
|
||||
logger.error(f"Exception in refresh_m3u_groups: {str(e)}", exc_info=True)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = f"Error refreshing M3U groups: {str(e)}"
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "parsing", 100, status="error", error=f"Error refreshing M3U groups: {str(e)}")
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
return "Failed to update m3u account"
|
||||
|
||||
# Only proceed with parsing if we actually have data and no errors were encountered
|
||||
# Get account type to handle XC accounts differently
|
||||
try:
|
||||
is_xc_account = account.account_type == M3UAccount.Types.XC
|
||||
except Exception:
|
||||
is_xc_account = False
|
||||
|
||||
# Modified validation logic for different account types
|
||||
if (not groups) or (not is_xc_account and not extinf_data):
|
||||
logger.error(f"No data to process for account {account_id}")
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = "No data available for processing"
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
send_m3u_update(account_id, "parsing", 100, status="error", error="No data available for processing")
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
return "Failed to update m3u account, no data available"
|
||||
|
||||
hash_keys = CoreSettings.get_m3u_hash_key().split(",")
|
||||
|
||||
existing_groups = {group.name: group.id for group in ChannelGroup.objects.filter(
|
||||
|
|
@ -431,50 +834,166 @@ def refresh_single_m3u_account(account_id):
|
|||
m3u_account__enabled=True # Filter by the enabled flag in the join table
|
||||
)}
|
||||
|
||||
# Break into batches and process in parallel
|
||||
batches = [extinf_data[i:i + BATCH_SIZE] for i in range(0, len(extinf_data), BATCH_SIZE)]
|
||||
task_group = group(process_m3u_batch.s(account_id, batch, existing_groups, hash_keys) for batch in batches)
|
||||
try:
|
||||
# Set status to parsing
|
||||
account.status = M3UAccount.Status.PARSING
|
||||
account.save(update_fields=['status'])
|
||||
|
||||
total_batches = len(batches)
|
||||
completed_batches = 0
|
||||
logger.debug(f"Dispatched {len(batches)} parallel tasks for account_id={account_id}.")
|
||||
if account.account_type == M3UAccount.Types.STADNARD:
|
||||
# Break into batches and process in parallel
|
||||
batches = [extinf_data[i:i + BATCH_SIZE] for i in range(0, len(extinf_data), BATCH_SIZE)]
|
||||
task_group = group(process_m3u_batch.s(account_id, batch, existing_groups, hash_keys) for batch in batches)
|
||||
else:
|
||||
# For XC accounts, get the groups with their custom properties containing xc_id
|
||||
logger.info(f"Processing XC account with groups: {existing_groups}")
|
||||
|
||||
# result = task_group.apply_async()
|
||||
result = task_group.apply_async()
|
||||
# Get the ChannelGroupM3UAccount entries with their custom_properties
|
||||
channel_group_relationships = ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account=account,
|
||||
enabled=True
|
||||
).select_related('channel_group')
|
||||
|
||||
while completed_batches < total_batches:
|
||||
for async_result in result:
|
||||
if async_result.ready(): # If the task has completed
|
||||
task_result = async_result.result # The result of the task
|
||||
logger.debug(f"Task completed with result: {task_result}")
|
||||
completed_batches += 1
|
||||
filtered_groups = {}
|
||||
for rel in channel_group_relationships:
|
||||
group_name = rel.channel_group.name
|
||||
group_id = rel.channel_group.id
|
||||
|
||||
# Calculate progress
|
||||
progress = int((completed_batches / total_batches) * 100)
|
||||
# Load the custom properties with the xc_id
|
||||
try:
|
||||
custom_props = json.loads(rel.custom_properties) if rel.custom_properties else {}
|
||||
if 'xc_id' in custom_props:
|
||||
filtered_groups[group_name] = {
|
||||
'xc_id': custom_props['xc_id'],
|
||||
'channel_group_id': group_id
|
||||
}
|
||||
logger.info(f"Added group {group_name} with xc_id {custom_props['xc_id']}")
|
||||
else:
|
||||
logger.warning(f"No xc_id found in custom properties for group {group_name}")
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.error(f"Error parsing custom properties for group {group_name}: {str(e)}")
|
||||
|
||||
# Send progress update via Channels
|
||||
# Don't send 100% because we want to clean up after
|
||||
if progress == 100:
|
||||
progress = 99
|
||||
logger.info(f"Filtered {len(filtered_groups)} groups for processing: {filtered_groups}")
|
||||
|
||||
send_m3u_update(account_id, "parsing", progress)
|
||||
# Batch the groups
|
||||
filtered_groups_list = list(filtered_groups.items())
|
||||
batches = [
|
||||
dict(filtered_groups_list[i:i + 2])
|
||||
for i in range(0, len(filtered_groups_list), 2)
|
||||
]
|
||||
|
||||
# Optionally remove completed task from the group to prevent processing it again
|
||||
result.remove(async_result)
|
||||
else:
|
||||
logger.debug(f"Task is still running.")
|
||||
logger.info(f"Created {len(batches)} batches for XC processing")
|
||||
task_group = group(process_xc_category.s(account_id, batch, existing_groups, hash_keys) for batch in batches)
|
||||
|
||||
# Run cleanup
|
||||
cleanup_streams(account_id)
|
||||
send_m3u_update(account_id, "parsing", 100)
|
||||
total_batches = len(batches)
|
||||
completed_batches = 0
|
||||
streams_processed = 0 # Track total streams processed
|
||||
logger.debug(f"Dispatched {len(batches)} parallel tasks for account_id={account_id}.")
|
||||
|
||||
end_time = time.time()
|
||||
# result = task_group.apply_async()
|
||||
result = task_group.apply_async()
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed_time = end_time - start_time
|
||||
account.save(update_fields=['updated_at'])
|
||||
# Wait for all tasks to complete and collect their result IDs
|
||||
completed_task_ids = set()
|
||||
while completed_batches < total_batches:
|
||||
for async_result in result:
|
||||
if async_result.ready() and async_result.id not in completed_task_ids: # If the task has completed and we haven't counted it
|
||||
task_result = async_result.result # The result of the task
|
||||
logger.debug(f"Task completed with result: {task_result}")
|
||||
|
||||
print(f"Function took {elapsed_time} seconds to execute.")
|
||||
# Extract stream counts from result string if available
|
||||
if isinstance(task_result, str):
|
||||
try:
|
||||
created_match = re.search(r"(\d+) created", task_result)
|
||||
updated_match = re.search(r"(\d+) updated", task_result)
|
||||
|
||||
if created_match and updated_match:
|
||||
created_count = int(created_match.group(1))
|
||||
updated_count = int(updated_match.group(1))
|
||||
streams_processed += created_count + updated_count
|
||||
streams_created += created_count
|
||||
streams_updated += updated_count
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
completed_batches += 1
|
||||
completed_task_ids.add(async_result.id) # Mark this task as processed
|
||||
|
||||
# Calculate progress
|
||||
progress = int((completed_batches / total_batches) * 100)
|
||||
|
||||
# Calculate elapsed time and estimated remaining time
|
||||
current_elapsed = time.time() - start_time
|
||||
if progress > 0:
|
||||
estimated_total = (current_elapsed / progress) * 100
|
||||
time_remaining = max(0, estimated_total - current_elapsed)
|
||||
else:
|
||||
time_remaining = 0
|
||||
|
||||
# Send progress update via Channels
|
||||
# Don't send 100% because we want to clean up after
|
||||
if progress == 100:
|
||||
progress = 99
|
||||
|
||||
send_m3u_update(
|
||||
account_id,
|
||||
"parsing",
|
||||
progress,
|
||||
elapsed_time=current_elapsed,
|
||||
time_remaining=time_remaining,
|
||||
streams_processed=streams_processed
|
||||
)
|
||||
|
||||
# Optionally remove completed task from the group to prevent processing it again
|
||||
result.remove(async_result)
|
||||
else:
|
||||
logger.debug(f"Task is still running.")
|
||||
|
||||
# Ensure all database transactions are committed before cleanup
|
||||
logger.info(f"All {total_batches} tasks completed, ensuring DB transactions are committed before cleanup")
|
||||
# Force a simple DB query to ensure connection sync
|
||||
Stream.objects.filter(id=-1).exists() # This will never find anything but ensures DB sync
|
||||
|
||||
# Now run cleanup
|
||||
cleanup_streams(account_id)
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# Set status to success and update timestamp BEFORE sending the final update
|
||||
account.status = M3UAccount.Status.SUCCESS
|
||||
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}."
|
||||
)
|
||||
account.updated_at = timezone.now()
|
||||
account.save(update_fields=['status', 'last_message', 'updated_at'])
|
||||
|
||||
# Send final update with complete metrics and explicitly include success status
|
||||
send_m3u_update(
|
||||
account_id,
|
||||
"parsing",
|
||||
100,
|
||||
status="success", # Explicitly set status to success
|
||||
elapsed_time=elapsed_time,
|
||||
time_remaining=0,
|
||||
streams_processed=streams_processed,
|
||||
streams_created=streams_created,
|
||||
streams_updated=streams_updated,
|
||||
streams_deleted=streams_deleted,
|
||||
message=account.last_message
|
||||
)
|
||||
|
||||
print(f"Function took {elapsed_time} seconds to execute.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing M3U for account {account_id}: {str(e)}")
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = f"Error processing M3U: {str(e)}"
|
||||
account.save(update_fields=['status', 'last_message'])
|
||||
raise # Re-raise the exception for Celery to handle
|
||||
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
|
||||
# Aggressive garbage collection
|
||||
del existing_groups, extinf_data, groups, batches
|
||||
|
|
@ -484,16 +1003,6 @@ def refresh_single_m3u_account(account_id):
|
|||
if os.path.exists(cache_path):
|
||||
os.remove(cache_path)
|
||||
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
|
||||
# cursor = 0
|
||||
# while True:
|
||||
# cursor, keys = redis_client.scan(cursor, match=f"m3u_refresh:*", count=BATCH_SIZE)
|
||||
# if keys:
|
||||
# redis_client.delete(*keys) # Delete the matching keys
|
||||
# if cursor == 0:
|
||||
# break
|
||||
|
||||
return f"Dispatched jobs complete."
|
||||
|
||||
def send_m3u_update(account_id, action, progress, **kwargs):
|
||||
|
|
@ -505,6 +1014,17 @@ def send_m3u_update(account_id, action, progress, **kwargs):
|
|||
"action": action,
|
||||
}
|
||||
|
||||
# Add the status and message if not already in kwargs
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
if account:
|
||||
if "status" not in kwargs:
|
||||
data["status"] = account.status
|
||||
if "message" not in kwargs and account.last_message:
|
||||
data["message"] = account.last_message
|
||||
except:
|
||||
pass # If account can't be retrieved, continue without these fields
|
||||
|
||||
# Add the additional key-value pairs from kwargs
|
||||
data.update(kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,11 +31,16 @@ def generate_m3u(request, profile_name=None):
|
|||
if channel.logo:
|
||||
tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
|
||||
|
||||
# create possible gracenote id insertion
|
||||
tvc_guide_stationid = ""
|
||||
if channel.tvc_guide_stationid:
|
||||
tvc_guide_stationid = f'tvc-guide-stationid="{channel.tvc_guide_stationid}" '
|
||||
|
||||
channel_number = channel.channel_number
|
||||
|
||||
extinf_line = (
|
||||
f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" '
|
||||
f'tvg-chno="{channel_number}" group-title="{group_title}",{channel.name}\n'
|
||||
f'tvg-chno="{channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n'
|
||||
)
|
||||
|
||||
base_url = request.build_absolute_uri('/')[:-1]
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ class TSConfig(BaseConfig):
|
|||
CHANNEL_INIT_GRACE_PERIOD = 5 # How long to wait for first client after initialization (seconds)
|
||||
CLIENT_HEARTBEAT_INTERVAL = 1 # How often to send client heartbeats (seconds)
|
||||
GHOST_CLIENT_MULTIPLIER = 5.0 # How many heartbeat intervals before client considered ghost (5 would mean 5 secondsif heartbeat interval is 1)
|
||||
CLIENT_WAIT_TIMEOUT = 30 # Seconds to wait for client to connect
|
||||
|
||||
|
||||
# TS packets are 188 bytes
|
||||
# Make chunk size a multiple of TS packet size for perfect alignment
|
||||
|
|
@ -58,7 +60,7 @@ class TSConfig(BaseConfig):
|
|||
MAX_RECONNECT_ATTEMPTS = 3 # Maximum reconnects to try before switching streams
|
||||
MIN_STABLE_TIME_BEFORE_RECONNECT = 30 # Minimum seconds a stream must be stable to try reconnect
|
||||
FAILOVER_GRACE_PERIOD = 20 # Extra time (seconds) to allow for stream switching before disconnecting clients
|
||||
|
||||
URL_SWITCH_TIMEOUT = 20 # Max time allowed for a stream switch operation
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,12 +34,12 @@ class ConfigHelper:
|
|||
@staticmethod
|
||||
def channel_shutdown_delay():
|
||||
"""Get channel shutdown delay in seconds"""
|
||||
return ConfigHelper.get('CHANNEL_SHUTDOWN_DELAY', 5)
|
||||
return ConfigHelper.get('CHANNEL_SHUTDOWN_DELAY', 0)
|
||||
|
||||
@staticmethod
|
||||
def initial_behind_chunks():
|
||||
"""Get number of chunks to start behind"""
|
||||
return ConfigHelper.get('INITIAL_BEHIND_CHUNKS', 10)
|
||||
return ConfigHelper.get('INITIAL_BEHIND_CHUNKS', 4)
|
||||
|
||||
@staticmethod
|
||||
def keepalive_interval():
|
||||
|
|
@ -75,3 +75,13 @@ class ConfigHelper:
|
|||
def retry_wait_interval():
|
||||
"""Get wait interval between connection retries in seconds"""
|
||||
return ConfigHelper.get('RETRY_WAIT_INTERVAL', 0.5) # Default to 0.5 second
|
||||
|
||||
@staticmethod
|
||||
def url_switch_timeout():
|
||||
"""Get URL switch timeout in seconds (max time allowed for a stream switch operation)"""
|
||||
return ConfigHelper.get('URL_SWITCH_TIMEOUT', 20) # Default to 20 seconds
|
||||
|
||||
@staticmethod
|
||||
def failover_grace_period():
|
||||
"""Get extra time (in seconds) to allow for stream switching before disconnecting clients"""
|
||||
return ConfigHelper.get('FAILOVER_GRACE_PERIOD', 20) # Default to 20 seconds
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from .utils import create_ts_packet, get_logger
|
|||
from .redis_keys import RedisKeys
|
||||
from .utils import get_logger
|
||||
from .constants import ChannelMetadataField
|
||||
from .config_helper import ConfigHelper # Add this import
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
|
@ -95,7 +96,7 @@ class StreamGenerator:
|
|||
def _wait_for_initialization(self):
|
||||
"""Wait for channel initialization to complete, sending keepalive packets."""
|
||||
initialization_start = time.time()
|
||||
max_init_wait = getattr(Config, 'CLIENT_WAIT_TIMEOUT', 30)
|
||||
max_init_wait = ConfigHelper.client_wait_timeout()
|
||||
keepalive_interval = 0.5
|
||||
last_keepalive = 0
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
|
|
@ -156,7 +157,7 @@ class StreamGenerator:
|
|||
return False
|
||||
|
||||
# Client state tracking - use config for initial position
|
||||
initial_behind = getattr(Config, 'INITIAL_BEHIND_CHUNKS', 10)
|
||||
initial_behind = ConfigHelper.initial_behind_chunks()
|
||||
current_buffer_index = buffer.index
|
||||
self.local_index = max(0, current_buffer_index - initial_behind)
|
||||
|
||||
|
|
@ -337,8 +338,8 @@ class StreamGenerator:
|
|||
def _is_timeout(self):
|
||||
"""Check if the stream has timed out."""
|
||||
# Get a more generous timeout for stream switching
|
||||
stream_timeout = getattr(Config, 'STREAM_TIMEOUT', 10)
|
||||
failover_grace_period = getattr(Config, 'FAILOVER_GRACE_PERIOD', 20)
|
||||
stream_timeout = ConfigHelper.stream_timeout()
|
||||
failover_grace_period = ConfigHelper.failover_grace_period()
|
||||
total_timeout = stream_timeout + failover_grace_period
|
||||
|
||||
# Disconnect after long inactivity
|
||||
|
|
@ -415,7 +416,7 @@ class StreamGenerator:
|
|||
|
||||
def delayed_shutdown():
|
||||
# Use the config setting instead of hardcoded value
|
||||
shutdown_delay = getattr(Config, 'CHANNEL_SHUTDOWN_DELAY', 5)
|
||||
shutdown_delay = ConfigHelper.channel_shutdown_delay() # Use ConfigHelper
|
||||
logger.info(f"Waiting {shutdown_delay}s before checking if channel should be stopped")
|
||||
gevent.sleep(shutdown_delay) # Replace time.sleep
|
||||
|
||||
|
|
@ -436,4 +437,4 @@ def create_stream_generator(channel_id, client_id, client_ip, client_user_agent,
|
|||
Returns a function that can be passed to StreamingHttpResponse.
|
||||
"""
|
||||
generator = StreamGenerator(channel_id, client_id, client_ip, client_user_agent, channel_initializing)
|
||||
return generator.generate
|
||||
return generator.generate
|
||||
|
|
@ -37,6 +37,8 @@ class StreamManager:
|
|||
self.current_response = None
|
||||
self.current_session = None
|
||||
self.url_switching = False
|
||||
self.url_switch_start_time = 0
|
||||
self.url_switch_timeout = ConfigHelper.url_switch_timeout()
|
||||
# Store worker_id for ownership checks
|
||||
self.worker_id = worker_id
|
||||
|
||||
|
|
@ -144,6 +146,13 @@ class StreamManager:
|
|||
|
||||
# Main stream switching loop - we'll try different streams if needed
|
||||
while self.running and stream_switch_attempts <= max_stream_switches:
|
||||
# Check for stuck switching state
|
||||
if self.url_switching and time.time() - self.url_switch_start_time > self.url_switch_timeout:
|
||||
logger.warning(f"URL switching state appears stuck for channel {self.channel_id} "
|
||||
f"({time.time() - self.url_switch_start_time:.1f}s > {self.url_switch_timeout}s timeout). "
|
||||
f"Resetting switching state.")
|
||||
self._reset_url_switching_state()
|
||||
|
||||
# Check stream type before connecting
|
||||
stream_type = detect_stream_type(self.url)
|
||||
if self.transcode == False and stream_type == StreamType.HLS:
|
||||
|
|
@ -568,44 +577,49 @@ class StreamManager:
|
|||
|
||||
# CRITICAL: Set a flag to prevent immediate reconnection with old URL
|
||||
self.url_switching = True
|
||||
self.url_switch_start_time = time.time()
|
||||
|
||||
# Check which type of connection we're using and close it properly
|
||||
if self.transcode or self.socket:
|
||||
logger.debug("Closing transcode process before URL change")
|
||||
self._close_socket()
|
||||
else:
|
||||
logger.debug("Closing HTTP connection before URL change")
|
||||
self._close_connection()
|
||||
try:
|
||||
# Check which type of connection we're using and close it properly
|
||||
if self.transcode or self.socket:
|
||||
logger.debug("Closing transcode process before URL change")
|
||||
self._close_socket()
|
||||
else:
|
||||
logger.debug("Closing HTTP connection before URL change")
|
||||
self._close_connection()
|
||||
|
||||
# Update URL and reset connection state
|
||||
old_url = self.url
|
||||
self.url = new_url
|
||||
self.connected = False
|
||||
# Update URL and reset connection state
|
||||
old_url = self.url
|
||||
self.url = new_url
|
||||
self.connected = False
|
||||
|
||||
# Update stream ID if provided
|
||||
if stream_id:
|
||||
old_stream_id = self.current_stream_id
|
||||
self.current_stream_id = stream_id
|
||||
# Add stream ID to tried streams for proper tracking
|
||||
self.tried_stream_ids.add(stream_id)
|
||||
logger.info(f"Updated stream ID from {old_stream_id} to {stream_id} for channel {self.buffer.channel_id}")
|
||||
# Update stream ID if provided
|
||||
if stream_id:
|
||||
old_stream_id = self.current_stream_id
|
||||
self.current_stream_id = stream_id
|
||||
# Add stream ID to tried streams for proper tracking
|
||||
self.tried_stream_ids.add(stream_id)
|
||||
logger.info(f"Updated stream ID from {old_stream_id} to {stream_id} for channel {self.buffer.channel_id}")
|
||||
|
||||
# Reset retry counter to allow immediate reconnect
|
||||
self.retry_count = 0
|
||||
# Reset retry counter to allow immediate reconnect
|
||||
self.retry_count = 0
|
||||
|
||||
# Also reset buffer position to prevent stale data after URL change
|
||||
if hasattr(self.buffer, 'reset_buffer_position'):
|
||||
try:
|
||||
self.buffer.reset_buffer_position()
|
||||
logger.debug("Reset buffer position for clean URL switch")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reset buffer position: {e}")
|
||||
# Also reset buffer position to prevent stale data after URL change
|
||||
if hasattr(self.buffer, 'reset_buffer_position'):
|
||||
try:
|
||||
self.buffer.reset_buffer_position()
|
||||
logger.debug("Reset buffer position for clean URL switch")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to reset buffer position: {e}")
|
||||
|
||||
# Done with URL switch
|
||||
self.url_switching = False
|
||||
logger.info(f"Stream switch completed for channel {self.buffer.channel_id}")
|
||||
|
||||
return True
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error during URL update: {e}", exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
# CRITICAL FIX: Always reset the URL switching flag when done, whether successful or not
|
||||
self.url_switching = False
|
||||
logger.info(f"Stream switch completed for channel {self.buffer.channel_id}")
|
||||
|
||||
def should_retry(self) -> bool:
|
||||
"""Check if connection retry is allowed"""
|
||||
|
|
@ -684,8 +698,14 @@ class StreamManager:
|
|||
|
||||
# Don't try to reconnect if we're already switching URLs
|
||||
if self.url_switching:
|
||||
logger.info("URL switching already in progress, skipping reconnect")
|
||||
return
|
||||
# Add timeout check to prevent permanent deadlock
|
||||
if time.time() - self.url_switch_start_time > self.url_switch_timeout:
|
||||
logger.warning(f"URL switching has been in progress too long ({time.time() - self.url_switch_start_time:.1f}s), "
|
||||
f"resetting switching state and allowing reconnect")
|
||||
self._reset_url_switching_state()
|
||||
else:
|
||||
logger.info("URL switching already in progress, skipping reconnect")
|
||||
return False
|
||||
|
||||
# Close existing connection
|
||||
if self.transcode or self.socket:
|
||||
|
|
@ -1060,4 +1080,11 @@ class StreamManager:
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error trying next stream for channel {self.channel_id}: {e}", exc_info=True)
|
||||
return False
|
||||
return False
|
||||
|
||||
# Add a new helper method to safely reset the URL switching state
|
||||
def _reset_url_switching_state(self):
|
||||
"""Safely reset the URL switching state if it gets stuck"""
|
||||
self.url_switching = False
|
||||
self.url_switch_start_time = 0
|
||||
logger.info(f"Reset URL switching state for channel {self.channel_id}")
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from django.shortcuts import get_object_or_404
|
||||
from .models import UserAgent, StreamProfile, CoreSettings
|
||||
from .models import UserAgent, StreamProfile, CoreSettings, STREAM_HASH_KEY
|
||||
from .serializers import UserAgentSerializer, StreamProfileSerializer, CoreSettingsSerializer
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
|
|
@ -11,6 +11,7 @@ from drf_yasg.utils import swagger_auto_schema
|
|||
import socket
|
||||
import requests
|
||||
import os
|
||||
from core.tasks import rehash_streams
|
||||
|
||||
class UserAgentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
|
|
@ -34,6 +35,15 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
|
|||
queryset = CoreSettings.objects.all()
|
||||
serializer_class = CoreSettingsSerializer
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
response = super().update(request, *args, **kwargs)
|
||||
if instance.key == STREAM_HASH_KEY:
|
||||
if instance.value != request.data['value']:
|
||||
rehash_streams.delay(request.data['value'].split(','))
|
||||
|
||||
return response
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='get',
|
||||
operation_description="Endpoint for environment details",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from apps.epg.models import EPGSource
|
|||
from apps.m3u.tasks import refresh_single_m3u_account
|
||||
from apps.epg.tasks import refresh_epg_data
|
||||
from .models import CoreSettings
|
||||
from apps.channels.models import Stream, ChannelStream
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -249,3 +251,35 @@ def fetch_channel_stats():
|
|||
"data": {"success": True, "type": "channel_stats", "stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})}
|
||||
},
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def rehash_streams(keys):
|
||||
batch_size = 1000
|
||||
queryset = Stream.objects.all()
|
||||
|
||||
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:
|
||||
obj.delete()
|
||||
continue
|
||||
|
||||
|
||||
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()
|
||||
|
||||
obj.stream_hash = stream_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[stream_hash] = obj.id
|
||||
|
||||
logger.debug(f"Re-hashed {batch_size} streams")
|
||||
|
||||
logger.debug(f"Re-hashing complete")
|
||||
|
|
|
|||
164
core/xtream_codes.py
Normal file
164
core/xtream_codes.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import requests
|
||||
import logging
|
||||
import traceback
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Client:
|
||||
"""Xtream Codes API Client with robust error handling"""
|
||||
|
||||
def __init__(self, server_url, username, password, user_agent=None):
|
||||
self.server_url = self._normalize_url(server_url)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.user_agent = user_agent
|
||||
|
||||
# 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}
|
||||
self.server_info = None
|
||||
|
||||
def _normalize_url(self, url):
|
||||
"""Normalize server URL by removing trailing slashes and paths"""
|
||||
if not url:
|
||||
raise ValueError("Server URL cannot be empty")
|
||||
|
||||
url = url.rstrip('/')
|
||||
# Remove any path after domain - we'll construct proper API URLs
|
||||
# Split by protocol first to preserve it
|
||||
if '://' in url:
|
||||
protocol, rest = url.split('://', 1)
|
||||
domain = rest.split('/', 1)[0]
|
||||
return f"{protocol}://{domain}"
|
||||
return url
|
||||
|
||||
def _make_request(self, endpoint, params=None):
|
||||
"""Make request with detailed error handling"""
|
||||
try:
|
||||
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.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
logger.debug(f"XC API Response: {url} status code: {response.status_code}")
|
||||
|
||||
# Check for XC-specific error responses
|
||||
if isinstance(data, dict) and data.get('user_info') is None and 'error' in data:
|
||||
error_msg = f"XC API Error: {data.get('error', 'Unknown error')}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
return data
|
||||
except requests.RequestException as e:
|
||||
error_msg = f"XC API Request failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
logger.error(f"Request details: URL={url}, Params={params}")
|
||||
raise
|
||||
except ValueError as e:
|
||||
# This could be from JSON parsing or our explicit raises
|
||||
logger.error(f"XC API Invalid response: {str(e)}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"XC API Unexpected error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticate and validate server response"""
|
||||
try:
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password
|
||||
}
|
||||
|
||||
self.server_info = self._make_request(endpoint, params)
|
||||
|
||||
if not self.server_info or not self.server_info.get('user_info'):
|
||||
error_msg = "Authentication failed: Invalid response from server"
|
||||
logger.error(f"{error_msg}. Response: {self.server_info}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"XC Authentication successful for user {self.username}")
|
||||
return self.server_info
|
||||
except Exception as e:
|
||||
logger.error(f"XC Authentication failed: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_live_categories(self):
|
||||
"""Get live TV categories"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_live_categories'
|
||||
}
|
||||
|
||||
categories = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(categories, list):
|
||||
error_msg = f"Invalid categories response: {categories}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(categories)} live categories")
|
||||
logger.debug(f"Categories: {json.dumps(categories[:5])}...")
|
||||
return categories
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get live categories: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def get_live_category_streams(self, category_id):
|
||||
"""Get streams for a specific category"""
|
||||
try:
|
||||
if not self.server_info:
|
||||
self.authenticate()
|
||||
|
||||
endpoint = "player_api.php"
|
||||
params = {
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'action': 'get_live_streams',
|
||||
'category_id': category_id
|
||||
}
|
||||
|
||||
streams = self._make_request(endpoint, params)
|
||||
|
||||
if not isinstance(streams, list):
|
||||
error_msg = f"Invalid streams response for category {category_id}: {streams}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(f"Successfully retrieved {len(streams)} streams for category {category_id}")
|
||||
return streams
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get streams for category {category_id}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
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"
|
||||
|
|
@ -6,12 +6,35 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class MyWebSocketConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
await self.accept()
|
||||
self.room_name = "updates"
|
||||
await self.channel_layer.group_add(self.room_name, self.channel_name)
|
||||
try:
|
||||
await self.accept()
|
||||
self.room_name = "updates"
|
||||
await self.channel_layer.group_add(self.room_name, self.channel_name)
|
||||
# Send a connection confirmation to the client with consistent format
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'connection_established',
|
||||
'data': {
|
||||
'success': True,
|
||||
'message': 'WebSocket connection established successfully'
|
||||
}
|
||||
}))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in WebSocket connect: {str(e)}")
|
||||
# If an error occurs during connection, attempt to close
|
||||
try:
|
||||
await self.close(code=1011) # Internal server error
|
||||
except:
|
||||
pass
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
await self.channel_layer.group_discard(self.room_name, self.channel_name)
|
||||
try:
|
||||
await self.channel_layer.group_discard(self.room_name, self.channel_name)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in WebSocket disconnect: {str(e)}")
|
||||
|
||||
async def receive(self, text_data):
|
||||
data = json.loads(text_data)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,15 @@ RUN apt-get update && \
|
|||
streamlink \
|
||||
wget \
|
||||
gnupg2 \
|
||||
lsb-release && \
|
||||
lsb-release \
|
||||
libva-drm2 \
|
||||
libva-x11-2 \
|
||||
libva-dev \
|
||||
libva-wayland2 \
|
||||
vainfo \
|
||||
i965-va-driver \
|
||||
intel-media-va-driver \
|
||||
mesa-va-drivers && \
|
||||
cp /app/docker/nginx.conf /etc/nginx/sites-enabled/default && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ server {
|
|||
proxy_connect_timeout 75;
|
||||
proxy_send_timeout 300;
|
||||
proxy_read_timeout 300;
|
||||
client_max_body_size 128M; # Allow file uploads up to 128MB
|
||||
client_max_body_size 0; # Allow file uploads up to 128MB
|
||||
|
||||
# Serve Django via uWSGI
|
||||
location / {
|
||||
|
|
|
|||
|
|
@ -65,14 +65,20 @@ const App = () => {
|
|||
// Authentication check
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const loggedIn = await initializeAuth();
|
||||
if (loggedIn) {
|
||||
await initData();
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
try {
|
||||
const loggedIn = await initializeAuth();
|
||||
if (loggedIn) {
|
||||
await initData();
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auth check failed:", error);
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [initializeAuth, initData, setIsAuthenticated, logout]);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,20 +5,363 @@ import React, {
|
|||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import useStreamsStore from './store/streams';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useChannelsStore from './store/channels';
|
||||
import usePlaylistsStore from './store/playlists';
|
||||
import useEPGsStore from './store/epgs';
|
||||
import { Box, Button, Stack } from '@mantine/core';
|
||||
import { Box, Button, Stack, Alert, Group } from '@mantine/core';
|
||||
import API from './api';
|
||||
import useSettingsStore from './store/settings';
|
||||
|
||||
export const WebsocketContext = createContext([false, () => { }, null]);
|
||||
|
||||
export const WebsocketProvider = ({ children }) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [val, setVal] = useState(null);
|
||||
const ws = useRef(null);
|
||||
const reconnectTimerRef = useRef(null);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const [connectionError, setConnectionError] = useState(null);
|
||||
const maxReconnectAttempts = 5;
|
||||
const initialBackoffDelay = 1000; // 1 second initial delay
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
|
||||
// Calculate reconnection delay with exponential backoff
|
||||
const getReconnectDelay = useCallback(() => {
|
||||
return Math.min(initialBackoffDelay * Math.pow(1.5, reconnectAttempts), 30000); // max 30 seconds
|
||||
}, [reconnectAttempts]);
|
||||
|
||||
// Clear any existing reconnect timers
|
||||
const clearReconnectTimer = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Function to get WebSocket URL that works with both HTTP and HTTPS
|
||||
const getWebSocketUrl = useCallback(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.hostname;
|
||||
const appPort = window.location.port;
|
||||
|
||||
// In development mode, connect directly to the WebSocket server on port 8001
|
||||
if (env_mode === 'dev') {
|
||||
return `${protocol}//${host}:8001/ws/`;
|
||||
} else {
|
||||
// In production mode, use the same port as the main application
|
||||
// This allows nginx to handle the WebSocket forwarding
|
||||
return appPort
|
||||
? `${protocol}//${host}:${appPort}/ws/`
|
||||
: `${protocol}//${host}/ws/`;
|
||||
}
|
||||
}, [env_mode]);
|
||||
|
||||
// Function to handle websocket connection
|
||||
const connectWebSocket = useCallback(() => {
|
||||
// Clear any existing timers to avoid multiple reconnection attempts
|
||||
clearReconnectTimer();
|
||||
|
||||
// Clear old websocket if exists
|
||||
if (ws.current) {
|
||||
// Remove event handlers to prevent duplicate events
|
||||
ws.current.onclose = null;
|
||||
ws.current.onerror = null;
|
||||
ws.current.onopen = null;
|
||||
ws.current.onmessage = null;
|
||||
|
||||
try {
|
||||
ws.current.close();
|
||||
} catch (e) {
|
||||
console.warn("Error closing existing WebSocket:", e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})...`);
|
||||
|
||||
// Use the function to get the correct WebSocket URL
|
||||
const wsUrl = getWebSocketUrl();
|
||||
console.log(`Connecting to WebSocket at: ${wsUrl}`);
|
||||
|
||||
// Create new WebSocket connection
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log("WebSocket connected successfully");
|
||||
setIsReady(true);
|
||||
setConnectionError(null);
|
||||
setReconnectAttempts(0);
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error("WebSocket connection error:", error);
|
||||
|
||||
// Don't show error notification on initial page load,
|
||||
// only show it after a connection was established then lost
|
||||
if (reconnectAttempts > 0 || isReady) {
|
||||
setConnectionError("Failed to connect to WebSocket server.");
|
||||
} else {
|
||||
console.log("Initial connection attempt failed, will retry...");
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
console.warn("WebSocket connection closed", event);
|
||||
setIsReady(false);
|
||||
|
||||
// Only attempt reconnect if we haven't reached max attempts
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
const delay = getReconnectDelay();
|
||||
setConnectionError(`Connection lost. Reconnecting in ${Math.ceil(delay / 1000)} seconds...`);
|
||||
console.log(`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})...`);
|
||||
|
||||
// Store timer reference so we can cancel it if needed
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
setReconnectAttempts(prev => prev + 1);
|
||||
connectWebSocket();
|
||||
}, delay);
|
||||
} else {
|
||||
setConnectionError("Maximum reconnection attempts reached. Please reload the page.");
|
||||
console.error("Maximum reconnection attempts reached. WebSocket connection failed.");
|
||||
}
|
||||
};
|
||||
|
||||
// Message handler
|
||||
socket.onmessage = async (event) => {
|
||||
try {
|
||||
const parsedEvent = JSON.parse(event.data);
|
||||
|
||||
// Handle connection_established event
|
||||
if (parsedEvent.type === 'connection_established') {
|
||||
console.log('WebSocket connection established:', parsedEvent.data?.message);
|
||||
// Don't need to do anything else for this event type
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle standard message format for other event types
|
||||
switch (parsedEvent.data?.type) {
|
||||
case 'epg_file':
|
||||
fetchEPGs();
|
||||
notifications.show({
|
||||
title: 'EPG File Detected',
|
||||
message: `Processing ${parsedEvent.data.filename}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'm3u_file':
|
||||
fetchPlaylists();
|
||||
notifications.show({
|
||||
title: 'M3U File Detected',
|
||||
message: `Processing ${parsedEvent.data.filename}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'm3u_refresh':
|
||||
// Update the store with progress information
|
||||
setRefreshProgress(parsedEvent.data);
|
||||
|
||||
// Update the playlist status whenever we receive a status update
|
||||
// Not just when progress is 100% or status is pending_setup
|
||||
if (parsedEvent.data.status && parsedEvent.data.account) {
|
||||
const playlistsState = usePlaylistsStore.getState();
|
||||
const playlist = playlistsState.playlists.find(p => p.id === parsedEvent.data.account);
|
||||
|
||||
if (playlist) {
|
||||
// When we receive a "success" status with 100% progress, this is a completed refresh
|
||||
// So we should also update the updated_at timestamp
|
||||
const updateData = {
|
||||
...playlist,
|
||||
status: parsedEvent.data.status,
|
||||
last_message: parsedEvent.data.message || playlist.last_message
|
||||
};
|
||||
|
||||
// Update the timestamp when we complete a successful refresh
|
||||
if (parsedEvent.data.status === 'success' && parsedEvent.data.progress === 100) {
|
||||
updateData.updated_at = new Date().toISOString();
|
||||
}
|
||||
|
||||
playlistsState.updatePlaylist(updateData);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'channel_stats':
|
||||
setChannelStats(JSON.parse(parsedEvent.data.stats));
|
||||
break;
|
||||
|
||||
case 'epg_channels':
|
||||
notifications.show({
|
||||
message: 'EPG channels updated!',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
// If source_id is provided, update that specific EPG's status
|
||||
if (parsedEvent.data.source_id) {
|
||||
const epgsState = useEPGsStore.getState();
|
||||
const epg = epgsState.epgs[parsedEvent.data.source_id];
|
||||
if (epg) {
|
||||
epgsState.updateEPG({
|
||||
...epg,
|
||||
status: 'success'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fetchEPGData();
|
||||
break;
|
||||
|
||||
case 'epg_match':
|
||||
notifications.show({
|
||||
message: parsedEvent.data.message || 'EPG match is complete!',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
// Check if we have associations data and use the more efficient batch API
|
||||
if (parsedEvent.data.associations && parsedEvent.data.associations.length > 0) {
|
||||
API.batchSetEPG(parsedEvent.data.associations);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'm3u_profile_test':
|
||||
setProfilePreview(parsedEvent.data.search_preview, parsedEvent.data.result);
|
||||
break;
|
||||
|
||||
case 'recording_started':
|
||||
notifications.show({
|
||||
title: 'Recording started!',
|
||||
message: `Started recording channel ${parsedEvent.data.channel}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'recording_ended':
|
||||
notifications.show({
|
||||
title: 'Recording finished!',
|
||||
message: `Stopped recording channel ${parsedEvent.data.channel}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'epg_fetch_error':
|
||||
notifications.show({
|
||||
title: 'EPG Source Error',
|
||||
message: parsedEvent.data.message,
|
||||
color: 'orange.5',
|
||||
autoClose: 8000,
|
||||
});
|
||||
|
||||
// Update EPG status in store
|
||||
if (parsedEvent.data.source_id) {
|
||||
const epgsState = useEPGsStore.getState();
|
||||
const epg = epgsState.epgs[parsedEvent.data.source_id];
|
||||
if (epg) {
|
||||
epgsState.updateEPG({
|
||||
...epg,
|
||||
status: 'error',
|
||||
last_message: parsedEvent.data.message
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'epg_refresh':
|
||||
// Update the store with progress information
|
||||
const epgsState = useEPGsStore.getState();
|
||||
epgsState.updateEPGProgress(parsedEvent.data);
|
||||
|
||||
// If we have source_id/account info, update the EPG source status
|
||||
if (parsedEvent.data.source_id || parsedEvent.data.account) {
|
||||
const sourceId = parsedEvent.data.source_id || parsedEvent.data.account;
|
||||
const epg = epgsState.epgs[sourceId];
|
||||
|
||||
if (epg) {
|
||||
// Check for any indication of an error (either via status or error field)
|
||||
const hasError = parsedEvent.data.status === "error" ||
|
||||
!!parsedEvent.data.error ||
|
||||
(parsedEvent.data.message && parsedEvent.data.message.toLowerCase().includes("error"));
|
||||
|
||||
if (hasError) {
|
||||
// Handle error state
|
||||
const errorMessage = parsedEvent.data.error || parsedEvent.data.message || "Unknown error occurred";
|
||||
|
||||
epgsState.updateEPG({
|
||||
...epg,
|
||||
status: 'error',
|
||||
last_message: errorMessage
|
||||
});
|
||||
|
||||
// Show notification for the error
|
||||
notifications.show({
|
||||
title: 'EPG Refresh Error',
|
||||
message: errorMessage,
|
||||
color: 'red.5',
|
||||
});
|
||||
}
|
||||
// Update status on completion only if no errors
|
||||
else if (parsedEvent.data.progress === 100) {
|
||||
epgsState.updateEPG({
|
||||
...epg,
|
||||
status: parsedEvent.data.status || 'success',
|
||||
last_message: parsedEvent.data.message || epg.last_message
|
||||
});
|
||||
|
||||
// Only show success notification if we've finished parsing programs and had no errors
|
||||
if (parsedEvent.data.action === "parsing_programs") {
|
||||
notifications.show({
|
||||
title: 'EPG Processing Complete',
|
||||
message: 'EPG data has been updated successfully',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
fetchEPGData();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown websocket event type: ${parsedEvent.data?.type}`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing WebSocket message:', error, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.current = socket;
|
||||
} catch (error) {
|
||||
console.error("Error creating WebSocket connection:", error);
|
||||
setConnectionError(`WebSocket error: ${error.message}`);
|
||||
|
||||
// Schedule a reconnect if we haven't reached max attempts
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
const delay = getReconnectDelay();
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
setReconnectAttempts(prev => prev + 1);
|
||||
connectWebSocket();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
}, [reconnectAttempts, clearReconnectTimer, getReconnectDelay, getWebSocketUrl, isReady]);
|
||||
|
||||
// Initial connection and cleanup
|
||||
useEffect(() => {
|
||||
connectWebSocket();
|
||||
|
||||
return () => {
|
||||
clearReconnectTimer(); // Clear any pending reconnect timers
|
||||
|
||||
if (ws.current) {
|
||||
console.log("Closing WebSocket connection due to component unmount");
|
||||
ws.current.onclose = null; // Remove handlers to avoid reconnection
|
||||
ws.current.close();
|
||||
ws.current = null;
|
||||
}
|
||||
};
|
||||
}, [connectWebSocket, clearReconnectTimer]);
|
||||
|
||||
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
|
||||
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
|
||||
|
|
@ -28,146 +371,28 @@ export const WebsocketProvider = ({ children }) => {
|
|||
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
|
||||
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
|
||||
|
||||
const ws = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let wsUrl = `${window.location.host}/ws/`;
|
||||
if (import.meta.env.DEV) {
|
||||
wsUrl = `${window.location.hostname}:8001/ws/`;
|
||||
}
|
||||
|
||||
if (window.location.protocol.match(/https/)) {
|
||||
wsUrl = `wss://${wsUrl}`;
|
||||
} else {
|
||||
wsUrl = `ws://${wsUrl}`;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('websocket connected');
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
// Reconnection logic
|
||||
socket.onclose = () => {
|
||||
setIsReady(false);
|
||||
setTimeout(() => {
|
||||
const reconnectWs = new WebSocket(wsUrl);
|
||||
reconnectWs.onopen = () => setIsReady(true);
|
||||
}, 3000); // Attempt to reconnect every 3 seconds
|
||||
};
|
||||
|
||||
socket.onmessage = async (event) => {
|
||||
event = JSON.parse(event.data);
|
||||
switch (event.data.type) {
|
||||
case 'epg_file':
|
||||
fetchEPGs();
|
||||
notifications.show({
|
||||
title: 'EPG File Detected',
|
||||
message: `Processing ${event.data.filename}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'm3u_file':
|
||||
fetchPlaylists();
|
||||
notifications.show({
|
||||
title: 'M3U File Detected',
|
||||
message: `Processing ${event.data.filename}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'm3u_group_refresh':
|
||||
fetchChannelGroups();
|
||||
fetchPlaylists();
|
||||
|
||||
notifications.show({
|
||||
title: 'Group processing finished!',
|
||||
autoClose: 5000,
|
||||
message: (
|
||||
<Stack>
|
||||
Refresh M3U or filter out groups to pull in streams.
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
API.refreshPlaylist(event.data.account);
|
||||
setRefreshProgress(event.data.account, 0);
|
||||
}}
|
||||
>
|
||||
Refresh Now
|
||||
</Button>
|
||||
</Stack>
|
||||
),
|
||||
color: 'green.5',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'm3u_refresh':
|
||||
setRefreshProgress(event.data);
|
||||
break;
|
||||
|
||||
case 'channel_stats':
|
||||
setChannelStats(JSON.parse(event.data.stats));
|
||||
break;
|
||||
|
||||
case 'epg_channels':
|
||||
notifications.show({
|
||||
message: 'EPG channels updated!',
|
||||
color: 'green.5',
|
||||
});
|
||||
fetchEPGData();
|
||||
break;
|
||||
|
||||
case 'epg_match':
|
||||
notifications.show({
|
||||
message: event.data.message || 'EPG match is complete!',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
// Check if we have associations data and use the more efficient batch API
|
||||
if (event.data.associations && event.data.associations.length > 0) {
|
||||
API.batchSetEPG(event.data.associations);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'm3u_profile_test':
|
||||
setProfilePreview(event.data.search_preview, event.data.result);
|
||||
break;
|
||||
|
||||
case 'recording_started':
|
||||
notifications.show({
|
||||
title: 'Recording started!',
|
||||
message: `Started recording channel ${event.data.channel}`,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'recording_ended':
|
||||
notifications.show({
|
||||
title: 'Recording finished!',
|
||||
message: `Stopped recording channel ${event.data.channel}`,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown websocket event type: ${event.type}`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ret = useMemo(() => {
|
||||
return [isReady, ws.current?.send.bind(ws.current), val];
|
||||
}, [isReady, val]);
|
||||
|
||||
return (
|
||||
<WebsocketContext.Provider value={ret}>
|
||||
{connectionError && !isReady && reconnectAttempts >= maxReconnectAttempts && (
|
||||
<Alert color="red" title="WebSocket Connection Failed" style={{ position: 'fixed', bottom: 10, right: 10, zIndex: 1000, maxWidth: 350 }}>
|
||||
{connectionError}
|
||||
<Button size="xs" mt={10} onClick={() => {
|
||||
setReconnectAttempts(0);
|
||||
connectWebSocket();
|
||||
}}>
|
||||
Try Again
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
{connectionError && !isReady && reconnectAttempts < maxReconnectAttempts && reconnectAttempts > 0 && (
|
||||
<Alert color="orange" title="WebSocket Reconnecting" style={{ position: 'fixed', bottom: 10, right: 10, zIndex: 1000, maxWidth: 350 }}>
|
||||
{connectionError}
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
</WebsocketContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -347,6 +347,11 @@ export default class API {
|
|||
payload.tvg_id = null;
|
||||
}
|
||||
|
||||
// Ensure tvc_guide_stationid is included properly (not as empty string)
|
||||
if (payload.tvc_guide_stationid === '') {
|
||||
payload.tvc_guide_stationid = null;
|
||||
}
|
||||
|
||||
// Handle channel_number properly
|
||||
if (payload.channel_number === '') {
|
||||
payload.channel_number = null;
|
||||
|
|
@ -373,6 +378,31 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async updateChannels(ids, values) {
|
||||
const body = [];
|
||||
for (const id in ids) {
|
||||
body.push({
|
||||
id,
|
||||
...values,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/channels/channels/edit/bulk/`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
useChannelsStore.getState().updateChannels(response);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update channels', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async setChannelEPG(channelId, epgDataId) {
|
||||
try {
|
||||
const response = await request(
|
||||
|
|
@ -403,16 +433,13 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async assignChannelNumbers(channelIds) {
|
||||
static async assignChannelNumbers(channelIds, startingNum = 1) {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/channels/assign/`, {
|
||||
method: 'POST',
|
||||
body: { channel_order: channelIds },
|
||||
body: { channel_ids: channelIds, starting_number: startingNum },
|
||||
});
|
||||
|
||||
// Optionally refesh the channel list in Zustand
|
||||
// await useChannelsStore.getState().fetchChannels();
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to assign channel #s', e);
|
||||
|
|
@ -655,6 +682,10 @@ export default class API {
|
|||
}
|
||||
|
||||
static async addPlaylist(values) {
|
||||
if (values.custom_properties) {
|
||||
values.custom_properties = JSON.stringify(values.custom_properties);
|
||||
}
|
||||
|
||||
try {
|
||||
let body = null;
|
||||
if (values.file) {
|
||||
|
|
@ -719,10 +750,26 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async updatePlaylist(values) {
|
||||
static async updatePlaylist(values, isToggle = false) {
|
||||
const { id, ...payload } = values;
|
||||
|
||||
try {
|
||||
// If this is just toggling the active state, make a simpler request
|
||||
if (
|
||||
isToggle &&
|
||||
'is_active' in payload &&
|
||||
Object.keys(payload).length === 1
|
||||
) {
|
||||
const response = await request(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: { is_active: payload.is_active },
|
||||
});
|
||||
|
||||
usePlaylistsStore.getState().updatePlaylist(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Original implementation for full updates
|
||||
let body = null;
|
||||
if (payload.file) {
|
||||
delete payload.server_url;
|
||||
|
|
@ -740,6 +787,7 @@ export default class API {
|
|||
body = { ...payload };
|
||||
delete body.file;
|
||||
}
|
||||
console.log(body);
|
||||
|
||||
const response = await request(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'PATCH',
|
||||
|
|
@ -803,10 +851,26 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async updateEPG(values) {
|
||||
static async updateEPG(values, isToggle = false) {
|
||||
const { id, ...payload } = values;
|
||||
|
||||
try {
|
||||
// If this is just toggling the active state, make a simpler request
|
||||
if (
|
||||
isToggle &&
|
||||
'is_active' in payload &&
|
||||
Object.keys(payload).length === 1
|
||||
) {
|
||||
const response = await request(`${host}/api/epg/sources/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: { is_active: payload.is_active },
|
||||
});
|
||||
|
||||
useEPGsStore.getState().updateEPG(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Original implementation for full updates
|
||||
let body = null;
|
||||
if (payload.files) {
|
||||
body = new FormData();
|
||||
|
|
@ -1246,10 +1310,13 @@ export default class API {
|
|||
|
||||
static async switchStream(channelId, streamId) {
|
||||
try {
|
||||
const response = await request(`${host}/proxy/ts/change_stream/${channelId}`, {
|
||||
method: 'POST',
|
||||
body: { stream_id: streamId },
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/proxy/ts/change_stream/${channelId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { stream_id: streamId },
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -1260,10 +1327,13 @@ export default class API {
|
|||
|
||||
static async nextStream(channelId, streamId) {
|
||||
try {
|
||||
const response = await request(`${host}/proxy/ts/next_stream/${channelId}`, {
|
||||
method: 'POST',
|
||||
body: { stream_id: streamId },
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/proxy/ts/next_stream/${channelId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { stream_id: streamId },
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -1301,4 +1371,16 @@ export default class API {
|
|||
errorNotification('Failed to update channel EPGs', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getChannel(id) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/channels/channels/${id}/?include_streams=true`
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch channel details', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
frontend/src/components/ConfirmationDialog.jsx
Normal file
77
frontend/src/components/ConfirmationDialog.jsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Modal, Group, Text, Button, Checkbox, Box } from '@mantine/core';
|
||||
import React, { useState } from 'react';
|
||||
import useWarningsStore from '../store/warnings';
|
||||
|
||||
/**
|
||||
* A reusable confirmation dialog with option to suppress future warnings
|
||||
*
|
||||
* @param {Object} props - Component props
|
||||
* @param {boolean} props.opened - Whether the dialog is visible
|
||||
* @param {Function} props.onClose - Function to call when closing without confirming
|
||||
* @param {Function} props.onConfirm - Function to call when confirming the action
|
||||
* @param {string} props.title - Dialog title
|
||||
* @param {string} props.message - Dialog message
|
||||
* @param {string} props.confirmLabel - Text for the confirm button
|
||||
* @param {string} props.cancelLabel - Text for the cancel button
|
||||
* @param {string} props.actionKey - Unique key for this type of action (used for suppression)
|
||||
* @param {Function} props.onSuppressChange - Called when "don't show again" option changes
|
||||
* @param {string} [props.size='md'] - Size of the modal
|
||||
*/
|
||||
const ConfirmationDialog = ({
|
||||
opened,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = 'Confirm Action',
|
||||
message = 'Are you sure you want to proceed?',
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
actionKey,
|
||||
onSuppressChange,
|
||||
size = 'md', // Add default size parameter - md is a medium width
|
||||
}) => {
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const [suppressChecked, setSuppressChecked] = useState(
|
||||
isWarningSuppressed(actionKey)
|
||||
);
|
||||
|
||||
const handleToggleSuppress = (e) => {
|
||||
setSuppressChecked(e.currentTarget.checked);
|
||||
if (onSuppressChange) {
|
||||
onSuppressChange(e.currentTarget.checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (suppressChecked) {
|
||||
suppressWarning(actionKey);
|
||||
}
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} size={size} centered>
|
||||
<Box mb={20}>{message}</Box>
|
||||
|
||||
{actionKey && (
|
||||
<Checkbox
|
||||
label="Don't ask me again"
|
||||
checked={suppressChecked}
|
||||
onChange={handleToggleSuppress}
|
||||
mb={20}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationDialog;
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// frontend/src/components/FloatingVideo.js
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Draggable from 'react-draggable';
|
||||
import useVideoStore from '../store/useVideoStore';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { CloseButton, Flex } from '@mantine/core';
|
||||
import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
|
||||
|
||||
export default function FloatingVideo() {
|
||||
const isVisible = useVideoStore((s) => s.isVisible);
|
||||
|
|
@ -12,40 +12,150 @@ export default function FloatingVideo() {
|
|||
const videoRef = useRef(null);
|
||||
const playerRef = useRef(null);
|
||||
const videoContainerRef = useRef(null);
|
||||
// Convert ref to state so we can use it for rendering
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
|
||||
// Safely destroy the player to prevent errors
|
||||
const safeDestroyPlayer = () => {
|
||||
try {
|
||||
if (playerRef.current) {
|
||||
// Set loading to false when destroying player
|
||||
setIsLoading(false);
|
||||
setLoadError(null);
|
||||
|
||||
// First unload the source to stop any in-progress fetches
|
||||
if (videoRef.current) {
|
||||
// Remove src attribute and force a load to clear any pending requests
|
||||
videoRef.current.removeAttribute('src');
|
||||
videoRef.current.load();
|
||||
}
|
||||
|
||||
// Pause the player first
|
||||
try {
|
||||
playerRef.current.pause();
|
||||
} catch (e) {
|
||||
// Ignore pause errors
|
||||
}
|
||||
|
||||
// Use a try-catch block specifically for the destroy call
|
||||
try {
|
||||
playerRef.current.destroy();
|
||||
} catch (error) {
|
||||
// Ignore expected abort errors
|
||||
if (error.name !== 'AbortError' && !error.message?.includes('aborted')) {
|
||||
console.log("Error during player destruction:", error.message);
|
||||
}
|
||||
} finally {
|
||||
playerRef.current = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error during player cleanup:", error);
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !streamUrl) {
|
||||
safeDestroyPlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
// If the browser supports MSE for live playback, initialize mpegts.js
|
||||
if (mpegts.getFeatureList().mseLivePlayback) {
|
||||
const player = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
url: streamUrl,
|
||||
isLive: true,
|
||||
// You can include other custom MPEGTS.js config fields here, e.g.:
|
||||
// cors: true,
|
||||
// withCredentials: false,
|
||||
});
|
||||
// Check if we have an existing player and clean it up
|
||||
safeDestroyPlayer();
|
||||
|
||||
player.attachMediaElement(videoRef.current);
|
||||
player.load();
|
||||
player.play();
|
||||
// Set loading state to true when starting a new stream
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
// Store player instance so we can clean up later
|
||||
playerRef.current = player;
|
||||
// Debug log to help diagnose stream issues
|
||||
console.log("Attempting to play stream:", streamUrl);
|
||||
|
||||
try {
|
||||
// If the browser supports MSE for live playback, initialize mpegts.js
|
||||
if (mpegts.getFeatureList().mseLivePlayback) {
|
||||
// Set loading flag
|
||||
setIsLoading(true);
|
||||
|
||||
const player = mpegts.createPlayer({
|
||||
type: 'mpegts', // MPEG-TS format
|
||||
url: streamUrl,
|
||||
isLive: true,
|
||||
enableWorker: true,
|
||||
enableStashBuffer: false, // Try disabling stash buffer for live streams
|
||||
liveBufferLatencyChasing: true,
|
||||
liveSync: true,
|
||||
cors: true, // Enable CORS for cross-domain requests
|
||||
// Add error recovery options
|
||||
autoCleanupSourceBuffer: true,
|
||||
autoCleanupMaxBackwardDuration: 10,
|
||||
autoCleanupMinBackwardDuration: 5,
|
||||
reuseRedirectedURL: true,
|
||||
});
|
||||
|
||||
player.attachMediaElement(videoRef.current);
|
||||
|
||||
// Add events to track loading state
|
||||
player.on(mpegts.Events.LOADING_COMPLETE, () => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
player.on(mpegts.Events.METADATA_ARRIVED, () => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Add error event handler
|
||||
player.on(mpegts.Events.ERROR, (errorType, errorDetail) => {
|
||||
setIsLoading(false);
|
||||
|
||||
// Filter out aborted errors
|
||||
if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) {
|
||||
console.error('Player error:', errorType, errorDetail);
|
||||
setLoadError(`Error: ${errorType}${errorDetail ? ` - ${errorDetail}` : ''}`);
|
||||
}
|
||||
});
|
||||
|
||||
player.load();
|
||||
|
||||
// Don't auto-play until we've loaded properly
|
||||
player.on(mpegts.Events.MEDIA_INFO, () => {
|
||||
setIsLoading(false);
|
||||
try {
|
||||
player.play().catch(e => {
|
||||
console.log("Auto-play prevented:", e);
|
||||
setLoadError("Auto-play was prevented. Click play to start.");
|
||||
});
|
||||
} catch (e) {
|
||||
console.log("Error during play:", e);
|
||||
setLoadError(`Playback error: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Store player instance so we can clean up later
|
||||
playerRef.current = player;
|
||||
}
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
setLoadError(`Initialization error: ${error.message}`);
|
||||
console.error("Error initializing player:", error);
|
||||
}
|
||||
|
||||
// Cleanup when component unmounts or streamUrl changes
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.destroy();
|
||||
playerRef.current = null;
|
||||
}
|
||||
safeDestroyPlayer();
|
||||
};
|
||||
}, [isVisible, streamUrl]);
|
||||
|
||||
// Modified hideVideo handler to clean up player first
|
||||
const handleClose = () => {
|
||||
safeDestroyPlayer();
|
||||
// Small delay before hiding the video component to ensure cleanup is complete
|
||||
setTimeout(() => {
|
||||
hideVideo();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// If the floating video is hidden or no URL is selected, do not render
|
||||
if (!isVisible || !streamUrl) {
|
||||
return null;
|
||||
|
|
@ -69,15 +179,66 @@ export default function FloatingVideo() {
|
|||
>
|
||||
{/* Simple header row with a close button */}
|
||||
<Flex justify="flex-end" style={{ padding: 3 }}>
|
||||
<CloseButton onClick={hideVideo} />
|
||||
<CloseButton onClick={handleClose} />
|
||||
</Flex>
|
||||
|
||||
{/* The <video> element used by mpegts.js */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
style={{ width: '100%', height: '180px', backgroundColor: '#000' }}
|
||||
/>
|
||||
{/* Video container with relative positioning for the overlay */}
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{/* The <video> element used by mpegts.js */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
style={{ width: '100%', height: '180px', backgroundColor: '#000' }}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Loader color="cyan" size="md" />
|
||||
<Text color="white" size="sm" mt={10}>
|
||||
Loading stream...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error message overlay */}
|
||||
{!isLoading && loadError && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 5,
|
||||
padding: '0 10px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Text color="red" size="sm">
|
||||
{loadError}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import { IconCheck } from '@tabler/icons-react';
|
|||
import useStreamsStore from '../store/streams';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import useEPGsStore from '../store/epgs';
|
||||
import { Stack, Button, Group } from '@mantine/core';
|
||||
import API from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function M3URefreshNotification() {
|
||||
const playlists = usePlaylistsStore((s) => s.playlists);
|
||||
|
|
@ -16,6 +19,7 @@ export default function M3URefreshNotification() {
|
|||
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
|
||||
|
||||
const [notificationStatus, setNotificationStatus] = useState({});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleM3UUpdate = (data) => {
|
||||
if (
|
||||
|
|
@ -24,18 +28,86 @@ export default function M3URefreshNotification() {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
const playlist = playlists.find((pl) => pl.id == data.account);
|
||||
if (!playlist) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the updated status first
|
||||
setNotificationStatus({
|
||||
...notificationStatus,
|
||||
[data.account]: data,
|
||||
});
|
||||
|
||||
// Special handling for pending setup status
|
||||
if (data.status === "pending_setup") {
|
||||
fetchChannelGroups();
|
||||
fetchPlaylists();
|
||||
|
||||
notifications.show({
|
||||
title: `M3U Setup: ${playlist.name}`,
|
||||
message: (
|
||||
<Stack>
|
||||
{data.message || "M3U groups loaded. Please select groups or refresh M3U to complete setup."}
|
||||
<Group grow>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
API.refreshPlaylist(data.account);
|
||||
}}
|
||||
>
|
||||
Refresh Now
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Store the ID we want to edit in the store first
|
||||
usePlaylistsStore.getState().setEditPlaylistId(data.account);
|
||||
|
||||
// Then navigate to the content sources page
|
||||
// Using the exact path that matches your app's routing structure
|
||||
navigate('/sources');
|
||||
}}
|
||||
>
|
||||
Edit Groups
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
),
|
||||
color: 'orange.5',
|
||||
autoClose: 5000, // Keep visible a bit longer
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for error status FIRST before doing anything else
|
||||
if (data.status === "error") {
|
||||
// Only show the error notification if we have a complete task (progress=100)
|
||||
// or if it's explicitly flagged as an error
|
||||
if (data.progress === 100) {
|
||||
notifications.show({
|
||||
title: `M3U Processing: ${playlist.name}`,
|
||||
message: `${data.action || 'Processing'} failed: ${data.error || "Unknown error"}`,
|
||||
color: 'red',
|
||||
autoClose: 5000, // Keep error visible a bit longer
|
||||
});
|
||||
}
|
||||
return; // Exit early for any error status
|
||||
}
|
||||
|
||||
// Check if we already have an error stored for this account, and if so, don't show further notifications
|
||||
const currentStatus = notificationStatus[data.account];
|
||||
if (currentStatus && currentStatus.status === "error") {
|
||||
// Don't show any other notifications once we've hit an error
|
||||
return;
|
||||
}
|
||||
|
||||
const taskProgress = data.progress;
|
||||
|
||||
// Only show start and completion notifications for normal operation
|
||||
if (data.progress != 0 && data.progress != 100) {
|
||||
console.log('not 0 or 100');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +131,7 @@ export default function M3URefreshNotification() {
|
|||
} else if (taskProgress == 100) {
|
||||
message = `${message} complete!`;
|
||||
|
||||
// Only trigger additional fetches on successful completion
|
||||
if (data.action == 'parsing') {
|
||||
fetchStreams();
|
||||
} else if (data.action == 'processing_groups') {
|
||||
|
|
@ -79,6 +152,18 @@ export default function M3URefreshNotification() {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Reset notificationStatus when playlists change to prevent stale data
|
||||
if (playlists.length > 0 && Object.keys(notificationStatus).length > 0) {
|
||||
const validIds = playlists.map(p => p.id);
|
||||
const currentIds = Object.keys(notificationStatus).map(Number);
|
||||
|
||||
// If we have notification statuses for playlists that no longer exist, reset the state
|
||||
if (!currentIds.every(id => validIds.includes(id))) {
|
||||
setNotificationStatus({});
|
||||
}
|
||||
}
|
||||
|
||||
// Process all refresh progress updates
|
||||
Object.values(refreshProgress).map((data) => handleM3UUpdate(data));
|
||||
}, [playlists, refreshProgress]);
|
||||
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
label="Public IP"
|
||||
ref={publicIPRef}
|
||||
value={environment.public_ip}
|
||||
readOnly={true}
|
||||
leftSection={
|
||||
environment.country_code && (
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import useEPGsStore from '../../store/epgs';
|
|||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
const Channel = ({ channel = null, isOpen, onClose }) => {
|
||||
const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const listRef = useRef(null);
|
||||
|
|
@ -59,7 +59,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
||||
const [epgPopoverOpened, setEpgPopoverOpened] = useState(false);
|
||||
const [logoPopoverOpened, setLogoPopoverOpened] = useState(false);
|
||||
const [selectedEPG, setSelectedEPG] = useState({});
|
||||
const [selectedEPG, setSelectedEPG] = useState('');
|
||||
const [tvgFilter, setTvgFilter] = useState('');
|
||||
const [logoFilter, setLogoFilter] = useState('');
|
||||
const [logoOptions, setLogoOptions] = useState([]);
|
||||
|
|
@ -94,10 +94,11 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
channel_number: 0,
|
||||
channel_group_id: Object.keys(channelGroups)[0],
|
||||
channel_number: '', // Change from 0 to empty string for consistency
|
||||
channel_group_id: Object.keys(channelGroups).length > 0 ? Object.keys(channelGroups)[0] : '',
|
||||
stream_profile_id: '0',
|
||||
tvg_id: '',
|
||||
tvc_guide_stationid: '',
|
||||
epg_data_id: '',
|
||||
logo_id: '',
|
||||
},
|
||||
|
|
@ -122,6 +123,9 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
// Ensure tvg_id is properly included (no empty strings)
|
||||
formattedValues.tvg_id = formattedValues.tvg_id || null;
|
||||
|
||||
// Ensure tvc_guide_stationid is properly included (no empty strings)
|
||||
formattedValues.tvc_guide_stationid = formattedValues.tvc_guide_stationid || null;
|
||||
|
||||
if (channel) {
|
||||
// If there's an EPG to set, use our enhanced endpoint
|
||||
if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
|
||||
|
|
@ -173,25 +177,26 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
useEffect(() => {
|
||||
if (channel) {
|
||||
if (channel.epg_data_id) {
|
||||
const epgSource = epgs[tvgsById[channel.epg_data_id].epg_source];
|
||||
setSelectedEPG(`${epgSource.id}`);
|
||||
const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
|
||||
setSelectedEPG(epgSource ? `${epgSource.id}` : '');
|
||||
}
|
||||
|
||||
formik.setValues({
|
||||
name: channel.name,
|
||||
channel_number: channel.channel_number,
|
||||
name: channel.name || '',
|
||||
channel_number: channel.channel_number !== null ? channel.channel_number : '',
|
||||
channel_group_id: channel.channel_group_id
|
||||
? `${channel.channel_group_id}`
|
||||
: '',
|
||||
stream_profile_id: channel.stream_profile_id
|
||||
? `${channel.stream_profile_id}`
|
||||
: '0',
|
||||
tvg_id: channel.tvg_id,
|
||||
tvg_id: channel.tvg_id || '',
|
||||
tvc_guide_stationid: channel.tvc_guide_stationid || '',
|
||||
epg_data_id: channel.epg_data_id ?? '',
|
||||
logo_id: `${channel.logo_id}`,
|
||||
logo_id: channel.logo_id ? `${channel.logo_id}` : '',
|
||||
});
|
||||
|
||||
setChannelStreams(channel.streams);
|
||||
setChannelStreams(channel.streams || []);
|
||||
} else {
|
||||
formik.resetForm();
|
||||
setTvgFilter('');
|
||||
|
|
@ -339,6 +344,20 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
// },
|
||||
// });
|
||||
|
||||
// Update the handler for when channel group modal is closed
|
||||
const handleChannelGroupModalClose = (newGroup) => {
|
||||
setChannelGroupModalOpen(false);
|
||||
|
||||
// If a new group was created and returned, update the form with it
|
||||
if (newGroup && newGroup.id) {
|
||||
// Preserve all current form values while updating just the channel_group_id
|
||||
formik.setValues({
|
||||
...formik.values,
|
||||
channel_group_id: `${newGroup.id}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
|
@ -565,7 +584,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
height={200} // Set max height for visible items
|
||||
itemCount={filteredLogos.length}
|
||||
itemSize={20} // Adjust row height for each item
|
||||
width="100%"
|
||||
style={{ width: '100%' }}
|
||||
ref={logoListRef}
|
||||
>
|
||||
{({ index, style }) => (
|
||||
|
|
@ -660,6 +679,16 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
size="xs"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="tvc_guide_stationid"
|
||||
name="tvc_guide_stationid"
|
||||
label="Gracenote StationId"
|
||||
value={formik.values.tvc_guide_stationid}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.tvc_guide_stationid ? formik.touched.tvc_guide_stationid : ''}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<Popover
|
||||
opened={epgPopoverOpened}
|
||||
onChange={setEpgPopoverOpened}
|
||||
|
|
@ -743,7 +772,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
height={200} // Set max height for visible items
|
||||
itemCount={filteredTvgs.length}
|
||||
itemSize={40} // Adjust row height for each item
|
||||
width="100%"
|
||||
style={{ width: '100%' }}
|
||||
ref={listRef}
|
||||
>
|
||||
{({ index, style }) => (
|
||||
|
|
@ -752,7 +781,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
key={filteredTvgs[index].id}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
fullWidth
|
||||
style={{ width: '100%' }}
|
||||
justify="left"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
|
|
@ -804,10 +833,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
<ChannelGroupForm
|
||||
isOpen={channelGroupModelOpen}
|
||||
onClose={() => setChannelGroupModalOpen(false)}
|
||||
onClose={handleChannelGroupModalClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channel;
|
||||
export default ChannelForm;
|
||||
|
|
|
|||
|
|
@ -18,13 +18,16 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
|
|||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
let newGroup;
|
||||
|
||||
if (channelGroup) {
|
||||
await API.updateChannelGroup({ id: channelGroup.id, ...values });
|
||||
newGroup = await API.updateChannelGroup({ id: channelGroup.id, ...values });
|
||||
} else {
|
||||
await API.addChannelGroup(values);
|
||||
newGroup = await API.addChannelGroup(values);
|
||||
}
|
||||
|
||||
return form.reset();
|
||||
form.reset();
|
||||
onClose(newGroup); // Pass the new/updated group back to parent
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this EPG source"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
|
|
@ -94,6 +95,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
description="Direct URL to the XMLTV file or API endpoint"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
|
|
@ -102,6 +104,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
description="API key for services that require authentication (like Schedules Direct)"
|
||||
{...form.getInputProps('api_key')}
|
||||
key={form.key('api_key')}
|
||||
/>
|
||||
|
|
@ -110,6 +113,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
description="Format of the EPG data source"
|
||||
{...form.getInputProps('source_type')}
|
||||
key={form.key('source_type')}
|
||||
data={[
|
||||
|
|
@ -126,15 +130,26 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description={<>How often to automatically refresh EPG data<br />
|
||||
(0 to disable automatic refreshes)</>}
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
label="Is Active"
|
||||
description="Enable or disable this EPG source"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={form.submiting}
|
||||
disabled={form.submitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
|
|
|
|||
|
|
@ -31,16 +31,6 @@ const LoginForm = () => {
|
|||
navigate('/channels'); // Or any other route you'd like
|
||||
};
|
||||
|
||||
// // Handle form submission
|
||||
// const handleSubmit = async (e) => {
|
||||
// e.preventDefault();
|
||||
// setLoading(true);
|
||||
// setError(''); // Reset error on each new submission
|
||||
|
||||
// await login(username, password)
|
||||
// navigate('/channels'); // Or any other route you'd like
|
||||
// };
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
|
|
@ -73,8 +63,8 @@ const LoginForm = () => {
|
|||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" size="sm" sx={{ pt: 1 }}>
|
||||
Submit
|
||||
<Button type="submit" mt="sm">
|
||||
Login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -18,30 +18,35 @@ import {
|
|||
Stack,
|
||||
Group,
|
||||
Switch,
|
||||
Box,
|
||||
PasswordInput,
|
||||
} from '@mantine/core';
|
||||
import M3UGroupFilter from './M3UGroupFilter';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
||||
const M3U = ({
|
||||
m3uAccount = null,
|
||||
isOpen,
|
||||
onClose,
|
||||
playlistCreated = false,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const userAgents = useUserAgentsStore((s) => s.userAgents);
|
||||
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
|
||||
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
|
||||
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
|
||||
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState('');
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
console.log(file);
|
||||
if (file) {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
const [showCredentialFields, setShowCredentialFields] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
|
|
@ -52,6 +57,11 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
is_active: true,
|
||||
max_streams: 0,
|
||||
refresh_interval: 24,
|
||||
account_type: 'STD',
|
||||
create_epg: false,
|
||||
username: '',
|
||||
password: '',
|
||||
stale_stream_days: 7,
|
||||
},
|
||||
|
||||
validate: {
|
||||
|
|
@ -62,22 +72,47 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (playlist) {
|
||||
console.log(m3uAccount);
|
||||
if (m3uAccount) {
|
||||
setPlaylist(m3uAccount);
|
||||
form.setValues({
|
||||
name: playlist.name,
|
||||
server_url: playlist.server_url,
|
||||
max_streams: playlist.max_streams,
|
||||
user_agent: playlist.user_agent ? `${playlist.user_agent}` : '0',
|
||||
is_active: playlist.is_active,
|
||||
refresh_interval: playlist.refresh_interval,
|
||||
name: m3uAccount.name,
|
||||
server_url: m3uAccount.server_url,
|
||||
max_streams: m3uAccount.max_streams,
|
||||
user_agent: m3uAccount.user_agent ? `${m3uAccount.user_agent}` : '0',
|
||||
is_active: m3uAccount.is_active,
|
||||
refresh_interval: m3uAccount.refresh_interval,
|
||||
account_type: m3uAccount.account_type,
|
||||
username: m3uAccount.username ?? '',
|
||||
password: '',
|
||||
stale_stream_days: m3uAccount.stale_stream_days || 7,
|
||||
});
|
||||
|
||||
if (m3uAccount.account_type == 'XC') {
|
||||
setShowCredentialFields(true);
|
||||
} else {
|
||||
setShowCredentialFields(false);
|
||||
}
|
||||
} else {
|
||||
setPlaylist(null);
|
||||
form.reset();
|
||||
}
|
||||
}, [playlist]);
|
||||
}, [m3uAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.values.account_type == 'XC') {
|
||||
setShowCredentialFields(true);
|
||||
}
|
||||
}, [form.values.account_type]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const { create_epg, ...values } = form.getValues();
|
||||
|
||||
if (values.account_type == 'XC' && values.password == '') {
|
||||
// If account XC and no password input, assuming no password change
|
||||
// from previously stored value.
|
||||
delete values.password;
|
||||
}
|
||||
|
||||
if (values.user_agent == '0') {
|
||||
values.user_agent = null;
|
||||
|
|
@ -96,15 +131,37 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
file,
|
||||
});
|
||||
|
||||
notifications.show({
|
||||
title: 'Fetching M3U Groups',
|
||||
message: 'Filter out groups or refresh M3U once complete.',
|
||||
// color: 'green.5',
|
||||
});
|
||||
if (create_epg) {
|
||||
API.addEPG({
|
||||
name: values.name,
|
||||
source_type: 'xmltv',
|
||||
url: `${values.server_url}/xmltv.php?username=${values.username}&password=${values.password}`,
|
||||
api_key: '',
|
||||
is_active: true,
|
||||
refresh_interval: 24,
|
||||
});
|
||||
}
|
||||
|
||||
// Don't prompt for group filters, but keeping this here
|
||||
// in case we want to revive it
|
||||
newPlaylist = null;
|
||||
if (values.account_type != 'XC') {
|
||||
notifications.show({
|
||||
title: 'Fetching M3U Groups',
|
||||
message: 'Filter out groups or refresh M3U once complete.',
|
||||
// color: 'green.5',
|
||||
});
|
||||
|
||||
// Don't prompt for group filters, but keeping this here
|
||||
// in case we want to revive it
|
||||
newPlaylist = null;
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPlaylist = await API.getPlaylist(newPlaylist.id);
|
||||
await Promise.all([fetchChannelGroups(), fetchPlaylists(), fetchEPGs()]);
|
||||
console.log('opening group options');
|
||||
setPlaylist(updatedPlaylist);
|
||||
setGroupFilterModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
|
@ -112,13 +169,16 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
onClose(newPlaylist);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
form.reset();
|
||||
setFile(null);
|
||||
setPlaylist(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const closeGroupFilter = () => {
|
||||
setGroupFilterModalOpen(false);
|
||||
if (playlistCreated) {
|
||||
form.reset();
|
||||
setFile(null);
|
||||
onClose();
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -132,7 +192,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal size={700} opened={isOpen} onClose={onClose} title="M3U Account">
|
||||
<Modal size={700} opened={isOpen} onClose={close} title="M3U Account">
|
||||
<LoadingOverlay
|
||||
visible={form.submitting}
|
||||
overlayBlur={2}
|
||||
|
|
@ -143,41 +203,99 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
<Group justify="space-between" align="top">
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
fullWidth
|
||||
style={{ width: '100%' }}
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this M3U account"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
fullWidth
|
||||
style={{ width: '100%' }}
|
||||
id="server_url"
|
||||
name="server_url"
|
||||
label="URL"
|
||||
description="Direct URL to the M3U playlist or server"
|
||||
{...form.getInputProps('server_url')}
|
||||
key={form.key('server_url')}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
id="file"
|
||||
label="Upload files"
|
||||
placeholder="Upload files"
|
||||
// value={formik.file}
|
||||
onChange={handleFileChange}
|
||||
<Select
|
||||
id="account_type"
|
||||
name="account_type"
|
||||
label="Account Type"
|
||||
description="Standard for direct M3U URLs, Xtream Codes for panel-based services"
|
||||
data={[
|
||||
{
|
||||
value: 'STD',
|
||||
label: 'Standard',
|
||||
},
|
||||
{
|
||||
value: 'XC',
|
||||
label: 'XTream Codes',
|
||||
},
|
||||
]}
|
||||
key={form.key('account_type')}
|
||||
{...form.getInputProps('account_type')}
|
||||
/>
|
||||
|
||||
{form.getValues().account_type == 'XC' && (
|
||||
<Box>
|
||||
{!m3uAccount && (
|
||||
<Group justify="space-between">
|
||||
<Box>Create EPG</Box>
|
||||
<Switch
|
||||
id="create_epg"
|
||||
name="create_epg"
|
||||
description="Automatically create matching EPG source for this Xtream account"
|
||||
key={form.key('create_epg')}
|
||||
{...form.getInputProps('create_epg', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
id="username"
|
||||
name="username"
|
||||
label="Username"
|
||||
description="Username for Xtream Codes authentication"
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
description="Password for Xtream Codes authentication (leave empty to keep existing)"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{form.getValues().account_type != 'XC' && (
|
||||
<FileInput
|
||||
id="file"
|
||||
label="Upload files"
|
||||
placeholder="Upload files"
|
||||
description="Upload a local M3U file instead of using URL"
|
||||
onChange={setFile}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
fullWidth
|
||||
style={{ width: '100%' }}
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams"
|
||||
placeholder="0 = Unlimited"
|
||||
description="Maximum number of concurrent streams (0 for unlimited)"
|
||||
{...form.getInputProps('max_streams')}
|
||||
key={form.key('max_streams')}
|
||||
/>
|
||||
|
|
@ -186,6 +304,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
description="User-Agent header to use when accessing this M3U source"
|
||||
{...form.getInputProps('user_agent')}
|
||||
key={form.key('user_agent')}
|
||||
data={[{ value: '0', label: '(use default)' }].concat(
|
||||
|
|
@ -198,12 +317,23 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description={<>How often to automatically refresh M3U data<br />
|
||||
(0 to disable automatic refreshes)</>}
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={365}
|
||||
label="Stale Stream Retention (days)"
|
||||
description="Streams not seen for this many days will be removed"
|
||||
{...form.getInputProps('stale_stream_days')}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Is Active"
|
||||
description="Enable or disable this M3U account"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
name: channelGroups[group.channel_group].name,
|
||||
}))
|
||||
);
|
||||
}, [channelGroups]);
|
||||
}, [playlist, channelGroups]);
|
||||
|
||||
const toggleGroupEnabled = (id) => {
|
||||
setGroupStates(
|
||||
|
|
@ -121,6 +121,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
.sort((a, b) => a.name > b.name)
|
||||
.map((group) => (
|
||||
<Button
|
||||
key={group.channel_group}
|
||||
color={group.enabled ? 'green' : 'gray'}
|
||||
variant="filled"
|
||||
checked={group.enabled}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
Title,
|
||||
Text,
|
||||
Paper,
|
||||
Badge,
|
||||
Grid,
|
||||
} from '@mantine/core';
|
||||
import { useWebSocket } from '../../WebSocket';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
|
|
@ -25,29 +27,46 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
const [searchPattern, setSearchPattern] = useState('');
|
||||
const [replacePattern, setReplacePattern] = useState('');
|
||||
const [debouncedPatterns, setDebouncedPatterns] = useState({});
|
||||
const [sampleInput, setSampleInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStreamUrl() {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', 1);
|
||||
params.append('page_size', 1);
|
||||
params.append('m3u_account', m3u.id);
|
||||
const response = await API.queryStreams(params);
|
||||
setStreamUrl(response.results[0].url);
|
||||
try {
|
||||
if (!m3u?.id) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', 1);
|
||||
params.append('page_size', 1);
|
||||
params.append('m3u_account', m3u.id);
|
||||
const response = await API.queryStreams(params);
|
||||
|
||||
if (response?.results?.length > 0) {
|
||||
setStreamUrl(response.results[0].url);
|
||||
setSampleInput(response.results[0].url); // Initialize sample input with a real stream URL
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching stream URL:', error);
|
||||
}
|
||||
}
|
||||
fetchStreamUrl();
|
||||
}, []);
|
||||
}, [m3u]);
|
||||
|
||||
useEffect(() => {
|
||||
sendMessage(
|
||||
JSON.stringify({
|
||||
type: 'm3u_profile_test',
|
||||
url: streamUrl,
|
||||
search: debouncedPatterns['search'] || '',
|
||||
replace: debouncedPatterns['replace'] || '',
|
||||
})
|
||||
);
|
||||
}, [m3u, debouncedPatterns, streamUrl]);
|
||||
if (!websocketReady || !streamUrl) return;
|
||||
|
||||
try {
|
||||
sendMessage(
|
||||
JSON.stringify({
|
||||
type: 'm3u_profile_test',
|
||||
url: sampleInput || streamUrl, // Use sampleInput if provided, otherwise use streamUrl
|
||||
search: debouncedPatterns['search'] || '',
|
||||
replace: debouncedPatterns['replace'] || '',
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error sending WebSocket message:', error);
|
||||
}
|
||||
}, [websocketReady, m3u, debouncedPatterns, streamUrl, sampleInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
|
|
@ -111,8 +130,33 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
}
|
||||
}, [profile]);
|
||||
|
||||
const handleSampleInputChange = (e) => {
|
||||
setSampleInput(e.target.value);
|
||||
};
|
||||
|
||||
// Local regex testing for immediate visual feedback
|
||||
const getHighlightedSearchText = () => {
|
||||
if (!searchPattern || !sampleInput) return sampleInput;
|
||||
try {
|
||||
const regex = new RegExp(searchPattern, 'g');
|
||||
return sampleInput.replace(regex, match => `<mark style="background-color: #ffee58;">${match}</mark>`);
|
||||
} catch (e) {
|
||||
return sampleInput;
|
||||
}
|
||||
};
|
||||
|
||||
const getLocalReplaceResult = () => {
|
||||
if (!searchPattern || !sampleInput) return sampleInput;
|
||||
try {
|
||||
const regex = new RegExp(searchPattern, 'g');
|
||||
return sampleInput.replace(regex, replacePattern);
|
||||
} catch (e) {
|
||||
return sampleInput;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="M3U Profile">
|
||||
<Modal opened={isOpen} onClose={onClose} title="M3U Profile" size="lg">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
|
|
@ -158,26 +202,52 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
align="flex-end"
|
||||
style={{ marginBottom: 5 }}
|
||||
>
|
||||
<Button type="submit" disabled={formik.isSubmitting} size="xs">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formik.isSubmitting}
|
||||
size="xs"
|
||||
style={{ width: formik.isSubmitting ? 'auto' : 'auto' }}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
<Paper shadow="sm" p="md" radius="md" withBorder>
|
||||
<Text>Search</Text>
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: profileSearchPreview || streamUrl,
|
||||
}}
|
||||
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
<Title order={4} mt={15} mb={10}>Live Regex Demonstration</Title>
|
||||
|
||||
<Paper shadow="sm" p="xs" radius="md" withBorder mb={8}>
|
||||
<Text size="sm" weight={500} mb={3}>Sample Text</Text>
|
||||
<TextInput
|
||||
value={sampleInput}
|
||||
onChange={handleSampleInputChange}
|
||||
placeholder="Enter a sample URL to test with"
|
||||
size="sm"
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Text>Replace</Text>
|
||||
<Text>{profileResult || streamUrl}</Text>
|
||||
</Paper>
|
||||
<Grid gutter="xs">
|
||||
<Grid.Col span={12}>
|
||||
<Paper shadow="sm" p="xs" radius="md" withBorder>
|
||||
<Text size="sm" weight={500} mb={3}>Matched Text <Badge size="xs" color="yellow">highlighted</Badge></Text>
|
||||
<Text
|
||||
size="sm"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getHighlightedSearchText(),
|
||||
}}
|
||||
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<Paper shadow="sm" p="xs" radius="md" withBorder>
|
||||
<Text size="sm" weight={500} mb={3}>Result After Replace</Text>
|
||||
<Text size="sm" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{getLocalReplaceResult()}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,8 +29,19 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
|||
const [profiles, setProfiles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfiles(allProfiles[playlist.id]);
|
||||
}, [allProfiles]);
|
||||
try {
|
||||
// Make sure playlist exists, has an id, and profiles exist for this playlist
|
||||
if (playlist && playlist.id && allProfiles && allProfiles[playlist.id]) {
|
||||
setProfiles(allProfiles[playlist.id]);
|
||||
} else {
|
||||
// Reset profiles if none are available
|
||||
setProfiles([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting profiles:', error);
|
||||
setProfiles([]);
|
||||
}
|
||||
}, [allProfiles, playlist]);
|
||||
|
||||
const editProfile = (profile = null) => {
|
||||
if (profile) {
|
||||
|
|
@ -41,21 +52,36 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
const deleteProfile = async (id) => {
|
||||
await API.deleteM3UProfile(playlist.id, id);
|
||||
if (!playlist || !playlist.id) return;
|
||||
try {
|
||||
await API.deleteM3UProfile(playlist.id, id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting profile:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (values) => {
|
||||
await API.updateM3UProfile(playlist.id, {
|
||||
...values,
|
||||
is_active: !values.is_active,
|
||||
});
|
||||
if (!playlist || !playlist.id) return;
|
||||
try {
|
||||
await API.updateM3UProfile(playlist.id, {
|
||||
...values,
|
||||
is_active: !values.is_active,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling profile active state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const modifyMaxStreams = async (value, item) => {
|
||||
await API.updateM3UProfile(playlist.id, {
|
||||
...item,
|
||||
max_streams: value,
|
||||
});
|
||||
if (!playlist || !playlist.id) return;
|
||||
try {
|
||||
await API.updateM3UProfile(playlist.id, {
|
||||
...item,
|
||||
max_streams: value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating max streams:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
|
|
@ -63,24 +89,27 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
|||
setProfileEditorOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen || !profiles) {
|
||||
// Don't render if modal is not open, or if playlist data is invalid
|
||||
if (!isOpen || !playlist || !playlist.id) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// Make sure profiles is always an array even if we have no data
|
||||
const profilesArray = Array.isArray(profiles) ? profiles : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={onClose} title="Profiles">
|
||||
{profiles
|
||||
// .filter((playlist) => playlist.is_default == false)
|
||||
{profilesArray
|
||||
.sort((a, b) => {
|
||||
// Always put default profile first
|
||||
if (a.is_default) return -1;
|
||||
if (b.is_default) return 1;
|
||||
// Sort remaining profiles alphabetically by name
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((item) => (
|
||||
<Card
|
||||
// key={item.id}
|
||||
// sx={{
|
||||
// display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// marginBottom: 2,
|
||||
// }}
|
||||
>
|
||||
<Card key={item.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{item.name}</Text>
|
||||
|
|
@ -113,7 +142,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
|||
color={theme.tailwind.yellow[3]}
|
||||
onClick={() => editProfile(item)}
|
||||
>
|
||||
<SquarePen size="=20" />
|
||||
<SquarePen size="20" />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
|
|
@ -136,7 +165,8 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
|||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={editProfile}
|
||||
onClick={() => editProfile()}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { TextInput, Center, Button, Paper, Title, Stack } from '@mantine/core';
|
|||
import API from '../../api';
|
||||
import useAuthStore from '../../store/auth';
|
||||
|
||||
function SuperuserForm({}) {
|
||||
function SuperuserForm() {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
|
|
@ -34,11 +34,7 @@ function SuperuserForm({}) {
|
|||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
// let msg = 'Failed to create superuser.';
|
||||
// if (err.response && err.response.data && err.response.data.error) {
|
||||
// msg += ` ${err.response.data.error}`;
|
||||
// }
|
||||
// setError(msg);
|
||||
setError('Failed to create superuser.');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ const DraggableRow = ({ row, index }) => {
|
|||
}}
|
||||
>
|
||||
<Flex align="center" style={{ height: '100%' }}>
|
||||
<Text size="xs">
|
||||
<Text component="div" size="xs">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -49,13 +49,15 @@ import useLocalStorage from '../../hooks/useLocalStorage';
|
|||
import { CustomTable, useTable } from './CustomTable';
|
||||
import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding';
|
||||
import ChannelTableHeader from './ChannelsTable/ChannelTableHeader';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
|
||||
const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`;
|
||||
const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`;
|
||||
const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`;
|
||||
|
||||
const ChannelEnabledSwitch = React.memo(
|
||||
({ rowId, selectedProfileId, toggleChannelEnabled }) => {
|
||||
({ rowId, selectedProfileId, selectedTableIds }) => {
|
||||
// Directly extract the channels set once to avoid re-renders on every change.
|
||||
const isEnabled = useChannelsStore(
|
||||
useCallback(
|
||||
|
|
@ -66,9 +68,17 @@ const ChannelEnabledSwitch = React.memo(
|
|||
)
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
toggleChannelEnabled([rowId], !isEnabled);
|
||||
}, [rowId, isEnabled, toggleChannelEnabled]);
|
||||
const handleToggle = () => {
|
||||
if (selectedTableIds.length > 1) {
|
||||
API.updateProfileChannels(
|
||||
selectedTableIds,
|
||||
selectedProfileId,
|
||||
!isEnabled
|
||||
);
|
||||
} else {
|
||||
API.updateProfileChannel(rowId, selectedProfileId, !isEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
|
|
@ -93,27 +103,41 @@ const ChannelRowActions = React.memo(
|
|||
createRecording,
|
||||
getChannelURL,
|
||||
}) => {
|
||||
// Extract the channel ID once to ensure consistency
|
||||
const channelId = row.original.id;
|
||||
const channelUuid = row.original.uuid;
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const onEdit = useCallback(() => {
|
||||
// Use the ID directly to avoid issues with filtered tables
|
||||
console.log(`Editing channel ID: ${channelId}`);
|
||||
editChannel(row.original);
|
||||
}, [row.original]);
|
||||
}, [channelId, row.original]);
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
deleteChannel(row.original.id);
|
||||
}, [row.original]);
|
||||
console.log(`Deleting channel ID: ${channelId}`);
|
||||
deleteChannel(channelId);
|
||||
}, [channelId]);
|
||||
|
||||
const onPreview = useCallback(() => {
|
||||
// Use direct channel UUID for preview to avoid issues
|
||||
console.log(`Previewing channel UUID: ${channelUuid}`);
|
||||
handleWatchStream(row.original);
|
||||
}, [row.original]);
|
||||
}, [channelUuid]);
|
||||
|
||||
const onRecord = useCallback(() => {
|
||||
console.log(`Recording channel ID: ${channelId}`);
|
||||
createRecording(row.original);
|
||||
}, [row.original]);
|
||||
}, [channelId]);
|
||||
|
||||
const iconSize =
|
||||
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
|
||||
|
||||
return (
|
||||
<Box style={{ width: '100%', justifyContent: 'left' }}>
|
||||
<Center>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={onEdit}
|
||||
|
|
@ -122,7 +146,7 @@ const ChannelRowActions = React.memo(
|
|||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={onDelete}
|
||||
|
|
@ -131,7 +155,7 @@ const ChannelRowActions = React.memo(
|
|||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={onPreview}
|
||||
|
|
@ -141,7 +165,7 @@ const ChannelRowActions = React.memo(
|
|||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" size="sm">
|
||||
<ActionIcon variant="transparent" size={iconSize}>
|
||||
<EllipsisVertical size="18" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
|
@ -218,6 +242,11 @@ const ChannelsTable = ({}) => {
|
|||
// store/settings
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
// store/warnings
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
|
||||
/**
|
||||
* useMemo
|
||||
|
|
@ -248,6 +277,11 @@ const ChannelsTable = ({}) => {
|
|||
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
|
||||
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
|
||||
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||
const [channelToDelete, setChannelToDelete] = useState(null);
|
||||
|
||||
/**
|
||||
* Dereived variables
|
||||
*/
|
||||
|
|
@ -295,15 +329,28 @@ const ChannelsTable = ({}) => {
|
|||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((e) => {
|
||||
// Remove useCallback to ensure we're using the latest setPagination function
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
// First reset pagination to page 0
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
// Then update filters
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleGroupChange = (value) => {
|
||||
// First reset pagination to page 0
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
// Then update filters
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
channel_group: value ? value : '',
|
||||
|
|
@ -316,20 +363,75 @@ const ChannelsTable = ({}) => {
|
|||
};
|
||||
|
||||
const deleteChannel = async (id) => {
|
||||
console.log(`Deleting channel with ID: ${id}`);
|
||||
table.setSelectedTableIds([]);
|
||||
|
||||
if (selectedChannelIds.length > 0) {
|
||||
return deleteChannels();
|
||||
// Use bulk delete for multiple selections
|
||||
setIsBulkDelete(true);
|
||||
setChannelToDelete(null);
|
||||
|
||||
if (isWarningSuppressed('delete-channels')) {
|
||||
// Skip warning if suppressed
|
||||
return executeDeleteChannels();
|
||||
}
|
||||
|
||||
setConfirmDeleteOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single channel delete
|
||||
setIsBulkDelete(false);
|
||||
setDeleteTarget(id);
|
||||
setChannelToDelete(channels[id]); // Store the channel object for displaying details
|
||||
|
||||
if (isWarningSuppressed('delete-channel')) {
|
||||
// Skip warning if suppressed
|
||||
return executeDeleteChannel(id);
|
||||
}
|
||||
|
||||
setConfirmDeleteOpen(true);
|
||||
};
|
||||
|
||||
const executeDeleteChannel = async (id) => {
|
||||
await API.deleteChannel(id);
|
||||
API.requeryChannels();
|
||||
setConfirmDeleteOpen(false);
|
||||
};
|
||||
|
||||
const deleteChannels = async () => {
|
||||
if (isWarningSuppressed('delete-channels')) {
|
||||
// Skip warning if suppressed
|
||||
return executeDeleteChannels();
|
||||
}
|
||||
|
||||
setIsBulkDelete(true);
|
||||
setConfirmDeleteOpen(true);
|
||||
};
|
||||
|
||||
const executeDeleteChannels = async () => {
|
||||
setIsLoading(true);
|
||||
await API.deleteChannels(table.selectedTableIds);
|
||||
await API.requeryChannels();
|
||||
setSelectedChannelIds([]);
|
||||
table.setSelectedTableIds([]);
|
||||
setIsLoading(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
};
|
||||
|
||||
const createRecording = (channel) => {
|
||||
console.log(`Recording channel ID: ${channel.id}`);
|
||||
setChannel(channel);
|
||||
setRecordingModalOpen(true);
|
||||
};
|
||||
|
||||
const getChannelURL = (channel) => {
|
||||
// Make sure we're using the channel UUID consistently
|
||||
if (!channel || !channel.uuid) {
|
||||
console.error('Invalid channel object or missing UUID:', channel);
|
||||
return '';
|
||||
}
|
||||
|
||||
const uri = `/proxy/ts/stream/${channel.uuid}`;
|
||||
let channelUrl = `${window.location.protocol}//${window.location.host}${uri}`;
|
||||
if (env_mode == 'dev') {
|
||||
|
|
@ -340,7 +442,13 @@ const ChannelsTable = ({}) => {
|
|||
};
|
||||
|
||||
const handleWatchStream = (channel) => {
|
||||
showVideo(getChannelURL(channel));
|
||||
// Add additional logging to help debug issues
|
||||
console.log(
|
||||
`Watching stream for channel: ${channel.name} (${channel.id}), UUID: ${channel.uuid}`
|
||||
);
|
||||
const url = getChannelURL(channel);
|
||||
console.log(`Stream URL: ${url}`);
|
||||
showVideo(url);
|
||||
};
|
||||
|
||||
const onRowSelectionChange = (newSelection) => {
|
||||
|
|
@ -365,31 +473,6 @@ const ChannelsTable = ({}) => {
|
|||
});
|
||||
};
|
||||
|
||||
const toggleChannelEnabled = useCallback(
|
||||
async (channelIds, enabled) => {
|
||||
if (channelIds.length == 1) {
|
||||
await API.updateProfileChannel(
|
||||
channelIds[0],
|
||||
selectedProfileId,
|
||||
enabled
|
||||
);
|
||||
} else {
|
||||
await API.updateProfileChannels(channelIds, selectedProfileId, enabled);
|
||||
}
|
||||
},
|
||||
[selectedProfileId]
|
||||
);
|
||||
|
||||
// (Optional) bulk delete, but your endpoint is @TODO
|
||||
const deleteChannels = async () => {
|
||||
setIsLoading(true);
|
||||
await API.deleteChannels(table.selectedTableIds);
|
||||
await API.requeryChannels();
|
||||
setSelectedChannelIds([]);
|
||||
table.setSelectedTableIds([]);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const closeChannelForm = () => {
|
||||
setChannel(null);
|
||||
setChannelModalOpen(false);
|
||||
|
|
@ -457,22 +540,6 @@ const ChannelsTable = ({}) => {
|
|||
}
|
||||
};
|
||||
|
||||
const EnabledHeaderSwitch = useCallback(() => {
|
||||
let enabled = false;
|
||||
for (const id of selectedChannelIds) {
|
||||
if (selectedProfileChannelIds.has(id)) {
|
||||
enabled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelected = () => {
|
||||
toggleChannelEnabled(selectedChannelIds, !enabled);
|
||||
};
|
||||
|
||||
return <Switch size="xs" checked={enabled} onChange={toggleSelected} />;
|
||||
}, [selectedChannelIds, selectedProfileChannelIds, data]);
|
||||
|
||||
/**
|
||||
* useEffect
|
||||
*/
|
||||
|
|
@ -512,12 +579,12 @@ const ChannelsTable = ({}) => {
|
|||
{
|
||||
id: 'enabled',
|
||||
size: 45,
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row, table }) => {
|
||||
return (
|
||||
<ChannelEnabledSwitch
|
||||
rowId={row.original.id}
|
||||
selectedProfileId={selectedProfileId}
|
||||
toggleChannelEnabled={toggleChannelEnabled}
|
||||
selectedTableIds={table.getState().selectedTableIds}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
@ -528,7 +595,7 @@ const ChannelsTable = ({}) => {
|
|||
size: 40,
|
||||
cell: ({ getValue }) => (
|
||||
<Flex justify="flex-end" style={{ width: '100%' }}>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
|
|
@ -543,7 +610,7 @@ const ChannelsTable = ({}) => {
|
|||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
|
|
@ -561,18 +628,28 @@ const ChannelsTable = ({}) => {
|
|||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'logo',
|
||||
accessorFn: (row) => logos[row.logo_id] ?? logo,
|
||||
accessorFn: (row) => {
|
||||
// Just pass the logo_id directly, not the full logo object
|
||||
return row.logo_id;
|
||||
},
|
||||
size: 75,
|
||||
header: '',
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
const src = value?.cache_url || logo;
|
||||
const logoId = getValue();
|
||||
let src = logo; // Default fallback
|
||||
|
||||
if (logoId && logos[logoId]) {
|
||||
// Try to use cache_url if available, otherwise construct it from the ID
|
||||
src =
|
||||
logos[logoId].cache_url || `/api/channels/logos/${logoId}/cache/`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<img
|
||||
|
|
@ -586,7 +663,7 @@ const ChannelsTable = ({}) => {
|
|||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: 75,
|
||||
size: tableSize == 'compact' ? 75 : 100,
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<ChannelRowActions
|
||||
|
|
@ -601,7 +678,7 @@ const ChannelsTable = ({}) => {
|
|||
),
|
||||
},
|
||||
],
|
||||
[selectedProfileId, channelGroups, logos]
|
||||
[selectedProfileId, channelGroups, logos, theme]
|
||||
);
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
|
|
@ -616,9 +693,6 @@ const ChannelsTable = ({}) => {
|
|||
|
||||
switch (header.id) {
|
||||
case 'enabled':
|
||||
// if (selectedProfileId !== '0' && table.selectedTableIds.length > 0) {
|
||||
// return EnabledHeaderSwitch();
|
||||
// }
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<ScanEye size="16" />
|
||||
|
|
@ -710,241 +784,284 @@ const ChannelsTable = ({}) => {
|
|||
channel_group: renderHeaderCell,
|
||||
enabled: renderHeaderCell,
|
||||
},
|
||||
getRowStyles: (row) => {
|
||||
const hasStreams =
|
||||
row.original.streams && row.original.streams.length > 0;
|
||||
return hasStreams
|
||||
? {} // Default style for channels with streams
|
||||
: {
|
||||
className: 'no-streams-row', // Add a class instead of background color
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header Row: outside the Paper */}
|
||||
<Flex style={{ alignItems: 'center', paddingBottom: 10 }} gap={15}>
|
||||
<Text
|
||||
w={88}
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Channels
|
||||
</Text>
|
||||
<Flex
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: 10,
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<Box>
|
||||
{/* Header Row: outside the Paper */}
|
||||
<Flex style={{ alignItems: 'center', paddingBottom: 10 }} gap={15}>
|
||||
<Text
|
||||
w={37}
|
||||
h={17}
|
||||
w={88}
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Links:
|
||||
Channels
|
||||
</Text>
|
||||
|
||||
<Group gap={5} style={{ paddingLeft: 10 }}>
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<Tv2 size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
color="green"
|
||||
variant="subtle"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.greenMain,
|
||||
color: theme.palette.custom.greenMain,
|
||||
}}
|
||||
>
|
||||
HDHR
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput value={hdhrUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyHDHRUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<ScreenShare size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
variant="subtle"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.indigoMain,
|
||||
color: theme.palette.custom.indigoMain,
|
||||
}}
|
||||
>
|
||||
M3U
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput value={m3uUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyM3UUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<Scroll size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
variant="subtle"
|
||||
color="gray.5"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.greyBorder,
|
||||
color: theme.palette.custom.greyBorder,
|
||||
}}
|
||||
>
|
||||
EPG
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput value={epgUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyEPGUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Paper container: contains top toolbar and table (or ghost state) */}
|
||||
<Paper
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 58px)',
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
<ChannelTableHeader
|
||||
rows={rows}
|
||||
editChannel={editChannel}
|
||||
deleteChannels={deleteChannels}
|
||||
selectedTableIds={table.selectedTableIds}
|
||||
/>
|
||||
|
||||
{/* Table or ghost empty state inside Paper */}
|
||||
<Box>
|
||||
{Object.keys(channels).length === 0 && (
|
||||
<ChannelsTableOnboarding editChannel={editChannel} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{Object.keys(channels).length > 0 && (
|
||||
<Box
|
||||
<Flex
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 110px)',
|
||||
alignItems: 'center',
|
||||
marginLeft: 10,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
<Text
|
||||
w={37}
|
||||
h={17}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
border: 'solid 1px rgb(68,68,68)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '14px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
}}
|
||||
>
|
||||
<CustomTable table={table} />
|
||||
</Box>
|
||||
Links:
|
||||
</Text>
|
||||
|
||||
<Group gap={5} style={{ paddingLeft: 10 }}>
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<Tv2 size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
color="green"
|
||||
variant="subtle"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.greenMain,
|
||||
color: theme.palette.custom.greenMain,
|
||||
}}
|
||||
>
|
||||
HDHR
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput value={hdhrUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyHDHRUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<ScreenShare size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
variant="subtle"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.indigoMain,
|
||||
color: theme.palette.custom.indigoMain,
|
||||
}}
|
||||
>
|
||||
M3U
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput value={m3uUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyM3UUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<Scroll size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
variant="subtle"
|
||||
color="gray.5"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.greyBorder,
|
||||
color: theme.palette.custom.greyBorder,
|
||||
}}
|
||||
>
|
||||
EPG
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput value={epgUrl} size="small" readOnly />
|
||||
<ActionIcon
|
||||
onClick={copyEPGUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<Copy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Paper container: contains top toolbar and table (or ghost state) */}
|
||||
<Paper
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 58px)',
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
<ChannelTableHeader
|
||||
rows={rows}
|
||||
editChannel={editChannel}
|
||||
deleteChannels={deleteChannels}
|
||||
selectedTableIds={table.selectedTableIds}
|
||||
/>
|
||||
|
||||
{/* Table or ghost empty state inside Paper */}
|
||||
<Box>
|
||||
{Object.keys(channels).length === 0 && (
|
||||
<ChannelsTableOnboarding editChannel={editChannel} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{Object.keys(channels).length > 0 && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 3,
|
||||
backgroundColor: '#27272A',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 110px)',
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap={5}
|
||||
justify="center"
|
||||
<Box
|
||||
style={{
|
||||
padding: 8,
|
||||
borderTop: '1px solid #666',
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
border: 'solid 1px rgb(68,68,68)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<CustomTable table={table} />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 3,
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap={5}
|
||||
justify="center"
|
||||
style={{
|
||||
padding: 8,
|
||||
borderTop: '1px solid #666',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<ChannelForm
|
||||
channel={channel}
|
||||
isOpen={channelModalOpen}
|
||||
onClose={closeChannelForm}
|
||||
/>
|
||||
<ChannelForm
|
||||
channel={channel}
|
||||
isOpen={channelModalOpen}
|
||||
onClose={closeChannelForm}
|
||||
/>
|
||||
|
||||
<RecordingForm
|
||||
channel={channel}
|
||||
isOpen={recordingModalOpen}
|
||||
onClose={closeRecordingForm}
|
||||
<RecordingForm
|
||||
channel={channel}
|
||||
isOpen={recordingModalOpen}
|
||||
onClose={closeRecordingForm}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() =>
|
||||
isBulkDelete
|
||||
? executeDeleteChannels()
|
||||
: executeDeleteChannel(deleteTarget)
|
||||
}
|
||||
title={`Confirm ${isBulkDelete ? 'Bulk ' : ''}Channel Deletion`}
|
||||
message={
|
||||
isBulkDelete ? (
|
||||
`Are you sure you want to delete ${table.selectedTableIds.length} channels? This action cannot be undone.`
|
||||
) : channelToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to delete the following channel?
|
||||
|
||||
Name: ${channelToDelete.name}
|
||||
Channel Number: ${channelToDelete.channel_number}
|
||||
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
) : (
|
||||
'Are you sure you want to delete this channel? This action cannot be undone.'
|
||||
)
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
actionKey={isBulkDelete ? 'delete-channels' : 'delete-channel'}
|
||||
onSuppressChange={suppressWarning}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import {
|
|||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
NumberInput,
|
||||
Popover,
|
||||
Select,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
|
|
@ -14,6 +16,7 @@ import {
|
|||
import {
|
||||
ArrowDown01,
|
||||
Binary,
|
||||
Check,
|
||||
CircleCheck,
|
||||
SquareMinus,
|
||||
SquarePlus,
|
||||
|
|
@ -87,6 +90,8 @@ const ChannelTableHeader = ({
|
|||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
|
||||
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
|
||||
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
|
||||
|
|
@ -110,11 +115,11 @@ const ChannelTableHeader = ({
|
|||
|
||||
const assignChannels = async () => {
|
||||
try {
|
||||
// Get row order from the table
|
||||
const rowOrder = rows.map((row) => row.original.id);
|
||||
|
||||
// Call our custom API endpoint
|
||||
const result = await API.assignChannelNumbers(rowOrder);
|
||||
const result = await API.assignChannelNumbers(
|
||||
selectedTableIds,
|
||||
channelNumAssignmentStart
|
||||
);
|
||||
|
||||
// We might get { message: "Channels have been auto-assigned!" }
|
||||
notifications.show({
|
||||
|
|
@ -194,15 +199,38 @@ const ChannelTableHeader = ({
|
|||
</Button>
|
||||
|
||||
<Tooltip label="Assign Channel #s">
|
||||
<Button
|
||||
leftSection={<ArrowDown01 size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={assignChannels}
|
||||
p={5}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<ArrowDown01 size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
p={5}
|
||||
disabled={selectedTableIds.length == 0}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<Text>Start #</Text>
|
||||
<NumberInput
|
||||
value={channelNumAssignmentStart}
|
||||
onChange={setChannelNumAssignmentStart}
|
||||
size="small"
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
color={theme.tailwind.green[5]}
|
||||
variant="transparent"
|
||||
onClick={assignChannels}
|
||||
>
|
||||
<Check />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Auto-Match EPG">
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { useCallback, useState, useRef } from 'react';
|
|||
import { flexRender } from '@tanstack/react-table';
|
||||
import table from '../../../helpers/table';
|
||||
import CustomTableBody from './CustomTableBody';
|
||||
import useLocalStorage from '../../../hooks/useLocalStorage';
|
||||
|
||||
const CustomTable = ({ table }) => {
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="divTable table-striped"
|
||||
className={`divTable table-striped table-size-${tableSize}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
|
|
@ -32,6 +35,7 @@ const CustomTable = ({ table }) => {
|
|||
expandedRowRenderer={table.expandedRowRenderer}
|
||||
renderBodyCell={table.renderBodyCell}
|
||||
getExpandedRowHeight={table.getExpandedRowHeight}
|
||||
getRowStyles={table.getRowStyles} // Pass the getRowStyles function
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const CustomTableBody = ({
|
|||
expandedRowRenderer,
|
||||
renderBodyCell,
|
||||
getExpandedRowHeight,
|
||||
getRowStyles, // Add this prop to receive row styles
|
||||
}) => {
|
||||
const renderExpandedRow = (row) => {
|
||||
if (expandedRowRenderer) {
|
||||
|
|
@ -72,17 +73,25 @@ const CustomTableBody = ({
|
|||
};
|
||||
|
||||
const renderTableBodyRow = (row, index, style = {}) => {
|
||||
// Get custom styles for this row if the function exists
|
||||
const customRowStyles = getRowStyles ? getRowStyles(row) : {};
|
||||
|
||||
// Extract any className from customRowStyles
|
||||
const customClassName = customRowStyles.className || '';
|
||||
delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style
|
||||
|
||||
return (
|
||||
<Box style={style} key={`row-${row.id}`}>
|
||||
<Box
|
||||
key={`tr-${row.id}`}
|
||||
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'}`}
|
||||
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'} ${customClassName}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
...(row.getIsSelected() && {
|
||||
backgroundColor: '#163632',
|
||||
}),
|
||||
...customRowStyles, // Apply the remaining custom styles here
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const useTable = ({
|
|||
expandedRowRenderer = () => <></>,
|
||||
onRowSelectionChange = null,
|
||||
getExpandedRowHeight = null,
|
||||
state = [],
|
||||
...options
|
||||
}) => {
|
||||
const [selectedTableIds, setSelectedTableIds] = useState([]);
|
||||
|
|
@ -155,7 +156,9 @@ const useTable = ({
|
|||
const rangeIds = allRowIds.slice(startIndex, endIndex + 1);
|
||||
|
||||
// Preserve existing selections outside the range
|
||||
const idsOutsideRange = selectedTableIds.filter(id => !rangeIds.includes(id));
|
||||
const idsOutsideRange = selectedTableIds.filter(
|
||||
(id) => !rangeIds.includes(id)
|
||||
);
|
||||
const newSelection = [...new Set([...rangeIds, ...idsOutsideRange])];
|
||||
updateSelectedTableIds(newSelection);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,26 +14,100 @@ import {
|
|||
Flex,
|
||||
useMantineTheme,
|
||||
Switch,
|
||||
Badge,
|
||||
Progress,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconSquarePlus } from '@tabler/icons-react';
|
||||
import { RefreshCcw, SquareMinus, SquarePen } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
|
||||
// Helper function to format status text
|
||||
const formatStatusText = (status) => {
|
||||
if (!status) return 'Unknown';
|
||||
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
// Helper function to get status text color
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'idle': return 'gray.5';
|
||||
case 'fetching': return 'blue.5';
|
||||
case 'parsing': return 'indigo.5';
|
||||
case 'error': return 'red.5';
|
||||
case 'success': return 'green.5';
|
||||
default: return 'gray.5';
|
||||
}
|
||||
};
|
||||
|
||||
const EPGsTable = () => {
|
||||
const [epg, setEPG] = useState(null);
|
||||
const [epgModalOpen, setEPGModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [epgToDelete, setEpgToDelete] = useState(null);
|
||||
|
||||
const epgs = useEPGsStore((s) => s.epgs);
|
||||
const refreshProgress = useEPGsStore((s) => s.refreshProgress);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
// Get tableSize directly from localStorage instead of the store
|
||||
const [tableSize] = useLocalStorage('table-size', 'default');
|
||||
|
||||
// Get proper size for action icons to match ChannelsTable
|
||||
const iconSize = tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm';
|
||||
|
||||
// Calculate density for Mantine Table
|
||||
const tableDensity = tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'xl' : 'md';
|
||||
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
|
||||
const toggleActive = async (epg) => {
|
||||
await API.updateEPG({
|
||||
...epg,
|
||||
is_active: !epg.is_active,
|
||||
});
|
||||
try {
|
||||
// Send only the is_active field to trigger our special handling
|
||||
await API.updateEPG({
|
||||
id: epg.id,
|
||||
is_active: !epg.is_active,
|
||||
}, true); // Add a new parameter to indicate this is just a toggle
|
||||
} catch (error) {
|
||||
console.error('Error toggling active state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const buildProgressDisplay = (data) => {
|
||||
const progress = refreshProgress[data.id] || null;
|
||||
|
||||
if (!progress) return null;
|
||||
|
||||
let label = '';
|
||||
switch (progress.action) {
|
||||
case 'downloading':
|
||||
label = 'Downloading';
|
||||
break;
|
||||
case 'parsing_channels':
|
||||
label = 'Parsing Channels';
|
||||
break;
|
||||
case 'parsing_programs':
|
||||
label = 'Parsing Programs';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Text size="xs">{label}: {parseInt(progress.progress)}%</Text>
|
||||
<Progress value={parseInt(progress.progress)} size="xs" style={{ margin: '2px 0' }} />
|
||||
{progress.speed && <Text size="xs">Speed: {parseInt(progress.speed)} KB/s</Text>}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
|
|
@ -52,23 +126,97 @@ const EPGsTable = () => {
|
|||
minSize: 100,
|
||||
},
|
||||
{
|
||||
header: 'URL / API Key',
|
||||
header: 'URL / API Key / File Path',
|
||||
accessorKey: 'url',
|
||||
size: 200,
|
||||
minSize: 120,
|
||||
enableSorting: false,
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
Cell: ({ cell, row }) => {
|
||||
const value = cell.getValue() || row.original.api_key || row.original.file_path || '';
|
||||
return (
|
||||
<Tooltip label={value} disabled={!value}>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
size: 100,
|
||||
minSize: 80,
|
||||
Cell: ({ row }) => {
|
||||
const data = row.original;
|
||||
|
||||
// Always show status text, even when there's progress happening
|
||||
return (
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
c={getStatusColor(data.status)}
|
||||
>
|
||||
{formatStatusText(data.status)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Status Message',
|
||||
accessorKey: 'last_message',
|
||||
size: 250,
|
||||
minSize: 150,
|
||||
enableSorting: false,
|
||||
Cell: ({ row }) => {
|
||||
const data = row.original;
|
||||
|
||||
// Check if there's an active progress for this EPG - show progress first if active
|
||||
if (refreshProgress[data.id] && refreshProgress[data.id].progress < 100) {
|
||||
return buildProgressDisplay(data);
|
||||
}
|
||||
|
||||
// Show error message when status is error
|
||||
if (data.status === 'error' && data.last_message) {
|
||||
return (
|
||||
<Tooltip label={data.last_message} multiline width={300}>
|
||||
<Text c="dimmed" size="xs" lineClamp={2} style={{ color: theme.colors.red[6], lineHeight: 1.3 }}>
|
||||
{data.last_message}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success message for successful sources
|
||||
if (data.status === 'success') {
|
||||
return (
|
||||
<Text c="dimmed" size="xs" style={{ color: theme.colors.green[6], lineHeight: 1.3 }}>
|
||||
EPG data refreshed successfully
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise return empty cell
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Updated',
|
||||
accessorKey: 'updated_at',
|
||||
size: 180,
|
||||
minSize: 100,
|
||||
enableSorting: false,
|
||||
Cell: ({ cell }) => {
|
||||
const value = cell.getValue();
|
||||
return value ? dayjs(value).format('MMMM D, YYYY h:mma') : 'Never';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
|
|
@ -89,15 +237,8 @@ const EPGsTable = () => {
|
|||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Updated',
|
||||
accessorFn: (row) => dayjs(row.updated_at).format('MMMM D, YYYY h:mma'),
|
||||
size: 180,
|
||||
minSize: 100,
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[]
|
||||
[refreshProgress]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
|
|
@ -112,9 +253,24 @@ const EPGsTable = () => {
|
|||
};
|
||||
|
||||
const deleteEPG = async (id) => {
|
||||
// Get EPG details for the confirmation dialog
|
||||
const epgObj = epgs[id];
|
||||
setEpgToDelete(epgObj);
|
||||
setDeleteTarget(id);
|
||||
|
||||
// Skip warning if it's been suppressed
|
||||
if (isWarningSuppressed('delete-epg')) {
|
||||
return executeDeleteEPG(id);
|
||||
}
|
||||
|
||||
setConfirmDeleteOpen(true);
|
||||
};
|
||||
|
||||
const executeDeleteEPG = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deleteEPG(id);
|
||||
setIsLoading(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
};
|
||||
|
||||
const refreshEPG = async (id) => {
|
||||
|
|
@ -147,7 +303,16 @@ const EPGsTable = () => {
|
|||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: Object.values(epgs),
|
||||
// Sort data before passing to table: active first, then by name
|
||||
data: Object.values(epgs)
|
||||
.sort((a, b) => {
|
||||
// First sort by active status (active items first)
|
||||
if (a.is_active !== b.is_active) {
|
||||
return a.is_active ? -1 : 1;
|
||||
}
|
||||
// Then sort by name (case-insensitive)
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
}),
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: false,
|
||||
|
|
@ -158,11 +323,12 @@ const EPGsTable = () => {
|
|||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
density: tableDensity,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
density: tableDensity,
|
||||
},
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'last',
|
||||
|
|
@ -176,28 +342,28 @@ const EPGsTable = () => {
|
|||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
size={iconSize} // Use standardized icon size
|
||||
color="yellow.5" // Red color for delete actions
|
||||
onClick={() => editEPG(row.original)}
|
||||
>
|
||||
<SquarePen size="18" /> {/* Small icon size */}
|
||||
<SquarePen size={tableSize === 'compact' ? 16 : 18} /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
size={iconSize} // Use standardized icon size
|
||||
color="red.9" // Red color for delete actions
|
||||
onClick={() => deleteEPG(row.original.id)}
|
||||
>
|
||||
<SquareMinus size="18" /> {/* Small icon size */}
|
||||
<SquareMinus size={tableSize === 'compact' ? 16 : 18} /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
size={iconSize} // Use standardized icon size
|
||||
color="blue.5" // Red color for delete actions
|
||||
onClick={() => refreshEPG(row.original.id)}
|
||||
disabled={!row.original.is_active}
|
||||
>
|
||||
<RefreshCcw size="18" /> {/* Small icon size */}
|
||||
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
|
|
@ -207,6 +373,33 @@ const EPGsTable = () => {
|
|||
overflowX: 'auto', // Ensure horizontal scrolling works
|
||||
},
|
||||
},
|
||||
mantineTableProps: {
|
||||
...TableHelper.defaultProperties.mantineTableProps,
|
||||
className: `table-size-${tableSize}`,
|
||||
},
|
||||
// Add custom cell styles to match CustomTable's sizing
|
||||
mantineTableBodyCellProps: ({ cell }) => {
|
||||
// Check if this is a status message cell with active progress
|
||||
const progressData = cell.column.id === 'last_message' &&
|
||||
refreshProgress[cell.row.original.id] &&
|
||||
refreshProgress[cell.row.original.id].progress < 100 ?
|
||||
refreshProgress[cell.row.original.id] : null;
|
||||
|
||||
// Only expand height for certain actions that need more space
|
||||
const needsExpandedHeight = progressData &&
|
||||
['downloading', 'parsing_channels', 'parsing_programs'].includes(progressData.action);
|
||||
|
||||
return {
|
||||
style: {
|
||||
// Apply taller height for progress cells (except initializing), otherwise use standard height
|
||||
height: needsExpandedHeight ? '80px' : (
|
||||
tableSize === 'compact' ? '28px' : tableSize === 'large' ? '48px' : '40px'
|
||||
),
|
||||
fontSize: tableSize === 'compact' ? 'var(--mantine-font-size-xs)' : 'var(--mantine-font-size-sm)',
|
||||
padding: tableSize === 'compact' ? '2px 8px' : '4px 10px'
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -276,6 +469,36 @@ const EPGsTable = () => {
|
|||
|
||||
<MantineReactTable table={table} />
|
||||
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() => executeDeleteEPG(deleteTarget)}
|
||||
title="Confirm EPG Source Deletion"
|
||||
message={
|
||||
epgToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to delete the following EPG source?
|
||||
|
||||
Name: ${epgToDelete.name}
|
||||
Source Type: ${epgToDelete.source_type}
|
||||
${epgToDelete.url ? `URL: ${epgToDelete.url}` :
|
||||
epgToDelete.api_key ? `API Key: ${epgToDelete.api_key}` :
|
||||
epgToDelete.file_path ? `File Path: ${epgToDelete.file_path}` : ''}
|
||||
|
||||
This will remove all related program information and channel associations.
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
) : (
|
||||
'Are you sure you want to delete this EPG source? This action cannot be undone.'
|
||||
)
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
actionKey="delete-epg"
|
||||
onSuppressChange={suppressWarning}
|
||||
size="lg"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,10 +14,44 @@ import {
|
|||
ActionIcon,
|
||||
Tooltip,
|
||||
Switch,
|
||||
Progress,
|
||||
Stack,
|
||||
Badge,
|
||||
Group,
|
||||
} from '@mantine/core';
|
||||
import { SquareMinus, SquarePen, RefreshCcw, Check, X } from 'lucide-react';
|
||||
import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons
|
||||
import dayjs from 'dayjs';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
|
||||
// Helper function to format status text
|
||||
const formatStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'idle': return 'Idle';
|
||||
case 'fetching': return 'Fetching';
|
||||
case 'parsing': return 'Parsing';
|
||||
case 'error': return 'Error';
|
||||
case 'success': return 'Success';
|
||||
case 'pending_setup': return 'Pending Setup';
|
||||
default: return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get status text color
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'idle': return 'gray.5';
|
||||
case 'fetching': return 'blue.5';
|
||||
case 'parsing': return 'indigo.5';
|
||||
case 'error': return 'red.5';
|
||||
case 'success': return 'green.5';
|
||||
case 'pending_setup': return 'orange.5'; // Orange to indicate action needed
|
||||
default: return 'gray.5';
|
||||
}
|
||||
};
|
||||
|
||||
const M3UTable = () => {
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
|
|
@ -26,12 +60,20 @@ const M3UTable = () => {
|
|||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
const [playlistCreated, setPlaylistCreated] = useState(false);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [playlistToDelete, setPlaylistToDelete] = useState(null);
|
||||
|
||||
const playlists = usePlaylistsStore((s) => s.playlists);
|
||||
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
|
||||
const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress);
|
||||
const editPlaylistId = usePlaylistsStore((s) => s.editPlaylistId);
|
||||
const setEditPlaylistId = usePlaylistsStore((s) => s.setEditPlaylistId);
|
||||
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
||||
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
const [tableSize] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const generateStatusString = (data) => {
|
||||
if (data.progress == 100) {
|
||||
|
|
@ -39,21 +81,25 @@ const M3UTable = () => {
|
|||
}
|
||||
|
||||
switch (data.action) {
|
||||
case 'initializing':
|
||||
return buildInitializingStats();
|
||||
|
||||
case 'downloading':
|
||||
return buildDownloadingStats(data);
|
||||
|
||||
case 'processing_groups':
|
||||
return 'Processing groups...';
|
||||
return buildGroupProcessingStats(data);
|
||||
|
||||
case 'parsing':
|
||||
return buildParsingStats(data);
|
||||
|
||||
default:
|
||||
return buildParsingStats(data);
|
||||
return data.status === 'error' ? buildErrorStats(data) : `${data.action || 'Processing'}...`;
|
||||
}
|
||||
};
|
||||
|
||||
const buildDownloadingStats = (data) => {
|
||||
if (data.progress == 100) {
|
||||
// fetchChannelGroups();
|
||||
// fetchPlaylists();
|
||||
return 'Download complete!';
|
||||
}
|
||||
|
||||
|
|
@ -61,21 +107,89 @@ const M3UTable = () => {
|
|||
return 'Downloading...';
|
||||
}
|
||||
|
||||
// Format time remaining in minutes:seconds
|
||||
const timeRemaining = data.time_remaining ?
|
||||
`${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}` :
|
||||
'calculating...';
|
||||
|
||||
// Format speed with appropriate unit (KB/s or MB/s)
|
||||
const speed = data.speed >= 1024 ?
|
||||
`${(data.speed / 1024).toFixed(2)} MB/s` :
|
||||
`${Math.round(data.speed)} KB/s`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text size="xs">Downloading: {parseInt(data.progress)}%</Text>
|
||||
{/* <Text size="xs">Speed: {parseInt(data.speed)} KB/s</Text>
|
||||
<Text size="xs">Time Remaining: {parseInt(data.time_remaining)}</Text> */}
|
||||
<Flex direction="column" gap={2}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Downloading:</Text>
|
||||
<Text size="xs">{parseInt(data.progress)}%</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Speed:</Text>
|
||||
<Text size="xs">{speed}</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Time left:</Text>
|
||||
<Text size="xs">{timeRemaining}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const buildGroupProcessingStats = (data) => {
|
||||
if (data.progress == 100) {
|
||||
return 'Groups processed!';
|
||||
}
|
||||
|
||||
if (data.progress == 0) {
|
||||
return 'Processing groups...';
|
||||
}
|
||||
|
||||
// Format time displays if available
|
||||
const elapsedTime = data.elapsed_time ?
|
||||
`${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}` :
|
||||
null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Processing groups:</Text>
|
||||
<Text size="xs">{parseInt(data.progress)}%</Text>
|
||||
</Flex>
|
||||
{elapsedTime && (
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Elapsed:</Text>
|
||||
<Text size="xs">{elapsedTime}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{data.groups_processed && (
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Groups:</Text>
|
||||
<Text size="xs">{data.groups_processed}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const buildErrorStats = (data) => {
|
||||
return (
|
||||
<Box>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Flex align="center">
|
||||
<Text size="xs" fw={500} color="red">Error:</Text>
|
||||
</Flex>
|
||||
<Text size="xs" color="red" style={{ lineHeight: 1.3 }}>{data.error || "Unknown error occurred"}</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const buildParsingStats = (data) => {
|
||||
if (data.progress == 100) {
|
||||
// fetchStreams();
|
||||
// fetchChannelGroups();
|
||||
// fetchEPGData();
|
||||
// fetchPlaylists();
|
||||
return 'Parsing complete!';
|
||||
}
|
||||
|
||||
|
|
@ -83,18 +197,123 @@ const M3UTable = () => {
|
|||
return 'Parsing...';
|
||||
}
|
||||
|
||||
return `Parsing: ${data.progress}%`;
|
||||
// Format time displays
|
||||
const timeRemaining = data.time_remaining ?
|
||||
`${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}` :
|
||||
'calculating...';
|
||||
|
||||
const elapsedTime = data.elapsed_time ?
|
||||
`${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}` :
|
||||
'0:00';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Parsing:</Text>
|
||||
<Text size="xs">{parseInt(data.progress)}%</Text>
|
||||
</Flex>
|
||||
{data.elapsed_time && (
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Elapsed:</Text>
|
||||
<Text size="xs">{elapsedTime}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{data.time_remaining && (
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Remaining:</Text>
|
||||
<Text size="xs">{timeRemaining}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{data.streams_processed && (
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text size="xs" fw={500}>Streams:</Text>
|
||||
<Text size="xs">{data.streams_processed}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const buildInitializingStats = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Flex align="center">
|
||||
<Text size="xs" fw={500}>Initializing refresh...</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const editPlaylist = async (playlist = null) => {
|
||||
if (playlist) {
|
||||
setPlaylist(playlist);
|
||||
}
|
||||
setPlaylistModalOpen(true);
|
||||
};
|
||||
|
||||
const refreshPlaylist = async (id) => {
|
||||
// Provide immediate visual feedback before the API call
|
||||
setRefreshProgress(id, {
|
||||
action: 'initializing',
|
||||
progress: 0,
|
||||
account: id,
|
||||
type: 'm3u_refresh'
|
||||
});
|
||||
|
||||
try {
|
||||
await API.refreshPlaylist(id);
|
||||
// No need to set again since WebSocket will update us once the task starts
|
||||
} catch (error) {
|
||||
// If the API call fails, show an error state
|
||||
setRefreshProgress(id, {
|
||||
action: 'error',
|
||||
progress: 0,
|
||||
account: id,
|
||||
type: 'm3u_refresh',
|
||||
error: 'Failed to start refresh task',
|
||||
status: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlaylist = async (id) => {
|
||||
// Get playlist details for the confirmation dialog
|
||||
const playlist = playlists.find(p => p.id === id);
|
||||
setPlaylistToDelete(playlist);
|
||||
setDeleteTarget(id);
|
||||
|
||||
// Skip warning if it's been suppressed
|
||||
if (isWarningSuppressed('delete-m3u')) {
|
||||
return executeDeletePlaylist(id);
|
||||
}
|
||||
|
||||
setConfirmDeleteOpen(true);
|
||||
};
|
||||
|
||||
const executeDeletePlaylist = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deletePlaylist(id);
|
||||
setIsLoading(false);
|
||||
setConfirmDeleteOpen(false);
|
||||
};
|
||||
|
||||
const toggleActive = async (playlist) => {
|
||||
await API.updatePlaylist({
|
||||
...playlist,
|
||||
is_active: !playlist.is_active,
|
||||
});
|
||||
try {
|
||||
// Send only the is_active field to trigger our special handling
|
||||
await API.updatePlaylist({
|
||||
id: playlist.id,
|
||||
is_active: !playlist.is_active,
|
||||
}, true); // Add a new parameter to indicate this is just a toggle
|
||||
} catch (error) {
|
||||
console.error('Error toggling active state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
|
|
@ -102,44 +321,134 @@ const M3UTable = () => {
|
|||
size: 150,
|
||||
minSize: 100, // Minimum width
|
||||
},
|
||||
{
|
||||
header: 'Account Type',
|
||||
accessorKey: 'account_type',
|
||||
size: 100,
|
||||
Cell: ({ cell }) => {
|
||||
const value = cell.getValue();
|
||||
return value === 'XC' ? 'XC' : 'M3U';
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'URL / File',
|
||||
accessorKey: 'server_url',
|
||||
size: 200,
|
||||
minSize: 120,
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
Cell: ({ cell, row }) => {
|
||||
const value = cell.getValue() || row.original.file_path || '';
|
||||
return (
|
||||
<Tooltip label={value} disabled={!value}>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Max Streams',
|
||||
accessorKey: 'max_streams',
|
||||
size: 120,
|
||||
minSize: 80,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorFn: (row) => {
|
||||
if (!row.id) {
|
||||
return '';
|
||||
}
|
||||
if (!refreshProgress[row.id]) {
|
||||
return 'Idle';
|
||||
accessorKey: 'status',
|
||||
size: 100,
|
||||
Cell: ({ cell }) => {
|
||||
const value = cell.getValue();
|
||||
if (!value) return null;
|
||||
|
||||
// Match EPG table styling with Text component - always use xs size
|
||||
return (
|
||||
<Text size="xs" c={getStatusColor(value)}>
|
||||
{formatStatusText(value)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Status Message',
|
||||
accessorKey: 'last_message',
|
||||
size: 250, // Increase default size
|
||||
minSize: 200, // Set minimum size
|
||||
maxSize: 400, // Allow expansion up to this size
|
||||
Cell: ({ cell, row }) => {
|
||||
const value = cell.getValue();
|
||||
const data = row.original;
|
||||
|
||||
// Get account id to check for refresh progress
|
||||
const accountId = data.id;
|
||||
const progressData = refreshProgress[accountId];
|
||||
|
||||
// If we have active progress data for this account, show that instead
|
||||
if (progressData && progressData.progress < 100) {
|
||||
return (
|
||||
<Box style={{
|
||||
// Use full height of the cell with proper spacing
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
// Add some padding to give content room to breathe
|
||||
padding: '4px 0'
|
||||
}}>
|
||||
{generateStatusString(progressData)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return generateStatusString(refreshProgress[row.id]);
|
||||
// No progress data, display normal status message
|
||||
if (!value) return null;
|
||||
|
||||
// Show error message with red styling for errors
|
||||
if (data.status === 'error') {
|
||||
return (
|
||||
<Tooltip label={value} multiline width={300}>
|
||||
<Text c="dimmed" size="xs" lineClamp={2} style={{ color: theme.colors.red[6], lineHeight: 1.3 }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success message with green styling for success
|
||||
if (data.status === 'success') {
|
||||
return (
|
||||
<Tooltip label={value} multiline width={300}>
|
||||
<Text c="dimmed" size="xs" style={{ color: theme.colors.green[6], lineHeight: 1.3 }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// For all other status values, just use dimmed text
|
||||
return (
|
||||
<Tooltip label={value} multiline width={300}>
|
||||
<Text c="dimmed" size="xs" lineClamp={2} style={{ lineHeight: 1.3 }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Updated',
|
||||
accessorKey: 'updated_at',
|
||||
size: 120,
|
||||
Cell: ({ cell }) => {
|
||||
const value = cell.getValue();
|
||||
return value ? <Text size="xs">{new Date(value).toLocaleString()}</Text> : <Text size="xs">Never</Text>;
|
||||
},
|
||||
size: 150,
|
||||
minSize: 80,
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
|
|
@ -150,25 +459,21 @@ const M3UTable = () => {
|
|||
mantineTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ row, cell }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={cell.getValue()}
|
||||
onChange={() => toggleActive(row.original)}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Updated',
|
||||
accessorFn: (row) => dayjs(row.updated_at).format('MMMM D, YYYY h:mma'),
|
||||
size: 180,
|
||||
minSize: 100,
|
||||
enableSorting: false,
|
||||
Cell: ({ cell, row }) => {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={cell.getValue()}
|
||||
onChange={() => toggleActive(row.original)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Remove the custom Actions column here
|
||||
],
|
||||
[refreshProgress]
|
||||
[refreshPlaylist, editPlaylist, deletePlaylist, toggleActive]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
|
|
@ -177,24 +482,6 @@ const M3UTable = () => {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editPlaylist = async (playlist = null) => {
|
||||
if (playlist) {
|
||||
setPlaylist(playlist);
|
||||
}
|
||||
setPlaylistModalOpen(true);
|
||||
};
|
||||
|
||||
const refreshPlaylist = async (id) => {
|
||||
await API.refreshPlaylist(id);
|
||||
setRefreshProgress(id, 0);
|
||||
};
|
||||
|
||||
const deletePlaylist = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deletePlaylist(id);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const closeModal = (newPlaylist = null) => {
|
||||
if (newPlaylist) {
|
||||
setPlaylistCreated(true);
|
||||
|
|
@ -228,10 +515,34 @@ const M3UTable = () => {
|
|||
}
|
||||
}, [sorting]);
|
||||
|
||||
// Listen for edit playlist requests from notifications
|
||||
useEffect(() => {
|
||||
if (editPlaylistId) {
|
||||
const playlistToEdit = playlists.find(p => p.id === editPlaylistId);
|
||||
if (playlistToEdit) {
|
||||
editPlaylist(playlistToEdit);
|
||||
// Reset the ID after handling
|
||||
setEditPlaylistId(null);
|
||||
}
|
||||
}
|
||||
}, [editPlaylistId, playlists]);
|
||||
|
||||
const tableDensity = tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'xl' : 'md';
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: playlists.filter((playlist) => playlist.locked === false),
|
||||
// Sort data before passing to table: active first, then by name
|
||||
data: playlists
|
||||
.filter((playlist) => playlist.locked === false)
|
||||
.sort((a, b) => {
|
||||
// First sort by active status (active items first)
|
||||
if (a.is_active !== b.is_active) {
|
||||
return a.is_active ? -1 : 1;
|
||||
}
|
||||
// Then sort by name (case-insensitive)
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
}),
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: false,
|
||||
|
|
@ -242,13 +553,16 @@ const M3UTable = () => {
|
|||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
// Use density directly from tableSize
|
||||
density: tableDensity,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
// Use density directly from tableSize
|
||||
density: tableDensity,
|
||||
},
|
||||
enableRowActions: true,
|
||||
enableRowActions: true, // Enable row actions
|
||||
positionActionsColumn: 'last',
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
|
|
@ -260,30 +574,30 @@ const M3UTable = () => {
|
|||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
size={tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm'} // Use standardized icon size
|
||||
color="yellow.5"
|
||||
onClick={() => {
|
||||
editPlaylist(row.original);
|
||||
}}
|
||||
>
|
||||
<SquarePen size="18" />
|
||||
<SquarePen size={tableSize === 'compact' ? 16 : 18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
size={tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm'} // Use standardized icon size
|
||||
color="red.9"
|
||||
onClick={() => deletePlaylist(row.original.id)}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
<SquareMinus size={tableSize === 'compact' ? 16 : 18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
size={tableSize === 'compact' ? 'xs' : tableSize === 'large' ? 'md' : 'sm'} // Use standardized icon size
|
||||
color="blue.5"
|
||||
onClick={() => refreshPlaylist(row.original.id)}
|
||||
disabled={!row.original.is_active}
|
||||
>
|
||||
<RefreshCcw size="18" />
|
||||
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
|
|
@ -293,6 +607,39 @@ const M3UTable = () => {
|
|||
overflowX: 'auto', // Ensure horizontal scrolling works
|
||||
},
|
||||
},
|
||||
mantineTableProps: {
|
||||
...TableHelper.defaultProperties.mantineTableProps,
|
||||
className: `table-size-${tableSize}`,
|
||||
},
|
||||
// Add custom cell styles to match CustomTable's sizing
|
||||
mantineTableBodyCellProps: ({ cell }) => {
|
||||
// Check if this is a status message cell with active progress
|
||||
const progressData = cell.column.id === 'last_message' &&
|
||||
refreshProgress[cell.row.original.id] &&
|
||||
refreshProgress[cell.row.original.id].progress < 100 ?
|
||||
refreshProgress[cell.row.original.id] : null;
|
||||
|
||||
// Only expand height for certain actions that need more space
|
||||
const needsExpandedHeight = progressData &&
|
||||
['downloading', 'parsing', 'processing_groups'].includes(progressData.action);
|
||||
|
||||
return {
|
||||
style: {
|
||||
// Apply taller height for progress cells (except initializing), otherwise use standard height
|
||||
height: needsExpandedHeight ? '80px' : (
|
||||
tableSize === 'compact' ? '28px' : tableSize === 'large' ? '48px' : '40px'
|
||||
),
|
||||
fontSize: tableSize === 'compact' ? 'var(--mantine-font-size-xs)' : 'var(--mantine-font-size-sm)',
|
||||
padding: tableSize === 'compact' ? '2px 8px' : '4px 10px'
|
||||
}
|
||||
};
|
||||
},
|
||||
// Additional text styling to match ChannelsTable
|
||||
mantineTableBodyProps: {
|
||||
style: {
|
||||
fontSize: tableSize === 'compact' ? 'var(--mantine-font-size-xs)' : 'var(--mantine-font-size-sm)',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -358,11 +705,39 @@ const M3UTable = () => {
|
|||
<MantineReactTable table={table} />
|
||||
|
||||
<M3UForm
|
||||
playlist={playlist}
|
||||
m3uAccount={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={closeModal}
|
||||
playlistCreated={playlistCreated}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() => executeDeletePlaylist(deleteTarget)}
|
||||
title="Confirm M3U Account Deletion"
|
||||
message={
|
||||
playlistToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to delete the following M3U account?
|
||||
|
||||
Name: ${playlistToDelete.name}
|
||||
Type: ${playlistToDelete.account_type === 'XC' ? 'Xtream Codes' : 'Standard'}
|
||||
Server: ${playlistToDelete.server_url || 'Local file'}
|
||||
|
||||
This will remove all related streams and may affect channels using these streams.
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
) : (
|
||||
'Are you sure you want to delete this M3U account? This action cannot be undone.'
|
||||
)
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
actionKey="delete-m3u"
|
||||
onSuppressChange={suppressWarning}
|
||||
size="lg"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -213,38 +213,14 @@ const StreamProfiles = () => {
|
|||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(60vh - 100px)',
|
||||
// height: 'calc(60vh - 100px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ width: '49%', padding: 0 }}>
|
||||
<Flex
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
// paddingBottom: 10,
|
||||
}}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Stream Profiles
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Stack gap={0} style={{ padding: 0 }}>
|
||||
<Paper
|
||||
style={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import useSettingsStore from '../../store/settings';
|
|||
import useVideoStore from '../../store/useVideoStore';
|
||||
import useChannelsTableStore from '../../store/channelsTable';
|
||||
import { CustomTable, useTable } from './CustomTable';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
|
||||
const StreamRowActions = ({
|
||||
theme,
|
||||
|
|
@ -52,6 +53,7 @@ const StreamRowActions = ({
|
|||
handleWatchStream,
|
||||
selectedChannelIds,
|
||||
}) => {
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
const channelSelectionStreams = useChannelsTableStore(
|
||||
(state) =>
|
||||
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
|
||||
|
|
@ -82,21 +84,32 @@ const StreamRowActions = ({
|
|||
|
||||
const onEdit = useCallback(() => {
|
||||
editStream(row.original);
|
||||
}, []);
|
||||
}, [row.original.id, editStream]);
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
deleteStream(row.original.id);
|
||||
}, []);
|
||||
}, [row.original.id, deleteStream]);
|
||||
|
||||
const onPreview = useCallback(() => {
|
||||
console.log(
|
||||
'Previewing stream:',
|
||||
row.original.name,
|
||||
'ID:',
|
||||
row.original.id,
|
||||
'Hash:',
|
||||
row.original.stream_hash
|
||||
);
|
||||
handleWatchStream(row.original.stream_hash);
|
||||
}, []);
|
||||
}, [row.original.id]); // Add proper dependency to ensure correct stream
|
||||
|
||||
const iconSize =
|
||||
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Add to Channel">
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
color={theme.tailwind.blue[6]}
|
||||
variant="transparent"
|
||||
onClick={addStreamToChannel}
|
||||
|
|
@ -115,7 +128,7 @@ const StreamRowActions = ({
|
|||
|
||||
<Tooltip label="Create New Channel">
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
color={theme.tailwind.green[5]}
|
||||
variant="transparent"
|
||||
onClick={createChannelFromStream}
|
||||
|
|
@ -126,7 +139,7 @@ const StreamRowActions = ({
|
|||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" size="xs">
|
||||
<ActionIcon variant="transparent" size={iconSize}>
|
||||
<EllipsisVertical size="18" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
|
@ -186,6 +199,9 @@ const StreamsTable = ({ }) => {
|
|||
});
|
||||
const debouncedFilters = useDebounce(filters, 500);
|
||||
|
||||
// Add state to track if stream groups are loaded
|
||||
const [groupsLoaded, setGroupsLoaded] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
|
|
@ -193,7 +209,10 @@ const StreamsTable = ({ }) => {
|
|||
*/
|
||||
const playlists = usePlaylistsStore((s) => s.playlists);
|
||||
|
||||
// Get direct access to channel groups without depending on other data
|
||||
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
|
||||
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
|
||||
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
|
||||
const channelSelectionStreams = useChannelsTableStore(
|
||||
|
|
@ -202,6 +221,7 @@ const StreamsTable = ({ }) => {
|
|||
);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const handleSelectClick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -215,7 +235,7 @@ const StreamsTable = ({ }) => {
|
|||
() => [
|
||||
{
|
||||
id: 'actions',
|
||||
size: 60,
|
||||
size: tableSize == 'compact' ? 60 : 80,
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
|
|
@ -232,7 +252,7 @@ const StreamsTable = ({ }) => {
|
|||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
|
|
@ -250,7 +270,7 @@ const StreamsTable = ({ }) => {
|
|||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
|
|
@ -268,7 +288,7 @@ const StreamsTable = ({ }) => {
|
|||
}}
|
||||
>
|
||||
<Tooltip label={getValue()} openDelay={500}>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
<Box>{getValue()}</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
|
|
@ -286,6 +306,12 @@ const StreamsTable = ({ }) => {
|
|||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
// Reset to first page whenever filters change to avoid "Invalid page" errors
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageIndex: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGroupChange = (value) => {
|
||||
|
|
@ -293,6 +319,12 @@ const StreamsTable = ({ }) => {
|
|||
...prev,
|
||||
channel_group: value ? value : '',
|
||||
}));
|
||||
|
||||
// Reset to first page whenever filters change to avoid "Invalid page" errors
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageIndex: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleM3UChange = (value) => {
|
||||
|
|
@ -300,9 +332,27 @@ const StreamsTable = ({ }) => {
|
|||
...prev,
|
||||
m3u_account: value ? value : '',
|
||||
}));
|
||||
|
||||
// Reset to first page whenever filters change to avoid "Invalid page" errors
|
||||
setPagination((prev) => ({
|
||||
...prev,
|
||||
pageIndex: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Ensure we have channel groups first (if not already loaded)
|
||||
if (!groupsLoaded && Object.keys(channelGroups).length === 0) {
|
||||
try {
|
||||
await fetchChannelGroups();
|
||||
setGroupsLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('Error fetching channel groups:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
|
@ -349,7 +399,7 @@ const StreamsTable = ({ }) => {
|
|||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [pagination, sorting, debouncedFilters]);
|
||||
}, [pagination, sorting, debouncedFilters, groupsLoaded, channelGroups, fetchChannelGroups]);
|
||||
|
||||
// Bulk creation: create channels from selected streams in one API call
|
||||
const createChannelsFromStreams = async () => {
|
||||
|
|
@ -542,7 +592,7 @@ const StreamsTable = ({ }) => {
|
|||
);
|
||||
}
|
||||
},
|
||||
[selectedChannelIds, channelSelectionStreams]
|
||||
[selectedChannelIds, channelSelectionStreams, theme, editStream, deleteStream, handleWatchStream]
|
||||
);
|
||||
|
||||
const table = useTable({
|
||||
|
|
@ -571,6 +621,7 @@ const StreamsTable = ({ }) => {
|
|||
* useEffects
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Load data independently, don't wait for logos or other data
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
|
|
@ -608,17 +659,34 @@ const StreamsTable = ({ }) => {
|
|||
<Box>
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
variant={selectedStreamIds.length > 0 && selectedChannelIds.length === 1 ? "light" : "default"}
|
||||
variant={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? 'light'
|
||||
: 'default'
|
||||
}
|
||||
size="xs"
|
||||
onClick={addStreamsToChannel}
|
||||
p={5}
|
||||
color={selectedStreamIds.length > 0 && selectedChannelIds.length === 1 ? theme.tailwind.green[5] : undefined}
|
||||
style={selectedStreamIds.length > 0 && selectedChannelIds.length === 1 ? {
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
} : undefined}
|
||||
disabled={!(selectedStreamIds.length > 0 && selectedChannelIds.length === 1)}
|
||||
color={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? theme.tailwind.green[5]
|
||||
: undefined
|
||||
}
|
||||
style={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? {
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={
|
||||
!(
|
||||
selectedStreamIds.length > 0 &&
|
||||
selectedChannelIds.length === 1
|
||||
)
|
||||
}
|
||||
>
|
||||
Add Streams to Channel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ const UserAgentsTable = () => {
|
|||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(60vh - 100px)',
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
// margin: 5,
|
||||
},
|
||||
|
|
@ -234,34 +234,7 @@ const UserAgentsTable = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ width: '49%', padding: 0 }}>
|
||||
<Flex
|
||||
style={
|
||||
{
|
||||
// display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// paddingTop: 10,
|
||||
// paddingBottom: 10,
|
||||
}
|
||||
}
|
||||
// gap={15}
|
||||
>
|
||||
<Text
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
// marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
User-Agents
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Stack gap={0} style={{ padding: 0 }}>
|
||||
<Paper
|
||||
style={
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,10 +45,24 @@ html {
|
|||
}
|
||||
|
||||
.td {
|
||||
height: 28px;
|
||||
border-bottom: solid 1px rgb(68, 68, 68);
|
||||
}
|
||||
|
||||
.divTable.table-size-compact .td {
|
||||
height: 28px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.divTable.table-size-default .td {
|
||||
height: 40px;
|
||||
font-size: var(--mantine-font-size-md);
|
||||
}
|
||||
|
||||
.divTable.table-size-large .td {
|
||||
height: 48px;
|
||||
font-size: var(--mantine-font-size-md);
|
||||
}
|
||||
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -93,6 +107,19 @@ html {
|
|||
background-color: #27272A;
|
||||
}
|
||||
|
||||
/* Style for rows with no streams */
|
||||
.no-streams-row {
|
||||
background-color: rgba(239, 68, 68, 0.15) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.3);
|
||||
/* Add subtle border */
|
||||
}
|
||||
|
||||
/* Special hover effect for rows with no streams */
|
||||
.table-striped .tbody .tr.no-streams-row:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3) !important;
|
||||
/* Darker red on hover */
|
||||
}
|
||||
|
||||
/* Prevent text selection when shift key is pressed */
|
||||
.shift-key-active {
|
||||
cursor: pointer !important;
|
||||
|
|
@ -152,4 +179,4 @@ html {
|
|||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
cursor: text !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* frontend/src/index.css */
|
||||
:root {
|
||||
--separator-border: transparent !important; /* Override Allotment's default border */
|
||||
--separator-border: transparent !important;
|
||||
/* Override Allotment's default border */
|
||||
--sash-hover-size: 3px !important;
|
||||
}
|
||||
|
||||
|
|
@ -11,7 +12,8 @@ body {
|
|||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #2E2F34; /* Ensure the global background is dark */
|
||||
background-color: #2E2F34;
|
||||
/* Ensure the global background is dark */
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
|
@ -24,12 +26,15 @@ code {
|
|||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #3B3C41;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
|
|
@ -45,7 +50,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
|
|||
}
|
||||
|
||||
.table-input-header input::placeholder {
|
||||
color: rgb(207,207,207);
|
||||
color: rgb(207, 207, 207);
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
@ -59,7 +64,8 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
|
|||
/* Styling for the sash (splitter) */
|
||||
.sash.sash-vertical {
|
||||
position: relative;
|
||||
width: 4px; /* Thin invisible divider */
|
||||
width: 4px;
|
||||
/* Thin invisible divider */
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
|
@ -70,9 +76,12 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
|
|||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6px; /* Width of the short bar */
|
||||
height: 50px; /* Short bar length */
|
||||
background: rgba(255, 255, 255, 0.2); /* Light color similar to your screenshot */
|
||||
width: 6px;
|
||||
/* Width of the short bar */
|
||||
height: 50px;
|
||||
/* Short bar length */
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
/* Light color similar to your screenshot */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +97,16 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Fix for "will-change memory consumption is too high" error */
|
||||
* {
|
||||
will-change: auto !important;
|
||||
}
|
||||
|
||||
/* For elements that specifically need transforms (like draggable elements) */
|
||||
[data-draggable="true"] {
|
||||
transform: translate3d(0, 0, 0) !important;
|
||||
}
|
||||
|
||||
/* styles.css */
|
||||
.custom-multiselect-input {
|
||||
display: flex;
|
||||
|
|
@ -98,6 +117,8 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
|
|||
}
|
||||
|
||||
.custom-multiselect .mantine-MultiSelect-input {
|
||||
min-height: 30px; /* Set a minimum height */
|
||||
max-height: 30px; /* Set max height */
|
||||
}
|
||||
min-height: 30px;
|
||||
/* Set a minimum height */
|
||||
max-height: 30px;
|
||||
/* Set max height */
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import ChannelsTable from '../components/tables/ChannelsTable';
|
||||
import StreamsTable from '../components/tables/StreamsTable';
|
||||
import { Box, Grid } from '@mantine/core';
|
||||
import { Box } from '@mantine/core';
|
||||
import { Allotment } from 'allotment';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
|
|
@ -12,18 +12,10 @@ const ChannelsPage = () => {
|
|||
style={{ height: '100%', width: '100%' }}
|
||||
className="custom-allotment"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 10 }}>
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 10 }}>
|
||||
<StreamsTable />
|
||||
</div>
|
||||
</Allotment>
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
>
|
||||
<Box>
|
||||
<Text
|
||||
component="div"
|
||||
size={isExpanded ? 'lg' : 'md'}
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
|
|
@ -851,10 +852,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{(searchQuery !== '' ||
|
||||
selectedGroupId !== 'all' ||
|
||||
selectedProfileId !== 'all') && (
|
||||
<Button variant="subtle" onClick={clearFilters} size="sm" compact>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="subtle" onClick={clearFilters} size="sm" compact>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Text size="sm" color="dimmed">
|
||||
{filteredChannels.length}{' '}
|
||||
|
|
@ -1209,9 +1210,13 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
paddingLeft: 0, // Remove any padding that might push content
|
||||
}}
|
||||
>
|
||||
{channelPrograms.map((prog) =>
|
||||
renderProgram(prog, start)
|
||||
)}
|
||||
{channelPrograms.map((program) => {
|
||||
return (
|
||||
<div key={`${channel.id}-${program.id}-${program.start_time}`}>
|
||||
{renderProgram(program, start)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,28 +4,30 @@ import useSettingsStore from '../store/settings';
|
|||
import useUserAgentsStore from '../store/userAgents';
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
import {
|
||||
Accordion,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Paper,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import useLocalStorage from '../hooks/useLocalStorage';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const userAgents = useUserAgentsStore((s) => s.userAgents);
|
||||
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
|
||||
|
||||
// UI / local storage settings
|
||||
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const regionChoices = [
|
||||
{ value: 'ad', label: 'AD' },
|
||||
{ value: 'ae', label: 'AE' },
|
||||
|
|
@ -284,19 +286,21 @@ const SettingsPage = () => {
|
|||
'default-stream-profile': '',
|
||||
'preferred-region': '',
|
||||
'auto-import-mapped-files': true,
|
||||
'm3u-hash-key': [],
|
||||
},
|
||||
|
||||
validate: {
|
||||
'default-user-agent': isNotEmpty('Select a channel'),
|
||||
'default-stream-profile': isNotEmpty('Select a start time'),
|
||||
'preferred-region': isNotEmpty('Select an end time'),
|
||||
'default-user-agent': isNotEmpty('Select a user agent'),
|
||||
'default-stream-profile': isNotEmpty('Select a stream profile'),
|
||||
'preferred-region': isNotEmpty('Select a region'),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
form.setValues(
|
||||
Object.entries(settings).reduce((acc, [key, value]) => {
|
||||
console.log(settings);
|
||||
const formValues = Object.entries(settings).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Modify each value based on its own properties
|
||||
switch (value.value) {
|
||||
case 'true':
|
||||
|
|
@ -307,10 +311,23 @@ const SettingsPage = () => {
|
|||
break;
|
||||
}
|
||||
|
||||
acc[key] = value.value;
|
||||
let val = null;
|
||||
switch (key) {
|
||||
case 'm3u-hash-key':
|
||||
val = value.value.split(',');
|
||||
break;
|
||||
default:
|
||||
val = value.value;
|
||||
break;
|
||||
}
|
||||
|
||||
acc[key] = val;
|
||||
return acc;
|
||||
}, {})
|
||||
},
|
||||
{}
|
||||
);
|
||||
console.log(formValues);
|
||||
form.setValues(formValues);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
|
|
@ -333,98 +350,156 @@ const SettingsPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const onUISettingsChange = (name, value) => {
|
||||
switch (name) {
|
||||
case 'table-size':
|
||||
setTableSize(value);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Center
|
||||
style={{
|
||||
height: '40vh',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{ padding: 20, width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Title order={4} align="center">
|
||||
Settings
|
||||
</Title>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-user-agent')}
|
||||
key={form.key('default-user-agent')}
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
data={userAgents.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-stream-profile')}
|
||||
key={form.key('default-stream-profile')}
|
||||
id={settings['default-stream-profile']?.id}
|
||||
name={settings['default-stream-profile']?.key}
|
||||
label={settings['default-stream-profile']?.name}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('preferred-region')}
|
||||
key={form.key('preferred-region')}
|
||||
id={settings['preferred-region']?.id || 'preferred-region'}
|
||||
name={settings['preferred-region']?.key || 'preferred-region'}
|
||||
label={settings['preferred-region']?.name || 'Preferred Region'}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" style={{ paddingTop: 5 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
Auto-Import Mapped Files
|
||||
</Text>
|
||||
<Switch
|
||||
{...form.getInputProps('auto-import-mapped-files', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
key={form.key('auto-import-mapped-files')}
|
||||
id={
|
||||
settings['auto-import-mapped-files']?.id ||
|
||||
'auto-import-mapped-files'
|
||||
}
|
||||
<Center
|
||||
style={{
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Box style={{ width: '100%', maxWidth: 800 }}>
|
||||
<Accordion variant="separated" defaultValue="ui-settings">
|
||||
<Accordion.Item value="ui-settings">
|
||||
<Accordion.Control>UI Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Select
|
||||
label="Table Size"
|
||||
value={tableSize}
|
||||
onChange={(val) => onUISettingsChange('table-size', val)}
|
||||
data={[
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default',
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: 'Compact',
|
||||
},
|
||||
{
|
||||
value: 'large',
|
||||
label: 'Large',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.submitting}
|
||||
variant="default"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
<Accordion.Item value="stream-settings">
|
||||
<Accordion.Control>Stream Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-user-agent')}
|
||||
key={form.key('default-user-agent')}
|
||||
id={settings['default-user-agent']?.id || 'default-user-agent'}
|
||||
name={settings['default-user-agent']?.key || 'default-user-agent'}
|
||||
label={settings['default-user-agent']?.name || 'Default User Agent'}
|
||||
data={userAgents.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Group
|
||||
justify="space-around"
|
||||
align="top"
|
||||
style={{ width: '100%' }}
|
||||
gap={0}
|
||||
>
|
||||
<StreamProfilesTable />
|
||||
<UserAgentsTable />
|
||||
</Group>
|
||||
</Stack>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-stream-profile')}
|
||||
key={form.key('default-stream-profile')}
|
||||
id={settings['default-stream-profile']?.id || 'default-stream-profile'}
|
||||
name={settings['default-stream-profile']?.key || 'default-stream-profile'}
|
||||
label={settings['default-stream-profile']?.name || 'Default Stream Profile'}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('preferred-region')}
|
||||
key={form.key('preferred-region')}
|
||||
id={settings['preferred-region']?.id || 'preferred-region'}
|
||||
name={settings['preferred-region']?.key || 'preferred-region'}
|
||||
label={settings['preferred-region']?.name || 'Preferred Region'}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" style={{ paddingTop: 5 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
Auto-Import Mapped Files
|
||||
</Text>
|
||||
<Switch
|
||||
{...form.getInputProps('auto-import-mapped-files', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
key={form.key('auto-import-mapped-files')}
|
||||
id={
|
||||
settings['auto-import-mapped-files']?.id ||
|
||||
'auto-import-mapped-files'
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<MultiSelect
|
||||
id="m3u-hash-key"
|
||||
name="m3u-hash-key"
|
||||
label="M3U Hash Key"
|
||||
data={[
|
||||
{
|
||||
value: 'name',
|
||||
label: 'Name',
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
label: 'URL',
|
||||
},
|
||||
{
|
||||
value: 'tvg_id',
|
||||
label: 'TVG-ID',
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps('m3u-hash-key')}
|
||||
key={form.key('m3u-hash-key')}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.submitting}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="user-agents">
|
||||
<Accordion.Control>User-Agents</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<UserAgentsTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="stream-profiles">
|
||||
<Accordion.Control>Stream Profiles</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<StreamProfilesTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Box>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
import api from '../api';
|
||||
import useSettingsStore from './settings';
|
||||
import useChannelsStore from './channels';
|
||||
import useUserAgentsStore from './userAgents';
|
||||
import usePlaylistsStore from './playlists';
|
||||
import useEPGsStore from './epgs';
|
||||
import useStreamProfilesStore from './streamProfiles';
|
||||
import useSettingsStore from './settings';
|
||||
import useUserAgentsStore from './userAgents';
|
||||
|
||||
const decodeToken = (token) => {
|
||||
if (!token) return null;
|
||||
|
|
@ -20,32 +20,46 @@ const isTokenExpired = (expirationTime) => {
|
|||
};
|
||||
|
||||
const useAuthStore = create((set, get) => ({
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
needsSuperuser: false,
|
||||
user: {
|
||||
username: '',
|
||||
email: '',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
initData: async () => {
|
||||
// Ensure settings are loaded first
|
||||
await useSettingsStore.getState().fetchSettings();
|
||||
|
||||
try {
|
||||
// Only after settings are loaded, fetch the dependent data
|
||||
await Promise.all([
|
||||
useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
usePlaylistsStore.getState().fetchPlaylists(),
|
||||
useEPGsStore.getState().fetchEPGs(),
|
||||
useEPGsStore.getState().fetchEPGData(),
|
||||
useChannelsStore.getState().fetchLogos(),
|
||||
useStreamProfilesStore.getState().fetchProfiles(),
|
||||
useUserAgentsStore.getState().fetchUserAgents(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error initializing data:", error);
|
||||
}
|
||||
},
|
||||
|
||||
accessToken: localStorage.getItem('accessToken') || null,
|
||||
refreshToken: localStorage.getItem('refreshToken') || null,
|
||||
tokenExpiration: localStorage.getItem('tokenExpiration') || null,
|
||||
superuserExists: true,
|
||||
isAuthenticated: false,
|
||||
|
||||
setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
|
||||
|
||||
setSuperuserExists: (superuserExists) => set({ superuserExists }),
|
||||
|
||||
initData: async () => {
|
||||
await Promise.all([
|
||||
useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
useChannelsStore.getState().fetchLogos(),
|
||||
useChannelsStore.getState().fetchChannelProfiles(),
|
||||
useChannelsStore.getState().fetchRecordings(),
|
||||
useUserAgentsStore.getState().fetchUserAgents(),
|
||||
usePlaylistsStore.getState().fetchPlaylists(),
|
||||
useEPGsStore.getState().fetchEPGs(),
|
||||
useEPGsStore.getState().fetchEPGData(),
|
||||
useStreamProfilesStore.getState().fetchProfiles(),
|
||||
useSettingsStore.getState().fetchSettings(),
|
||||
]);
|
||||
},
|
||||
|
||||
getToken: async () => {
|
||||
const tokenExpiration = localStorage.getItem('tokenExpiration');
|
||||
let accessToken = null;
|
||||
|
|
@ -61,7 +75,7 @@ const useAuthStore = create((set, get) => ({
|
|||
// Action to login
|
||||
login: async ({ username, password }) => {
|
||||
try {
|
||||
const response = await API.login(username, password);
|
||||
const response = await api.login(username, password);
|
||||
if (response.access) {
|
||||
const expiration = decodeToken(response.access);
|
||||
set({
|
||||
|
|
@ -83,11 +97,11 @@ const useAuthStore = create((set, get) => ({
|
|||
// Action to refresh the token
|
||||
getRefreshToken: async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return;
|
||||
if (!refreshToken) return false; // Add explicit return here
|
||||
|
||||
try {
|
||||
const data = await API.refreshToken(refreshToken);
|
||||
if (data.access) {
|
||||
const data = await api.refreshToken(refreshToken);
|
||||
if (data && data.access) {
|
||||
set({
|
||||
accessToken: data.access,
|
||||
tokenExpiration: decodeToken(data.access),
|
||||
|
|
@ -98,12 +112,12 @@ const useAuthStore = create((set, get) => ({
|
|||
|
||||
return data.access;
|
||||
}
|
||||
return false; // Add explicit return for when data.access is not available
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
get().logout();
|
||||
return false; // Add explicit return after error
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Action to logout
|
||||
|
|
|
|||
|
|
@ -151,6 +151,26 @@ const useChannelsStore = create((set, get) => ({
|
|||
},
|
||||
})),
|
||||
|
||||
updateChannels: (channels) => {
|
||||
const channelsByUUID = {};
|
||||
const updatedChannels = channels.reduce((acc, chan) => {
|
||||
channelsByUUID[chan.uuid] = chan;
|
||||
acc[chan.id] = chan;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return set((state) => ({
|
||||
channels: {
|
||||
...state.channels,
|
||||
...updatedChannels,
|
||||
},
|
||||
channelsByUUID: {
|
||||
...state.channelsByUUID,
|
||||
...channelsByUUID,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
removeChannels: (channelIds) => {
|
||||
set((state) => {
|
||||
const updatedChannels = { ...state.channels };
|
||||
|
|
@ -323,11 +343,17 @@ const useChannelsStore = create((set, get) => ({
|
|||
acc[ch.channel_id] = ch;
|
||||
if (currentStats.channels) {
|
||||
if (oldChannels[ch.channel_id] === undefined) {
|
||||
notifications.show({
|
||||
title: 'New channel streaming',
|
||||
message: channels[channelsByUUID[ch.channel_id]].name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
// Add null checks to prevent accessing properties on undefined
|
||||
const channelId = channelsByUUID[ch.channel_id];
|
||||
const channel = channelId ? channels[channelId] : null;
|
||||
|
||||
if (channel) {
|
||||
notifications.show({
|
||||
title: 'New channel streaming',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ch.clients.map((client) => {
|
||||
|
|
@ -349,11 +375,23 @@ const useChannelsStore = create((set, get) => ({
|
|||
if (currentStats.channels) {
|
||||
for (const uuid in oldChannels) {
|
||||
if (newChannels[uuid] === undefined) {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: channels[channelsByUUID[uuid]].name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
// Add null check for channel name
|
||||
const channelId = channelsByUUID[uuid];
|
||||
const channel = channelId && channels[channelId];
|
||||
|
||||
if (channel) {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: channel.name,
|
||||
color: 'blue.5',
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'Channel streaming stopped',
|
||||
message: `Channel (${uuid})`,
|
||||
color: 'blue.5',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const clientId in oldClients) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const useEPGsStore = create((set) => ({
|
|||
tvgsById: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refreshProgress: {},
|
||||
|
||||
fetchEPGs: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
|
@ -62,6 +63,50 @@ const useEPGsStore = create((set) => ({
|
|||
|
||||
return { epgs: updatedEPGs };
|
||||
}),
|
||||
|
||||
updateEPGProgress: (data) =>
|
||||
set((state) => {
|
||||
// Early exit if source doesn't exist in our EPGs store
|
||||
if (!state.epgs[data.source] && !data.status) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Create a new refreshProgress object that includes the current update
|
||||
const newRefreshProgress = {
|
||||
...state.refreshProgress,
|
||||
[data.source]: {
|
||||
action: data.action,
|
||||
progress: data.progress,
|
||||
speed: data.speed,
|
||||
elapsed_time: data.elapsed_time,
|
||||
time_remaining: data.time_remaining,
|
||||
status: data.status || 'in_progress'
|
||||
}
|
||||
};
|
||||
|
||||
// Set the EPG source status based on the update
|
||||
// First prioritize explicit status values from the backend
|
||||
const sourceStatus = data.status ? data.status // Use explicit status if provided
|
||||
: data.action === "downloading" ? "fetching"
|
||||
: data.action === "parsing_channels" || data.action === "parsing_programs" ? "parsing"
|
||||
: data.progress === 100 ? "success" // Mark as success when progress is 100%
|
||||
: state.epgs[data.source]?.status || 'idle';
|
||||
|
||||
// Create a new epgs object with the updated source status
|
||||
const newEpgs = {
|
||||
...state.epgs,
|
||||
[data.source]: {
|
||||
...state.epgs[data.source],
|
||||
status: sourceStatus,
|
||||
last_message: data.status === 'error' ? (data.error || 'Unknown error') : state.epgs[data.source]?.last_message
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
refreshProgress: newRefreshProgress,
|
||||
epgs: newEpgs
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
export default useEPGsStore;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ const usePlaylistsStore = create((set) => ({
|
|||
profileSearchPreview: '',
|
||||
profileResult: '',
|
||||
|
||||
// Add a state variable to trigger M3U editing
|
||||
editPlaylistId: null,
|
||||
|
||||
setEditPlaylistId: (id) =>
|
||||
set((state) => ({
|
||||
editPlaylistId: id,
|
||||
})),
|
||||
|
||||
fetchPlaylists: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
|
|
@ -65,13 +73,37 @@ const usePlaylistsStore = create((set) => ({
|
|||
// @TODO: remove playlist profiles here
|
||||
})),
|
||||
|
||||
setRefreshProgress: (data) =>
|
||||
set((state) => ({
|
||||
refreshProgress: {
|
||||
...state.refreshProgress,
|
||||
[data.account]: data,
|
||||
},
|
||||
})),
|
||||
setRefreshProgress: (accountIdOrData, data) =>
|
||||
set((state) => {
|
||||
// If called with two parameters, it's the direct setter
|
||||
if (data !== undefined) {
|
||||
return {
|
||||
refreshProgress: {
|
||||
...state.refreshProgress,
|
||||
[accountIdOrData]: data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// If called with WebSocket data, preserve 'initializing' status
|
||||
// until we get a real progress update from the server
|
||||
const accountId = accountIdOrData.account;
|
||||
const existingProgress = state.refreshProgress[accountId];
|
||||
|
||||
// Don't replace 'initializing' status with empty/early server messages
|
||||
if (existingProgress &&
|
||||
existingProgress.action === 'initializing' &&
|
||||
accountIdOrData.progress === 0) {
|
||||
return state; // Keep showing 'initializing' until real progress comes
|
||||
}
|
||||
|
||||
return {
|
||||
refreshProgress: {
|
||||
...state.refreshProgress,
|
||||
[accountId]: accountIdOrData,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
removeRefreshProgress: (id) =>
|
||||
set((state) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import api from '../api';
|
|||
|
||||
const useSettingsStore = create((set) => ({
|
||||
settings: {},
|
||||
environment: {},
|
||||
environment: {
|
||||
// Add default values for environment settings
|
||||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
|
@ -18,7 +24,12 @@ const useSettingsStore = create((set) => ({
|
|||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
environment: env,
|
||||
environment: env || {
|
||||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: 'Failed to load settings.', isLoading: false });
|
||||
|
|
|
|||
29
frontend/src/store/warnings.jsx
Normal file
29
frontend/src/store/warnings.jsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
const useWarningsStore = create((set) => ({
|
||||
// Map of action keys to whether they're suppressed
|
||||
suppressedWarnings: {},
|
||||
|
||||
// Function to check if a warning is suppressed
|
||||
isWarningSuppressed: (actionKey) => {
|
||||
const state = useWarningsStore.getState();
|
||||
return state.suppressedWarnings[actionKey] === true;
|
||||
},
|
||||
|
||||
// Function to suppress a warning
|
||||
suppressWarning: (actionKey, suppressed = true) => {
|
||||
set((state) => ({
|
||||
suppressedWarnings: {
|
||||
...state.suppressedWarnings,
|
||||
[actionKey]: suppressed
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
// Function to reset all suppressions
|
||||
resetSuppressions: () => {
|
||||
set({ suppressedWarnings: {} });
|
||||
}
|
||||
}));
|
||||
|
||||
export default useWarningsStore;
|
||||
Loading…
Add table
Add a link
Reference in a new issue