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..ceeae01e 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__) @@ -132,6 +133,75 @@ class Stream(models.Model): stream = cls.objects.create(**fields_to_update) return stream, True # True means it was created + # @TODO: honor stream's stream profile + def get_stream_profile(self): + stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id()) + + return stream_profile + + def get_stream(self): + """ + Finds an available stream for the requested channel and returns the selected stream and profile. + """ + + profile_id = redis_client.get(f"stream_profile:{self.id}") + if profile_id: + profile_id = int(profile_id) + return self.id, profile_id + + # Retrieve the M3U account associated with the stream. + m3u_account = self.m3u_account + m3u_profiles = m3u_account.profiles.all() + default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) + profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default] + + for profile in profiles: + logger.info(profile) + # Skip inactive profiles + if profile.is_active == False: + continue + + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + # Check if profile has available slots (or unlimited connections) + if profile.max_streams == 0 or current_connections < profile.max_streams: + # Start a new stream + redis_client.set(f"channel_stream:{self.id}", self.id) + redis_client.set(f"stream_profile:{self.id}", profile.id) # Store only the matched profile + + # Increment connection count for profiles with limits + if profile.max_streams > 0: + redis_client.incr(profile_connections_key) + + return self.id, profile.id # Return newly assigned stream and matched profile + + # 4. No available streams + return None, None + + def release_stream(self): + """ + Called when a stream is finished to release the lock. + """ + stream_id = self.id + # Get the matched profile for cleanup + profile_id = redis_client.get(f"stream_profile:{stream_id}") + if not profile_id: + logger.debug("Invalid profile ID pulled from stream index") + return + + redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association + + profile_id = int(profile_id) + logger.debug(f"Found profile ID {profile_id} associated with stream {stream_id}") + + profile_connections_key = f"profile_connections:{profile_id}" + + # Only decrement if the profile had a max_connections limit + current_count = int(redis_client.get(profile_connections_key) or 0) + if current_count > 0: + redis_client.decr(profile_connections_key) + class ChannelManager(models.Manager): def active(self): return self.all() @@ -164,7 +234,13 @@ class Channel(models.Model): help_text="Channel group this channel belongs to." ) tvg_id = models.CharField(max_length=255, blank=True, null=True) - tvg_name = models.CharField(max_length=255, blank=True, null=True) + epg_data = models.ForeignKey( + EPGData, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='channels' + ) stream_profile = models.ForeignKey( StreamProfile, @@ -190,6 +266,7 @@ class Channel(models.Model): def __str__(self): return f"{self.channel_number} - {self.name}" + # @TODO: honor stream's stream profile def get_stream_profile(self): stream_profile = self.stream_profile if not stream_profile: @@ -219,8 +296,6 @@ class Channel(models.Model): default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default] - logger.info('profiles') - for profile in profiles: logger.info(profile) # Skip inactive profiles diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index a075297d..9f31eea2 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 @@ -12,7 +14,7 @@ class StreamSerializer(serializers.ModelSerializer): allow_null=True, required=False ) - read_only_fields = ['is_custom', 'm3u_account'] + read_only_fields = ['is_custom', 'm3u_account', 'stream_hash'] class Meta: model = Stream @@ -29,6 +31,7 @@ class StreamSerializer(serializers.ModelSerializer): 'stream_profile_id', 'is_custom', 'channel_group', + 'stream_hash', ] def get_fields(self): @@ -68,6 +71,13 @@ class ChannelSerializer(serializers.ModelSerializer): write_only=True, required=False ) + epg_data = EPGDataSerializer(read_only=True) + epg_data_id = serializers.PrimaryKeyRelatedField( + queryset=EPGData.objects.all(), + source="epg_data", + write_only=True, + required=False, + ) stream_profile_id = serializers.PrimaryKeyRelatedField( queryset=StreamProfile.objects.all(), @@ -92,7 +102,8 @@ class ChannelSerializer(serializers.ModelSerializer): 'channel_group', 'channel_group_id', 'tvg_id', - 'tvg_name', + 'epg_data', + 'epg_data_id', 'streams', 'stream_ids', 'stream_profile_id', @@ -126,7 +137,7 @@ class ChannelSerializer(serializers.ModelSerializer): instance.name = validated_data.get('name', instance.name) instance.logo_url = validated_data.get('logo_url', instance.logo_url) instance.tvg_id = validated_data.get('tvg_id', instance.tvg_id) - instance.tvg_name = validated_data.get('tvg_name', instance.tvg_name) + instance.epg_data = validated_data.get('epg_data', instance.epg_data) # If serializer allows changing channel_group or stream_profile: if 'channel_group' in validated_data: diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index f9b4992b..ee2eed79 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -14,6 +14,9 @@ from apps.epg.models import EPGData, EPGSource from core.models import CoreSettings from apps.epg.tasks import parse_programs_for_tvg_id # <-- we import our new helper +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + logger = logging.getLogger(__name__) # Load the sentence-transformers model once at the module level @@ -84,9 +87,10 @@ def match_epg_channels(): region_code = None # Gather EPGData rows so we can do fuzzy matching in memory - all_epg = list(EPGData.objects.all()) + all_epg = {e.id: e for e in EPGData.objects.all()} + epg_rows = [] - for e in all_epg: + for e in list(all_epg.values()): epg_rows.append({ "epg_id": e.id, "tvg_id": e.tvg_id or "", @@ -121,7 +125,7 @@ def match_epg_channels(): continue # C) Perform name-based fuzzy matching - fallback_name = chan.tvg_name.strip() if chan.tvg_name else chan.name + fallback_name = chan.epg_data.name.strip() if chan.epg_data else chan.name norm_chan = normalize_name(fallback_name) if not norm_chan: logger.info(f"Channel {chan.id} '{chan.name}' => empty after normalization, skipping") @@ -165,12 +169,12 @@ def match_epg_channels(): # If best_score is above BEST_FUZZY_THRESHOLD => direct accept if best_score >= BEST_FUZZY_THRESHOLD: - chan.tvg_id = best_epg["tvg_id"] + chan.epg_data = all_epg[best_epg["epg_id"]] chan.save() # Attempt to parse program data for this channel if epg_file_path: - parse_programs_for_tvg_id(epg_file_path, best_epg["tvg_id"]) + parse_programs_for_tvg_id(epg_file_path, all_epg[best_epg["epg_id"]]) logger.info(f"Loaded program data for tvg_id={best_epg['tvg_id']}") matched_channels.append((chan.id, fallback_name, best_epg["tvg_id"])) @@ -187,11 +191,11 @@ def match_epg_channels(): top_value = float(sim_scores[top_index]) if top_value >= EMBED_SIM_THRESHOLD: matched_epg = epg_rows[top_index] - chan.tvg_id = matched_epg["tvg_id"] + chan.epg_data = all_epg[matched_epg["epg_id"]] chan.save() if epg_file_path: - parse_programs_for_tvg_id(epg_file_path, matched_epg["tvg_id"]) + parse_programs_for_tvg_id(epg_file_path, all_epg[matched_epg["epg_id"]]) logger.info(f"Loaded program data for tvg_id={matched_epg['tvg_id']}") matched_channels.append((chan.id, fallback_name, matched_epg["tvg_id"])) @@ -219,4 +223,14 @@ def match_epg_channels(): logger.info("No new channels were matched.") logger.info("Finished EPG matching logic.") + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": {"success": True, "type": "epg_match"} + } + ) + return f"Done. Matched {total_matched} channel(s)." 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/migrations/0005_programdata_custom_properties_and_more.py b/apps/epg/migrations/0005_programdata_custom_properties_and_more.py new file mode 100644 index 00000000..35d8d1c3 --- /dev/null +++ b/apps/epg/migrations/0005_programdata_custom_properties_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-27 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'), + ] + + operations = [ + migrations.AddField( + model_name='programdata', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterUniqueTogether( + name='epgdata', + unique_together={('tvg_id', 'epg_source')}, + ), + ] diff --git a/apps/epg/models.py b/apps/epg/models.py index 305d30ed..c98e2d2b 100644 --- a/apps/epg/models.py +++ b/apps/epg/models.py @@ -19,8 +19,18 @@ class EPGSource(models.Model): class EPGData(models.Model): # Removed the Channel foreign key. We now just store the original tvg_id # and a name (which might simply be the tvg_id if no real channel exists). - tvg_id = models.CharField(max_length=255, null=True, blank=True, unique=True) + tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) name = models.CharField(max_length=255) + epg_source = models.ForeignKey( + EPGSource, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="epgs", + ) + + class Meta: + unique_together = ('tvg_id', 'epg_source') def __str__(self): return f"EPG Data for {self.name}" @@ -34,6 +44,7 @@ class ProgramData(models.Model): sub_title = models.CharField(max_length=255, blank=True, null=True) description = models.TextField(blank=True, null=True) tvg_id = models.CharField(max_length=255, null=True, blank=True) + custom_properties = models.TextField(null=True, blank=True) def __str__(self): return f"{self.title} ({self.start_time} - {self.end_time})" 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..c5045503 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -14,6 +14,8 @@ from django.db import transaction from django.utils import timezone from apps.channels.models import Channel +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer from .models import EPGSource, EPGData, ProgramData @@ -62,21 +64,23 @@ def fetch_xmltv(source): source.file_path = file_path source.save(update_fields=['file_path']) - epg_entries = EPGData.objects.exclude(tvg_id__isnull=True).exclude(tvg_id__exact='') - for epg in epg_entries: - if Channel.objects.filter(tvg_id=epg.tvg_id).exists(): - logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") - parse_programs_for_tvg_id(file_path, epg.tvg_id) - # Now parse blocks only - parse_channels_only(file_path) + parse_channels_only(source, file_path) + + epg_entries = EPGData.objects.filter(epg_source=source) + for epg in epg_entries: + if epg.tvg_id: + if Channel.objects.filter(epg_data=epg).exists(): + logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") + parse_programs_for_tvg_id(file_path, epg) except Exception as e: logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True) -def parse_channels_only(file_path): +def parse_channels_only(source, file_path): logger.info(f"Parsing channels from EPG file: {file_path}") + existing_epgs = {e.tvg_id: e for e in EPGData.objects.filter(epg_source=source)} # Read entire file (decompress if .gz) if file_path.endswith('.gz'): @@ -90,33 +94,48 @@ def parse_channels_only(file_path): root = ET.fromstring(xml_data) channels = root.findall('channel') + epgs_to_create = [] + epgs_to_update = [] + logger.info(f"Found {len(channels)} entries in {file_path}") - with transaction.atomic(): - for channel_elem in channels: - tvg_id = channel_elem.get('id', '').strip() - if not tvg_id: - continue # skip blank/invalid IDs + for channel_elem in channels: + tvg_id = channel_elem.get('id', '').strip() + if not tvg_id: + continue # skip blank/invalid IDs - display_name = channel_elem.findtext('display-name', default=tvg_id).strip() + display_name = channel_elem.findtext('display-name', default=tvg_id).strip() - epg_obj, created = EPGData.objects.get_or_create( + if tvg_id in existing_epgs: + epg_obj = existing_epgs[tvg_id] + if epg_obj.name != display_name: + epg_obj.name = display_name + epgs_to_update.append(epg_obj) + else: + epgs_to_create.append(EPGData( tvg_id=tvg_id, - defaults={'name': display_name} - ) - if not created: - # Optionally update if new name is different - if epg_obj.name != display_name: - epg_obj.name = display_name - epg_obj.save() - logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}") + name=display_name, + epg_source=source, + )) - parse_programs_for_tvg_id(file_path, tvg_id) + if epgs_to_create: + EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True) + if epgs_to_update: + EPGData.objects.bulk_update(epgs_to_update, ["name"]) + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": {"success": True, "type": "epg_channels"} + } + ) logger.info("Finished parsing channel info.") -def parse_programs_for_tvg_id(file_path, tvg_id): - logger.info(f"Parsing 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 +147,10 @@ def parse_programs_for_tvg_id(file_path, tvg_id): xml_data = xml_file.read() root = ET.fromstring(xml_data) - # Retrieve the EPGData record - try: - epg_obj = EPGData.objects.get(tvg_id=tvg_id) - except EPGData.DoesNotExist: - logger.warning(f"No EPGData record found for tvg_id={tvg_id}") - return # Find only elements for this tvg_id - matched_programmes = [p for p in root.findall('programme') if p.get('channel') == tvg_id] - logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={tvg_id}") + matched_programmes = [p for p in root.findall('programme') if p.get('channel') == epg.tvg_id] + logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={epg.tvg_id}") with transaction.atomic(): for prog in matched_programmes: @@ -147,19 +160,19 @@ def parse_programs_for_tvg_id(file_path, tvg_id): desc = prog.findtext('desc', default='') obj, created = ProgramData.objects.update_or_create( - epg=epg_obj, + epg=epg, start_time=start_time, title=title, defaults={ 'end_time': end_time, 'description': desc, 'sub_title': '', - 'tvg_id': tvg_id, + 'tvg_id': epg.tvg_id, } ) if created: logger.debug(f"Created ProgramData: {title} [{start_time} - {end_time}]") - logger.info(f"Completed program parsing for tvg_id={tvg_id}.") + logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.") def fetch_schedules_direct(source): diff --git a/apps/m3u/migrations/0005_m3uaccount_custom_properties.py b/apps/m3u/migrations/0005_m3uaccount_custom_properties.py new file mode 100644 index 00000000..587a3496 --- /dev/null +++ b/apps/m3u/migrations/0005_m3uaccount_custom_properties.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-27 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0004_m3uaccount_stream_profile'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccount', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index 773261df..18e21e0b 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -67,6 +67,7 @@ class M3UAccount(models.Model): blank=True, related_name='m3u_accounts' ) + custom_properties = models.TextField(null=True, blank=True) def __str__(self): return self.name diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 82f2310b..605cd7fe 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -15,7 +15,6 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.utils import timezone import time -from channels.layers import get_channel_layer import json from core.utils import redis_client from core.models import CoreSettings @@ -235,14 +234,13 @@ def process_m3u_batch(account_id, batch, group_names, hash_keys): logger.error(json.dumps(stream_info)) existing_streams = {s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys())} - logger.info(f"Hashed {len(stream_hashes.keys())} unique streams") for stream_hash, stream_props in stream_hashes.items(): if stream_hash in existing_streams: obj = existing_streams[stream_hash] changed = False for key, value in stream_props.items(): - if getattr(obj, key) == value: + if hasattr(obj, key) and getattr(obj, key) == value: continue changed = True setattr(obj, key, value) 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/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index 2dd923fd..282c5a74 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -17,7 +17,7 @@ import os import json from typing import Dict, Optional, Set from apps.proxy.config import TSConfig as Config -from apps.channels.models import Channel +from apps.channels.models import Channel, Stream from core.utils import redis_client as global_redis_client, redis_pubsub_client as global_redis_pubsub_client # Import both global Redis clients from redis.exceptions import ConnectionError, TimeoutError from .stream_manager import StreamManager @@ -740,12 +740,16 @@ class ProxyServer: # Force release resources in the Channel model try: - from apps.channels.models import Channel channel = Channel.objects.get(uuid=channel_id) channel.release_stream() logger.info(f"Released stream allocation for zombie channel {channel_id}") except Exception as e: - logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}") + try: + stream = Stream.objects.get(stream_hash=channel_id) + stream.release_stream() + logger.info(f"Released stream allocation for zombie channel {channel_id}") + except Exception as e: + logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}") return True except Exception as e: @@ -1067,8 +1071,12 @@ class ProxyServer: def _clean_redis_keys(self, channel_id): """Clean up all Redis keys for a channel more efficiently""" # Release the channel, stream, and profile keys from the channel - channel = Channel.objects.get(uuid=channel_id) - channel.release_stream() + try: + channel = Channel.objects.get(uuid=channel_id) + channel.release_stream() + except: + stream = Stream.objects.get(stream_hash=channel_id) + stream.release_stream() if not self.redis_client: return 0 diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 2b7363dc..451af7d5 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -248,8 +248,11 @@ class ChannelService: logger.info(f"Released channel {channel_id} stream allocation") model_released = True except Channel.DoesNotExist: - logger.warning(f"Could not find Channel model for UUID {channel_id}") - model_released = False + logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash") + stream = Stream.objects.get(stream_hash=channel_id) + stream.release_stream() + logger.info(f"Released stream {channel_id} stream allocation") + model_released = True except Exception as e: logger.error(f"Error releasing channel stream: {e}") model_released = False diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index 0f08df97..98a4ff0a 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -17,7 +17,7 @@ from .utils import detect_stream_type, get_logger from .redis_keys import RedisKeys from .constants import ChannelState, EventType, StreamType, ChannelMetadataField, TS_PACKET_SIZE from .config_helper import ConfigHelper -from .url_utils import get_alternate_streams, get_stream_info_for_switch +from .url_utils import get_alternate_streams, get_stream_info_for_switch, get_stream_object logger = get_logger() @@ -304,7 +304,7 @@ class StreamManager: """Establish a connection using transcoding""" try: logger.debug(f"Building transcode command for channel {self.channel_id}") - channel = get_object_or_404(Channel, uuid=self.channel_id) + channel = get_stream_object(self.channel_id) # Use FFmpeg specifically for HLS streams if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg: @@ -909,5 +909,3 @@ class StreamManager: except Exception as e: logger.error(f"Error trying next stream for channel {self.channel_id}: {e}", exc_info=True) return False - - diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 8183c4f2..de4c9109 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -10,9 +10,20 @@ from apps.channels.models import Channel, Stream from apps.m3u.models import M3UAccount, M3UAccountProfile from core.models import UserAgent, CoreSettings from .utils import get_logger +from uuid import UUID logger = get_logger() +def get_stream_object(id: str): + try: + uuid_obj = UUID(id, version=4) + logger.info(f"Fetching channel ID {id}") + return get_object_or_404(Channel, uuid=id) + except: + # UUID check failed, assume stream hash + logger.info(f"Fetching stream hash {id}") + return get_object_or_404(Stream, stream_hash=id) + def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]: """ Generate the appropriate stream URL for a channel based on its profile settings. @@ -24,7 +35,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool]: Tuple[str, str, bool]: (stream_url, user_agent, transcode_flag) """ # Get channel and related objects - channel = get_object_or_404(Channel, uuid=channel_id) + channel = get_stream_object(channel_id) stream_id, profile_id = channel.get_stream() if stream_id is None or profile_id is None: @@ -178,7 +189,11 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No """ try: # Get channel object - channel = get_object_or_404(Channel, uuid=channel_id) + channel = get_stream_object(channel_id) + if isinstance(channel, Stream): + logger.error(f"Stream is not a channel") + return [] + logger.debug(f"Looking for alternate streams for channel {channel_id}, current stream ID: {current_stream_id}") # Get all assigned streams for this channel diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 504fa72c..41074b6d 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -21,8 +21,9 @@ from rest_framework.permissions import IsAuthenticated from .constants import ChannelState, EventType, StreamType, ChannelMetadataField from .config_helper import ConfigHelper from .services.channel_service import ChannelService -from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch +from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch, get_stream_object from .utils import get_logger +from uuid import UUID logger = get_logger() @@ -30,9 +31,9 @@ logger = get_logger() @api_view(['GET']) def stream_ts(request, channel_id): """Stream TS data to client with immediate response and keep-alive packets during initialization""" + channel = get_stream_object(channel_id) + client_user_agent = None - logger.info(f"Fetching channel ID {channel_id}") - channel = get_object_or_404(Channel, uuid=channel_id) try: # Generate a unique client ID 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/App.jsx b/frontend/src/App.jsx index c769dcc6..2386efb5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -28,6 +28,7 @@ import mantineTheme from './mantineTheme'; import API from './api'; import { Notifications } from '@mantine/notifications'; import M3URefreshNotification from './components/M3URefreshNotification'; +import 'allotment/dist/style.css'; const drawerWidth = 240; const miniDrawerWidth = 60; diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index bd9f0e5d..88cb7b7c 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -18,7 +18,8 @@ export const WebsocketProvider = ({ children }) => { const [val, setVal] = useState(null); const { fetchStreams } = useStreamsStore(); - const { setChannelStats, fetchChannelGroups } = useChannelsStore(); + const { fetchChannels, setChannelStats, fetchChannelGroups } = + useChannelsStore(); const { fetchPlaylists, setRefreshProgress } = usePlaylistsStore(); const { fetchEPGData } = useEPGsStore(); @@ -78,6 +79,23 @@ export const WebsocketProvider = ({ children }) => { setChannelStats(JSON.parse(event.data.stats)); break; + case 'epg_channels': + notifications.show({ + message: 'EPG channels updated!', + color: 'green.5', + }); + fetchEPGData(); + break; + + case 'epg_match': + notifications.show({ + message: 'EPG match is complete!', + color: 'green.5', + }); + fetchChannels(); + fetchEPGData(); + break; + default: console.error(`Unknown websocket event type: ${event.type}`); break; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index a2cfeb75..889ecd0f 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 ( <> { name="channel_group_id" label="Channel Group" value={formik.values.channel_group_id} + searchable onChange={(value) => { formik.setFieldValue('channel_group_id', value); // Update Formik's state with the new value }} @@ -434,31 +456,96 @@ const Channel = ({ channel = null, isOpen, onClose }) => { - ({ + 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/M3U.jsx b/frontend/src/components/forms/M3U.jsx index b717aa3b..2ddb9c65 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -15,13 +15,18 @@ import { NativeSelect, FileInput, Space, + useMantineTheme, } from '@mantine/core'; import M3UGroupFilter from './M3UGroupFilter'; import useChannelsStore from '../../store/channels'; +import usePlaylistsStore from '../../store/playlists'; const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { + const theme = useMantineTheme(); + const { userAgents } = useUserAgentsStore(); const { fetchChannelGroups } = useChannelsStore(); + const { setRefreshProgress } = usePlaylistsStore(); const [file, setFile] = useState(null); const [profileModalOpen, setProfileModalOpen] = useState(false); @@ -190,38 +195,27 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => { {playlist && ( <> )} - {!playlist && ( - - )} 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..afd24fd5 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,22 +29,34 @@ const useEPGsStore = create((set) => ({ set({ isLoading: true, error: null }); try { const tvgs = await api.getEPGData(); - set({ tvgs: tvgs, isLoading: false }); + set({ + tvgs: tvgs, + tvgsById: tvgs.reduce((acc, tvg) => { + acc[tvg.id] = tvg; + return acc; + }, {}), + isLoading: false, + }); } catch (error) { console.error('Failed to fetch tvgs:', error); set({ error: 'Failed to load tvgs.', isLoading: false }); } }, - addEPG: (newPlaylist) => + addEPG: (epg) => set((state) => ({ - epgs: [...state.epgs, newPlaylist], + epgs: { ...state.epgs, [epg.id]: epg }, })), removeEPGs: (epgIds) => - set((state) => ({ - epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)), - })), + set((state) => { + const updatedEPGs = { ...state.epgs }; + for (const id of epgIds) { + delete updatedEPGs[id]; + } + + return { epgs: updatedEPGs }; + }), })); export default useEPGsStore;