mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 09:09:22 +00:00
Merge branch 'epg-refactor' of https://github.com/Dispatcharr/Dispatcharr into dev
This commit is contained in:
commit
9c3e20e756
43 changed files with 1260 additions and 320 deletions
|
|
@ -26,10 +26,10 @@ class ChannelAdmin(admin.ModelAdmin):
|
|||
'uuid',
|
||||
'name',
|
||||
'channel_group',
|
||||
'tvg_name'
|
||||
'epg_data'
|
||||
)
|
||||
list_filter = ('channel_group',)
|
||||
search_fields = ('id', 'name', 'channel_group__name', 'tvg_name') # Added 'id'
|
||||
search_fields = ('id', 'name', 'channel_group__name', 'epg_data') # Added 'id'
|
||||
ordering = ('channel_number',)
|
||||
|
||||
@admin.register(ChannelGroup)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from .tasks import match_epg_channels
|
|||
import django_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
from apps.epg.models import EPGData
|
||||
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
|
@ -196,6 +197,12 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
'logo_url': stream.logo_url,
|
||||
'streams': [stream_id]
|
||||
}
|
||||
|
||||
# Attempt to find existing EPGs with the same tvg-id
|
||||
epgs = EPGData.objects.filter(tvg_id=stream.tvg_id)
|
||||
if epgs:
|
||||
channel_data["epg_data_id"] = epgs.first().id
|
||||
|
||||
serializer = self.get_serializer(data=channel_data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
channel = serializer.save()
|
||||
|
|
@ -291,6 +298,12 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
"logo_url": stream.logo_url,
|
||||
"streams": [stream_id],
|
||||
}
|
||||
|
||||
# Attempt to find existing EPGs with the same tvg-id
|
||||
epgs = EPGData.objects.filter(tvg_id=stream.tvg_id)
|
||||
if epgs:
|
||||
channel_data["epg_data"] = epgs.first()
|
||||
|
||||
serializer = self.get_serializer(data=channel_data)
|
||||
if serializer.is_valid():
|
||||
channel = serializer.save()
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class StreamForm(forms.ModelForm):
|
|||
'name',
|
||||
'url',
|
||||
'logo_url',
|
||||
'tvg_id',
|
||||
'epg_data',
|
||||
'local_file',
|
||||
'channel_group',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 5.1.6 on 2025-03-26 12:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0008_stream_stream_hash'),
|
||||
('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='channel',
|
||||
name='tvg_name',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='epg_data',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='epg.epgdata'),
|
||||
),
|
||||
]
|
||||
|
|
@ -9,6 +9,7 @@ import uuid
|
|||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from apps.epg.models import EPGData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -132,6 +133,75 @@ class Stream(models.Model):
|
|||
stream = cls.objects.create(**fields_to_update)
|
||||
return stream, True # True means it was created
|
||||
|
||||
# @TODO: honor stream's stream profile
|
||||
def get_stream_profile(self):
|
||||
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
|
||||
|
||||
return stream_profile
|
||||
|
||||
def get_stream(self):
|
||||
"""
|
||||
Finds an available stream for the requested channel and returns the selected stream and profile.
|
||||
"""
|
||||
|
||||
profile_id = redis_client.get(f"stream_profile:{self.id}")
|
||||
if profile_id:
|
||||
profile_id = int(profile_id)
|
||||
return self.id, profile_id
|
||||
|
||||
# Retrieve the M3U account associated with the stream.
|
||||
m3u_account = self.m3u_account
|
||||
m3u_profiles = m3u_account.profiles.all()
|
||||
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
|
||||
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
|
||||
|
||||
for profile in profiles:
|
||||
logger.info(profile)
|
||||
# Skip inactive profiles
|
||||
if profile.is_active == False:
|
||||
continue
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile.id}"
|
||||
current_connections = int(redis_client.get(profile_connections_key) or 0)
|
||||
|
||||
# Check if profile has available slots (or unlimited connections)
|
||||
if profile.max_streams == 0 or current_connections < profile.max_streams:
|
||||
# Start a new stream
|
||||
redis_client.set(f"channel_stream:{self.id}", self.id)
|
||||
redis_client.set(f"stream_profile:{self.id}", profile.id) # Store only the matched profile
|
||||
|
||||
# Increment connection count for profiles with limits
|
||||
if profile.max_streams > 0:
|
||||
redis_client.incr(profile_connections_key)
|
||||
|
||||
return self.id, profile.id # Return newly assigned stream and matched profile
|
||||
|
||||
# 4. No available streams
|
||||
return None, None
|
||||
|
||||
def release_stream(self):
|
||||
"""
|
||||
Called when a stream is finished to release the lock.
|
||||
"""
|
||||
stream_id = self.id
|
||||
# Get the matched profile for cleanup
|
||||
profile_id = redis_client.get(f"stream_profile:{stream_id}")
|
||||
if not profile_id:
|
||||
logger.debug("Invalid profile ID pulled from stream index")
|
||||
return
|
||||
|
||||
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
|
||||
|
||||
profile_id = int(profile_id)
|
||||
logger.debug(f"Found profile ID {profile_id} associated with stream {stream_id}")
|
||||
|
||||
profile_connections_key = f"profile_connections:{profile_id}"
|
||||
|
||||
# Only decrement if the profile had a max_connections limit
|
||||
current_count = int(redis_client.get(profile_connections_key) or 0)
|
||||
if current_count > 0:
|
||||
redis_client.decr(profile_connections_key)
|
||||
|
||||
class ChannelManager(models.Manager):
|
||||
def active(self):
|
||||
return self.all()
|
||||
|
|
@ -164,7 +234,13 @@ class Channel(models.Model):
|
|||
help_text="Channel group this channel belongs to."
|
||||
)
|
||||
tvg_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
tvg_name = models.CharField(max_length=255, blank=True, null=True)
|
||||
epg_data = models.ForeignKey(
|
||||
EPGData,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='channels'
|
||||
)
|
||||
|
||||
stream_profile = models.ForeignKey(
|
||||
StreamProfile,
|
||||
|
|
@ -190,6 +266,7 @@ class Channel(models.Model):
|
|||
def __str__(self):
|
||||
return f"{self.channel_number} - {self.name}"
|
||||
|
||||
# @TODO: honor stream's stream profile
|
||||
def get_stream_profile(self):
|
||||
stream_profile = self.stream_profile
|
||||
if not stream_profile:
|
||||
|
|
@ -219,8 +296,6 @@ class Channel(models.Model):
|
|||
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
|
||||
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
|
||||
|
||||
logger.info('profiles')
|
||||
|
||||
for profile in profiles:
|
||||
logger.info(profile)
|
||||
# Skip inactive profiles
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount
|
||||
from apps.epg.serializers import EPGDataSerializer
|
||||
from core.models import StreamProfile
|
||||
from apps.epg.models import EPGData
|
||||
|
||||
#
|
||||
# Stream
|
||||
|
|
@ -12,7 +14,7 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
allow_null=True,
|
||||
required=False
|
||||
)
|
||||
read_only_fields = ['is_custom', 'm3u_account']
|
||||
read_only_fields = ['is_custom', 'm3u_account', 'stream_hash']
|
||||
|
||||
class Meta:
|
||||
model = Stream
|
||||
|
|
@ -29,6 +31,7 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
'stream_profile_id',
|
||||
'is_custom',
|
||||
'channel_group',
|
||||
'stream_hash',
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
|
|
@ -68,6 +71,13 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
write_only=True,
|
||||
required=False
|
||||
)
|
||||
epg_data = EPGDataSerializer(read_only=True)
|
||||
epg_data_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=EPGData.objects.all(),
|
||||
source="epg_data",
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
stream_profile_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=StreamProfile.objects.all(),
|
||||
|
|
@ -92,7 +102,8 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
'channel_group',
|
||||
'channel_group_id',
|
||||
'tvg_id',
|
||||
'tvg_name',
|
||||
'epg_data',
|
||||
'epg_data_id',
|
||||
'streams',
|
||||
'stream_ids',
|
||||
'stream_profile_id',
|
||||
|
|
@ -126,7 +137,7 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
instance.name = validated_data.get('name', instance.name)
|
||||
instance.logo_url = validated_data.get('logo_url', instance.logo_url)
|
||||
instance.tvg_id = validated_data.get('tvg_id', instance.tvg_id)
|
||||
instance.tvg_name = validated_data.get('tvg_name', instance.tvg_name)
|
||||
instance.epg_data = validated_data.get('epg_data', instance.epg_data)
|
||||
|
||||
# If serializer allows changing channel_group or stream_profile:
|
||||
if 'channel_group' in validated_data:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ from apps.epg.models import EPGData, EPGSource
|
|||
from core.models import CoreSettings
|
||||
from apps.epg.tasks import parse_programs_for_tvg_id # <-- we import our new helper
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load the sentence-transformers model once at the module level
|
||||
|
|
@ -84,9 +87,10 @@ def match_epg_channels():
|
|||
region_code = None
|
||||
|
||||
# Gather EPGData rows so we can do fuzzy matching in memory
|
||||
all_epg = list(EPGData.objects.all())
|
||||
all_epg = {e.id: e for e in EPGData.objects.all()}
|
||||
|
||||
epg_rows = []
|
||||
for e in all_epg:
|
||||
for e in list(all_epg.values()):
|
||||
epg_rows.append({
|
||||
"epg_id": e.id,
|
||||
"tvg_id": e.tvg_id or "",
|
||||
|
|
@ -121,7 +125,7 @@ def match_epg_channels():
|
|||
continue
|
||||
|
||||
# C) Perform name-based fuzzy matching
|
||||
fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.name
|
||||
fallback_name = chan.epg_data.name.strip() if chan.epg_data else chan.name
|
||||
norm_chan = normalize_name(fallback_name)
|
||||
if not norm_chan:
|
||||
logger.info(f"Channel {chan.id} '{chan.name}' => empty after normalization, skipping")
|
||||
|
|
@ -165,12 +169,12 @@ def match_epg_channels():
|
|||
|
||||
# If best_score is above BEST_FUZZY_THRESHOLD => direct accept
|
||||
if best_score >= BEST_FUZZY_THRESHOLD:
|
||||
chan.tvg_id = best_epg["tvg_id"]
|
||||
chan.epg_data = all_epg[best_epg["epg_id"]]
|
||||
chan.save()
|
||||
|
||||
# Attempt to parse program data for this channel
|
||||
if epg_file_path:
|
||||
parse_programs_for_tvg_id(epg_file_path, best_epg["tvg_id"])
|
||||
parse_programs_for_tvg_id(epg_file_path, all_epg[best_epg["epg_id"]])
|
||||
logger.info(f"Loaded program data for tvg_id={best_epg['tvg_id']}")
|
||||
|
||||
matched_channels.append((chan.id, fallback_name, best_epg["tvg_id"]))
|
||||
|
|
@ -187,11 +191,11 @@ def match_epg_channels():
|
|||
top_value = float(sim_scores[top_index])
|
||||
if top_value >= EMBED_SIM_THRESHOLD:
|
||||
matched_epg = epg_rows[top_index]
|
||||
chan.tvg_id = matched_epg["tvg_id"]
|
||||
chan.epg_data = all_epg[matched_epg["epg_id"]]
|
||||
chan.save()
|
||||
|
||||
if epg_file_path:
|
||||
parse_programs_for_tvg_id(epg_file_path, matched_epg["tvg_id"])
|
||||
parse_programs_for_tvg_id(epg_file_path, all_epg[matched_epg["epg_id"]])
|
||||
logger.info(f"Loaded program data for tvg_id={matched_epg['tvg_id']}")
|
||||
|
||||
matched_channels.append((chan.id, fallback_name, matched_epg["tvg_id"]))
|
||||
|
|
@ -219,4 +223,14 @@ def match_epg_channels():
|
|||
logger.info("No new channels were matched.")
|
||||
|
||||
logger.info("Finished EPG matching logic.")
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
'updates',
|
||||
{
|
||||
'type': 'update',
|
||||
"data": {"success": True, "type": "epg_match"}
|
||||
}
|
||||
)
|
||||
|
||||
return f"Done. Matched {total_matched} channel(s)."
|
||||
|
|
|
|||
18
apps/epg/migrations/0003_alter_epgdata_tvg_id.py
Normal file
18
apps/epg/migrations/0003_alter_epgdata_tvg_id.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.6 on 2025-03-25 19:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0002_epgsource_file_path'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='epgdata',
|
||||
name='tvg_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 5.1.6 on 2025-03-26 12:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0003_alter_epgdata_tvg_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='epgdata',
|
||||
name='epg_source',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='epgs', to='epg.epgsource'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='epgdata',
|
||||
name='tvg_id',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 5.1.6 on 2025-03-27 17:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='programdata',
|
||||
name='custom_properties',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='epgdata',
|
||||
unique_together={('tvg_id', 'epg_source')},
|
||||
),
|
||||
]
|
||||
|
|
@ -19,8 +19,18 @@ class EPGSource(models.Model):
|
|||
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).
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True, unique=True)
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||||
name = models.CharField(max_length=255)
|
||||
epg_source = models.ForeignKey(
|
||||
EPGSource,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="epgs",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('tvg_id', 'epg_source')
|
||||
|
||||
def __str__(self):
|
||||
return f"EPG Data for {self.name}"
|
||||
|
|
@ -34,6 +44,7 @@ class ProgramData(models.Model):
|
|||
sub_title = models.CharField(max_length=255, blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.start_time} - {self.end_time})"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ from .models import EPGSource, EPGData, ProgramData
|
|||
from apps.channels.models import Channel
|
||||
|
||||
class EPGSourceSerializer(serializers.ModelSerializer):
|
||||
epg_data_ids = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = EPGSource
|
||||
fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active']
|
||||
fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active', 'epg_data_ids']
|
||||
|
||||
def get_epg_data_ids(self, obj):
|
||||
return list(obj.epgs.values_list('id', flat=True))
|
||||
|
||||
class ProgramDataSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
|
@ -17,10 +22,13 @@ class EPGDataSerializer(serializers.ModelSerializer):
|
|||
Only returns the tvg_id and the 'name' field from EPGData.
|
||||
We assume 'name' is effectively the channel name.
|
||||
"""
|
||||
read_only_fields = ['epg_source']
|
||||
|
||||
class Meta:
|
||||
model = EPGData
|
||||
fields = [
|
||||
'id',
|
||||
'tvg_id',
|
||||
'name',
|
||||
]
|
||||
'epg_source',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ from django.db import transaction
|
|||
from django.utils import timezone
|
||||
from apps.channels.models import Channel
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
|
||||
|
|
@ -62,21 +64,23 @@ def fetch_xmltv(source):
|
|||
source.file_path = file_path
|
||||
source.save(update_fields=['file_path'])
|
||||
|
||||
epg_entries = EPGData.objects.exclude(tvg_id__isnull=True).exclude(tvg_id__exact='')
|
||||
for epg in epg_entries:
|
||||
if Channel.objects.filter(tvg_id=epg.tvg_id).exists():
|
||||
logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}")
|
||||
parse_programs_for_tvg_id(file_path, epg.tvg_id)
|
||||
|
||||
# Now parse <channel> blocks only
|
||||
parse_channels_only(file_path)
|
||||
parse_channels_only(source, file_path)
|
||||
|
||||
epg_entries = EPGData.objects.filter(epg_source=source)
|
||||
for epg in epg_entries:
|
||||
if epg.tvg_id:
|
||||
if Channel.objects.filter(epg_data=epg).exists():
|
||||
logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}")
|
||||
parse_programs_for_tvg_id(file_path, epg)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True)
|
||||
|
||||
|
||||
def parse_channels_only(file_path):
|
||||
def parse_channels_only(source, file_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'):
|
||||
|
|
@ -90,33 +94,48 @@ def parse_channels_only(file_path):
|
|||
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}")
|
||||
with transaction.atomic():
|
||||
for channel_elem in channels:
|
||||
tvg_id = channel_elem.get('id', '').strip()
|
||||
if not tvg_id:
|
||||
continue # skip blank/invalid IDs
|
||||
for channel_elem in 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()
|
||||
display_name = channel_elem.findtext('display-name', default=tvg_id).strip()
|
||||
|
||||
epg_obj, created = EPGData.objects.get_or_create(
|
||||
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,
|
||||
defaults={'name': display_name}
|
||||
)
|
||||
if not created:
|
||||
# Optionally update if new name is different
|
||||
if epg_obj.name != display_name:
|
||||
epg_obj.name = display_name
|
||||
epg_obj.save()
|
||||
logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}")
|
||||
name=display_name,
|
||||
epg_source=source,
|
||||
))
|
||||
|
||||
parse_programs_for_tvg_id(file_path, tvg_id)
|
||||
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"])
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
def parse_programs_for_tvg_id(file_path, tvg_id):
|
||||
logger.info(f"Parsing <programme> for tvg_id={tvg_id} from {file_path}")
|
||||
def parse_programs_for_tvg_id(file_path, epg):
|
||||
logger.info(f"Parsing <programme> for tvg_id={epg.tvg_id} from {file_path}")
|
||||
|
||||
# Read entire file (decompress if .gz)
|
||||
if file_path.endswith('.gz'):
|
||||
|
|
@ -128,16 +147,10 @@ def parse_programs_for_tvg_id(file_path, tvg_id):
|
|||
xml_data = xml_file.read()
|
||||
|
||||
root = ET.fromstring(xml_data)
|
||||
# Retrieve the EPGData record
|
||||
try:
|
||||
epg_obj = EPGData.objects.get(tvg_id=tvg_id)
|
||||
except EPGData.DoesNotExist:
|
||||
logger.warning(f"No EPGData record found for tvg_id={tvg_id}")
|
||||
return
|
||||
|
||||
# Find only <programme> elements for this tvg_id
|
||||
matched_programmes = [p for p in root.findall('programme') if p.get('channel') == tvg_id]
|
||||
logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={tvg_id}")
|
||||
matched_programmes = [p for p in root.findall('programme') if p.get('channel') == epg.tvg_id]
|
||||
logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={epg.tvg_id}")
|
||||
|
||||
with transaction.atomic():
|
||||
for prog in matched_programmes:
|
||||
|
|
@ -147,19 +160,19 @@ def parse_programs_for_tvg_id(file_path, tvg_id):
|
|||
desc = prog.findtext('desc', default='')
|
||||
|
||||
obj, created = ProgramData.objects.update_or_create(
|
||||
epg=epg_obj,
|
||||
epg=epg,
|
||||
start_time=start_time,
|
||||
title=title,
|
||||
defaults={
|
||||
'end_time': end_time,
|
||||
'description': desc,
|
||||
'sub_title': '',
|
||||
'tvg_id': tvg_id,
|
||||
'tvg_id': epg.tvg_id,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
logger.debug(f"Created ProgramData: {title} [{start_time} - {end_time}]")
|
||||
logger.info(f"Completed program parsing for tvg_id={tvg_id}.")
|
||||
logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.")
|
||||
|
||||
|
||||
def fetch_schedules_direct(source):
|
||||
|
|
|
|||
18
apps/m3u/migrations/0005_m3uaccount_custom_properties.py
Normal file
18
apps/m3u/migrations/0005_m3uaccount_custom_properties.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.6 on 2025-03-27 17:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('m3u', '0004_m3uaccount_stream_profile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='custom_properties',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -67,6 +67,7 @@ class M3UAccount(models.Model):
|
|||
blank=True,
|
||||
related_name='m3u_accounts'
|
||||
)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from asgiref.sync import async_to_sync
|
|||
from channels.layers import get_channel_layer
|
||||
from django.utils import timezone
|
||||
import time
|
||||
from channels.layers import get_channel_layer
|
||||
import json
|
||||
from core.utils import redis_client
|
||||
from core.models import CoreSettings
|
||||
|
|
@ -235,14 +234,13 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys):
|
|||
logger.error(json.dumps(stream_info))
|
||||
|
||||
existing_streams = {s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys())}
|
||||
logger.info(f"Hashed {len(stream_hashes.keys())} unique streams")
|
||||
|
||||
for stream_hash, stream_props in stream_hashes.items():
|
||||
if stream_hash in existing_streams:
|
||||
obj = existing_streams[stream_hash]
|
||||
changed = False
|
||||
for key, value in stream_props.items():
|
||||
if getattr(obj, key) == value:
|
||||
if hasattr(obj, key) and getattr(obj, key) == value:
|
||||
continue
|
||||
changed = True
|
||||
setattr(obj, key, value)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ def generate_m3u(request):
|
|||
for channel in channels:
|
||||
group_title = channel.channel_group.name if channel.channel_group else "Default"
|
||||
tvg_id = channel.tvg_id or ""
|
||||
tvg_name = channel.tvg_name or channel.name
|
||||
tvg_name = channel.tvg_id or channel.name
|
||||
tvg_logo = channel.logo_url or ""
|
||||
channel_number = channel.channel_number
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import os
|
|||
import json
|
||||
from typing import Dict, Optional, Set
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
from apps.channels.models import Channel
|
||||
from apps.channels.models import Channel, Stream
|
||||
from core.utils import redis_client as global_redis_client, redis_pubsub_client as global_redis_pubsub_client # Import both global Redis clients
|
||||
from redis.exceptions import ConnectionError, TimeoutError
|
||||
from .stream_manager import StreamManager
|
||||
|
|
@ -740,12 +740,16 @@ class ProxyServer:
|
|||
|
||||
# Force release resources in the Channel model
|
||||
try:
|
||||
from apps.channels.models import Channel
|
||||
channel = Channel.objects.get(uuid=channel_id)
|
||||
channel.release_stream()
|
||||
logger.info(f"Released stream allocation for zombie channel {channel_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}")
|
||||
try:
|
||||
stream = Stream.objects.get(stream_hash=channel_id)
|
||||
stream.release_stream()
|
||||
logger.info(f"Released stream allocation for zombie channel {channel_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
|
|
@ -1067,8 +1071,12 @@ class ProxyServer:
|
|||
def _clean_redis_keys(self, channel_id):
|
||||
"""Clean up all Redis keys for a channel more efficiently"""
|
||||
# Release the channel, stream, and profile keys from the channel
|
||||
channel = Channel.objects.get(uuid=channel_id)
|
||||
channel.release_stream()
|
||||
try:
|
||||
channel = Channel.objects.get(uuid=channel_id)
|
||||
channel.release_stream()
|
||||
except:
|
||||
stream = Stream.objects.get(stream_hash=channel_id)
|
||||
stream.release_stream()
|
||||
|
||||
if not self.redis_client:
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -248,8 +248,11 @@ class ChannelService:
|
|||
logger.info(f"Released channel {channel_id} stream allocation")
|
||||
model_released = True
|
||||
except Channel.DoesNotExist:
|
||||
logger.warning(f"Could not find Channel model for UUID {channel_id}")
|
||||
model_released = False
|
||||
logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash")
|
||||
stream = Stream.objects.get(stream_hash=channel_id)
|
||||
stream.release_stream()
|
||||
logger.info(f"Released stream {channel_id} stream allocation")
|
||||
model_released = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing channel stream: {e}")
|
||||
model_released = False
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from .utils import detect_stream_type, get_logger
|
|||
from .redis_keys import RedisKeys
|
||||
from .constants import ChannelState, EventType, StreamType, ChannelMetadataField, TS_PACKET_SIZE
|
||||
from .config_helper import ConfigHelper
|
||||
from .url_utils import get_alternate_streams, get_stream_info_for_switch
|
||||
from .url_utils import get_alternate_streams, get_stream_info_for_switch, get_stream_object
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
|
@ -304,7 +304,7 @@ class StreamManager:
|
|||
"""Establish a connection using transcoding"""
|
||||
try:
|
||||
logger.debug(f"Building transcode command for channel {self.channel_id}")
|
||||
channel = get_object_or_404(Channel, uuid=self.channel_id)
|
||||
channel = get_stream_object(self.channel_id)
|
||||
|
||||
# Use FFmpeg specifically for HLS streams
|
||||
if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg:
|
||||
|
|
@ -909,5 +909,3 @@ class StreamManager:
|
|||
except Exception as e:
|
||||
logger.error(f"Error trying next stream for channel {self.channel_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,20 @@ from apps.channels.models import Channel, Stream
|
|||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from core.models import UserAgent, CoreSettings
|
||||
from .utils import get_logger
|
||||
from uuid import UUID
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
def get_stream_object(id: str):
|
||||
try:
|
||||
uuid_obj = UUID(id, version=4)
|
||||
logger.info(f"Fetching channel ID {id}")
|
||||
return get_object_or_404(Channel, uuid=id)
|
||||
except:
|
||||
# UUID check failed, assume stream hash
|
||||
logger.info(f"Fetching stream hash {id}")
|
||||
return get_object_or_404(Stream, stream_hash=id)
|
||||
|
||||
def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]:
|
||||
"""
|
||||
Generate the appropriate stream URL for a channel based on its profile settings.
|
||||
|
|
@ -24,7 +35,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]:
|
|||
Tuple[str, str, bool]: (stream_url, user_agent, transcode_flag)
|
||||
"""
|
||||
# Get channel and related objects
|
||||
channel = get_object_or_404(Channel, uuid=channel_id)
|
||||
channel = get_stream_object(channel_id)
|
||||
stream_id, profile_id = channel.get_stream()
|
||||
|
||||
if stream_id is None or profile_id is None:
|
||||
|
|
@ -178,7 +189,11 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
|
|||
"""
|
||||
try:
|
||||
# Get channel object
|
||||
channel = get_object_or_404(Channel, uuid=channel_id)
|
||||
channel = get_stream_object(channel_id)
|
||||
if isinstance(channel, Stream):
|
||||
logger.error(f"Stream is not a channel")
|
||||
return []
|
||||
|
||||
logger.debug(f"Looking for alternate streams for channel {channel_id}, current stream ID: {current_stream_id}")
|
||||
|
||||
# Get all assigned streams for this channel
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from .constants import ChannelState, EventType, StreamType, ChannelMetadataField
|
||||
from .config_helper import ConfigHelper
|
||||
from .services.channel_service import ChannelService
|
||||
from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch
|
||||
from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch, get_stream_object
|
||||
from .utils import get_logger
|
||||
from uuid import UUID
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
|
@ -30,9 +31,9 @@ logger = get_logger()
|
|||
@api_view(['GET'])
|
||||
def stream_ts(request, channel_id):
|
||||
"""Stream TS data to client with immediate response and keep-alive packets during initialization"""
|
||||
channel = get_stream_object(channel_id)
|
||||
|
||||
client_user_agent = None
|
||||
logger.info(f"Fetching channel ID {channel_id}")
|
||||
channel = get_object_or_404(Channel, uuid=channel_id)
|
||||
|
||||
try:
|
||||
# Generate a unique client ID
|
||||
|
|
|
|||
334
frontend/package-lock.json
generated
334
frontend/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "vite",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@mantine/charts": "^7.17.2",
|
||||
"@mantine/core": "^7.17.2",
|
||||
"@mantine/dates": "^7.17.2",
|
||||
"@mantine/dropzone": "^7.17.2",
|
||||
|
|
@ -29,6 +30,8 @@
|
|||
"react-draggable": "^4.4.6",
|
||||
"react-pro-sidebar": "^1.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"react-window": "^1.8.11",
|
||||
"recharts": "^2.15.1",
|
||||
"video.js": "^8.21.0",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
|
|
@ -1082,6 +1085,19 @@
|
|||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@mantine/charts": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-7.17.2.tgz",
|
||||
"integrity": "sha512-ckB23pIqRjzysUz2EiWZD9AVyf7t0r7o7zfJbl01nzOezFgYq5RGeRoxvpcsfBC+YoSbB/43rjNcXtYhtA7QzA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "7.17.2",
|
||||
"@mantine/hooks": "7.17.2",
|
||||
"react": "^18.x || ^19.x",
|
||||
"react-dom": "^18.x || ^19.x",
|
||||
"recharts": "^2.13.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/core": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.2.tgz",
|
||||
|
|
@ -1776,6 +1792,69 @@
|
|||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
|
||||
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
|
|
@ -2205,6 +2284,127 @@
|
|||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
|
|
@ -2228,6 +2428,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -2592,6 +2798,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
|
|
@ -2947,6 +3162,15 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
|
|
@ -3215,6 +3439,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
|
|
@ -3640,6 +3870,12 @@
|
|||
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-number-format": {
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.3.tgz",
|
||||
|
|
@ -3753,6 +3989,21 @@
|
|||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
|
|
@ -3808,6 +4059,61 @@
|
|||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "1.8.11",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
|
||||
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"memoize-one": ">=3.1.1 <6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
|
||||
"integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
|
|
@ -3998,6 +4304,12 @@
|
|||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-warning": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||
|
|
@ -4145,6 +4457,28 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/video.js": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.22.0.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/charts": "^7.17.2",
|
||||
"@mantine/core": "^7.17.2",
|
||||
"@mantine/dates": "^7.17.2",
|
||||
"@mantine/dropzone": "^7.17.2",
|
||||
|
|
@ -31,6 +32,8 @@
|
|||
"react-draggable": "^4.4.6",
|
||||
"react-pro-sidebar": "^1.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"react-window": "^1.8.11",
|
||||
"recharts": "^2.15.1",
|
||||
"video.js": "^8.21.0",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import mantineTheme from './mantineTheme';
|
|||
import API from './api';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import M3URefreshNotification from './components/M3URefreshNotification';
|
||||
import 'allotment/dist/style.css';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const miniDrawerWidth = 60;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ export const WebsocketProvider = ({ children }) => {
|
|||
const [val, setVal] = useState(null);
|
||||
|
||||
const { fetchStreams } = useStreamsStore();
|
||||
const { setChannelStats, fetchChannelGroups } = useChannelsStore();
|
||||
const { fetchChannels, setChannelStats, fetchChannelGroups } =
|
||||
useChannelsStore();
|
||||
const { fetchPlaylists, setRefreshProgress } = usePlaylistsStore();
|
||||
const { fetchEPGData } = useEPGsStore();
|
||||
|
||||
|
|
@ -78,6 +79,23 @@ export const WebsocketProvider = ({ children }) => {
|
|||
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: 'EPG match is complete!',
|
||||
color: 'green.5',
|
||||
});
|
||||
fetchChannels();
|
||||
fetchEPGData();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown websocket event type: ${event.type}`);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
|
@ -25,24 +25,32 @@ import {
|
|||
Divider,
|
||||
Stack,
|
||||
useMantineTheme,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
} from '@mantine/core';
|
||||
import { ListOrdered, SquarePlus, SquareX } from 'lucide-react';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
const Channel = ({ channel = null, isOpen, onClose }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const listRef = useRef(null);
|
||||
|
||||
const channelGroups = useChannelsStore((state) => state.channelGroups);
|
||||
const streams = useStreamsStore((state) => state.streams);
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
const { playlists } = usePlaylistsStore();
|
||||
const { tvgs } = useEPGsStore();
|
||||
const { epgs, tvgs, tvgsById } = useEPGsStore();
|
||||
|
||||
const [logoFile, setLogoFile] = useState(null);
|
||||
const [logoPreview, setLogoPreview] = useState(null);
|
||||
const [channelStreams, setChannelStreams] = useState([]);
|
||||
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
||||
const [epgPopoverOpened, setEpgPopoverOpened] = useState(false);
|
||||
const [selectedEPG, setSelectedEPG] = useState({});
|
||||
const [tvgFilter, setTvgFilter] = useState('');
|
||||
|
||||
const addStream = (stream) => {
|
||||
const streamSet = new Set(channelStreams);
|
||||
|
|
@ -74,7 +82,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
channel_group_id: '',
|
||||
stream_profile_id: '0',
|
||||
tvg_id: '',
|
||||
tvg_name: '',
|
||||
epg_data_id: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
|
|
@ -109,27 +117,36 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
setLogoFile(null);
|
||||
setLogoPreview(null);
|
||||
setSubmitting(false);
|
||||
setTvgFilter('');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
if (channel.epg_data) {
|
||||
const epgSource = epgs[channel.epg_data.epg_source];
|
||||
setSelectedEPG(`${epgSource.id}`);
|
||||
}
|
||||
|
||||
formik.setValues({
|
||||
name: channel.name,
|
||||
channel_number: channel.channel_number,
|
||||
channel_group_id: channel.channel_group?.id,
|
||||
stream_profile_id: channel.stream_profile_id || '0',
|
||||
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_name: channel.tvg_name,
|
||||
epg_data_id: channel.epg_data ? `${channel.epg_data?.id}` : '',
|
||||
});
|
||||
|
||||
console.log(channel);
|
||||
setChannelStreams(channel.streams);
|
||||
} else {
|
||||
formik.resetForm();
|
||||
setTvgFilter('');
|
||||
}
|
||||
}, [channel]);
|
||||
}, [channel, tvgsById]);
|
||||
|
||||
// const activeStreamsTable = useMantineReactTable({
|
||||
// data: channelStreams,
|
||||
|
|
@ -263,6 +280,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
return <></>;
|
||||
}
|
||||
|
||||
const filteredTvgs = tvgs
|
||||
.filter((tvg) => tvg.epg_source == selectedEPG)
|
||||
.filter((tvg) => tvg.name.toLowerCase().includes(tvgFilter));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
|
|
@ -296,6 +317,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
name="channel_group_id"
|
||||
label="Channel Group"
|
||||
value={formik.values.channel_group_id}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('channel_group_id', value); // Update Formik's state with the new value
|
||||
}}
|
||||
|
|
@ -434,31 +456,96 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
<Stack gap="5" style={{ flex: 1 }} justify="flex-start">
|
||||
<TextInput
|
||||
id="tvg_name"
|
||||
name="tvg_name"
|
||||
label="TVG Name"
|
||||
value={formik.values.tvg_name}
|
||||
id="tvg_id"
|
||||
name="tvg_id"
|
||||
label="TVG-ID"
|
||||
value={formik.values.tvg_id}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.tvg_name ? formik.touched.tvg_name : ''}
|
||||
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="tvg_id"
|
||||
name="tvg_id"
|
||||
label="TVG ID"
|
||||
searchable
|
||||
value={formik.values.tvg_id}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('tvg_id', value); // Update Formik's state with the new value
|
||||
}}
|
||||
error={formik.errors.tvg_id}
|
||||
data={tvgs.map((tvg) => ({
|
||||
value: tvg.name,
|
||||
label: tvg.tvg_id,
|
||||
}))}
|
||||
size="xs"
|
||||
/>
|
||||
<Popover
|
||||
opened={epgPopoverOpened}
|
||||
onChange={setEpgPopoverOpened}
|
||||
// position="bottom-start"
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<TextInput
|
||||
id="epg_data_id"
|
||||
name="epg_data_id"
|
||||
label="EPG"
|
||||
readOnly
|
||||
value={
|
||||
formik.values.epg_data_id
|
||||
? tvgsById[formik.values.epg_data_id].name
|
||||
: ''
|
||||
}
|
||||
onClick={() => setEpgPopoverOpened(true)}
|
||||
size="xs"
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Group>
|
||||
<Select
|
||||
label="Source"
|
||||
value={selectedEPG}
|
||||
onChange={setSelectedEPG}
|
||||
data={Object.values(epgs).map((epg) => ({
|
||||
value: `${epg.id}`,
|
||||
label: epg.name,
|
||||
}))}
|
||||
size="xs"
|
||||
mb="xs"
|
||||
/>
|
||||
|
||||
{/* Filter Input */}
|
||||
<TextInput
|
||||
label="Filter"
|
||||
value={tvgFilter}
|
||||
onChange={(event) =>
|
||||
setTvgFilter(event.currentTarget.value)
|
||||
}
|
||||
mb="xs"
|
||||
size="xs"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ height: 200 }}>
|
||||
<List
|
||||
height={200} // Set max height for visible items
|
||||
itemCount={filteredTvgs.length}
|
||||
itemSize={40} // Adjust row height for each item
|
||||
width="100%"
|
||||
ref={listRef}
|
||||
>
|
||||
{({ index, style }) => (
|
||||
<div style={style}>
|
||||
<Button
|
||||
key={filteredTvgs[index].id}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
fullWidth
|
||||
justify="left"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
formik.setFieldValue(
|
||||
'epg_data_id',
|
||||
filteredTvgs[index].id
|
||||
);
|
||||
setEpgPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
{filteredTvgs[index].tvg_id}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<TextInput
|
||||
id="logo_url"
|
||||
|
|
@ -495,6 +582,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ChannelGroupForm
|
||||
isOpen={channelGroupModelOpen}
|
||||
onClose={() => setChannelGroupModalOpen(false)}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,18 @@ import {
|
|||
NativeSelect,
|
||||
FileInput,
|
||||
Space,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import M3UGroupFilter from './M3UGroupFilter';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const { userAgents } = useUserAgentsStore();
|
||||
const { fetchChannelGroups } = useChannelsStore();
|
||||
const { setRefreshProgress } = usePlaylistsStore();
|
||||
|
||||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
|
|
@ -190,38 +195,27 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
{playlist && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="filled"
|
||||
// color={theme.custom.colors.buttonPrimary}
|
||||
size="sm"
|
||||
onClick={() => setGroupFilterModalOpen(true)}
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="filled"
|
||||
// color={theme.custom.colors.buttonPrimary}
|
||||
size="sm"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!playlist && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
Save & Select Groups
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
// color={theme.custom.colors.buttonPrimary}
|
||||
disabled={formik.isSubmitting}
|
||||
size="sm"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Modal.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
|
|
@ -79,6 +79,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
id="channel_group"
|
||||
name="channel_group"
|
||||
label="Group"
|
||||
searchable
|
||||
value={formik.values.channel_group}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('channel_group', value); // Update Formik's state with the new value
|
||||
|
|
@ -95,6 +96,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
name="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
placeholder="Optional"
|
||||
searchable
|
||||
value={formik.values.stream_profile_id}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
gap: 12px;
|
||||
padding: 5px 8px !important;
|
||||
border-radius: 6px;
|
||||
color: #D4D4D8; /* Default color when not active */
|
||||
background-color: transparent; /* Default background when not active */
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
|
|
|
|||
|
|
@ -232,9 +232,6 @@ const ChannelsTable = ({}) => {
|
|||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
mantineTableHeadCellProps: {
|
||||
sx: { textAlign: 'center' },
|
||||
},
|
||||
Header: ({ column }) => (
|
||||
<TextInput
|
||||
name="name"
|
||||
|
|
@ -247,6 +244,7 @@ const ChannelsTable = ({}) => {
|
|||
size="xs"
|
||||
variant="unstyled"
|
||||
className="table-input-header"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => (
|
||||
|
|
@ -265,19 +263,21 @@ const ChannelsTable = ({}) => {
|
|||
header: 'Group',
|
||||
accessorFn: (row) => row.channel_group?.name || '',
|
||||
Header: ({ column }) => (
|
||||
<Select
|
||||
placeholder="Group"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFound="No options"
|
||||
onChange={(e, value) => {
|
||||
e.stopPropagation();
|
||||
handleGroupChange(value);
|
||||
}}
|
||||
data={channelGroupOptions}
|
||||
variant="unstyled"
|
||||
className="table-input-header"
|
||||
/>
|
||||
<Box onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
placeholder="Group"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFound="No options"
|
||||
onChange={(e, value) => {
|
||||
e.stopPropagation();
|
||||
handleGroupChange(value);
|
||||
}}
|
||||
data={channelGroupOptions}
|
||||
variant="unstyled"
|
||||
className="table-input-header"
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -502,7 +502,7 @@ const ChannelsTable = ({}) => {
|
|||
},
|
||||
},
|
||||
'mrt-row-actions': {
|
||||
size: 74,
|
||||
size: 50,
|
||||
},
|
||||
},
|
||||
mantineExpandButtonProps: ({ row, table }) => ({
|
||||
|
|
@ -520,7 +520,7 @@ const ChannelsTable = ({}) => {
|
|||
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
|
||||
),
|
||||
renderRowActions: ({ row }) => (
|
||||
<Box sx={{ justifyContent: 'right' }}>
|
||||
<Box style={{ width: '100%', justifyContent: 'left' }}>
|
||||
<Center>
|
||||
<Tooltip label="Edit Channel">
|
||||
<ActionIcon
|
||||
|
|
@ -561,7 +561,7 @@ const ChannelsTable = ({}) => {
|
|||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(100vh - 127px)',
|
||||
height: 'calc(100vh - 110px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
|
|
@ -710,7 +710,7 @@ const ChannelsTable = ({}) => {
|
|||
{/* Paper container: contains top toolbar and table (or ghost state) */}
|
||||
<Paper
|
||||
style={{
|
||||
height: 'calc(100vh - 75px)',
|
||||
height: 'calc(100vh - 60px)',
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ const EPGsTable = () => {
|
|||
const [epgModalOpen, setEPGModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
const { epgs } = useEPGsStore();
|
||||
console.log(epgs);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ const EPGsTable = () => {
|
|||
{
|
||||
header: 'URL / API Key',
|
||||
accessorKey: 'max_streams',
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[]
|
||||
|
|
@ -93,7 +95,7 @@ const EPGsTable = () => {
|
|||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: epgs,
|
||||
data: Object.values(epgs),
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: false,
|
||||
|
|
@ -144,6 +146,11 @@ const EPGsTable = () => {
|
|||
height: 'calc(40vh - 0px)',
|
||||
},
|
||||
},
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,34 +13,11 @@ import {
|
|||
Box,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Select,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
Tv2,
|
||||
ScreenShare,
|
||||
Scroll,
|
||||
SquareMinus,
|
||||
Pencil,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
TvMinimalPlay,
|
||||
SquarePen,
|
||||
RefreshCcw,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconDeviceDesktopSearch,
|
||||
IconSelector,
|
||||
IconSortAscendingNumbers,
|
||||
IconSquarePlus,
|
||||
} from '@tabler/icons-react'; // Import custom icons
|
||||
import M3UGroupFilter from '../forms/M3UGroupFilter';
|
||||
import { SquareMinus, SquarePen, RefreshCcw, Check, X } from 'lucide-react';
|
||||
import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons
|
||||
|
||||
const Example = () => {
|
||||
const M3UTable = () => {
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
|
||||
|
|
@ -116,7 +93,9 @@ const Example = () => {
|
|||
};
|
||||
|
||||
const deletePlaylist = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deletePlaylist(id);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const closeModal = (newPlaylist = null) => {
|
||||
|
|
@ -282,4 +261,4 @@ const Example = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Example;
|
||||
export default M3UTable;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const StreamProfiles = () => {
|
|||
{
|
||||
header: 'Parameters',
|
||||
accessorKey: 'parameters',
|
||||
enableSorting: false,
|
||||
mantineTableBodyCellProps: {
|
||||
style: {
|
||||
whiteSpace: 'nowrap',
|
||||
|
|
@ -61,13 +62,21 @@ const StreamProfiles = () => {
|
|||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 50,
|
||||
size: 10,
|
||||
enableSorting: false,
|
||||
mantineTableHeadCellProps: {
|
||||
align: 'right',
|
||||
},
|
||||
mantineTableBodyCellProps: {
|
||||
align: 'right',
|
||||
},
|
||||
Cell: ({ row, cell }) => (
|
||||
<Center>
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={cell.getValue()}
|
||||
onChange={() => toggleProfileIsActive(row.original)}
|
||||
disabled={row.original.locked}
|
||||
/>
|
||||
</Center>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -35,15 +35,10 @@ import {
|
|||
MultiSelect,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconDeviceDesktopSearch,
|
||||
IconSelector,
|
||||
IconSortAscendingNumbers,
|
||||
IconSquarePlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { IconSquarePlus } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import useVideoStore from '../../store/useVideoStore';
|
||||
|
||||
const StreamsTable = ({}) => {
|
||||
const theme = useMantineTheme();
|
||||
|
|
@ -91,6 +86,10 @@ const StreamsTable = ({}) => {
|
|||
const channelSelectionStreams = useChannelsStore(
|
||||
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
|
||||
);
|
||||
const {
|
||||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
const { showVideo } = useVideoStore();
|
||||
|
||||
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
|
||||
|
||||
|
|
@ -110,9 +109,6 @@ const StreamsTable = ({}) => {
|
|||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
mantineTableHeadCellProps: {
|
||||
style: { textAlign: 'center', backgroundColor: 'rgb(56, 58, 63)' }, // Center-align the header
|
||||
},
|
||||
Header: ({ column }) => (
|
||||
<TextInput
|
||||
name="name"
|
||||
|
|
@ -123,9 +119,6 @@ const StreamsTable = ({}) => {
|
|||
size="xs"
|
||||
variant="unstyled"
|
||||
className="table-input-header"
|
||||
style={{
|
||||
paddingLeft: 10,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => (
|
||||
|
|
@ -142,10 +135,13 @@ const StreamsTable = ({}) => {
|
|||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorFn: (row) => channelGroups[row.channel_group].name,
|
||||
accessorFn: (row) =>
|
||||
channelGroups[row.channel_group]
|
||||
? channelGroups[row.channel_group].name
|
||||
: '',
|
||||
size: 100,
|
||||
Header: ({ column }) => (
|
||||
<Box onClick={handleSelectClick}>
|
||||
<Box onClick={handleSelectClick} style={{ width: '100%' }}>
|
||||
<MultiSelect
|
||||
placeholder="Group"
|
||||
searchable
|
||||
|
|
@ -155,7 +151,11 @@ const StreamsTable = ({}) => {
|
|||
onChange={handleGroupChange}
|
||||
data={groupOptions}
|
||||
variant="unstyled"
|
||||
className="table-input-header"
|
||||
className="table-input-header custom-multiselect"
|
||||
clearable
|
||||
valueComponent={({ value }) => {
|
||||
return <div>foo</div>; // Override to display custom text
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
|
|
@ -429,6 +429,14 @@ const StreamsTable = ({}) => {
|
|||
setPagination(updater);
|
||||
};
|
||||
|
||||
function handleWatchStream(streamHash) {
|
||||
let vidUrl = `/proxy/ts/stream/${streamHash}`;
|
||||
if (env_mode == 'dev') {
|
||||
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
|
||||
}
|
||||
showVideo(vidUrl);
|
||||
}
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
|
|
@ -510,9 +518,10 @@ const StreamsTable = ({}) => {
|
|||
<Tooltip label="Add to Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color={theme.tailwind.blue[4]}
|
||||
color={theme.tailwind.blue[6]}
|
||||
variant="transparent"
|
||||
onClick={() => addStreamToChannel(row.original.id)}
|
||||
style={{ background: 'none' }}
|
||||
disabled={
|
||||
channelsPageSelection.length !== 1 ||
|
||||
(channelSelectionStreams &&
|
||||
|
|
@ -562,13 +571,18 @@ const StreamsTable = ({}) => {
|
|||
>
|
||||
Delete Stream
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => handleWatchStream(row.original.stream_hash)}
|
||||
>
|
||||
Preview Stream
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(100vh - 167px)',
|
||||
height: 'calc(100vh - 150px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const UserAgentsTable = () => {
|
|||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
enableSorting: false,
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -54,6 +55,7 @@ const UserAgentsTable = () => {
|
|||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
enableSorting: false,
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -69,10 +71,14 @@ const UserAgentsTable = () => {
|
|||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
size: 10,
|
||||
sortingFn: 'basic',
|
||||
enableSorting: false,
|
||||
mantineTableHeadCellProps: {
|
||||
align: 'right',
|
||||
},
|
||||
mantineTableBodyCellProps: {
|
||||
align: 'left',
|
||||
align: 'right',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Center>
|
||||
|
|
@ -216,6 +222,11 @@ const UserAgentsTable = () => {
|
|||
height: 'calc(43vh - 55px)',
|
||||
},
|
||||
},
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,10 +14,14 @@ export default {
|
|||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
mantineTableProps: {
|
||||
striped: true,
|
||||
},
|
||||
mantinePaperProps: {
|
||||
style: {
|
||||
'--mrt-selected-row-background-color': '#163632',
|
||||
'--mrt-base-background-color': '#27272A',
|
||||
'--mrt-striped-row-background-color': '#18181B',
|
||||
},
|
||||
},
|
||||
mantineSelectAllCheckboxProps: {
|
||||
|
|
@ -45,7 +49,7 @@ export default {
|
|||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
borderColor: '#444',
|
||||
color: '#E0E0E0',
|
||||
// color: '#E0E0E0',
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
},
|
||||
|
|
@ -56,27 +60,12 @@ export default {
|
|||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
fontWeight: 'normal',
|
||||
color: '#CFCFCF',
|
||||
backgroundColor: '#383A3F',
|
||||
// color: '#CFCFCF',
|
||||
backgroundColor: '#3F3F46',
|
||||
borderColor: '#444',
|
||||
// fontWeight: 600,
|
||||
// fontSize: '0.8rem',
|
||||
},
|
||||
},
|
||||
mantineTableBodyProps: {
|
||||
style: {
|
||||
// Subtle row striping
|
||||
'& tr:nth-of-type(odd)': {
|
||||
backgroundColor: '#2F3034',
|
||||
},
|
||||
'& tr:nth-of-type(even)': {
|
||||
backgroundColor: '#333539',
|
||||
},
|
||||
// Row hover effect
|
||||
'& tr:hover td': {
|
||||
backgroundColor: '#3B3D41',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
/* frontend/src/index.css */
|
||||
:root {
|
||||
--separator-border: transparent !important; /* Override Allotment's default border */
|
||||
--sash-hover-size: 3px !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
|
@ -32,7 +36,7 @@ code {
|
|||
|
||||
@supports (-moz-appearance: none) {
|
||||
input[readonly][aria-haspopup] {
|
||||
pointer-events: auto !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +45,59 @@ 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);
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
color: rgb(207,207,207);
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Ensure Allotment uses its default layout */
|
||||
.split-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Styling for the sash (splitter) */
|
||||
.sash.sash-vertical {
|
||||
position: relative;
|
||||
width: 4px; /* Thin invisible divider */
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Create a short vertical bar */
|
||||
.sash.sash-vertical::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
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 */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Optional: Highlight when hovering */
|
||||
.sash.sash-vertical:hover::before {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Tables should fill available space */
|
||||
.split-view-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* styles.css */
|
||||
.custom-multiselect-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-height: 30px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.custom-multiselect .mantine-MultiSelect-input {
|
||||
min-height: 30px; /* Set a minimum height */
|
||||
max-height: 30px; /* Set max height */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { createTheme, MantineProvider, rem } from '@mantine/core';
|
||||
|
||||
const theme = createTheme({
|
||||
globalStyles: (theme) => ({
|
||||
':root': {
|
||||
'--mantine-color-text': '#fff',
|
||||
'--mantine-color-body': '#27272A',
|
||||
'--mrt-striped-row-background-color': '#fff',
|
||||
},
|
||||
':root[data-mantine-color-scheme="dark"]': {
|
||||
'--mantine-color-text': '#fff',
|
||||
},
|
||||
':root[data-mantine-color-scheme="light"]': {
|
||||
'--mantine-color-text': '#fff',
|
||||
},
|
||||
}),
|
||||
|
||||
tailwind: {
|
||||
red: [
|
||||
'oklch(0.971 0.013 17.38)',
|
||||
|
|
@ -204,6 +218,10 @@ const theme = createTheme({
|
|||
},
|
||||
|
||||
custom: {
|
||||
colors: {
|
||||
buttonPrimary: '#14917E',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
activeBackground: 'rgba(21, 69, 62, 0.67)',
|
||||
activeBorder: '#14917e',
|
||||
|
|
|
|||
|
|
@ -2,17 +2,32 @@ import React, { useState } from 'react';
|
|||
import ChannelsTable from '../components/tables/ChannelsTable';
|
||||
import StreamsTable from '../components/tables/StreamsTable';
|
||||
import { Box, Grid } from '@mantine/core';
|
||||
import { Allotment } from 'allotment';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
return (
|
||||
<Grid style={{ padding: 18 }}>
|
||||
<Grid.Col span={6}>
|
||||
<ChannelsTable />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StreamsTable />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<div style={{ height: '100vh', width: '100%', display: 'flex' }}>
|
||||
<Allotment
|
||||
defaultSizes={[50, 50]}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
className="custom-allotment"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<StreamsTable />
|
||||
</div>
|
||||
</Allotment>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
MINUTE_BLOCK_WIDTH;
|
||||
guideRef.current.scrollLeft = Math.max(scrollPosition, 0);
|
||||
}
|
||||
}, [programs, start]);
|
||||
}, [programs]);
|
||||
|
||||
// Update “now” every 60s
|
||||
useEffect(() => {
|
||||
|
|
@ -462,7 +462,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{now.isAfter(dayjs(selectedProgram.start_time)) &&
|
||||
now.isBefore(dayjs(selectedProgram.end_time)) && (
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button onClick={() => handleWatchStream(selectedProgram)}>
|
||||
<Button
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => handleWatchStream(selectedProgram)}
|
||||
>
|
||||
Watch Now
|
||||
</Button>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import { TableHelper } from '../helpers';
|
||||
|
|
@ -19,24 +20,41 @@ import API from '../api';
|
|||
import useChannelsStore from '../store/channels';
|
||||
import logo from '../images/logo.png';
|
||||
import {
|
||||
Tv2,
|
||||
ScreenShare,
|
||||
Scroll,
|
||||
SquareMinus,
|
||||
CirclePlay,
|
||||
SquarePen,
|
||||
Binary,
|
||||
ArrowDown01,
|
||||
Gauge,
|
||||
HardDriveDownload,
|
||||
HardDriveUpload,
|
||||
SquareX,
|
||||
Timer,
|
||||
Users,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { Sparkline } from '@mantine/charts';
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatSpeed(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const sizes = ['bps', 'Kbps', 'Mbps', 'Gbps'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
const getStartDate = (uptime) => {
|
||||
// Get the current date and time
|
||||
const currentDate = new Date();
|
||||
|
|
@ -56,8 +74,12 @@ const getStartDate = (uptime) => {
|
|||
};
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const { channels, channelsByUUID, stats: channelStats } = useChannelsStore();
|
||||
const [activeChannels, setActiveChannels] = useState([]);
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
|
||||
const [activeChannels, setActiveChannels] = useState({});
|
||||
const [clients, setClients] = useState([]);
|
||||
|
||||
const channelsColumns = useMemo(
|
||||
|
|
@ -148,35 +170,35 @@ const ChannelsPage = () => {
|
|||
await API.stopClient(channelId, clientId);
|
||||
};
|
||||
|
||||
const channelsTable = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
renderTopToolbar: false,
|
||||
columns: channelsColumns,
|
||||
data: activeChannels,
|
||||
enableRowActions: true,
|
||||
mantineTableBodyCellProps: {
|
||||
style: {
|
||||
padding: 4,
|
||||
borderColor: '#444',
|
||||
color: '#E0E0E0',
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
},
|
||||
renderRowActions: ({ row }) => (
|
||||
<Box sx={{ justifyContent: 'right' }}>
|
||||
<Center>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onClick={() => stopChannel(row.original.uuid)}
|
||||
>
|
||||
<SquareX size="18" />
|
||||
</ActionIcon>
|
||||
</Center>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
// const channelsTable = useMantineReactTable({
|
||||
// ...TableHelper.defaultProperties,
|
||||
// renderTopToolbar: false,
|
||||
// columns: channelsColumns,
|
||||
// data: activeChannels,
|
||||
// enableRowActions: true,
|
||||
// mantineTableBodyCellProps: {
|
||||
// style: {
|
||||
// padding: 4,
|
||||
// borderColor: '#444',
|
||||
// color: '#E0E0E0',
|
||||
// fontSize: '0.85rem',
|
||||
// },
|
||||
// },
|
||||
// renderRowActions: ({ row }) => (
|
||||
// <Box sx={{ justifyContent: 'right' }}>
|
||||
// <Center>
|
||||
// <ActionIcon
|
||||
// size="sm"
|
||||
// variant="transparent"
|
||||
// color="red.9"
|
||||
// onClick={() => stopChannel(row.original.uuid)}
|
||||
// >
|
||||
// <SquareX size="18" />
|
||||
// </ActionIcon>
|
||||
// </Center>
|
||||
// </Box>
|
||||
// ),
|
||||
// });
|
||||
|
||||
const clientsTable = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
|
|
@ -184,19 +206,19 @@ const ChannelsPage = () => {
|
|||
data: clients,
|
||||
columns: useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
size: 250,
|
||||
mantineTableBodyCellProps: {
|
||||
style: {
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 400,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
// {
|
||||
// header: 'User-Agent',
|
||||
// accessorKey: 'user_agent',
|
||||
// size: 250,
|
||||
// mantineTableBodyCellProps: {
|
||||
// style: {
|
||||
// whiteSpace: 'nowrap',
|
||||
// maxWidth: 400,
|
||||
// paddingLeft: 10,
|
||||
// paddingRight: 10,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
{
|
||||
header: 'IP Address',
|
||||
accessorKey: 'ip_address',
|
||||
|
|
@ -209,7 +231,7 @@ const ChannelsPage = () => {
|
|||
style: {
|
||||
padding: 4,
|
||||
borderColor: '#444',
|
||||
color: '#E0E0E0',
|
||||
// color: '#E0E0E0',
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
},
|
||||
|
|
@ -236,16 +258,73 @@ const ChannelsPage = () => {
|
|||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
renderDetailPanel: ({ row }) => <Box>{row.original.user_agent}</Box>,
|
||||
mantineExpandButtonProps: ({ row, table }) => ({
|
||||
size: 'xs',
|
||||
style: {
|
||||
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
}),
|
||||
enableExpandAll: false,
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-expand': {
|
||||
size: 15,
|
||||
header: '',
|
||||
// mantineTableHeadCellProps: {
|
||||
// style: {
|
||||
// padding: 0,
|
||||
// minWidth: '20px !important',
|
||||
// },
|
||||
// },
|
||||
// mantineTableBodyCellProps: {
|
||||
// style: {
|
||||
// padding: 0,
|
||||
// minWidth: '20px !important',
|
||||
// },
|
||||
// },
|
||||
},
|
||||
'mrt-row-actions': {
|
||||
size: 74,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const stats = channelStats.channels.map((ch) => ({
|
||||
...ch,
|
||||
...channels[channelsByUUID[ch.channel_id]],
|
||||
}));
|
||||
if (!channelStats.channels) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = channelStats.channels.reduce((acc, ch) => {
|
||||
let bitrates = [];
|
||||
if (activeChannels[ch.channel_id]) {
|
||||
bitrates = activeChannels[ch.channel_id].bitrates;
|
||||
const bitrate =
|
||||
ch.total_bytes - activeChannels[ch.channel_id].total_bytes;
|
||||
if (bitrate > 0) {
|
||||
bitrates.push(bitrate);
|
||||
}
|
||||
|
||||
if (bitrates.length > 15) {
|
||||
bitrates = bitrates.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
acc[ch.channel_id] = {
|
||||
...ch,
|
||||
...channels[channelsByUUID[ch.channel_id]],
|
||||
bitrates,
|
||||
stream_profile: streamProfiles.find(
|
||||
(profile) => profile.id == parseInt(ch.profile)
|
||||
),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
setActiveChannels(stats);
|
||||
|
||||
const clientStats = stats.reduce((acc, ch) => {
|
||||
const clientStats = Object.values(stats).reduce((acc, ch) => {
|
||||
return acc.concat(
|
||||
ch.clients.map((client) => ({
|
||||
...client,
|
||||
|
|
@ -257,19 +336,25 @@ const ChannelsPage = () => {
|
|||
}, [channelStats]);
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={2} spacing="md" style={{ padding: 10 }}>
|
||||
{activeChannels.map((channel) => (
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder>
|
||||
<Stack>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Group>
|
||||
<Title order={5}>{channel.name}</Title>
|
||||
<img
|
||||
src={channel.logo_url || logo}
|
||||
width="20"
|
||||
alt="channel logo"
|
||||
/>
|
||||
</Group>
|
||||
<SimpleGrid cols={3} spacing="md" style={{ padding: 10 }}>
|
||||
{Object.values(activeChannels).map((channel) => (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
color: '#fff',
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
<Stack style={{ position: 'relative' }}>
|
||||
<Group justify="space-between">
|
||||
<img
|
||||
src={channel.logo_url || logo}
|
||||
width="30"
|
||||
alt="channel logo"
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Box>
|
||||
|
|
@ -288,19 +373,39 @@ const ChannelsPage = () => {
|
|||
</Tooltip>
|
||||
</Center>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Flex justify="space-between" align="center">
|
||||
<Group>
|
||||
<Text fw={500}>{channel.name}</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap={5}>
|
||||
<Video size="18" />
|
||||
{channel.stream_profile.name}
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
<Box>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ paddingRight: 10, paddingLeft: 10 }}
|
||||
>
|
||||
<Text>Clients</Text>
|
||||
<Text>{channel.client_count}</Text>
|
||||
</Flex>
|
||||
<MantineReactTable table={clientsTable} />
|
||||
</Box>
|
||||
<Group justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Gauge style={{ paddingRight: 5 }} size="22" />
|
||||
<Text size="sm">{formatSpeed(channel.bitrates.at(-1))}</Text>
|
||||
</Group>
|
||||
|
||||
<Text size="sm">Avg: {channel.avg_bitrate}</Text>
|
||||
|
||||
<Group gap={4}>
|
||||
<HardDriveDownload size="18" />
|
||||
<Text size="sm">{formatBytes(channel.total_bytes)}</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap={5}>
|
||||
<Users size="18" />
|
||||
<Text size="sm">{channel.client_count}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<MantineReactTable table={clientsTable} />
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { create } from 'zustand';
|
|||
import api from '../api';
|
||||
|
||||
const useEPGsStore = create((set) => ({
|
||||
epgs: [],
|
||||
epgs: {},
|
||||
tvgs: [],
|
||||
tvgsById: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
|
@ -11,7 +12,13 @@ const useEPGsStore = create((set) => ({
|
|||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const epgs = await api.getEPGs();
|
||||
set({ epgs: epgs, isLoading: false });
|
||||
set({
|
||||
epgs: epgs.reduce((acc, epg) => {
|
||||
acc[epg.id] = epg;
|
||||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch epgs:', error);
|
||||
set({ error: 'Failed to load epgs.', isLoading: false });
|
||||
|
|
@ -22,22 +29,34 @@ const useEPGsStore = create((set) => ({
|
|||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const tvgs = await api.getEPGData();
|
||||
set({ tvgs: tvgs, isLoading: false });
|
||||
set({
|
||||
tvgs: tvgs,
|
||||
tvgsById: tvgs.reduce((acc, tvg) => {
|
||||
acc[tvg.id] = tvg;
|
||||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tvgs:', error);
|
||||
set({ error: 'Failed to load tvgs.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addEPG: (newPlaylist) =>
|
||||
addEPG: (epg) =>
|
||||
set((state) => ({
|
||||
epgs: [...state.epgs, newPlaylist],
|
||||
epgs: { ...state.epgs, [epg.id]: epg },
|
||||
})),
|
||||
|
||||
removeEPGs: (epgIds) =>
|
||||
set((state) => ({
|
||||
epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)),
|
||||
})),
|
||||
set((state) => {
|
||||
const updatedEPGs = { ...state.epgs };
|
||||
for (const id of epgIds) {
|
||||
delete updatedEPGs[id];
|
||||
}
|
||||
|
||||
return { epgs: updatedEPGs };
|
||||
}),
|
||||
}));
|
||||
|
||||
export default useEPGsStore;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue