Merge branch 'epg-refactor' of https://github.com/Dispatcharr/Dispatcharr into dev

This commit is contained in:
SergeantPanda 2025-03-27 15:19:12 -05:00
commit 9c3e20e756
43 changed files with 1260 additions and 320 deletions

View file

@ -26,10 +26,10 @@ class ChannelAdmin(admin.ModelAdmin):
'uuid',
'name',
'channel_group',
'tvg_name'
'epg_data'
)
list_filter = ('channel_group',)
search_fields = ('id', 'name', 'channel_group__name', 'tvg_name') # Added 'id'
search_fields = ('id', 'name', 'channel_group__name', 'epg_data') # Added 'id'
ordering = ('channel_number',)
@admin.register(ChannelGroup)

View file

@ -13,6 +13,7 @@ from .tasks import match_epg_channels
import django_filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from apps.epg.models import EPGData
from rest_framework.pagination import PageNumberPagination
@ -196,6 +197,12 @@ class ChannelViewSet(viewsets.ModelViewSet):
'logo_url': stream.logo_url,
'streams': [stream_id]
}
# Attempt to find existing EPGs with the same tvg-id
epgs = EPGData.objects.filter(tvg_id=stream.tvg_id)
if epgs:
channel_data["epg_data_id"] = epgs.first().id
serializer = self.get_serializer(data=channel_data)
serializer.is_valid(raise_exception=True)
channel = serializer.save()
@ -291,6 +298,12 @@ class ChannelViewSet(viewsets.ModelViewSet):
"logo_url": stream.logo_url,
"streams": [stream_id],
}
# Attempt to find existing EPGs with the same tvg-id
epgs = EPGData.objects.filter(tvg_id=stream.tvg_id)
if epgs:
channel_data["epg_data"] = epgs.first()
serializer = self.get_serializer(data=channel_data)
if serializer.is_valid():
channel = serializer.save()

View file

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

View file

@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-03-26 12:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0008_stream_stream_hash'),
('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'),
]
operations = [
migrations.RemoveField(
model_name='channel',
name='tvg_name',
),
migrations.AddField(
model_name='channel',
name='epg_data',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='epg.epgdata'),
),
]

View file

@ -9,6 +9,7 @@ import uuid
from datetime import datetime
import hashlib
import json
from apps.epg.models import EPGData
logger = logging.getLogger(__name__)
@ -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

View file

@ -1,6 +1,8 @@
from rest_framework import serializers
from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount
from apps.epg.serializers import EPGDataSerializer
from core.models import StreamProfile
from apps.epg.models import EPGData
#
# Stream
@ -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:

View file

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

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-25 19:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0002_epgsource_file_path'),
]
operations = [
migrations.AlterField(
model_name='epgdata',
name='tvg_id',
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-03-26 12:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0003_alter_epgdata_tvg_id'),
]
operations = [
migrations.AddField(
model_name='epgdata',
name='epg_source',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='epgs', to='epg.epgsource'),
),
migrations.AlterField(
model_name='epgdata',
name='tvg_id',
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
),
]

View file

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

View file

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

View file

@ -3,9 +3,14 @@ from .models import EPGSource, EPGData, ProgramData
from apps.channels.models import Channel
class EPGSourceSerializer(serializers.ModelSerializer):
epg_data_ids = serializers.SerializerMethodField()
class Meta:
model = EPGSource
fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active']
fields = ['id', 'name', 'source_type', 'url', 'api_key', 'is_active', 'epg_data_ids']
def get_epg_data_ids(self, obj):
return list(obj.epgs.values_list('id', flat=True))
class ProgramDataSerializer(serializers.ModelSerializer):
class Meta:
@ -17,10 +22,13 @@ class EPGDataSerializer(serializers.ModelSerializer):
Only returns the tvg_id and the 'name' field from EPGData.
We assume 'name' is effectively the channel name.
"""
read_only_fields = ['epg_source']
class Meta:
model = EPGData
fields = [
'id',
'tvg_id',
'name',
]
'epg_source',
]

View file

@ -14,6 +14,8 @@ from django.db import transaction
from django.utils import timezone
from apps.channels.models import Channel
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from .models import EPGSource, EPGData, ProgramData
@ -62,21 +64,23 @@ def fetch_xmltv(source):
source.file_path = file_path
source.save(update_fields=['file_path'])
epg_entries = EPGData.objects.exclude(tvg_id__isnull=True).exclude(tvg_id__exact='')
for epg in epg_entries:
if Channel.objects.filter(tvg_id=epg.tvg_id).exists():
logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}")
parse_programs_for_tvg_id(file_path, epg.tvg_id)
# Now parse <channel> blocks only
parse_channels_only(file_path)
parse_channels_only(source, file_path)
epg_entries = EPGData.objects.filter(epg_source=source)
for epg in epg_entries:
if epg.tvg_id:
if Channel.objects.filter(epg_data=epg).exists():
logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}")
parse_programs_for_tvg_id(file_path, epg)
except Exception as e:
logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True)
def parse_channels_only(file_path):
def parse_channels_only(source, file_path):
logger.info(f"Parsing channels from EPG file: {file_path}")
existing_epgs = {e.tvg_id: e for e in EPGData.objects.filter(epg_source=source)}
# Read entire file (decompress if .gz)
if file_path.endswith('.gz'):
@ -90,33 +94,48 @@ def parse_channels_only(file_path):
root = ET.fromstring(xml_data)
channels = root.findall('channel')
epgs_to_create = []
epgs_to_update = []
logger.info(f"Found {len(channels)} <channel> entries in {file_path}")
with transaction.atomic():
for channel_elem in channels:
tvg_id = channel_elem.get('id', '').strip()
if not tvg_id:
continue # skip blank/invalid IDs
for channel_elem in channels:
tvg_id = channel_elem.get('id', '').strip()
if not tvg_id:
continue # skip blank/invalid IDs
display_name = channel_elem.findtext('display-name', default=tvg_id).strip()
display_name = channel_elem.findtext('display-name', default=tvg_id).strip()
epg_obj, created = EPGData.objects.get_or_create(
if tvg_id in existing_epgs:
epg_obj = existing_epgs[tvg_id]
if epg_obj.name != display_name:
epg_obj.name = display_name
epgs_to_update.append(epg_obj)
else:
epgs_to_create.append(EPGData(
tvg_id=tvg_id,
defaults={'name': display_name}
)
if not created:
# Optionally update if new name is different
if epg_obj.name != display_name:
epg_obj.name = display_name
epg_obj.save()
logger.debug(f"Channel <{tvg_id}> => EPGData.id={epg_obj.id}, created={created}")
name=display_name,
epg_source=source,
))
parse_programs_for_tvg_id(file_path, tvg_id)
if epgs_to_create:
EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True)
if epgs_to_update:
EPGData.objects.bulk_update(epgs_to_update, ["name"])
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'updates',
{
'type': 'update',
"data": {"success": True, "type": "epg_channels"}
}
)
logger.info("Finished parsing channel info.")
def parse_programs_for_tvg_id(file_path, tvg_id):
logger.info(f"Parsing <programme> for tvg_id={tvg_id} from {file_path}")
def parse_programs_for_tvg_id(file_path, epg):
logger.info(f"Parsing <programme> for tvg_id={epg.tvg_id} from {file_path}")
# Read entire file (decompress if .gz)
if file_path.endswith('.gz'):
@ -128,16 +147,10 @@ def parse_programs_for_tvg_id(file_path, tvg_id):
xml_data = xml_file.read()
root = ET.fromstring(xml_data)
# Retrieve the EPGData record
try:
epg_obj = EPGData.objects.get(tvg_id=tvg_id)
except EPGData.DoesNotExist:
logger.warning(f"No EPGData record found for tvg_id={tvg_id}")
return
# Find only <programme> elements for this tvg_id
matched_programmes = [p for p in root.findall('programme') if p.get('channel') == tvg_id]
logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={tvg_id}")
matched_programmes = [p for p in root.findall('programme') if p.get('channel') == epg.tvg_id]
logger.debug(f"Found {len(matched_programmes)} programmes for tvg_id={epg.tvg_id}")
with transaction.atomic():
for prog in matched_programmes:
@ -147,19 +160,19 @@ def parse_programs_for_tvg_id(file_path, tvg_id):
desc = prog.findtext('desc', default='')
obj, created = ProgramData.objects.update_or_create(
epg=epg_obj,
epg=epg,
start_time=start_time,
title=title,
defaults={
'end_time': end_time,
'description': desc,
'sub_title': '',
'tvg_id': tvg_id,
'tvg_id': epg.tvg_id,
}
)
if created:
logger.debug(f"Created ProgramData: {title} [{start_time} - {end_time}]")
logger.info(f"Completed program parsing for tvg_id={tvg_id}.")
logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.")
def fetch_schedules_direct(source):

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-27 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0004_m3uaccount_stream_profile'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
]

View file

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

View file

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

View file

@ -14,7 +14,7 @@ def generate_m3u(request):
for channel in channels:
group_title = channel.channel_group.name if channel.channel_group else "Default"
tvg_id = channel.tvg_id or ""
tvg_name = channel.tvg_name or channel.name
tvg_name = channel.tvg_id or channel.name
tvg_logo = channel.logo_url or ""
channel_number = channel.channel_number

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@
"name": "vite",
"version": "0.0.0",
"dependencies": {
"@mantine/charts": "^7.17.2",
"@mantine/core": "^7.17.2",
"@mantine/dates": "^7.17.2",
"@mantine/dropzone": "^7.17.2",
@ -29,6 +30,8 @@
"react-draggable": "^4.4.6",
"react-pro-sidebar": "^1.1.0",
"react-router-dom": "^7.3.0",
"react-window": "^1.8.11",
"recharts": "^2.15.1",
"video.js": "^8.21.0",
"yup": "^1.6.1",
"zustand": "^5.0.3"
@ -1082,6 +1085,19 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
"license": "Apache-2.0"
},
"node_modules/@mantine/charts": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-7.17.2.tgz",
"integrity": "sha512-ckB23pIqRjzysUz2EiWZD9AVyf7t0r7o7zfJbl01nzOezFgYq5RGeRoxvpcsfBC+YoSbB/43rjNcXtYhtA7QzA==",
"license": "MIT",
"peerDependencies": {
"@mantine/core": "7.17.2",
"@mantine/hooks": "7.17.2",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x",
"recharts": "^2.13.3"
}
},
"node_modules/@mantine/core": {
"version": "7.17.2",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.2.tgz",
@ -1776,6 +1792,69 @@
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@ -2205,6 +2284,127 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
@ -2228,6 +2428,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2592,6 +2798,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -2947,6 +3162,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@ -3215,6 +3439,12 @@
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -3640,6 +3870,12 @@
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
"license": "MIT"
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-number-format": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.3.tgz",
@ -3753,6 +3989,21 @@
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -3808,6 +4059,61 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.0.0",
"memoize-one": ">=3.1.1 <6"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
"integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
@ -3998,6 +4304,12 @@
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@ -4145,6 +4457,28 @@
}
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/video.js": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.22.0.tgz",

View file

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@mantine/charts": "^7.17.2",
"@mantine/core": "^7.17.2",
"@mantine/dates": "^7.17.2",
"@mantine/dropzone": "^7.17.2",
@ -31,6 +32,8 @@
"react-draggable": "^4.4.6",
"react-pro-sidebar": "^1.1.0",
"react-router-dom": "^7.3.0",
"react-window": "^1.8.11",
"recharts": "^2.15.1",
"video.js": "^8.21.0",
"yup": "^1.6.1",
"zustand": "^5.0.3"

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
@ -25,24 +25,32 @@ import {
Divider,
Stack,
useMantineTheme,
Popover,
ScrollArea,
} from '@mantine/core';
import { ListOrdered, SquarePlus, SquareX } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
import { Dropzone } from '@mantine/dropzone';
import { FixedSizeList as List } from 'react-window';
const Channel = ({ channel = null, isOpen, onClose }) => {
const theme = useMantineTheme();
const listRef = useRef(null);
const channelGroups = useChannelsStore((state) => state.channelGroups);
const streams = useStreamsStore((state) => state.streams);
const { profiles: streamProfiles } = useStreamProfilesStore();
const { playlists } = usePlaylistsStore();
const { tvgs } = useEPGsStore();
const { epgs, tvgs, tvgsById } = useEPGsStore();
const [logoFile, setLogoFile] = useState(null);
const [logoPreview, setLogoPreview] = useState(null);
const [channelStreams, setChannelStreams] = useState([]);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const [epgPopoverOpened, setEpgPopoverOpened] = useState(false);
const [selectedEPG, setSelectedEPG] = useState({});
const [tvgFilter, setTvgFilter] = useState('');
const addStream = (stream) => {
const streamSet = new Set(channelStreams);
@ -74,7 +82,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
channel_group_id: '',
stream_profile_id: '0',
tvg_id: '',
tvg_name: '',
epg_data_id: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
@ -109,27 +117,36 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
setLogoFile(null);
setLogoPreview(null);
setSubmitting(false);
setTvgFilter('');
onClose();
},
});
useEffect(() => {
if (channel) {
if (channel.epg_data) {
const epgSource = epgs[channel.epg_data.epg_source];
setSelectedEPG(`${epgSource.id}`);
}
formik.setValues({
name: channel.name,
channel_number: channel.channel_number,
channel_group_id: channel.channel_group?.id,
stream_profile_id: channel.stream_profile_id || '0',
channel_group_id: `${channel.channel_group?.id}`,
stream_profile_id: channel.stream_profile_id
? `${channel.stream_profile_id}`
: '0',
tvg_id: channel.tvg_id,
tvg_name: channel.tvg_name,
epg_data_id: channel.epg_data ? `${channel.epg_data?.id}` : '',
});
console.log(channel);
setChannelStreams(channel.streams);
} else {
formik.resetForm();
setTvgFilter('');
}
}, [channel]);
}, [channel, tvgsById]);
// const activeStreamsTable = useMantineReactTable({
// data: channelStreams,
@ -263,6 +280,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
return <></>;
}
const filteredTvgs = tvgs
.filter((tvg) => tvg.epg_source == selectedEPG)
.filter((tvg) => tvg.name.toLowerCase().includes(tvgFilter));
return (
<>
<Modal
@ -296,6 +317,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
searchable
onChange={(value) => {
formik.setFieldValue('channel_group_id', value); // Update Formik's state with the new value
}}
@ -434,31 +456,96 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
<Stack gap="5" style={{ flex: 1 }} justify="flex-start">
<TextInput
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
id="tvg_id"
name="tvg_id"
label="TVG-ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
error={formik.errors.tvg_name ? formik.touched.tvg_name : ''}
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
size="xs"
/>
<Select
id="tvg_id"
name="tvg_id"
label="TVG ID"
searchable
value={formik.values.tvg_id}
onChange={(value) => {
formik.setFieldValue('tvg_id', value); // Update Formik's state with the new value
}}
error={formik.errors.tvg_id}
data={tvgs.map((tvg) => ({
value: tvg.name,
label: tvg.tvg_id,
}))}
size="xs"
/>
<Popover
opened={epgPopoverOpened}
onChange={setEpgPopoverOpened}
// position="bottom-start"
withArrow
>
<Popover.Target>
<TextInput
id="epg_data_id"
name="epg_data_id"
label="EPG"
readOnly
value={
formik.values.epg_data_id
? tvgsById[formik.values.epg_data_id].name
: ''
}
onClick={() => setEpgPopoverOpened(true)}
size="xs"
/>
</Popover.Target>
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
<Group>
<Select
label="Source"
value={selectedEPG}
onChange={setSelectedEPG}
data={Object.values(epgs).map((epg) => ({
value: `${epg.id}`,
label: epg.name,
}))}
size="xs"
mb="xs"
/>
{/* Filter Input */}
<TextInput
label="Filter"
value={tvgFilter}
onChange={(event) =>
setTvgFilter(event.currentTarget.value)
}
mb="xs"
size="xs"
/>
</Group>
<ScrollArea style={{ height: 200 }}>
<List
height={200} // Set max height for visible items
itemCount={filteredTvgs.length}
itemSize={40} // Adjust row height for each item
width="100%"
ref={listRef}
>
{({ index, style }) => (
<div style={style}>
<Button
key={filteredTvgs[index].id}
variant="subtle"
color="gray"
fullWidth
justify="left"
size="xs"
onClick={() => {
formik.setFieldValue(
'epg_data_id',
filteredTvgs[index].id
);
setEpgPopoverOpened(false);
}}
>
{filteredTvgs[index].tvg_id}
</Button>
</div>
)}
</List>
</ScrollArea>
</Popover.Dropdown>
</Popover>
<TextInput
id="logo_url"
@ -495,6 +582,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
</Flex>
</form>
</Modal>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={() => setChannelGroupModalOpen(false)}

View file

@ -15,13 +15,18 @@ import {
NativeSelect,
FileInput,
Space,
useMantineTheme,
} from '@mantine/core';
import M3UGroupFilter from './M3UGroupFilter';
import useChannelsStore from '../../store/channels';
import usePlaylistsStore from '../../store/playlists';
const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
const theme = useMantineTheme();
const { userAgents } = useUserAgentsStore();
const { fetchChannelGroups } = useChannelsStore();
const { setRefreshProgress } = usePlaylistsStore();
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
@ -190,38 +195,27 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
{playlist && (
<>
<Button
variant="contained"
color="primary"
size="small"
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setGroupFilterModalOpen(true)}
>
Groups
</Button>
<Button
variant="contained"
color="primary"
size="small"
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
</>
)}
{!playlist && (
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="sm"
>
Save & Select Groups
</Button>
)}
<Button
type="submit"
variant="contained"
color="primary"
variant="filled"
// color={theme.custom.colors.buttonPrimary}
disabled={formik.isSubmitting}
size="sm"
>

View file

@ -1,5 +1,5 @@
// Modal.js
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
@ -79,6 +79,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
id="channel_group"
name="channel_group"
label="Group"
searchable
value={formik.values.channel_group}
onChange={(value) => {
formik.setFieldValue('channel_group', value); // Update Formik's state with the new value
@ -95,6 +96,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
name="stream_profile_id"
label="Stream Profile"
placeholder="Optional"
searchable
value={formik.values.stream_profile_id}
onChange={(value) => {
formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value

View file

@ -6,7 +6,6 @@
gap: 12px;
padding: 5px 8px !important;
border-radius: 6px;
color: #D4D4D8; /* Default color when not active */
background-color: transparent; /* Default background when not active */
border: 1px solid transparent;
transition: all 0.3s ease;

View file

@ -232,9 +232,6 @@ const ChannelsTable = ({}) => {
{
header: 'Name',
accessorKey: 'name',
mantineTableHeadCellProps: {
sx: { textAlign: 'center' },
},
Header: ({ column }) => (
<TextInput
name="name"
@ -247,6 +244,7 @@ const ChannelsTable = ({}) => {
size="xs"
variant="unstyled"
className="table-input-header"
onClick={(e) => e.stopPropagation()}
/>
),
Cell: ({ cell }) => (
@ -265,19 +263,21 @@ const ChannelsTable = ({}) => {
header: 'Group',
accessorFn: (row) => row.channel_group?.name || '',
Header: ({ column }) => (
<Select
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
onChange={(e, value) => {
e.stopPropagation();
handleGroupChange(value);
}}
data={channelGroupOptions}
variant="unstyled"
className="table-input-header"
/>
<Box onClick={(e) => e.stopPropagation()}>
<Select
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
onChange={(e, value) => {
e.stopPropagation();
handleGroupChange(value);
}}
data={channelGroupOptions}
variant="unstyled"
className="table-input-header"
/>
</Box>
),
},
{
@ -502,7 +502,7 @@ const ChannelsTable = ({}) => {
},
},
'mrt-row-actions': {
size: 74,
size: 50,
},
},
mantineExpandButtonProps: ({ row, table }) => ({
@ -520,7 +520,7 @@ const ChannelsTable = ({}) => {
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
),
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<Box style={{ width: '100%', justifyContent: 'left' }}>
<Center>
<Tooltip label="Edit Channel">
<ActionIcon
@ -561,7 +561,7 @@ const ChannelsTable = ({}) => {
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 127px)',
height: 'calc(100vh - 110px)',
overflowY: 'auto',
},
},
@ -710,7 +710,7 @@ const ChannelsTable = ({}) => {
{/* Paper container: contains top toolbar and table (or ghost state) */}
<Paper
style={{
height: 'calc(100vh - 75px)',
height: 'calc(100vh - 60px)',
backgroundColor: '#27272A',
}}
>

View file

@ -23,7 +23,8 @@ const EPGsTable = () => {
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const epgs = useEPGsStore((state) => state.epgs);
const { epgs } = useEPGsStore();
console.log(epgs);
const theme = useMantineTheme();
@ -41,6 +42,7 @@ const EPGsTable = () => {
{
header: 'URL / API Key',
accessorKey: 'max_streams',
enableSorting: false,
},
],
[]
@ -93,7 +95,7 @@ const EPGsTable = () => {
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: epgs,
data: Object.values(epgs),
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: false,
@ -144,6 +146,11 @@ const EPGsTable = () => {
height: 'calc(40vh - 0px)',
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 10,
},
},
});
return (

View file

@ -13,34 +13,11 @@ import {
Box,
ActionIcon,
Tooltip,
Select,
} from '@mantine/core';
import {
Tv2,
ScreenShare,
Scroll,
SquareMinus,
Pencil,
ArrowUp,
ArrowDown,
ArrowUpDown,
TvMinimalPlay,
SquarePen,
RefreshCcw,
Check,
X,
} from 'lucide-react';
import {
IconArrowDown,
IconArrowUp,
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquarePlus,
} from '@tabler/icons-react'; // Import custom icons
import M3UGroupFilter from '../forms/M3UGroupFilter';
import { SquareMinus, SquarePen, RefreshCcw, Check, X } from 'lucide-react';
import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons
const Example = () => {
const M3UTable = () => {
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
@ -116,7 +93,9 @@ const Example = () => {
};
const deletePlaylist = async (id) => {
setIsLoading(true);
await API.deletePlaylist(id);
setIsLoading(false);
};
const closeModal = (newPlaylist = null) => {
@ -282,4 +261,4 @@ const Example = () => {
);
};
export default Example;
export default M3UTable;

View file

@ -49,6 +49,7 @@ const StreamProfiles = () => {
{
header: 'Parameters',
accessorKey: 'parameters',
enableSorting: false,
mantineTableBodyCellProps: {
style: {
whiteSpace: 'nowrap',
@ -61,13 +62,21 @@ const StreamProfiles = () => {
{
header: 'Active',
accessorKey: 'is_active',
size: 50,
size: 10,
enableSorting: false,
mantineTableHeadCellProps: {
align: 'right',
},
mantineTableBodyCellProps: {
align: 'right',
},
Cell: ({ row, cell }) => (
<Center>
<Switch
size="xs"
checked={cell.getValue()}
onChange={() => toggleProfileIsActive(row.original)}
disabled={row.original.locked}
/>
</Center>
),

View file

@ -35,15 +35,10 @@ import {
MultiSelect,
useMantineTheme,
} from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquarePlus,
} from '@tabler/icons-react';
import { IconSquarePlus } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
const StreamsTable = ({}) => {
const theme = useMantineTheme();
@ -91,6 +86,10 @@ const StreamsTable = ({}) => {
const channelSelectionStreams = useChannelsStore(
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
);
const {
environment: { env_mode },
} = useSettingsStore();
const { showVideo } = useVideoStore();
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
@ -110,9 +109,6 @@ const StreamsTable = ({}) => {
{
header: 'Name',
accessorKey: 'name',
mantineTableHeadCellProps: {
style: { textAlign: 'center', backgroundColor: 'rgb(56, 58, 63)' }, // Center-align the header
},
Header: ({ column }) => (
<TextInput
name="name"
@ -123,9 +119,6 @@ const StreamsTable = ({}) => {
size="xs"
variant="unstyled"
className="table-input-header"
style={{
paddingLeft: 10,
}}
/>
),
Cell: ({ cell }) => (
@ -142,10 +135,13 @@ const StreamsTable = ({}) => {
},
{
header: 'Group',
accessorFn: (row) => channelGroups[row.channel_group].name,
accessorFn: (row) =>
channelGroups[row.channel_group]
? channelGroups[row.channel_group].name
: '',
size: 100,
Header: ({ column }) => (
<Box onClick={handleSelectClick}>
<Box onClick={handleSelectClick} style={{ width: '100%' }}>
<MultiSelect
placeholder="Group"
searchable
@ -155,7 +151,11 @@ const StreamsTable = ({}) => {
onChange={handleGroupChange}
data={groupOptions}
variant="unstyled"
className="table-input-header"
className="table-input-header custom-multiselect"
clearable
valueComponent={({ value }) => {
return <div>foo</div>; // Override to display custom text
}}
/>
</Box>
),
@ -429,6 +429,14 @@ const StreamsTable = ({}) => {
setPagination(updater);
};
function handleWatchStream(streamHash) {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode == 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
}
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
@ -510,9 +518,10 @@ const StreamsTable = ({}) => {
<Tooltip label="Add to Channel">
<ActionIcon
size="sm"
color={theme.tailwind.blue[4]}
color={theme.tailwind.blue[6]}
variant="transparent"
onClick={() => addStreamToChannel(row.original.id)}
style={{ background: 'none' }}
disabled={
channelsPageSelection.length !== 1 ||
(channelSelectionStreams &&
@ -562,13 +571,18 @@ const StreamsTable = ({}) => {
>
Delete Stream
</Menu.Item>
<Menu.Item
onClick={() => handleWatchStream(row.original.stream_hash)}
>
Preview Stream
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 167px)',
height: 'calc(100vh - 150px)',
overflowY: 'auto',
},
},

View file

@ -39,6 +39,7 @@ const UserAgentsTable = () => {
{
header: 'User-Agent',
accessorKey: 'user_agent',
enableSorting: false,
Cell: ({ cell }) => (
<div
style={{
@ -54,6 +55,7 @@ const UserAgentsTable = () => {
{
header: 'Desecription',
accessorKey: 'description',
enableSorting: false,
Cell: ({ cell }) => (
<div
style={{
@ -69,10 +71,14 @@ const UserAgentsTable = () => {
{
header: 'Active',
accessorKey: 'is_active',
size: 100,
size: 10,
sortingFn: 'basic',
enableSorting: false,
mantineTableHeadCellProps: {
align: 'right',
},
mantineTableBodyCellProps: {
align: 'left',
align: 'right',
},
Cell: ({ cell }) => (
<Center>
@ -216,6 +222,11 @@ const UserAgentsTable = () => {
height: 'calc(43vh - 55px)',
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 10,
},
},
});
return (

View file

@ -14,10 +14,14 @@ export default {
initialState: {
density: 'compact',
},
mantineTableProps: {
striped: true,
},
mantinePaperProps: {
style: {
'--mrt-selected-row-background-color': '#163632',
'--mrt-base-background-color': '#27272A',
'--mrt-striped-row-background-color': '#18181B',
},
},
mantineSelectAllCheckboxProps: {
@ -45,7 +49,7 @@ export default {
paddingLeft: 10,
paddingRight: 10,
borderColor: '#444',
color: '#E0E0E0',
// color: '#E0E0E0',
fontSize: '0.85rem',
},
},
@ -56,27 +60,12 @@ export default {
paddingTop: 2,
paddingBottom: 2,
fontWeight: 'normal',
color: '#CFCFCF',
backgroundColor: '#383A3F',
// color: '#CFCFCF',
backgroundColor: '#3F3F46',
borderColor: '#444',
// fontWeight: 600,
// fontSize: '0.8rem',
},
},
mantineTableBodyProps: {
style: {
// Subtle row striping
'& tr:nth-of-type(odd)': {
backgroundColor: '#2F3034',
},
'& tr:nth-of-type(even)': {
backgroundColor: '#333539',
},
// Row hover effect
'& tr:hover td': {
backgroundColor: '#3B3D41',
},
},
},
},
};

View file

@ -1,4 +1,8 @@
/* frontend/src/index.css */
:root {
--separator-border: transparent !important; /* Override Allotment's default border */
--sash-hover-size: 3px !important;
}
body {
margin: 0;
@ -32,7 +36,7 @@ code {
@supports (-moz-appearance: none) {
input[readonly][aria-haspopup] {
pointer-events: auto !important;
pointer-events: auto !important;
}
}
@ -41,7 +45,59 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
}
.table-input-header input::placeholder {
color: rgb(207,207,207);
font-weight: normal;
font-size: 14px;
color: rgb(207,207,207);
font-weight: normal;
font-size: 14px;
}
/* Ensure Allotment uses its default layout */
.split-view {
width: 100%;
height: 100%;
}
/* Styling for the sash (splitter) */
.sash.sash-vertical {
position: relative;
width: 4px; /* Thin invisible divider */
background: transparent;
}
/* Create a short vertical bar */
.sash.sash-vertical::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6px; /* Width of the short bar */
height: 50px; /* Short bar length */
background: rgba(255, 255, 255, 0.2); /* Light color similar to your screenshot */
border-radius: 4px;
}
/* Optional: Highlight when hovering */
.sash.sash-vertical:hover::before {
background: rgba(255, 255, 255, 0.4);
}
/* Tables should fill available space */
.split-view-view {
width: 100%;
height: 100%;
overflow: auto;
}
/* styles.css */
.custom-multiselect-input {
display: flex;
flex-wrap: wrap;
max-height: 30px;
overflow-y: auto;
padding: 4px;
}
.custom-multiselect .mantine-MultiSelect-input {
min-height: 30px; /* Set a minimum height */
max-height: 30px; /* Set max height */
}

View file

@ -1,6 +1,20 @@
import { createTheme, MantineProvider, rem } from '@mantine/core';
const theme = createTheme({
globalStyles: (theme) => ({
':root': {
'--mantine-color-text': '#fff',
'--mantine-color-body': '#27272A',
'--mrt-striped-row-background-color': '#fff',
},
':root[data-mantine-color-scheme="dark"]': {
'--mantine-color-text': '#fff',
},
':root[data-mantine-color-scheme="light"]': {
'--mantine-color-text': '#fff',
},
}),
tailwind: {
red: [
'oklch(0.971 0.013 17.38)',
@ -204,6 +218,10 @@ const theme = createTheme({
},
custom: {
colors: {
buttonPrimary: '#14917E',
},
sidebar: {
activeBackground: 'rgba(21, 69, 62, 0.67)',
activeBorder: '#14917e',

View file

@ -2,17 +2,32 @@ import React, { useState } from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Box, Grid } from '@mantine/core';
import { Allotment } from 'allotment';
const ChannelsPage = () => {
return (
<Grid style={{ padding: 18 }}>
<Grid.Col span={6}>
<ChannelsTable />
</Grid.Col>
<Grid.Col span={6}>
<StreamsTable />
</Grid.Col>
</Grid>
<div style={{ height: '100vh', width: '100%', display: 'flex' }}>
<Allotment
defaultSizes={[50, 50]}
style={{ height: '100%', width: '100%' }}
className="custom-allotment"
>
<div
style={{
padding: 10,
}}
>
<ChannelsTable />
</div>
<div
style={{
padding: 10,
}}
>
<StreamsTable />
</div>
</Allotment>
</div>
);
};

View file

@ -136,7 +136,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
MINUTE_BLOCK_WIDTH;
guideRef.current.scrollLeft = Math.max(scrollPosition, 0);
}
}, [programs, start]);
}, [programs]);
// Update now every 60s
useEffect(() => {
@ -462,7 +462,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
{now.isAfter(dayjs(selectedProgram.start_time)) &&
now.isBefore(dayjs(selectedProgram.end_time)) && (
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button onClick={() => handleWatchStream(selectedProgram)}>
<Button
variant="transparent"
color="gray"
onClick={() => handleWatchStream(selectedProgram)}
>
Watch Now
</Button>
</Flex>

View file

@ -1,17 +1,18 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
import {
ActionIcon,
Box,
Card,
Center,
Container,
Flex,
Grid,
Group,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import { TableHelper } from '../helpers';
@ -19,24 +20,41 @@ import API from '../api';
import useChannelsStore from '../store/channels';
import logo from '../images/logo.png';
import {
Tv2,
ScreenShare,
Scroll,
SquareMinus,
CirclePlay,
SquarePen,
Binary,
ArrowDown01,
Gauge,
HardDriveDownload,
HardDriveUpload,
SquareX,
Timer,
Users,
Video,
} from 'lucide-react';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { Sparkline } from '@mantine/charts';
import useStreamProfilesStore from '../store/streamProfiles';
dayjs.extend(duration);
dayjs.extend(relativeTime);
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
function formatSpeed(bytes) {
if (bytes === 0) return '0 Bytes';
const sizes = ['bps', 'Kbps', 'Mbps', 'Gbps'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
}
const getStartDate = (uptime) => {
// Get the current date and time
const currentDate = new Date();
@ -56,8 +74,12 @@ const getStartDate = (uptime) => {
};
const ChannelsPage = () => {
const theme = useMantineTheme();
const { channels, channelsByUUID, stats: channelStats } = useChannelsStore();
const [activeChannels, setActiveChannels] = useState([]);
const { profiles: streamProfiles } = useStreamProfilesStore();
const [activeChannels, setActiveChannels] = useState({});
const [clients, setClients] = useState([]);
const channelsColumns = useMemo(
@ -148,35 +170,35 @@ const ChannelsPage = () => {
await API.stopClient(channelId, clientId);
};
const channelsTable = useMantineReactTable({
...TableHelper.defaultProperties,
renderTopToolbar: false,
columns: channelsColumns,
data: activeChannels,
enableRowActions: true,
mantineTableBodyCellProps: {
style: {
padding: 4,
borderColor: '#444',
color: '#E0E0E0',
fontSize: '0.85rem',
},
},
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<Center>
<ActionIcon
size="sm"
variant="transparent"
color="red.9"
onClick={() => stopChannel(row.original.uuid)}
>
<SquareX size="18" />
</ActionIcon>
</Center>
</Box>
),
});
// const channelsTable = useMantineReactTable({
// ...TableHelper.defaultProperties,
// renderTopToolbar: false,
// columns: channelsColumns,
// data: activeChannels,
// enableRowActions: true,
// mantineTableBodyCellProps: {
// style: {
// padding: 4,
// borderColor: '#444',
// color: '#E0E0E0',
// fontSize: '0.85rem',
// },
// },
// renderRowActions: ({ row }) => (
// <Box sx={{ justifyContent: 'right' }}>
// <Center>
// <ActionIcon
// size="sm"
// variant="transparent"
// color="red.9"
// onClick={() => stopChannel(row.original.uuid)}
// >
// <SquareX size="18" />
// </ActionIcon>
// </Center>
// </Box>
// ),
// });
const clientsTable = useMantineReactTable({
...TableHelper.defaultProperties,
@ -184,19 +206,19 @@ const ChannelsPage = () => {
data: clients,
columns: useMemo(
() => [
{
header: 'User-Agent',
accessorKey: 'user_agent',
size: 250,
mantineTableBodyCellProps: {
style: {
whiteSpace: 'nowrap',
maxWidth: 400,
paddingLeft: 10,
paddingRight: 10,
},
},
},
// {
// header: 'User-Agent',
// accessorKey: 'user_agent',
// size: 250,
// mantineTableBodyCellProps: {
// style: {
// whiteSpace: 'nowrap',
// maxWidth: 400,
// paddingLeft: 10,
// paddingRight: 10,
// },
// },
// },
{
header: 'IP Address',
accessorKey: 'ip_address',
@ -209,7 +231,7 @@ const ChannelsPage = () => {
style: {
padding: 4,
borderColor: '#444',
color: '#E0E0E0',
// color: '#E0E0E0',
fontSize: '0.85rem',
},
},
@ -236,16 +258,73 @@ const ChannelsPage = () => {
overflowY: 'auto',
},
},
renderDetailPanel: ({ row }) => <Box>{row.original.user_agent}</Box>,
mantineExpandButtonProps: ({ row, table }) => ({
size: 'xs',
style: {
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
},
}),
enableExpandAll: false,
displayColumnDefOptions: {
'mrt-row-expand': {
size: 15,
header: '',
// mantineTableHeadCellProps: {
// style: {
// padding: 0,
// minWidth: '20px !important',
// },
// },
// mantineTableBodyCellProps: {
// style: {
// padding: 0,
// minWidth: '20px !important',
// },
// },
},
'mrt-row-actions': {
size: 74,
},
},
});
useEffect(() => {
const stats = channelStats.channels.map((ch) => ({
...ch,
...channels[channelsByUUID[ch.channel_id]],
}));
if (!channelStats.channels) {
return;
}
const stats = channelStats.channels.reduce((acc, ch) => {
let bitrates = [];
if (activeChannels[ch.channel_id]) {
bitrates = activeChannels[ch.channel_id].bitrates;
const bitrate =
ch.total_bytes - activeChannels[ch.channel_id].total_bytes;
if (bitrate > 0) {
bitrates.push(bitrate);
}
if (bitrates.length > 15) {
bitrates = bitrates.slice(1);
}
}
acc[ch.channel_id] = {
...ch,
...channels[channelsByUUID[ch.channel_id]],
bitrates,
stream_profile: streamProfiles.find(
(profile) => profile.id == parseInt(ch.profile)
),
};
return acc;
}, {});
setActiveChannels(stats);
const clientStats = stats.reduce((acc, ch) => {
const clientStats = Object.values(stats).reduce((acc, ch) => {
return acc.concat(
ch.clients.map((client) => ({
...client,
@ -257,19 +336,25 @@ const ChannelsPage = () => {
}, [channelStats]);
return (
<SimpleGrid cols={2} spacing="md" style={{ padding: 10 }}>
{activeChannels.map((channel) => (
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack>
<Flex justify="space-between" align="center">
<Group>
<Title order={5}>{channel.name}</Title>
<img
src={channel.logo_url || logo}
width="20"
alt="channel logo"
/>
</Group>
<SimpleGrid cols={3} spacing="md" style={{ padding: 10 }}>
{Object.values(activeChannels).map((channel) => (
<Card
shadow="sm"
padding="md"
radius="md"
withBorder
style={{
color: '#fff',
backgroundColor: '#27272A',
}}
>
<Stack style={{ position: 'relative' }}>
<Group justify="space-between">
<img
src={channel.logo_url || logo}
width="30"
alt="channel logo"
/>
<Group>
<Box>
@ -288,19 +373,39 @@ const ChannelsPage = () => {
</Tooltip>
</Center>
</Group>
</Group>
<Flex justify="space-between" align="center">
<Group>
<Text fw={500}>{channel.name}</Text>
</Group>
<Group gap={5}>
<Video size="18" />
{channel.stream_profile.name}
</Group>
</Flex>
<Box>
<Flex
justify="space-between"
align="center"
style={{ paddingRight: 10, paddingLeft: 10 }}
>
<Text>Clients</Text>
<Text>{channel.client_count}</Text>
</Flex>
<MantineReactTable table={clientsTable} />
</Box>
<Group justify="space-between">
<Group gap={4}>
<Gauge style={{ paddingRight: 5 }} size="22" />
<Text size="sm">{formatSpeed(channel.bitrates.at(-1))}</Text>
</Group>
<Text size="sm">Avg: {channel.avg_bitrate}</Text>
<Group gap={4}>
<HardDriveDownload size="18" />
<Text size="sm">{formatBytes(channel.total_bytes)}</Text>
</Group>
<Group gap={5}>
<Users size="18" />
<Text size="sm">{channel.client_count}</Text>
</Group>
</Group>
<MantineReactTable table={clientsTable} />
</Stack>
</Card>
))}

View file

@ -2,8 +2,9 @@ import { create } from 'zustand';
import api from '../api';
const useEPGsStore = create((set) => ({
epgs: [],
epgs: {},
tvgs: [],
tvgsById: {},
isLoading: false,
error: null,
@ -11,7 +12,13 @@ const useEPGsStore = create((set) => ({
set({ isLoading: true, error: null });
try {
const epgs = await api.getEPGs();
set({ epgs: epgs, isLoading: false });
set({
epgs: epgs.reduce((acc, epg) => {
acc[epg.id] = epg;
return acc;
}, {}),
isLoading: false,
});
} catch (error) {
console.error('Failed to fetch epgs:', error);
set({ error: 'Failed to load epgs.', isLoading: false });
@ -22,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;