epg refactor, new relations and matching

This commit is contained in:
dekzter 2025-03-26 13:28:03 -04:00
parent adf06f96bf
commit de5063d20b
27 changed files with 839 additions and 170 deletions

View file

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

View file

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

View file

@ -40,7 +40,7 @@ class StreamForm(forms.ModelForm):
'name',
'url',
'logo_url',
'tvg_id',
'epg_data',
'local_file',
'channel_group',
]

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -116,7 +116,9 @@ const Example = () => {
};
const deletePlaylist = async (id) => {
setIsLoading(true);
await API.deletePlaylist(id);
setIsLoading(false);
};
const closeModal = (newPlaylist = null) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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