forked from Mirrors/Dispatcharr
epg refactor, new relations and matching
This commit is contained in:
parent
adf06f96bf
commit
de5063d20b
27 changed files with 839 additions and 170 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__)
|
||||
|
||||
|
|
@ -164,7 +165,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,
|
||||
|
|
@ -219,8 +226,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
|
||||
|
|
@ -68,6 +70,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 +101,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 +136,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:
|
||||
|
|
|
|||
|
|
@ -84,9 +84,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 +122,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 +166,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 +188,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"]))
|
||||
|
|
|
|||
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),
|
||||
),
|
||||
]
|
||||
|
|
@ -19,8 +19,15 @@ 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",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"EPG Data for {self.name}"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -62,20 +62,21 @@ 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}")
|
||||
|
||||
# Read entire file (decompress if .gz)
|
||||
|
|
@ -101,7 +102,8 @@ def parse_channels_only(file_path):
|
|||
|
||||
epg_obj, created = EPGData.objects.get_or_create(
|
||||
tvg_id=tvg_id,
|
||||
defaults={'name': display_name}
|
||||
epg_source=source,
|
||||
defaults={'name': display_name},
|
||||
)
|
||||
if not created:
|
||||
# Optionally update if new name is different
|
||||
|
|
@ -110,13 +112,11 @@ def parse_channels_only(file_path):
|
|||
epg_obj.save()
|
||||
logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}")
|
||||
|
||||
parse_programs_for_tvg_id(file_path, tvg_id)
|
||||
|
||||
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 +128,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 +141,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):
|
||||
|
|
|
|||
|
|
@ -235,14 +235,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
|
||||
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -434,31 +455,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 +581,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ChannelGroupForm
|
||||
isOpen={channelGroupModelOpen}
|
||||
onClose={() => setChannelGroupModalOpen(false)}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const EPGsTable = () => {
|
|||
const [epgModalOpen, setEPGModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
const { epgs } = useEPGsStore();
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ const EPGsTable = () => {
|
|||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: epgs,
|
||||
data: Object.values(epgs),
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: false,
|
||||
|
|
|
|||
|
|
@ -116,7 +116,9 @@ const Example = () => {
|
|||
};
|
||||
|
||||
const deletePlaylist = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deletePlaylist(id);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const closeModal = (newPlaylist = null) => {
|
||||
|
|
|
|||
|
|
@ -142,7 +142,10 @@ 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}>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export default {
|
|||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
borderColor: '#444',
|
||||
color: '#E0E0E0',
|
||||
// color: '#E0E0E0',
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
},
|
||||
|
|
@ -56,7 +56,7 @@ export default {
|
|||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
fontWeight: 'normal',
|
||||
color: '#CFCFCF',
|
||||
// color: '#CFCFCF',
|
||||
backgroundColor: '#383A3F',
|
||||
borderColor: '#444',
|
||||
// fontWeight: 600,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import { createTheme, MantineProvider, rem } from '@mantine/core';
|
||||
|
||||
const theme = createTheme({
|
||||
globalStyles: (theme) => ({
|
||||
':root': {
|
||||
'--mantine-color-text': '#fff',
|
||||
'--mantine-color-body': '#27272A',
|
||||
},
|
||||
':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)',
|
||||
|
|
|
|||
|
|
@ -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,21 +29,30 @@ 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)),
|
||||
epgs: Object.fromEntries(
|
||||
Object.entries(state.epgs).filter(([id]) => !epgIds.includes(id))
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue