mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-20 16:51:10 +00:00
commit
0843d32388
35 changed files with 2435 additions and 665 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
18
apps/m3u/migrations/0013_alter_m3ufilter_filter_type.py
Normal file
18
apps/m3u/migrations/0013_alter_m3ufilter_filter_type.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
1029
apps/m3u/tasks.py
1029
apps/m3u/tasks.py
File diff suppressed because it is too large
Load diff
|
|
@ -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>")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
142
frontend/src/components/forms/M3UFilter.jsx
Normal file
142
frontend/src/components/forms/M3UFilter.jsx
Normal 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;
|
||||
340
frontend/src/components/forms/M3UFilters.jsx
Normal file
340
frontend/src/components/forms/M3UFilters.jsx
Normal 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;
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue