diff --git a/apps/channels/admin.py b/apps/channels/admin.py index 302811af..410f101d 100644 --- a/apps/channels/admin.py +++ b/apps/channels/admin.py @@ -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) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 579d9dfa..e5e365ba 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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() diff --git a/apps/channels/forms.py b/apps/channels/forms.py index bee073b6..342bd0fe 100644 --- a/apps/channels/forms.py +++ b/apps/channels/forms.py @@ -40,7 +40,7 @@ class StreamForm(forms.ModelForm): 'name', 'url', 'logo_url', - 'tvg_id', + 'epg_data', 'local_file', 'channel_group', ] diff --git a/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py b/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py new file mode 100644 index 00000000..814ea1ff --- /dev/null +++ b/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py @@ -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'), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index ec95e309..7f299639 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -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 diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index a075297d..65843dea 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -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: diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index f9b4992b..c9b89c85 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -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"])) diff --git a/apps/epg/migrations/0003_alter_epgdata_tvg_id.py b/apps/epg/migrations/0003_alter_epgdata_tvg_id.py new file mode 100644 index 00000000..f339b981 --- /dev/null +++ b/apps/epg/migrations/0003_alter_epgdata_tvg_id.py @@ -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), + ), + ] diff --git a/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py b/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py new file mode 100644 index 00000000..ff8f7a11 --- /dev/null +++ b/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py @@ -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), + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index 305d30ed..83dc7aad 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -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}" diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index b10e7371..8ea4100d 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -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', - ] \ No newline at end of file + 'epg_source', + ] diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 9b9fa49f..ed0faf92 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -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 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 for tvg_id={tvg_id} from {file_path}") +def parse_programs_for_tvg_id(file_path, epg): + logger.info(f"Parsing 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 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): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 82f2310b..83de4d05 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -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) diff --git a/apps/output/views.py b/apps/output/views.py index cffc20e1..a5c766a0 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b91bb9b..f1abbe02 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index de53fcc4..27a7b049 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index a2cfeb75..1a3f1573 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -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 ( <> { - ({ + value: `${epg.id}`, + label: epg.name, + }))} + size="xs" + mb="xs" + /> + + {/* Filter Input */} + + setTvgFilter(event.currentTarget.value) + } + mb="xs" + size="xs" + /> + + + + + {({ index, style }) => ( +
+ +
+ )} +
+
+ + {
+ setChannelGroupModalOpen(false)} diff --git a/frontend/src/components/forms/Stream.jsx b/frontend/src/components/forms/Stream.jsx index e30ed618..269c8113 100644 --- a/frontend/src/components/forms/Stream.jsx +++ b/frontend/src/components/forms/Stream.jsx @@ -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'; diff --git a/frontend/src/components/sidebar.css b/frontend/src/components/sidebar.css index f24edd4a..4626db4c 100644 --- a/frontend/src/components/sidebar.css +++ b/frontend/src/components/sidebar.css @@ -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; diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index c3a9a17f..021ce012 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -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, diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 3add80e6..f937a481 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -116,7 +116,9 @@ const Example = () => { }; const deletePlaylist = async (id) => { + setIsLoading(true); await API.deletePlaylist(id); + setIsLoading(false); }; const closeModal = (newPlaylist = null) => { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 2b8c8f3c..680d4c7d 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -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 }) => ( diff --git a/frontend/src/helpers/table.jsx b/frontend/src/helpers/table.jsx index 208a4100..7f7205c4 100644 --- a/frontend/src/helpers/table.jsx +++ b/frontend/src/helpers/table.jsx @@ -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, diff --git a/frontend/src/mantineTheme.jsx b/frontend/src/mantineTheme.jsx index 280320c8..6c3611c7 100644 --- a/frontend/src/mantineTheme.jsx +++ b/frontend/src/mantineTheme.jsx @@ -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)', diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 67dad742..6c7f61f8 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -462,7 +462,11 @@ export default function TVChannelGuide({ startDate, endDate }) { {now.isAfter(dayjs(selectedProgram.start_time)) && now.isBefore(dayjs(selectedProgram.end_time)) && ( - diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index d22eac46..62675ff6 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -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 }) => ( - -
- stopChannel(row.original.uuid)} - > - - -
-
- ), - }); + // 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 }) => ( + // + //
+ // stopChannel(row.original.uuid)} + // > + // + // + //
+ //
+ // ), + // }); 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 }) => {row.original.user_agent}, + 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 ( - - {activeChannels.map((channel) => ( - - - - - {channel.name} - channel logo - + + {Object.values(activeChannels).map((channel) => ( + + + + channel logo @@ -288,19 +373,39 @@ const ChannelsPage = () => { + + + + + {channel.name} + + + + - - - Clients - {channel.client_count} - - - + + + + {formatSpeed(channel.bitrates.at(-1))} + + + Avg: {channel.avg_bitrate} + + + + {formatBytes(channel.total_bytes)} + + + + + {channel.client_count} + + + + ))} diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx index c749c6bb..45257c0c 100644 --- a/frontend/src/store/epgs.jsx +++ b/frontend/src/store/epgs.jsx @@ -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)) + ), })), }));