Merge pull request #313 from Dispatcharr/dev

Dev
This commit is contained in:
SergeantPanda 2025-08-19 12:04:23 -05:00 committed by GitHub
commit 0843d32388
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2435 additions and 665 deletions

View file

@ -1508,9 +1508,17 @@ class UpdateChannelMembershipAPIView(APIView):
"""Enable or disable a channel for a specific group"""
channel_profile = get_object_or_404(ChannelProfile, id=profile_id)
channel = get_object_or_404(Channel, id=channel_id)
membership = get_object_or_404(
ChannelProfileMembership, channel_profile=channel_profile, channel=channel
)
try:
membership = ChannelProfileMembership.objects.get(
channel_profile=channel_profile, channel=channel
)
except ChannelProfileMembership.DoesNotExist:
# Create the membership if it does not exist (for custom channels)
membership = ChannelProfileMembership.objects.create(
channel_profile=channel_profile,
channel=channel,
enabled=False # Default to False, will be updated below
)
serializer = ChannelProfileMembershipSerializer(
membership, data=request.data, partial=True

View file

@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-07-29 02:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0022_channel_auto_created_channel_auto_created_by_and_more'),
]
operations = [
migrations.AddField(
model_name='stream',
name='stream_stats',
field=models.JSONField(blank=True, help_text='JSON object containing stream statistics like video codec, resolution, etc.', null=True),
),
migrations.AddField(
model_name='stream',
name='stream_stats_updated_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='When stream statistics were last updated', null=True),
),
]

View file

@ -95,6 +95,19 @@ class Stream(models.Model):
)
last_seen = models.DateTimeField(db_index=True, default=datetime.now)
custom_properties = models.TextField(null=True, blank=True)
# Stream statistics fields
stream_stats = models.JSONField(
null=True,
blank=True,
help_text="JSON object containing stream statistics like video codec, resolution, etc."
)
stream_stats_updated_at = models.DateTimeField(
null=True,
blank=True,
help_text="When stream statistics were last updated",
db_index=True
)
class Meta:
# If you use m3u_account, you might do unique_together = ('name','url','m3u_account')

View file

@ -16,6 +16,7 @@ from apps.epg.models import EPGData
from django.urls import reverse
from rest_framework import serializers
from django.utils import timezone
from core.utils import validate_flexible_url
class LogoSerializer(serializers.ModelSerializer):
@ -32,10 +33,10 @@ class LogoSerializer(serializers.ModelSerializer):
"""Validate that the URL is unique for creation or update"""
if self.instance and self.instance.url == value:
return value
if Logo.objects.filter(url=value).exists():
raise serializers.ValidationError("A logo with this URL already exists.")
return value
def create(self, validated_data):
@ -79,6 +80,12 @@ class LogoSerializer(serializers.ModelSerializer):
# Stream
#
class StreamSerializer(serializers.ModelSerializer):
url = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
validators=[validate_flexible_url]
)
stream_profile_id = serializers.PrimaryKeyRelatedField(
queryset=StreamProfile.objects.all(),
source="stream_profile",
@ -104,6 +111,8 @@ class StreamSerializer(serializers.ModelSerializer):
"is_custom",
"channel_group",
"stream_hash",
"stream_stats",
"stream_stats_updated_at",
]
def get_fields(self):

View file

@ -1,3 +1,4 @@
from core.utils import validate_flexible_url
from rest_framework import serializers
from .models import EPGSource, EPGData, ProgramData
from apps.channels.models import Channel
@ -5,6 +6,12 @@ from apps.channels.models import Channel
class EPGSourceSerializer(serializers.ModelSerializer):
epg_data_ids = serializers.SerializerMethodField()
read_only_fields = ['created_at', 'updated_at']
url = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
validators=[validate_flexible_url]
)
class Meta:
model = EPGSource

View file

@ -28,6 +28,8 @@ from core.utils import acquire_task_lock, release_task_lock, send_websocket_upda
logger = logging.getLogger(__name__)
MAX_EXTRACT_CHUNK_SIZE = 65536 # 64kb (base2)
def send_epg_update(source_id, action, progress, **kwargs):
"""Send WebSocket update about EPG download/parsing progress"""
@ -641,7 +643,11 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False):
# Reset file pointer and extract the content
gz_file.seek(0)
with open(extracted_path, 'wb') as out_file:
out_file.write(gz_file.read())
while True:
chunk = gz_file.read(MAX_EXTRACT_CHUNK_SIZE)
if not chunk or len(chunk) == 0:
break
out_file.write(chunk)
except Exception as e:
logger.error(f"Error extracting GZIP file: {e}", exc_info=True)
return None
@ -685,9 +691,13 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False):
return None
# Extract the first XML file
xml_content = zip_file.read(xml_files[0])
with open(extracted_path, 'wb') as out_file:
out_file.write(xml_content)
with zip_file.open(xml_files[0], "r") as xml_file:
while True:
chunk = xml_file.read(MAX_EXTRACT_CHUNK_SIZE)
if not chunk or len(chunk) == 0:
break
out_file.write(chunk)
logger.info(f"Successfully extracted zip file to: {extracted_path}")

View file

@ -2,56 +2,73 @@ from django.contrib import admin
from django.utils.html import format_html
from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent
class M3UFilterInline(admin.TabularInline):
model = M3UFilter
extra = 1
verbose_name = "M3U Filter"
verbose_name_plural = "M3U Filters"
@admin.register(M3UAccount)
class M3UAccountAdmin(admin.ModelAdmin):
list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'user_agent_display', 'uploaded_file_link', 'created_at', 'updated_at')
list_filter = ('is_active', 'server_group')
search_fields = ('name', 'server_url', 'server_group__name')
list_display = (
"name",
"server_url",
"server_group",
"max_streams",
"is_active",
"user_agent_display",
"uploaded_file_link",
"created_at",
"updated_at",
)
list_filter = ("is_active", "server_group")
search_fields = ("name", "server_url", "server_group__name")
inlines = [M3UFilterInline]
actions = ['activate_accounts', 'deactivate_accounts']
actions = ["activate_accounts", "deactivate_accounts"]
# Handle both ForeignKey and ManyToManyField cases for UserAgent
def user_agent_display(self, obj):
if hasattr(obj, 'user_agent'): # ForeignKey case
if hasattr(obj, "user_agent"): # ForeignKey case
return obj.user_agent.user_agent if obj.user_agent else "None"
elif hasattr(obj, 'user_agents'): # ManyToManyField case
elif hasattr(obj, "user_agents"): # ManyToManyField case
return ", ".join([ua.user_agent for ua in obj.user_agents.all()]) or "None"
return "None"
user_agent_display.short_description = "User Agent(s)"
def uploaded_file_link(self, obj):
if obj.uploaded_file:
return format_html("<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url)
return format_html(
"<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url
)
return "No file uploaded"
uploaded_file_link.short_description = "Uploaded File"
@admin.action(description='Activate selected accounts')
@admin.action(description="Activate selected accounts")
def activate_accounts(self, request, queryset):
queryset.update(is_active=True)
@admin.action(description='Deactivate selected accounts')
@admin.action(description="Deactivate selected accounts")
def deactivate_accounts(self, request, queryset):
queryset.update(is_active=False)
# Add ManyToManyField for Django Admin (if applicable)
if hasattr(M3UAccount, 'user_agents'):
filter_horizontal = ('user_agents',) # Only for ManyToManyField
if hasattr(M3UAccount, "user_agents"):
filter_horizontal = ("user_agents",) # Only for ManyToManyField
@admin.register(M3UFilter)
class M3UFilterAdmin(admin.ModelAdmin):
list_display = ('m3u_account', 'filter_type', 'regex_pattern', 'exclude')
list_filter = ('filter_type', 'exclude')
search_fields = ('regex_pattern',)
ordering = ('m3u_account',)
list_display = ("m3u_account", "filter_type", "regex_pattern", "exclude")
list_filter = ("filter_type", "exclude")
search_fields = ("regex_pattern",)
ordering = ("m3u_account",)
@admin.register(ServerGroup)
class ServerGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ("name",)
search_fields = ("name",)

View file

@ -1,18 +1,38 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet, M3UAccountProfileViewSet
from .api_views import (
M3UAccountViewSet,
M3UFilterViewSet,
ServerGroupViewSet,
RefreshM3UAPIView,
RefreshSingleM3UAPIView,
UserAgentViewSet,
M3UAccountProfileViewSet,
)
app_name = 'm3u'
app_name = "m3u"
router = DefaultRouter()
router.register(r'accounts', M3UAccountViewSet, basename='m3u-account')
router.register(r'accounts\/(?P<account_id>\d+)\/profiles', M3UAccountProfileViewSet, basename='m3u-account-profiles')
router.register(r'filters', M3UFilterViewSet, basename='m3u-filter')
router.register(r'server-groups', ServerGroupViewSet, basename='server-group')
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
router.register(
r"accounts\/(?P<account_id>\d+)\/profiles",
M3UAccountProfileViewSet,
basename="m3u-account-profiles",
)
router.register(
r"accounts\/(?P<account_id>\d+)\/filters",
M3UFilterViewSet,
basename="m3u-filters",
)
router.register(r"server-groups", ServerGroupViewSet, basename="server-group")
urlpatterns = [
path('refresh/', RefreshM3UAPIView.as_view(), name='m3u_refresh'),
path('refresh/<int:account_id>/', RefreshSingleM3UAPIView.as_view(), name='m3u_refresh_single'),
path("refresh/", RefreshM3UAPIView.as_view(), name="m3u_refresh"),
path(
"refresh/<int:account_id>/",
RefreshSingleM3UAPIView.as_view(),
name="m3u_refresh_single",
),
]
urlpatterns += router.urls

View file

@ -183,8 +183,6 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
class M3UFilterViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U filters"""
queryset = M3UFilter.objects.all()
serializer_class = M3UFilterSerializer
@ -194,6 +192,23 @@ class M3UFilterViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
def get_queryset(self):
m3u_account_id = self.kwargs["account_id"]
return M3UFilter.objects.filter(m3u_account_id=m3u_account_id)
def perform_create(self, serializer):
# Get the account ID from the URL
account_id = self.kwargs["account_id"]
# # Get the M3UAccount instance for the account_id
# m3u_account = M3UAccount.objects.get(id=account_id)
# Save the 'm3u_account' in the serializer context
serializer.context["m3u_account"] = account_id
# Perform the actual save
serializer.save(m3u_account_id=account_id)
class ServerGroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Server Groups"""

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-07-22 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0012_alter_m3uaccount_refresh_interval'),
]
operations = [
migrations.AlterField(
model_name='m3ufilter',
name='filter_type',
field=models.CharField(choices=[('group', 'Group'), ('name', 'Stream Name'), ('url', 'Stream URL')], default='group', help_text='Filter based on either group title or stream name.', max_length=50),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.1.6 on 2025-07-31 17:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0013_alter_m3ufilter_filter_type'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={'ordering': ['order']},
),
migrations.AddField(
model_name='m3ufilter',
name='order',
field=models.PositiveIntegerField(default=0),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.2.4 on 2025-08-02 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0014_alter_m3ufilter_options_m3ufilter_order'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={},
),
migrations.AddField(
model_name='m3ufilter',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
]

View file

@ -155,9 +155,11 @@ class M3UFilter(models.Model):
"""Defines filters for M3U accounts based on stream name or group title."""
FILTER_TYPE_CHOICES = (
("group", "Group Title"),
("group", "Group"),
("name", "Stream Name"),
("url", "Stream URL"),
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
@ -177,6 +179,8 @@ class M3UFilter(models.Model):
default=True,
help_text="If True, matching items are excluded; if False, only matches are included.",
)
order = models.PositiveIntegerField(default=0)
custom_properties = models.TextField(null=True, blank=True)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == "group" else stream_name
@ -226,9 +230,6 @@ class ServerGroup(models.Model):
return self.name
from django.db import models
class M3UAccountProfile(models.Model):
"""Represents a profile associated with an M3U Account."""

View file

@ -1,3 +1,4 @@
from core.utils import validate_flexible_url
from rest_framework import serializers
from rest_framework.response import Response
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
@ -5,7 +6,6 @@ from core.models import UserAgent
from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
from apps.channels.serializers import (
ChannelGroupM3UAccountSerializer,
ChannelGroupSerializer,
)
import logging
@ -15,11 +15,16 @@ logger = logging.getLogger(__name__)
class M3UFilterSerializer(serializers.ModelSerializer):
"""Serializer for M3U Filters"""
channel_groups = ChannelGroupM3UAccountSerializer(source="m3u_account", many=True)
class Meta:
model = M3UFilter
fields = ["id", "filter_type", "regex_pattern", "exclude", "channel_groups"]
fields = [
"id",
"filter_type",
"regex_pattern",
"exclude",
"order",
"custom_properties",
]
class M3UAccountProfileSerializer(serializers.ModelSerializer):
@ -63,7 +68,7 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = M3UFilterSerializer(many=True, read_only=True)
filters = serializers.SerializerMethodField()
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
@ -76,6 +81,12 @@ class M3UAccountSerializer(serializers.ModelSerializer):
channel_groups = ChannelGroupM3UAccountSerializer(
source="channel_group", many=True, required=False
)
server_url = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
validators=[validate_flexible_url],
)
class Meta:
model = M3UAccount
@ -142,6 +153,10 @@ class M3UAccountSerializer(serializers.ModelSerializer):
return instance
def get_filters(self, obj):
filters = obj.filters.order_by("order")
return M3UFilterSerializer(filters, many=True).data
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""

File diff suppressed because it is too large Load diff

View file

@ -266,7 +266,7 @@ def generate_dummy_epg(
# Create program entry with escaped channel name
xml_lines.append(
f' <programme start="{start_str}" stop="{stop_str}" channel="{program['channel_id']}">'
f' <programme start="{start_str}" stop="{stop_str}" channel="{program["channel_id"]}">'
)
xml_lines.append(f" <title>{html.escape(program['title'])}</title>")
xml_lines.append(f" <desc>{html.escape(program['description'])}</desc>")

View file

@ -417,8 +417,8 @@ class ChannelService:
return False, None, None, {"error": f"Exception: {str(e)}"}
@staticmethod
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video"):
"""Parse FFmpeg stream info line and store in Redis metadata"""
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None):
"""Parse FFmpeg stream info line and store in Redis metadata and database"""
try:
if stream_type == "input":
# Example lines:
@ -432,6 +432,9 @@ class ChannelService:
# Store in Redis if we have valid data
if input_format:
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, None, None, None, None, input_format)
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(stream_id, stream_type=input_format)
logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}")
@ -480,6 +483,16 @@ class ChannelService:
# Store in Redis if we have valid data
if any(x is not None for x in [video_codec, resolution, source_fps, pixel_format, video_bitrate]):
ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None, None)
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
video_codec=video_codec,
resolution=resolution,
source_fps=source_fps,
pixel_format=pixel_format,
video_bitrate=video_bitrate
)
logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, "
f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, "
@ -511,9 +524,15 @@ class ChannelService:
# Store in Redis if we have valid data
if any(x is not None for x in [audio_codec, sample_rate, channels, audio_bitrate]):
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate, None)
logger.info(f"Audio stream info - Codec: {audio_codec}, Sample Rate: {sample_rate} Hz, "
f"Channels: {channels}, Audio Bitrate: {audio_bitrate} kb/s")
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
audio_codec=audio_codec,
sample_rate=sample_rate,
audio_channels=channels,
audio_bitrate=audio_bitrate
)
except Exception as e:
logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}")
@ -575,6 +594,35 @@ class ChannelService:
logger.error(f"Error updating stream info in Redis: {e}")
return False
@staticmethod
def _update_stream_stats_in_db(stream_id, **stats):
"""Update stream stats in database"""
try:
from apps.channels.models import Stream
from django.utils import timezone
stream = Stream.objects.get(id=stream_id)
# Get existing stats or create new dict
current_stats = stream.stream_stats or {}
# Update with new stats
for key, value in stats.items():
if value is not None:
current_stats[key] = value
# Save updated stats and timestamp
stream.stream_stats = current_stats
stream.stream_stats_updated_at = timezone.now()
stream.save(update_fields=['stream_stats', 'stream_stats_updated_at'])
logger.debug(f"Updated stream stats in database for stream {stream_id}: {stats}")
return True
except Exception as e:
logger.error(f"Error updating stream stats in database for stream {stream_id}: {e}")
return False
# Helper methods for Redis operations
@staticmethod

View file

@ -587,9 +587,9 @@ class StreamManager:
from .services.channel_service import ChannelService
if "video:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "video")
ChannelService.parse_and_store_stream_info(self.channel_id, content, "video", self.current_stream_id)
elif "audio:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio")
ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio", self.current_stream_id)
# Determine log level based on content
if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']):
@ -605,7 +605,7 @@ class StreamManager:
if content.startswith('Input #0'):
# If it's input 0, parse stream info
from .services.channel_service import ChannelService
ChannelService.parse_and_store_stream_info(self.channel_id, content, "input")
ChannelService.parse_and_store_stream_info(self.channel_id, content, "input", self.current_stream_id)
else:
# Everything else at debug level
logger.debug(f"FFmpeg stderr for channel {self.channel_id}: {content}")
@ -649,6 +649,14 @@ class StreamManager:
if any(x is not None for x in [ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_output_bitrate]):
self._update_ffmpeg_stats_in_redis(ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_output_bitrate)
# Also save ffmpeg_output_bitrate to database if we have stream_id
if ffmpeg_output_bitrate is not None and self.current_stream_id:
from .services.channel_service import ChannelService
ChannelService._update_stream_stats_in_db(
self.current_stream_id,
ffmpeg_output_bitrate=ffmpeg_output_bitrate
)
# Fix the f-string formatting
actual_fps_str = f"{actual_fps:.1f}" if actual_fps is not None else "N/A"
ffmpeg_output_bitrate_str = f"{ffmpeg_output_bitrate:.1f}" if ffmpeg_output_bitrate is not None else "N/A"

View file

@ -9,6 +9,8 @@ from redis.exceptions import ConnectionError, TimeoutError
from django.core.cache import cache
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
import gc
logger = logging.getLogger(__name__)
@ -354,3 +356,33 @@ def is_protected_path(file_path):
return True
return False
def validate_flexible_url(value):
"""
Custom URL validator that accepts URLs with hostnames that aren't FQDNs.
This allows URLs like "http://hostname/" which
Django's standard URLValidator rejects.
"""
if not value:
return # Allow empty values since the field is nullable
# Create a standard Django URL validator
url_validator = URLValidator()
try:
# First try the standard validation
url_validator(value)
except ValidationError as e:
# If standard validation fails, check if it's a non-FQDN hostname
import re
# More flexible pattern for non-FQDN hostnames with paths
# Matches: http://hostname, http://hostname/, http://hostname:port/path/to/file.xml
non_fqdn_pattern = r'^https?://[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\:[0-9]+)?(/[^\s]*)?$'
non_fqdn_match = re.match(non_fqdn_pattern, value)
if non_fqdn_match:
return # Accept non-FQDN hostnames
# If it doesn't match our flexible patterns, raise the original error
raise ValidationError("Enter a valid URL.")

View file

@ -33,7 +33,8 @@ export POSTGRES_USER=${POSTGRES_USER:-dispatch}
export POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-secret}
export POSTGRES_HOST=${POSTGRES_HOST:-localhost}
export POSTGRES_PORT=${POSTGRES_PORT:-5432}
export PG_VERSION=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1)
export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
export REDIS_HOST=${REDIS_HOST:-localhost}
export REDIS_DB=${REDIS_DB:-0}
export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191}
@ -107,13 +108,13 @@ echo "Starting init process..."
# Start PostgreSQL
echo "Starting Postgres..."
su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
# Wait for PostgreSQL to be ready
until su - postgres -c "/usr/lib/postgresql/14/bin/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
echo_with_timestamp "Waiting for PostgreSQL to be ready..."
sleep 1
done
postgres_pid=$(su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
echo "✅ Postgres started with PID $postgres_pid"
pids+=("$postgres_pid")

View file

@ -27,6 +27,66 @@ if [ -e "/data/postgresql.conf" ]; then
echo "Migration completed successfully."
fi
PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION"
# Detect current version from data directory, if present
if [ -f "$PG_VERSION_FILE" ]; then
CURRENT_VERSION=$(cat "$PG_VERSION_FILE")
else
CURRENT_VERSION=""
fi
# Only run upgrade if current version is set and not the target
if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then
echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..."
# Set binary paths for upgrade if needed
OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin"
NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin"
PG_INSTALLED_BY_SCRIPT=0
if [ ! -d "$OLD_BINDIR" ]; then
echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..."
apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
if [ $? -ne 0 ]; then
echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting."
exit 1
fi
PG_INSTALLED_BY_SCRIPT=1
fi
# Prepare new data directory
NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION"
# Remove new data directory if it already exists (from a failed/partial upgrade)
if [ -d "$NEW_POSTGRES_DIR" ]; then
echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues."
rm -rf "$NEW_POSTGRES_DIR"
fi
mkdir -p "$NEW_POSTGRES_DIR"
chown -R postgres:postgres "$NEW_POSTGRES_DIR"
chmod 700 "$NEW_POSTGRES_DIR"
# Initialize new data directory
echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..."
su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR"
echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..."
# Run pg_upgrade
su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR"
# Move old data directory for backup, move new into place
mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)"
mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR"
echo "Upgrade complete. Old data directory backed up."
# Uninstall PostgreSQL if we installed it just for upgrade
if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then
echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..."
apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION
apt autoremove -y
fi
fi
# Initialize PostgreSQL database
if [ -z "$(ls -A $POSTGRES_DIR)" ]; then
echo "Initializing PostgreSQL database..."
@ -35,21 +95,21 @@ if [ -z "$(ls -A $POSTGRES_DIR)" ]; then
chmod 700 $POSTGRES_DIR
# Initialize PostgreSQL
su - postgres -c "/usr/lib/postgresql/14/bin/initdb -D ${POSTGRES_DIR}"
su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}"
# Configure PostgreSQL
echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf"
echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf"
# Start PostgreSQL
echo "Starting Postgres..."
su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'"
# Wait for PostgreSQL to be ready
until su - postgres -c "/usr/lib/postgresql/14/bin/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do
echo "Waiting for PostgreSQL to be ready..."
sleep 1
done
postgres_pid=$(su - postgres -c "/usr/lib/postgresql/14/bin/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p')
# Setup database if needed
if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then
@ -69,8 +129,8 @@ END
\$\$;
EOF
echo "Setting PostgreSQL user privileges..."
su postgres -c "/usr/lib/postgresql/14/bin/psql -p ${POSTGRES_PORT} -c \"ALTER DATABASE ${POSTGRES_DB} OWNER TO $POSTGRES_USER;\""
su postgres -c "/usr/lib/postgresql/14/bin/psql -p ${POSTGRES_PORT} -c \"GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO $POSTGRES_USER;\""
su postgres -c "$PG_BINDIR/psql -p ${POSTGRES_PORT} -c \"ALTER DATABASE ${POSTGRES_DB} OWNER TO $POSTGRES_USER;\""
su postgres -c "$PG_BINDIR/psql -p ${POSTGRES_PORT} -c \"GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO $POSTGRES_USER;\""
# Finished setting up PosgresSQL database
echo "PostgreSQL database setup complete."
fi

View file

@ -256,7 +256,7 @@ export default class API {
hasChannels: false,
hasM3UAccounts: false,
canEdit: true,
canDelete: true
canDelete: true,
};
useChannelsStore.getState().addChannelGroup(processedGroup);
// Refresh channel groups to update the UI
@ -736,10 +736,13 @@ export default class API {
static async updateM3UGroupSettings(playlistId, groupSettings) {
try {
const response = await request(`${host}/api/m3u/accounts/${playlistId}/group-settings/`, {
method: 'PATCH',
body: { group_settings: groupSettings },
});
const response = await request(
`${host}/api/m3u/accounts/${playlistId}/group-settings/`,
{
method: 'PATCH',
body: { group_settings: groupSettings },
}
);
// Fetch the updated playlist and update the store
const updatedPlaylist = await API.getPlaylist(playlistId);
usePlaylistsStore.getState().updatePlaylist(updatedPlaylist);
@ -854,7 +857,6 @@ export default class API {
body = { ...payload };
delete body.file;
}
console.log(body);
const response = await request(`${host}/api/m3u/accounts/${id}/`, {
method: 'PATCH',
@ -1110,6 +1112,48 @@ export default class API {
}
}
static async addM3UFilter(accountId, values) {
try {
const response = await request(
`${host}/api/m3u/accounts/${accountId}/filters/`,
{
method: 'POST',
body: values,
}
);
return response;
} catch (e) {
errorNotification(`Failed to add profile to account ${accountId}`, e);
}
}
static async deleteM3UFilter(accountId, id) {
try {
await request(`${host}/api/m3u/accounts/${accountId}/filters/${id}/`, {
method: 'DELETE',
});
} catch (e) {
errorNotification(`Failed to delete profile for account ${accountId}`, e);
}
}
static async updateM3UFilter(accountId, filterId, values) {
const { id, ...payload } = values;
try {
await request(
`${host}/api/m3u/accounts/${accountId}/filters/${filterId}/`,
{
method: 'PUT',
body: payload,
}
);
} catch (e) {
errorNotification(`Failed to update profile for account ${accountId}`, e);
}
}
static async getSettings() {
try {
const response = await request(`${host}/api/core/settings/`);
@ -1230,7 +1274,9 @@ export default class API {
static async getLogos(params = {}) {
try {
const queryParams = new URLSearchParams(params);
const response = await request(`${host}/api/channels/logos/?${queryParams.toString()}`);
const response = await request(
`${host}/api/channels/logos/?${queryParams.toString()}`
);
return response;
} catch (e) {
@ -1369,7 +1415,7 @@ export default class API {
});
// Remove multiple logos from store
ids.forEach(id => {
ids.forEach((id) => {
useChannelsStore.getState().removeLogo(id);
});
@ -1675,4 +1721,18 @@ export default class API {
errorNotification('Failed to trigger stream rehash', e);
}
}
static async getStreamsByIds(ids) {
try {
const params = new URLSearchParams();
params.append('ids', ids.join(','));
const response = await request(
`${host}/api/channels/streams/?${params.toString()}`
);
return response.results || response;
} catch (e) {
errorNotification('Failed to retrieve streams by IDs', e);
}
}
}

View file

@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
ListOrdered,
Play,
@ -27,6 +28,7 @@ import {
ActionIcon,
Menu,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
@ -168,19 +170,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}, []);
const copyPublicIP = async () => {
try {
await navigator.clipboard.writeText(environment.public_ip);
} catch (err) {
const inputElement = publicIPRef.current; // Get the actual input
console.log(inputElement);
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
}
const success = await copyToClipboard(environment.public_ip);
if (success) {
notifications.show({
title: 'Success',
message: 'Public IP copied to clipboard',
color: 'green',
});
} else {
console.error('Failed to copy public IP to clipboard');
}
};

View file

@ -27,6 +27,7 @@ import usePlaylistsStore from '../../store/playlists';
import { notifications } from '@mantine/notifications';
import { isNotEmpty, useForm } from '@mantine/form';
import useEPGsStore from '../../store/epgs';
import M3UFilters from './M3UFilters';
const M3U = ({
m3uAccount = null,
@ -45,6 +46,7 @@ const M3U = ({
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
const [filterModalOpen, setFilterModalOpen] = useState(false);
const [loadingText, setLoadingText] = useState('');
const [showCredentialFields, setShowCredentialFields] = useState(false);
@ -85,7 +87,11 @@ const M3U = ({
account_type: m3uAccount.account_type,
username: m3uAccount.username ?? '',
password: '',
stale_stream_days: m3uAccount.stale_stream_days !== undefined && m3uAccount.stale_stream_days !== null ? m3uAccount.stale_stream_days : 7,
stale_stream_days:
m3uAccount.stale_stream_days !== undefined &&
m3uAccount.stale_stream_days !== null
? m3uAccount.stale_stream_days
: 7,
});
if (m3uAccount.account_type == 'XC') {
@ -145,7 +151,8 @@ const M3U = ({
if (values.account_type != 'XC') {
notifications.show({
title: 'Fetching M3U Groups',
message: 'Configure group filters and auto sync settings once complete.',
message:
'Configure group filters and auto sync settings once complete.',
});
// Don't prompt for group filters, but keeping this here
@ -177,7 +184,10 @@ const M3U = ({
const closeGroupFilter = () => {
setGroupFilterModalOpen(false);
close();
};
const closeFilter = () => {
setFilterModalOpen(false);
};
useEffect(() => {
@ -191,201 +201,225 @@ const M3U = ({
}
return (
<Modal size={700} opened={isOpen} onClose={close} title="M3U Account">
<LoadingOverlay
visible={form.submitting}
overlayBlur={2}
loaderProps={loadingText ? { children: loadingText } : {}}
/>
<>
<Modal size={700} opened={isOpen} onClose={close} title="M3U Account">
<LoadingOverlay
visible={form.submitting}
overlayBlur={2}
loaderProps={loadingText ? { children: loadingText } : {}}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
style={{ width: '100%' }}
id="name"
name="name"
label="Name"
description="Unique identifier for this M3U account"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<TextInput
style={{ width: '100%' }}
id="server_url"
name="server_url"
label="URL"
description="Direct URL to the M3U playlist or server"
{...form.getInputProps('server_url')}
key={form.key('server_url')}
/>
<Select
id="account_type"
name="account_type"
label="Account Type"
description={<>Standard for direct M3U URLs, <br />Xtream Codes for panel-based services</>}
data={[
{
value: 'STD',
label: 'Standard',
},
{
value: 'XC',
label: 'Xtream Codes',
},
]}
key={form.key('account_type')}
{...form.getInputProps('account_type')}
/>
{form.getValues().account_type == 'XC' && (
<Box>
{!m3uAccount && (
<Group justify="space-between">
<Box>Create EPG</Box>
<Switch
id="create_epg"
name="create_epg"
description="Automatically create matching EPG source for this Xtream account"
key={form.key('create_epg')}
{...form.getInputProps('create_epg', {
type: 'checkbox',
})}
/>
</Group>
)}
<TextInput
id="username"
name="username"
label="Username"
description="Username for Xtream Codes authentication"
{...form.getInputProps('username')}
/>
<PasswordInput
id="password"
name="password"
label="Password"
description="Password for Xtream Codes authentication (leave empty to keep existing)"
{...form.getInputProps('password')}
/>
</Box>
)}
{form.getValues().account_type != 'XC' && (
<FileInput
id="file"
label="Upload files"
placeholder="Upload files"
description="Upload a local M3U file instead of using URL"
onChange={setFile}
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
style={{ width: '100%' }}
id="name"
name="name"
label="Name"
description="Unique identifier for this M3U account"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<TextInput
style={{ width: '100%' }}
id="server_url"
name="server_url"
label="URL"
description="Direct URL to the M3U playlist or server"
{...form.getInputProps('server_url')}
key={form.key('server_url')}
/>
)}
</Stack>
<Divider size="sm" orientation="vertical" />
<Select
id="account_type"
name="account_type"
label="Account Type"
description={
<>
Standard for direct M3U URLs, <br />
Xtream Codes for panel-based services
</>
}
data={[
{
value: 'STD',
label: 'Standard',
},
{
value: 'XC',
label: 'Xtream Codes',
},
]}
key={form.key('account_type')}
{...form.getInputProps('account_type')}
/>
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
style={{ width: '100%' }}
id="max_streams"
name="max_streams"
label="Max Streams"
placeholder="0 = Unlimited"
description="Maximum number of concurrent streams (0 for unlimited)"
{...form.getInputProps('max_streams')}
key={form.key('max_streams')}
/>
{form.getValues().account_type == 'XC' && (
<Box>
{!m3uAccount && (
<Group justify="space-between">
<Box>Create EPG</Box>
<Switch
id="create_epg"
name="create_epg"
description="Automatically create matching EPG source for this Xtream account"
key={form.key('create_epg')}
{...form.getInputProps('create_epg', {
type: 'checkbox',
})}
/>
</Group>
)}
<Select
id="user_agent"
name="user_agent"
label="User-Agent"
description="User-Agent header to use when accessing this M3U source"
{...form.getInputProps('user_agent')}
key={form.key('user_agent')}
data={[{ value: '0', label: '(Use Default)' }].concat(
userAgents.map((ua) => ({
label: ua.name,
value: `${ua.id}`,
}))
<TextInput
id="username"
name="username"
label="Username"
description="Username for Xtream Codes authentication"
{...form.getInputProps('username')}
/>
<PasswordInput
id="password"
name="password"
label="Password"
description="Password for Xtream Codes authentication (leave empty to keep existing)"
{...form.getInputProps('password')}
/>
</Box>
)}
/>
<NumberInput
label="Refresh Interval (hours)"
description={<>How often to automatically refresh M3U data<br />
(0 to disable automatic refreshes)</>}
{...form.getInputProps('refresh_interval')}
key={form.key('refresh_interval')}
/>
{form.getValues().account_type != 'XC' && (
<FileInput
id="file"
label="Upload files"
placeholder="Upload files"
description="Upload a local M3U file instead of using URL"
onChange={setFile}
/>
)}
</Stack>
<NumberInput
min={0}
max={365}
label="Stale Stream Retention (days)"
description="Streams not seen for this many days will be removed"
{...form.getInputProps('stale_stream_days')}
/>
<Divider size="sm" orientation="vertical" />
<Checkbox
label="Is Active"
description="Enable or disable this M3U account"
{...form.getInputProps('is_active', { type: 'checkbox' })}
key={form.key('is_active')}
/>
</Stack>
</Group>
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
style={{ width: '100%' }}
id="max_streams"
name="max_streams"
label="Max Streams"
placeholder="0 = Unlimited"
description="Maximum number of concurrent streams (0 for unlimited)"
{...form.getInputProps('max_streams')}
key={form.key('max_streams')}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
{playlist && (
<>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setGroupFilterModalOpen(true)}
>
Groups
</Button>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
</>
)}
<Select
id="user_agent"
name="user_agent"
label="User-Agent"
description="User-Agent header to use when accessing this M3U source"
{...form.getInputProps('user_agent')}
key={form.key('user_agent')}
data={[{ value: '0', label: '(Use Default)' }].concat(
userAgents.map((ua) => ({
label: ua.name,
value: `${ua.id}`,
}))
)}
/>
<Button
type="submit"
variant="filled"
disabled={form.submitting}
size="sm"
>
Save
</Button>
</Flex>
{playlist && (
<>
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
<M3UGroupFilter
isOpen={groupFilterModalOpen}
playlist={playlist}
onClose={closeGroupFilter}
/>
</>
)}
</form>
</Modal>
<NumberInput
label="Refresh Interval (hours)"
description={
<>
How often to automatically refresh M3U data
<br />
(0 to disable automatic refreshes)
</>
}
{...form.getInputProps('refresh_interval')}
key={form.key('refresh_interval')}
/>
<NumberInput
min={0}
max={365}
label="Stale Stream Retention (days)"
description="Streams not seen for this many days will be removed"
{...form.getInputProps('stale_stream_days')}
/>
<Checkbox
label="Is Active"
description="Enable or disable this M3U account"
{...form.getInputProps('is_active', { type: 'checkbox' })}
key={form.key('is_active')}
/>
</Stack>
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
{playlist && (
<>
<Button
variant="filled"
size="sm"
onClick={() => setFilterModalOpen(true)}
>
Filters
</Button>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setGroupFilterModalOpen(true)}
>
Groups
</Button>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
</>
)}
<Button
type="submit"
variant="filled"
disabled={form.submitting}
size="sm"
>
Save
</Button>
</Flex>
</form>
</Modal>
{playlist && (
<>
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
<M3UGroupFilter
isOpen={groupFilterModalOpen}
playlist={playlist}
onClose={closeGroupFilter}
/>
<M3UFilters
isOpen={filterModalOpen}
playlist={playlist}
onClose={closeFilter}
/>
</>
)}
</>
);
};

View file

@ -0,0 +1,142 @@
// Modal.js
import React, { useEffect } from 'react';
import API from '../../api';
import {
TextInput,
Button,
Modal,
Flex,
Select,
Group,
Stack,
Switch,
Box,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { M3U_FILTER_TYPES } from '../../constants';
import usePlaylistsStore from '../../store/playlists';
import { setCustomProperty } from '../../utils';
const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
const form = useForm({
mode: 'uncontrolled',
initialValues: {
filter_type: 'group',
regex_pattern: '',
exclude: true,
case_sensitive: true,
},
validate: (values) => ({}),
});
useEffect(() => {
if (filter) {
form.setValues({
filter_type: filter.filter_type,
regex_pattern: filter.regex_pattern,
exclude: filter.exclude,
case_sensitive:
JSON.parse(filter.custom_properties || '{}').case_sensitive ?? true,
});
} else {
form.reset();
}
}, [filter]);
const onSubmit = async () => {
const values = form.getValues();
values.custom_properties = setCustomProperty(
filter ? filter.custom_properties : {},
'case_sensitive',
values.case_sensitive,
true
);
delete values.case_sensitive;
if (!filter) {
// By default, new rule will go at the end
values.order = m3u.filters.length;
await API.addM3UFilter(m3u.id, values);
} else {
await API.updateM3UFilter(m3u.id, filter.id, values);
}
const updatedPlaylist = await fetchPlaylist(m3u.id);
form.reset();
onClose(updatedPlaylist);
};
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Filter">
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="xs" style={{ flex: 1 }}>
<Select
label="Field"
description="Specify which property of the stream object this rule will apply to"
data={M3U_FILTER_TYPES}
{...form.getInputProps('filter_type')}
key={form.key('filter_type')}
/>
<TextInput
id="regex_pattern"
name="regex_pattern"
label="Regex Pattern"
description="Regular expression to execute on the value to determine if the filter applies to the item"
{...form.getInputProps('regex_pattern')}
key={form.key('regex_pattern')}
/>
<Group justify="space-between">
<Box>Exclude</Box>
<Switch
id="exclude"
name="exclude"
description="Specify if this is an exclusion or inclusion rule"
key={form.key('exclude')}
{...form.getInputProps('exclude', {
type: 'checkbox',
})}
/>
</Group>
<Group justify="space-between">
<Box>Case Sensitive</Box>
<Switch
id="case_sensitive"
name="case_sensitive"
description="If the regex should be case sensitive or not"
key={form.key('case_sensitive')}
{...form.getInputProps('case_sensitive', {
type: 'checkbox',
})}
/>
</Group>
</Stack>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
disabled={form.submitting}
size="small"
>
Save
</Button>
</Flex>
</form>
</Modal>
);
};
export default M3UFilter;

View file

@ -0,0 +1,340 @@
import React, { useState, useEffect } from 'react';
import API from '../../api';
import usePlaylistsStore from '../../store/playlists';
import ConfirmationDialog from '../ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import {
Flex,
Modal,
Button,
Box,
ActionIcon,
Text,
useMantineTheme,
Center,
Group,
Alert,
} from '@mantine/core';
import { GripHorizontal, Info, SquareMinus, SquarePen } from 'lucide-react';
import M3UFilter from './M3UFilter';
import { M3U_FILTER_TYPES } from '../../constants';
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useDraggable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
const RowDragHandleCell = ({ rowId }) => {
const { attributes, listeners, setNodeRef } = useDraggable({
id: rowId,
});
return (
<Center>
<ActionIcon
ref={setNodeRef}
{...listeners}
{...attributes}
variant="transparent"
size="xs"
style={{
cursor: 'grab', // this is enough
}}
>
<GripHorizontal color="white" />
</ActionIcon>
</Center>
);
};
// Row Component
const DraggableRow = ({ filter, editFilter, onDelete }) => {
const theme = useMantineTheme();
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: filter.id,
});
const style = {
transform: CSS.Transform.toString(transform), //let dnd-kit do its thing
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: 'relative',
};
return (
<Box
ref={setNodeRef}
key={filter.id}
spacing="xs"
style={{
...style,
padding: '8px',
paddingBottom: '8px',
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: '#2A2A2E',
flexDirection: 'column',
alignItems: 'stretch',
marginBottom: 5,
}}
>
<Flex gap="sm" justify="space-between" alignItems="middle">
<Group justify="left">
<RowDragHandleCell rowId={filter.id} />
<Text
size="sm"
fw={700}
style={{
color: filter.exclude
? theme.tailwind.red[6]
: theme.tailwind.green[5],
}}
>
{filter.exclude ? 'Exclude' : 'Include'}
</Text>
<Text size="sm">
{
M3U_FILTER_TYPES.find((type) => type.value == filter.filter_type)
.label
}
</Text>
<Text size="sm">matching</Text>
<Text size="sm">
"<code>{filter.regex_pattern}</code>"
</Text>
</Group>
<Group align="flex-end" gap="xs">
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editFilter(filter)}
>
<SquarePen size="20" />
</ActionIcon>
<ActionIcon
color={theme.tailwind.red[6]}
onClick={() => onDelete(filter.id)}
size="small"
variant="transparent"
>
<SquareMinus size="20" />
</ActionIcon>
</Group>
</Flex>
</Box>
);
};
const M3UFilters = ({ playlist, isOpen, onClose }) => {
const theme = useMantineTheme();
const [editorOpen, setEditorOpen] = useState(false);
const [filter, setFilter] = useState(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [filterToDelete, setFilterToDelete] = useState(null);
const [filters, setFilters] = useState([]);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
useEffect(() => {
setFilters(playlist.filters || []);
}, [playlist]);
const editFilter = (filter = null) => {
if (filter) {
setFilter(filter);
}
setEditorOpen(true);
};
const onDelete = async (id) => {
if (!playlist || !playlist.id) return;
// Get profile details for the confirmation dialog
const filterObj = playlist.filters.find((p) => p.id === id);
setFilterToDelete(filterObj);
setDeleteTarget(id);
// Skip warning if it's been suppressed
if (isWarningSuppressed('delete-filter')) {
return deleteFilter(id);
}
setConfirmDeleteOpen(true);
};
const deleteFilter = async (id) => {
if (!playlist || !playlist.id) return;
try {
await API.deleteM3UFilter(playlist.id, id);
setConfirmDeleteOpen(false);
} catch (error) {
console.error('Error deleting profile:', error);
setConfirmDeleteOpen(false);
}
fetchPlaylist(playlist.id);
setFilters(filters.filter((f) => f.id !== id));
};
const closeEditor = (updatedPlaylist = null) => {
setFilter(null);
setEditorOpen(false);
if (updatedPlaylist) {
setFilters(updatedPlaylist.filters);
}
};
const handleDragEnd = async ({ active, over }) => {
if (!over || active.id === over.id) return;
const originalFilters = [...filters];
const oldIndex = filters.findIndex((f) => f.id === active.id);
const newIndex = filters.findIndex((f) => f.id === over.id);
const newFilters = arrayMove(filters, oldIndex, newIndex);
setFilters(newFilters);
// Recalculate and compare order
const updatedFilters = newFilters.map((filter, index) => ({
...filter,
newOrder: index,
}));
// Filter only those whose order actually changed
const changedFilters = updatedFilters.filter((f) => f.order !== f.newOrder);
// Send updates
try {
await Promise.all(
changedFilters.map((f) =>
API.updateM3UFilter(playlist.id, f.id, { ...f, order: f.newOrder })
)
);
await fetchPlaylist(playlist.id);
} catch (e) {
setFilters(originalFilters);
}
};
// Don't render if modal is not open, or if playlist data is invalid
if (!isOpen || !playlist || !playlist.id) {
return <></>;
}
return (
<>
<Modal opened={isOpen} onClose={onClose} title="Filters" size="lg">
<Alert
icon={<Info size={16} />}
color="blue"
variant="light"
style={{ marginBottom: 5 }}
>
<Text size="sm">
<strong>Order Matters!</strong> Rules are processed in the order
below. Once a stream matches a given rule, no other rules are
checked.
</Text>
</Alert>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={filters.map(({ id }) => id)}
strategy={verticalListSortingStrategy}
>
{filters.map((filter) => (
<DraggableRow
key={filter.id}
filter={filter}
editFilter={editFilter}
onDelete={onDelete}
/>
))}
</SortableContext>
</DndContext>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
variant="contained"
color="primary"
size="small"
onClick={() => editFilter()}
style={{ width: '100%' }}
>
New
</Button>
</Flex>
</Modal>
<M3UFilter
m3u={playlist}
filter={filter}
isOpen={editorOpen}
onClose={closeEditor}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => deleteFilter(deleteTarget)}
title="Confirm Filter Deletion"
message={
filterToDelete ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to delete the following filter?
Type: ${filterToDelete.type}
Patter: ${filterToDelete.regex_pattern}
This action cannot be undone.`}
</div>
) : (
'Are you sure you want to delete this filter? This action cannot be undone.'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
actionKey="delete-filter"
onSuppressChange={suppressWarning}
size="md"
/>
</>
);
};
export default M3UFilters;

View file

@ -410,8 +410,13 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
if (newCustomProps.channel_sort_order === undefined) {
newCustomProps.channel_sort_order = '';
}
// Keep channel_sort_reverse if it exists
if (newCustomProps.channel_sort_reverse === undefined) {
newCustomProps.channel_sort_reverse = false;
}
} else {
delete newCustomProps.channel_sort_order;
delete newCustomProps.channel_sort_reverse; // Remove reverse when sort is removed
}
return {
@ -428,36 +433,65 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
/>
{/* Show only channel_sort_order if selected */}
{group.custom_properties?.channel_sort_order !== undefined && (
<Select
label="Channel Sort Order"
placeholder="Select sort order..."
value={group.custom_properties?.channel_sort_order || ''}
onChange={(value) => {
setGroupStates(
groupStates.map((state) => {
if (state.channel_group === group.channel_group) {
return {
...state,
custom_properties: {
...state.custom_properties,
channel_sort_order: value || '',
},
};
}
return state;
})
);
}}
data={[
{ value: '', label: 'Provider Order (Default)' },
{ value: 'name', label: 'Name' },
{ value: 'tvg_id', label: 'TVG ID' },
{ value: 'updated_at', label: 'Updated At' },
]}
clearable
searchable
size="xs"
/>
<>
<Select
label="Channel Sort Order"
placeholder="Select sort order..."
value={group.custom_properties?.channel_sort_order || ''}
onChange={(value) => {
setGroupStates(
groupStates.map((state) => {
if (state.channel_group === group.channel_group) {
return {
...state,
custom_properties: {
...state.custom_properties,
channel_sort_order: value || '',
},
};
}
return state;
})
);
}}
data={[
{ value: '', label: 'Provider Order (Default)' },
{ value: 'name', label: 'Name' },
{ value: 'tvg_id', label: 'TVG ID' },
{ value: 'updated_at', label: 'Updated At' },
]}
clearable
searchable
size="xs"
/>
{/* Add reverse sort checkbox when sort order is selected (including default) */}
{group.custom_properties?.channel_sort_order !== undefined && (
<Flex align="center" gap="xs" mt="xs">
<Checkbox
label="Reverse Sort Order"
checked={group.custom_properties?.channel_sort_reverse || false}
onChange={(event) => {
setGroupStates(
groupStates.map((state) => {
if (state.channel_group === group.channel_group) {
return {
...state,
custom_properties: {
...state.custom_properties,
channel_sort_reverse: event.target.checked,
},
};
}
return state;
})
);
}}
size="xs"
/>
</Flex>
)}
</>
)}
{/* Show profile selection only if profile_assignment is selected */}

View file

@ -1,6 +1,7 @@
import React, { useMemo, useState, useEffect } from 'react';
import API from '../../api';
import { GripHorizontal, SquareMinus } from 'lucide-react';
import { copyToClipboard } from '../../utils';
import { GripHorizontal, SquareMinus, ChevronDown, ChevronRight, Eye } from 'lucide-react';
import {
Box,
ActionIcon,
@ -8,7 +9,14 @@ import {
Text,
useMantineTheme,
Center,
Badge,
Group,
Tooltip,
Collapse,
Button,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
useReactTable,
getCoreRowModel,
@ -17,6 +25,8 @@ import {
import './table.css';
import useChannelsTableStore from '../../store/channelsTable';
import usePlaylistsStore from '../../store/playlists';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import {
DndContext,
KeyboardSensor,
@ -123,6 +133,15 @@ const ChannelStreams = ({ channel, isExpanded }) => {
);
const playlists = usePlaylistsStore((s) => s.playlists);
const authUser = useAuthStore((s) => s.user);
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
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 [data, setData] = useState(channelStreams || []);
@ -141,6 +160,135 @@ const ChannelStreams = ({ channel, isExpanded }) => {
await API.requeryChannels();
};
// Create M3U account map for quick lookup
const m3uAccountsMap = useMemo(() => {
const map = {};
if (playlists && Array.isArray(playlists)) {
playlists.forEach((account) => {
if (account.id) {
map[account.id] = account.name;
}
});
}
return map;
}, [playlists]);
// Add state for tracking which streams have advanced stats expanded
const [expandedAdvancedStats, setExpandedAdvancedStats] = useState(new Set());
// Helper function to categorize stream stats
const categorizeStreamStats = (stats) => {
if (!stats) return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
const categories = {
basic: {},
video: {},
audio: {},
technical: {},
other: {}
};
// Define which stats go in which category
const categoryMapping = {
basic: ['resolution', 'video_codec', 'source_fps', 'audio_codec', 'audio_channels'],
video: ['video_bitrate', 'pixel_format', 'width', 'height', 'aspect_ratio', 'frame_rate'],
audio: ['audio_bitrate', 'sample_rate', 'audio_format', 'audio_channels_layout'],
technical: ['stream_type', 'container_format', 'duration', 'file_size', 'ffmpeg_output_bitrate', 'input_bitrate'],
other: [] // Will catch anything not categorized above
};
// Categorize each stat
Object.entries(stats).forEach(([key, value]) => {
let categorized = false;
for (const [category, keys] of Object.entries(categoryMapping)) {
if (keys.includes(key)) {
categories[category][key] = value;
categorized = true;
break;
}
}
// If not categorized, put it in 'other'
if (!categorized) {
categories.other[key] = value;
}
});
return categories;
};
// Function to format stat values for display
const formatStatValue = (key, value) => {
if (value === null || value === undefined) return 'N/A';
// Handle specific formatting cases
switch (key) {
case 'video_bitrate':
case 'audio_bitrate':
case 'ffmpeg_output_bitrate':
return `${value} kbps`;
case 'source_fps':
case 'frame_rate':
return `${value} fps`;
case 'sample_rate':
return `${value} Hz`;
case 'file_size':
// Convert bytes to appropriate unit
if (typeof value === 'number') {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(2)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return value;
case 'duration':
// Format duration if it's in seconds
if (typeof value === 'number') {
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const seconds = Math.floor(value % 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return value;
default:
return value.toString();
}
};
// Function to render a stats category
const renderStatsCategory = (categoryName, stats) => {
if (!stats || Object.keys(stats).length === 0) return null;
return (
<Box key={categoryName} mb="xs">
<Text size="xs" fw={600} mb={4} tt="uppercase" c="dimmed">
{categoryName}
</Text>
<Group gap={4} mb="xs">
{Object.entries(stats).map(([key, value]) => (
<Tooltip key={key} label={`${key}: ${formatStatValue(key, value)}`}>
<Badge size="xs" variant="light" color="gray">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}: {formatStatValue(key, value)}
</Badge>
</Tooltip>
))}
</Group>
</Box>
);
};
// Function to toggle advanced stats for a stream
const toggleAdvancedStats = (streamId) => {
const newExpanded = new Set(expandedAdvancedStats);
if (newExpanded.has(streamId)) {
newExpanded.delete(streamId);
} else {
newExpanded.add(streamId);
}
setExpandedAdvancedStats(newExpanded);
};
const table = useReactTable({
columns: useMemo(
() => [
@ -152,14 +300,162 @@ const ChannelStreams = ({ channel, isExpanded }) => {
},
{
id: 'name',
header: 'Name',
header: 'Stream Info',
accessorKey: 'name',
},
{
id: 'm3u',
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
cell: ({ row }) => {
const stream = row.original;
const playlistName = playlists[stream.m3u_account]?.name || 'Unknown';
const accountName = m3uAccountsMap[stream.m3u_account] || playlistName;
// Categorize stream stats
const categorizedStats = categorizeStreamStats(stream.stream_stats);
const hasAdvancedStats = Object.values(categorizedStats).some(category =>
Object.keys(category).length > 0
);
return (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">{stream.name}</Text>
<Badge size="xs" variant="light" color="teal">
{accountName}
</Badge>
{stream.quality && (
<Badge size="xs" variant="light" color="gray">
{stream.quality}
</Badge>
)}
{stream.url && (
<>
<Tooltip label={stream.url}>
<Badge
size="xs"
variant="light"
color="indigo"
style={{ cursor: 'pointer' }}
onClick={async (e) => {
e.stopPropagation();
const success = await copyToClipboard(stream.url);
notifications.show({
title: success ? 'URL Copied' : 'Copy Failed',
message: success ? 'Stream URL copied to clipboard' : 'Failed to copy URL to clipboard',
color: success ? 'green' : 'red',
});
}}
>
URL
</Badge>
</Tooltip>
<Tooltip label="Preview Stream">
<ActionIcon
size="xs"
color="blue"
variant="light"
onClick={() => handleWatchStream(stream.stream_hash || stream.id)}
style={{ marginLeft: 2 }}
>
<Eye size={16} />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
{/* Basic Stream Stats (always shown) */}
{stream.stream_stats && (
<Group gap="xs" mt={4} align="center">
{/* Video Information */}
{(stream.stream_stats.video_codec || stream.stream_stats.resolution || stream.stream_stats.video_bitrate || stream.stream_stats.source_fps) && (
<>
<Text size="xs" c="dimmed" fw={500}>Video:</Text>
{stream.stream_stats.resolution && (
<Badge size="xs" variant="light" color="red">
{stream.stream_stats.resolution}
</Badge>
)}
{stream.stream_stats.video_bitrate && (
<Badge size="xs" variant="light" color="orange" style={{ textTransform: 'none' }}>
{stream.stream_stats.video_bitrate} kbps
</Badge>
)}
{stream.stream_stats.source_fps && (
<Badge size="xs" variant="light" color="orange">
{stream.stream_stats.source_fps} FPS
</Badge>
)}
{stream.stream_stats.video_codec && (
<Badge size="xs" variant="light" color="blue">
{stream.stream_stats.video_codec.toUpperCase()}
</Badge>
)}
</>
)}
{/* Audio Information */}
{(stream.stream_stats.audio_codec || stream.stream_stats.audio_channels) && (
<>
<Text size="xs" c="dimmed" fw={500}>Audio:</Text>
{stream.stream_stats.audio_channels && (
<Badge size="xs" variant="light" color="pink">
{stream.stream_stats.audio_channels}
</Badge>
)}
{stream.stream_stats.audio_codec && (
<Badge size="xs" variant="light" color="pink">
{stream.stream_stats.audio_codec.toUpperCase()}
</Badge>
)}
</>
)}
{/* Output Bitrate */}
{(stream.stream_stats.ffmpeg_output_bitrate) && (
<>
<Text size="xs" c="dimmed" fw={500}>Output Bitrate:</Text>
{stream.stream_stats.ffmpeg_output_bitrate && (
<Badge size="xs" variant="light" color="orange" style={{ textTransform: 'none' }}>
{stream.stream_stats.ffmpeg_output_bitrate} kbps
</Badge>
)}
</>
)}
</Group>
)}
{/* Advanced Stats Toggle Button */}
{hasAdvancedStats && (
<Group gap="xs" mt={6}>
<Button
variant="subtle"
size="xs"
leftSection={expandedAdvancedStats.has(stream.id) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
onClick={() => toggleAdvancedStats(stream.id)}
c="dimmed"
>
{expandedAdvancedStats.has(stream.id) ? 'Hide' : 'Show'} Advanced Stats
</Button>
</Group>
)}
{/* Advanced Stats (expandable) */}
<Collapse in={expandedAdvancedStats.has(stream.id)}>
<Box mt="sm" p="xs" style={{ backgroundColor: 'rgba(0,0,0,0.1)', borderRadius: '4px' }}>
{renderStatsCategory('Video', categorizedStats.video)}
{renderStatsCategory('Audio', categorizedStats.audio)}
{renderStatsCategory('Technical', categorizedStats.technical)}
{renderStatsCategory('Other', categorizedStats.other)}
{/* Show when stats were last updated */}
{stream.stream_stats_updated_at && (
<Text size="xs" c="dimmed" mt="xs">
Last updated: {new Date(stream.stream_stats_updated_at).toLocaleString()}
</Text>
)}
</Box>
</Collapse>
</Box>
);
},
},
{
id: 'actions',
@ -178,7 +474,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
),
},
],
[data, playlists]
[data, playlists, m3uAccountsMap, expandedAdvancedStats]
),
data,
state: {

View file

@ -524,24 +524,12 @@ const ChannelsTable = ({ }) => {
};
const handleCopy = async (textToCopy, ref) => {
try {
await navigator.clipboard.writeText(textToCopy);
notifications.show({
title: 'Copied!',
// style: { width: '200px', left: '200px' },
});
} catch (err) {
const inputElement = ref.current; // Get the actual input
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
notifications.show({ title: 'Copied!' });
}
}
const success = await copyToClipboard(textToCopy);
notifications.show({
title: success ? 'Copied!' : 'Copy Failed',
message: success ? undefined : 'Failed to copy to clipboard',
color: success ? 'green' : 'red',
});
};
// Build URLs with parameters
const buildM3UUrl = () => {
@ -564,25 +552,30 @@ const ChannelsTable = ({ }) => {
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
};
// Example copy URLs
const copyM3UUrl = () => {
copyToClipboard(buildM3UUrl());
const copyM3UUrl = async () => {
const success = await copyToClipboard(buildM3UUrl());
notifications.show({
title: 'M3U URL Copied!',
message: 'The M3U URL has been copied to your clipboard.',
title: success ? 'M3U URL Copied!' : 'Copy Failed',
message: success ? 'The M3U URL has been copied to your clipboard.' : 'Failed to copy M3U URL to clipboard',
color: success ? 'green' : 'red',
});
};
const copyEPGUrl = () => {
copyToClipboard(buildEPGUrl());
const copyEPGUrl = async () => {
const success = await copyToClipboard(buildEPGUrl());
notifications.show({
title: 'EPG URL Copied!',
message: 'The EPG URL has been copied to your clipboard.',
title: success ? 'EPG URL Copied!' : 'Copy Failed',
message: success ? 'The EPG URL has been copied to your clipboard.' : 'Failed to copy EPG URL to clipboard',
color: success ? 'green' : 'red',
});
};
const copyHDHRUrl = () => {
copyToClipboard(hdhrUrl);
const copyHDHRUrl = async () => {
const success = await copyToClipboard(hdhrUrl);
notifications.show({
title: 'HDHR URL Copied!',
message: 'The HDHR URL has been copied to your clipboard.',
title: success ? 'HDHR URL Copied!' : 'Copy Failed',
message: success ? 'The HDHR URL has been copied to your clipboard.' : 'Failed to copy HDHR URL to clipboard',
color: success ? 'green' : 'red',
});
};

View file

@ -803,7 +803,12 @@ const M3UTable = () => {
return (
<Box>
<Flex
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10 }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 10,
}}
gap={15}
>
<Text
@ -853,8 +858,7 @@ const M3UTable = () => {
padding: 0,
// gap: 1,
}}
>
</Box>
></Box>
</Paper>
<Box

View file

@ -102,10 +102,10 @@ const StreamRowActions = ({
'ID:',
row.original.id,
'Hash:',
row.original.stream_hash
row.original.stream_hash,
);
handleWatchStream(row.original.stream_hash);
}, [row.original.id]); // Add proper dependency to ensure correct stream
}, [row.original, handleWatchStream]); // Add proper dependencies to ensure correct stream
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
@ -410,8 +410,16 @@ const StreamsTable = ({ }) => {
try {
const selectedChannelProfileId = useChannelsStore.getState().selectedProfileId;
// Try to fetch the actual stream data for selected streams
let streamsData = [];
try {
streamsData = await API.getStreamsByIds(selectedStreamIds);
} catch (error) {
console.warn('Could not fetch stream details, using IDs only:', error);
}
const streamData = selectedStreamIds.map(streamId => {
const stream = data.find(s => s.id === streamId);
const stream = streamsData.find(s => s.id === streamId);
return {
stream_id: streamId,
name: stream?.name || `Stream ${streamId}`,

View file

@ -33,19 +33,23 @@ export const NETWORK_ACCESS_OPTIONS = {
export const PROXY_SETTINGS_OPTIONS = {
buffering_timeout: {
label: 'Buffering Timeout',
description: 'Maximum time (in seconds) to wait for buffering before switching streams',
description:
'Maximum time (in seconds) to wait for buffering before switching streams',
},
buffering_speed: {
label: 'Buffering Speed',
description: 'Speed threshold below which buffering is detected (1.0 = normal speed)',
description:
'Speed threshold below which buffering is detected (1.0 = normal speed)',
},
redis_chunk_ttl: {
label: 'Buffer Chunk TTL',
description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
description:
'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
},
channel_shutdown_delay: {
label: 'Channel Shutdown Delay',
description: 'Delay in seconds before shutting down a channel after last client disconnects',
description:
'Delay in seconds before shutting down a channel after last client disconnects',
},
channel_init_grace_period: {
label: 'Channel Initialization Grace Period',
@ -53,6 +57,21 @@ export const PROXY_SETTINGS_OPTIONS = {
},
};
export const M3U_FILTER_TYPES = [
{
label: 'Group',
value: 'group',
},
{
label: 'Stream Name',
value: 'name',
},
{
label: 'Stream URL',
value: 'url',
},
];
export const REGION_CHOICES = [
{ value: 'ad', label: 'AD' },
{ value: 'ae', label: 'AE' },

View file

@ -94,6 +94,7 @@ const ChannelCard = ({
const [activeStreamId, setActiveStreamId] = useState(null);
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
const [data, setData] = useState([]);
const [previewedStream, setPreviewedStream] = useState(null);
// Get M3U account data from the playlists store
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
@ -425,12 +426,29 @@ const ChannelCard = ({
// Get logo URL from the logos object if available
const logoUrl =
channel.logo_id && logos && logos[channel.logo_id]
(channel.logo_id && logos && logos[channel.logo_id]
? logos[channel.logo_id].cache_url
: null;
: null) ||
(previewedStream && previewedStream.logo_url) ||
null;
// Ensure these values exist to prevent errors
const channelName = channel.name || 'Unnamed Channel';
useEffect(() => {
let isMounted = true;
// Only fetch if we have a stream_id and NO channel.name
if (!channel.name && channel.stream_id) {
API.getStreamsByIds([channel.stream_id]).then((streams) => {
if (isMounted && streams && streams.length > 0) {
setPreviewedStream(streams[0]);
}
});
}
return () => { isMounted = false; };
}, [channel.name, channel.stream_id]);
const channelName =
channel.name ||
previewedStream?.name ||
'Unnamed Channel';
const uptime = channel.uptime || 0;
const bitrates = channel.bitrates || [];
const totalBytes = channel.total_bytes || 0;

View file

@ -19,6 +19,26 @@ const usePlaylistsStore = create((set) => ({
editPlaylistId: id,
})),
fetchPlaylist: async (id) => {
set({ isLoading: true, error: null });
try {
const playlist = await api.getPlaylist(id);
set((state) => ({
playlists: state.playlists.map((p) => (p.id == id ? playlist : p)),
isLoading: false,
profiles: {
...state.profiles,
[id]: playlist.profiles,
},
}));
return playlist;
} catch (error) {
console.error('Failed to fetch playlists:', error);
set({ error: 'Failed to load playlists.', isLoading: false });
}
},
fetchPlaylists: async () => {
set({ isLoading: true, error: null });
try {
@ -91,9 +111,11 @@ const usePlaylistsStore = create((set) => ({
const existingProgress = state.refreshProgress[accountId];
// Don't replace 'initializing' status with empty/early server messages
if (existingProgress &&
if (
existingProgress &&
existingProgress.action === 'initializing' &&
accountIdOrData.progress === 0) {
accountIdOrData.progress === 0
) {
return state; // Keep showing 'initializing' until real progress comes
}

View file

@ -65,24 +65,54 @@ export const getDescendantProp = (obj, path) =>
path.split('.').reduce((acc, part) => acc && acc[part], obj);
export const copyToClipboard = async (value) => {
let copied = false;
if (navigator.clipboard) {
// Modern method, using navigator.clipboard
try {
await navigator.clipboard.writeText(value);
copied = true;
return true;
} catch (err) {
console.error('Failed to copy: ', err);
}
}
if (!copied) {
// Fallback method for environments without clipboard support
// Fallback method for environments without clipboard support
try {
const textarea = document.createElement('textarea');
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
return successful;
} catch (err) {
console.error('Failed to copy with fallback method: ', err);
return false;
}
};
export const setCustomProperty = (input, key, value, serialize = false) => {
let obj;
if (input == null) {
// matches null or undefined
obj = {};
} else if (typeof input === 'string') {
try {
obj = JSON.parse(input);
} catch (e) {
obj = {};
}
} else if (typeof input === 'object' && !Array.isArray(input)) {
obj = { ...input }; // shallow copy
} else {
obj = {};
}
obj[key] = value;
if (serialize === true) {
return JSON.stringify(obj);
}
return obj;
};