looooots of updates for user-management, initial commit of access control

This commit is contained in:
dekzter 2025-05-31 18:01:46 -04:00
parent 6504db3bd4
commit 3f445607e0
40 changed files with 669 additions and 681 deletions

View file

@ -4,6 +4,8 @@ from .api_views import (
AuthViewSet,
UserViewSet,
GroupViewSet,
TokenObtainPairView,
TokenRefreshView,
list_permissions,
initialize_superuser,
)
@ -30,8 +32,8 @@ urlpatterns = [
path("initialize-superuser/", initialize_superuser, name="initialize_superuser"),
# Permissions API
path("permissions/", list_permissions, name="list-permissions"),
path("token/", jwt_views.TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
# 🔹 Include ViewSet routes

View file

@ -3,18 +3,35 @@ from django.contrib.auth.models import Group, Permission
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import viewsets
from rest_framework import viewsets, status
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
import json
from .permissions import ReadOnly, IsAdmin
from .permissions import IsAdmin, Authenticated
from dispatcharr.utils import network_access_allowed
from .models import User
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
class TokenObtainPairView(TokenObtainPairView):
def post(self, request, *args, **kwargs):
# Custom logic here
if not network_access_allowed(request, "UI"):
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
return super().post(request, *args, **kwargs)
class TokenRefreshView(TokenRefreshView):
def post(self, request, *args, **kwargs):
# Custom logic here
if not network_access_allowed(request, "UI"):
return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN)
return super().post(request, *args, **kwargs)
@csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists.
@ -102,7 +119,7 @@ class UserViewSet(viewsets.ModelViewSet):
def get_permissions(self):
if self.action == "me":
return [IsAuthenticated()]
return [Authenticated()]
return [IsAdmin()]
@ -146,7 +163,7 @@ class GroupViewSet(viewsets.ModelViewSet):
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [IsAuthenticated]
permission_classes = [Authenticated]
@swagger_auto_schema(
operation_description="Retrieve a list of groups",
@ -179,7 +196,7 @@ class GroupViewSet(viewsets.ModelViewSet):
responses={200: PermissionSerializer(many=True)},
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
@permission_classes([Authenticated])
def list_permissions(request):
"""Returns a list of all available permissions"""
permissions = Permission.objects.all()

View file

@ -11,7 +11,7 @@ class User(AbstractUser):
class UserLevel(models.IntegerChoices):
STREAMER = 0, "Streamer"
READ_ONLY = 1, "ReadOnly"
STANDARD = 1, "Standard User"
ADMIN = 10, "Admin"
avatar_config = models.JSONField(default=dict, blank=True, null=True)

View file

@ -1,19 +1,37 @@
from rest_framework.permissions import BasePermission, IsAuthenticated
from rest_framework.permissions import IsAuthenticated
from .models import User
from dispatcharr.utils import network_access_allowed
class ReadOnly(BasePermission):
class Authenticated(IsAuthenticated):
def has_permission(self, request, view):
return request.user and request.user.user_level >= User.UserLevel.READ_ONLY
is_authenticated = super().has_permission(request, view)
network_allowed = network_access_allowed(request, "UI")
return is_authenticated and network_allowed
class IsAdmin(BasePermission):
class IsStandardUser(Authenticated):
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
return request.user and request.user.user_level >= User.UserLevel.STANDARD
class IsAdmin(Authenticated):
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
return request.user.user_level >= 10
class IsOwnerOfObject(BasePermission):
class IsOwnerOfObject(Authenticated):
def has_object_permission(self, request, view, obj):
if not super().has_permission(request, view):
return False
is_admin = IsAdmin().has_permission(request, view)
is_owner = request.user in obj.users.all()
@ -21,16 +39,16 @@ class IsOwnerOfObject(BasePermission):
permission_classes_by_action = {
"list": [ReadOnly],
"list": [IsStandardUser],
"create": [IsAdmin],
"retrieve": [ReadOnly],
"retrieve": [IsStandardUser],
"update": [IsAdmin],
"partial_update": [IsAdmin],
"destroy": [IsAdmin],
}
permission_classes_by_method = {
"GET": [ReadOnly],
"GET": [IsStandardUser],
"POST": [IsAdmin],
"PATCH": [IsAdmin],
"PUT": [IsAdmin],

View file

@ -44,9 +44,7 @@ class UserSerializer(serializers.ModelSerializer):
def create(self, validated_data):
channel_profiles = validated_data.pop("channel_profiles", [])
user = User(
username=validated_data["username"], email=validated_data.get("email", "")
)
user = User(**validated_data)
user.set_password(validated_data["password"])
user.is_active = True
user.save()

View file

@ -1,7 +1,7 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.permissions import AllowAny
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser
from drf_yasg.utils import swagger_auto_schema
@ -10,6 +10,7 @@ from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
import os, json, requests
from apps.accounts.permissions import (
Authenticated,
IsAdmin,
IsOwnerOfObject,
permission_classes_by_action,
@ -110,7 +111,7 @@ class StreamViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
def get_queryset(self):
qs = super().get_queryset()
@ -181,7 +182,7 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
# ─────────────────────────────────────────────────────────
@ -239,7 +240,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
def get_queryset(self):
qs = (
@ -815,7 +816,7 @@ class BulkDeleteStreamsAPIView(APIView):
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete streams by ID",
@ -851,7 +852,7 @@ class BulkDeleteChannelsAPIView(APIView):
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete channels by ID",
@ -891,7 +892,7 @@ class LogoViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
@action(detail=False, methods=["post"])
def upload(self, request):
@ -991,7 +992,7 @@ class ChannelProfileViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
class GetChannelStreamsAPIView(APIView):
@ -1001,7 +1002,7 @@ class GetChannelStreamsAPIView(APIView):
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
def get(self, request, channel_id):
channel = get_object_or_404(Channel, id=channel_id)
@ -1039,7 +1040,7 @@ class BulkUpdateChannelMembershipAPIView(APIView):
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
def patch(self, request, profile_id):
"""Bulk enable or disable channels for a specific profile"""
@ -1080,4 +1081,4 @@ class RecordingViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]

View file

@ -2,7 +2,6 @@ import logging, os
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
@ -16,6 +15,7 @@ from .serializers import (
) # Updated serializer
from .tasks import refresh_epg_data
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
permission_classes_by_method,
)
@ -38,7 +38,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG sources.")
@ -101,7 +101,7 @@ class ProgramViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG programs.")
@ -120,7 +120,7 @@ class EPGGridAPIView(APIView):
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
@swagger_auto_schema(
operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
@ -276,7 +276,7 @@ class EPGImportAPIView(APIView):
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers an EPG data import",
@ -307,4 +307,4 @@ class EPGDataViewSet(viewsets.ReadOnlyModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]

View file

@ -1,9 +1,7 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.accounts.permissions import permission_classes_by_action
from apps.accounts.permissions import permission_classes_by_action
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
import logging
from drf_yasg.utils import swagger_auto_schema
@ -43,7 +41,7 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
# 🔹 2) Discover API

View file

@ -1,8 +1,7 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.accounts.permissions import permission_classes_by_action
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
@ -36,7 +35,7 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
# 🔹 2) Discover API

View file

@ -1,8 +1,8 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
permission_classes_by_method,
)
@ -45,7 +45,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
def create(self, request, *args, **kwargs):
# Handle file upload first, if any
@ -155,7 +155,7 @@ class M3UFilterViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
class ServerGroupViewSet(viewsets.ModelViewSet):
@ -168,7 +168,7 @@ class ServerGroupViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
class RefreshM3UAPIView(APIView):
@ -180,7 +180,7 @@ class RefreshM3UAPIView(APIView):
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of all active M3U accounts",
@ -203,7 +203,7 @@ class RefreshSingleM3UAPIView(APIView):
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of a single M3U account",
@ -230,7 +230,7 @@ class UserAgentViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
class M3UAccountProfileViewSet(viewsets.ModelViewSet):
@ -241,7 +241,7 @@ class M3UAccountProfileViewSet(viewsets.ModelViewSet):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
return [Authenticated()]
def get_queryset(self):
m3u_account_id = self.kwargs["account_id"]

View file

@ -1,16 +1,14 @@
from django.urls import path, re_path, include
from .views import generate_m3u, generate_epg, xc_get
from .views import m3u_endpoint, epg_endpoint, xc_get
from core.views import stream_view
app_name = 'output'
app_name = "output"
urlpatterns = [
# Allow `/m3u`, `/m3u/`, `/m3u/profile_name`, and `/m3u/profile_name/`
re_path(r'^m3u(?:/(?P<profile_name>[^/]+))?/?$', generate_m3u, name='generate_m3u'),
re_path(r"^m3u(?:/(?P<profile_name>[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"),
# Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/`
re_path(r'^epg(?:/(?P<profile_name>[^/]+))?/?$', generate_epg, name='generate_epg'),
re_path(r"^epg(?:/(?P<profile_name>[^/]+))?/?$", epg_endpoint, name="epg_endpoint"),
# Allow both `/stream/<int:stream_id>` and `/stream/<int:stream_id>/`
re_path(r'^stream/(?P<channel_uuid>[0-9a-fA-F\-]+)/?$', stream_view, name='stream'),
re_path(r"^stream/(?P<channel_uuid>[0-9a-fA-F\-]+)/?$", stream_view, name="stream"),
]

View file

@ -1,9 +1,12 @@
import ipaddress
from django.http import HttpResponse, JsonResponse, Http404
from rest_framework.response import Response
from django.urls import reverse
from apps.channels.models import Channel, ChannelProfile, ChannelGroup
from apps.epg.models import ProgramData
from apps.accounts.models import User
from core.models import CoreSettings, NETWORK_ACCESS
from dispatcharr.utils import network_access_allowed
from django.utils import timezone
from django.shortcuts import get_object_or_404
from datetime import datetime, timedelta
@ -13,7 +16,19 @@ from tzlocal import get_localzone
import time
import json
from urllib.parse import urlparse
import base64
def m3u_endpoint(request, profile_name=None, user=None):
if not network_access_allowed(request, "M3U_EPG"):
return JsonResponse({"error": "Forbidden"}, status=403)
return generate_m3u(request, profile_name, user)
def epg_endpoint(request, profile_name=None, user=None):
if not network_access_allowed(request, "M3U_EPG"):
return JsonResponse({"error": "Forbidden"}, status=403)
return generate_epg(request, profile_name, user)
def generate_m3u(request, profile_name=None, user=None):
"""
@ -98,26 +113,7 @@ def generate_m3u(request, profile_name=None, user=None):
return response
def generate_dummy_epg(
channel_id, channel_name, xml_lines=None, num_days=1, program_length_hours=4
):
"""
Generate dummy EPG programs for channels without EPG data.
Creates program blocks for a specified number of days.
Args:
channel_id: The channel ID to use in the program entries
channel_name: The name of the channel to use in program titles
xml_lines: Optional list to append lines to, otherwise returns new list
num_days: Number of days to generate EPG data for (default: 1)
program_length_hours: Length of each program block in hours (default: 4)
Returns:
List of XML lines for the dummy EPG entries
"""
if xml_lines is None:
xml_lines = []
def generate_dummy_programs(channel_id, channel_name, num_days=1, program_length_hours=4):
# Get current time rounded to hour
now = timezone.now()
now = now.replace(minute=0, second=0, microsecond=0)
@ -156,6 +152,8 @@ def generate_dummy_epg(
],
}
programs = []
# Create programs for each day
for day in range(num_days):
day_start = now + timedelta(days=day)
@ -181,17 +179,49 @@ def generate_dummy_epg(
# Fallback description if somehow no range matches
description = f"Placeholder program for {channel_name} - EPG data went on vacation"
# Format times in XMLTV format
start_str = start_time.strftime("%Y%m%d%H%M%S %z")
stop_str = end_time.strftime("%Y%m%d%H%M%S %z")
programs.append({
"channel_id": channel_id,
"start_time": start_time,
"end_time": end_time,
"title": channel_name,
"description": description,
})
# Create program entry with escaped channel name
xml_lines.append(
f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">'
)
xml_lines.append(f" <title>{html.escape(channel_name)}</title>")
xml_lines.append(f" <desc>{html.escape(description)}</desc>")
xml_lines.append(f" </programme>")
return programs
def generate_dummy_epg(
channel_id, channel_name, xml_lines=None, num_days=1, program_length_hours=4
):
"""
Generate dummy EPG programs for channels without EPG data.
Creates program blocks for a specified number of days.
Args:
channel_id: The channel ID to use in the program entries
channel_name: The name of the channel to use in program titles
xml_lines: Optional list to append lines to, otherwise returns new list
num_days: Number of days to generate EPG data for (default: 1)
program_length_hours: Length of each program block in hours (default: 4)
Returns:
List of XML lines for the dummy EPG entries
"""
if xml_lines is None:
xml_lines = []
for program in generate_dummy_programs(channel_id, channel_name, num_days=1, program_length_hours=4):
# Format times in XMLTV format
start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z")
stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z")
# Create program entry with escaped channel name
xml_lines.append(
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>")
xml_lines.append(f" </programme>")
return xml_lines
@ -427,7 +457,7 @@ def xc_get_user(request):
password = request.GET.get("password")
if not username or not password:
raise Http404()
return None
user = get_object_or_404(User, username=username)
custom_properties = (
@ -435,20 +465,22 @@ def xc_get_user(request):
)
if "xc_password" not in custom_properties:
raise Http404()
return None
if custom_properties["xc_password"] != password:
raise Http404()
return None
return user
def xc_player_api(request):
action = request.GET.get("action")
def xc_get_info(request, full=False):
if not network_access_allowed(request, 'XC_API'):
return JsonResponse({'error': 'Forbidden'}, status=403)
user = xc_get_user(request)
if user is None:
raise Http404()
return JsonResponse({'error': 'Unauthorized'}, status=401)
raw_host = request.get_host()
if ":" in raw_host:
@ -457,62 +489,114 @@ def xc_player_api(request):
hostname = raw_host
port = "443" if request.is_secure() else "80"
if not action:
return JsonResponse(
{
"user_info": {
"username": request.GET.get("username"),
"password": request.GET.get("password"),
"message": "",
"auth": 1,
"status": "Active",
"exp_date": "1715062090",
"max_connections": "99",
"allowed_output_formats": [
"ts",
],
},
"server_info": {
"url": hostname,
"server_protocol": request.scheme,
"port": port,
"timezone": get_localzone().key,
"timestamp_now": int(time.time()),
"time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"process": True,
},
}
)
info = {
"user_info": {
"username": request.GET.get("username"),
"password": request.GET.get("password"),
"message": "",
"auth": 1,
"status": "Active",
"exp_date": "1715062090",
"max_connections": "99",
"allowed_output_formats": [
"ts",
],
},
"server_info": {
"url": hostname,
"server_protocol": request.scheme,
"port": port,
"timezone": get_localzone().key,
"timestamp_now": int(time.time()),
"time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"process": True,
},
}
if action == "get_live_categories":
return xc_get_live_categories(user)
if action == "get_live_streams":
return xc_get_live_streams(request, user, request.GET.get("category_id"))
if full == True:
info['categories'] = {
"series": [],
"movie": [],
"live": xc_get_live_categories(user),
}
info['available_channels'] = {channel["stream_id"]: channel for channel in xc_get_live_streams(request, user, request.GET.get("category_id"))}
if action == "get_vod_categories":
return JsonResponse([], safe=False)
if action == "get_vod_streams":
return JsonResponse([], safe=False)
return info
def xc_get(request):
def xc_player_api(request, full=False):
if not network_access_allowed(request, 'XC_API'):
return JsonResponse({'error': 'Forbidden'}, status=403)
action = request.GET.get("action")
user = xc_get_user(request)
if user is None:
raise Http404()
return JsonResponse({'error': 'Unauthorized'}, status=401)
server_info = xc_get_info(request)
if not action:
return generate_m3u(request, None, user)
return JsonResponse(server_info)
if action == "get_live_categories":
return JsonResponse(xc_get_live_categories(user), safe=False)
if action == "get_live_streams":
return JsonResponse(xc_get_live_streams(request, user, request.GET.get("category_id")), safe=False)
if action == "get_short_epg":
return JsonResponse(xc_get_epg(request, user, short=True), safe=False)
if action == "get_simple_data_table":
return JsonResponse(xc_get_epg(request, user, short=False), safe=False)
# Endpoints not implemented, but still provide a response
if action in [
"get_vod_categories",
"get_vod_streams",
"get_series",
"get_series_categories",
"get_series_info",
"get_vod_info",
]:
return JsonResponse([], safe=False)
raise Http404()
def xc_xmltv(request):
def xc_panel_api(request):
if not network_access_allowed(request, 'XC_API'):
return JsonResponse({'error': 'Forbidden'}, status=403)
user = xc_get_user(request)
if user is None:
raise Http404()
return JsonResponse({'error': 'Unauthorized'}, status=401)
return generate_epg(request, user)
return JsonResponse(xc_get_info(request, True))
def xc_get(request):
if not network_access_allowed(request, 'XC_API'):
return JsonResponse({'error': 'Forbidden'}, status=403)
action = request.GET.get("action")
user = xc_get_user(request)
if user is None:
return JsonResponse({'error': 'Unauthorized'}, status=401)
return generate_m3u(request, None, user)
def xc_xmltv(request):
if not network_access_allowed(request, 'XC_API'):
return JsonResponse({'error': 'Forbidden'}, status=403)
user = xc_get_user(request)
if user is None:
return JsonResponse({'error': 'Unauthorized'}, status=401)
return generate_epg(request, None, user)
def xc_get_live_categories(user):
@ -546,7 +630,7 @@ def xc_get_live_categories(user):
}
)
return JsonResponse(response, safe=False)
return response
def xc_get_live_streams(request, user, category_id=None):
@ -578,7 +662,7 @@ def xc_get_live_streams(request, user, category_id=None):
for channel in channels:
streams.append(
{
"num": channel.channel_number,
"num": int(channel.channel_number) if channel.channel_number.is_integer() else channel.channel_number,
"name": channel.name,
"stream_type": "live",
"stream_id": channel.id,
@ -589,7 +673,7 @@ def xc_get_live_streams(request, user, category_id=None):
reverse("api:channels:logo-cache", args=[channel.logo.id])
)
),
"epg_channel_id": channel.epg_data.tvg_id if channel.epg_data else "",
"epg_channel_id": int(channel.channel_number) if channel.channel_number.is_integer() else channel.channel_number,
"added": int(time.time()), # @TODO: make this the actual created date
"is_adult": 0,
"category_id": channel.channel_group.id,
@ -601,4 +685,72 @@ def xc_get_live_streams(request, user, category_id=None):
}
)
return JsonResponse(streams, safe=False)
return streams
def xc_get_epg(request, user, short=False):
channel_id = request.GET.get('stream_id')
if not channel_id:
raise Http404()
channel = None
if user.user_level < 10:
filters = {
"id": channel_id,
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
filters["channelprofilemembership__channel_profile__in"] = channel_profiles
channel = get_object_or_404(Channel, **filters)
else:
channel = get_object_or_404(Channel, id=channel_id)
if not channel:
raise Http404()
limit = request.GET.get('limit', 4)
if channel.epg_data:
if short == False:
programs = channel.epg_data.programs.filter(
start_time__gte=timezone.now()
).order_by('start_time')
else:
programs = channel.epg_data.programs.all().order_by('start_time')[:limit]
else:
programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name)
output = {"epg_listings": []}
for program in programs:
id = "0"
epg_id = "0"
title = program['title'] if isinstance(program, dict) else program.title
description = program['description'] if isinstance(program, dict) else program.description
start = program["start_time"] if isinstance(program, dict) else program.start_time
end = program["end_time"] if isinstance(program, dict) else program.end_time
program_output = {
"id": f"{id}",
"epg_id": f"{epg_id}",
"title": base64.b64encode(title.encode()).decode(),
"lang": "",
"start": start.strftime("%Y%m%d%H%M%S"),
"end": end.strftime("%Y%m%d%H%M%S"),
"description": base64.b64encode(description.encode()).decode(),
"channel_id": int(channel.channel_number) if channel.channel_number.is_integer() else channel.channel_number,
"start_timestamp": int(start.timestamp()),
"stop_timestamp": int(end.timestamp()),
"stream_id": f"{channel_id}",
}
if short == False:
program_output["now_playing"] = 1 if start <= timezone.now() <= end else 0
program_output["has_archive"] = "0"
output['epg_listings'].append(program_output)
return output

View file

@ -3,6 +3,7 @@ import threading
import time
import random
import re
import pathlib
from django.http import StreamingHttpResponse, JsonResponse, HttpResponseRedirect
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import get_object_or_404
@ -37,12 +38,16 @@ from .url_utils import (
from .utils import get_logger
from uuid import UUID
import gevent
from dispatcharr.utils import network_access_allowed
logger = get_logger()
@api_view(["GET"])
def stream_ts(request, channel_id):
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
"""Stream TS data to client with immediate response and keep-alive packets during initialization"""
channel = get_stream_object(channel_id)
@ -466,6 +471,9 @@ def stream_ts(request, channel_id):
def stream_xc(request, username, password, channel_id):
user = get_object_or_404(User, username=username)
extension = pathlib.Path(channel_id).suffix
channel_id = pathlib.Path(channel_id).stem
custom_properties = (
json.loads(user.custom_properties) if user.custom_properties else {}
)
@ -476,9 +484,10 @@ def stream_xc(request, username, password, channel_id):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
print(f"Fetchin channel with ID: {channel_id}")
if user.user_level < 10:
filters = {
"id": channel_id,
"id": int(channel_id),
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
@ -487,10 +496,13 @@ def stream_xc(request, username, password, channel_id):
channel_profiles = user.channel_profiles.all()
filters["channelprofilemembership__channel_profile__in"] = channel_profiles
channel = get_object_or_404(Channel, **filters)
channel = Channel.objects.filter(**filters).distinct().first()
if not channel:
return JsonResponse({"error": "Not found"}, status=404)
else:
channel = get_object_or_404(Channel, id=channel_id)
# @TODO: we've got the file 'type' via extension, support this when we support multiple outputs
return stream_ts(request._request, channel.uuid)

View file

@ -9,13 +9,15 @@ from .serializers import (
StreamProfileSerializer,
CoreSettingsSerializer,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes
from drf_yasg.utils import swagger_auto_schema
import socket
import requests
import os
from core.tasks import rehash_streams
from apps.accounts.permissions import (
Authenticated,
)
class UserAgentViewSet(viewsets.ModelViewSet):
@ -61,7 +63,7 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
responses={200: "Environment variables"},
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
@permission_classes([Authenticated])
def environment(request):
public_ip = None
local_ip = None

View file

@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-03-01 14:01
from django.db import migrations
from django.utils.text import slugify
def preload_network_access_settings(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
CoreSettings.objects.create(
key=slugify("Network Access"),
name="Network Access",
value="{}",
)
class Migration(migrations.Migration):
dependencies = [
("core", "0012_default_active_m3u_accounts"),
]
operations = [
migrations.RunPython(preload_network_access_settings),
]

View file

@ -2,25 +2,24 @@
from django.db import models
from django.utils.text import slugify
class UserAgent(models.Model):
name = models.CharField(
max_length=512,
unique=True,
help_text="The User-Agent name."
max_length=512, unique=True, help_text="The User-Agent name."
)
user_agent = models.CharField(
max_length=512,
unique=True,
help_text="The complete User-Agent string sent by the client."
help_text="The complete User-Agent string sent by the client.",
)
description = models.CharField(
max_length=255,
blank=True,
help_text="An optional description of the client or device type."
help_text="An optional description of the client or device type.",
)
is_active = models.BooleanField(
default=True,
help_text="Whether this user agent is currently allowed/recognized."
help_text="Whether this user agent is currently allowed/recognized.",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@ -28,31 +27,34 @@ class UserAgent(models.Model):
def __str__(self):
return self.name
PROXY_PROFILE_NAME = 'Proxy'
REDIRECT_PROFILE_NAME = 'Redirect'
PROXY_PROFILE_NAME = "Proxy"
REDIRECT_PROFILE_NAME = "Redirect"
class StreamProfile(models.Model):
name = models.CharField(max_length=255, help_text="Name of the stream profile")
command = models.CharField(
max_length=255,
help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')",
blank=True
blank=True,
)
parameters = models.TextField(
help_text="Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.",
blank=True
blank=True,
)
locked = models.BooleanField(
default=False,
help_text="Protected - can't be deleted or modified"
default=False, help_text="Protected - can't be deleted or modified"
)
is_active = models.BooleanField(
default=True, help_text="Whether this profile is active"
)
is_active = models.BooleanField(default=True, help_text="Whether this profile is active")
user_agent = models.ForeignKey(
"UserAgent",
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Optional user agent to use. If not set, you can fall back to a default."
help_text="Optional user agent to use. If not set, you can fall back to a default.",
)
def __str__(self):
@ -77,7 +79,9 @@ class StreamProfile(models.Model):
new_value = new_value.pk
if field_name not in allowed_fields and orig_value != new_value:
raise ValidationError(f"Cannot modify {field_name} on a protected profile.")
raise ValidationError(
f"Cannot modify {field_name} on a protected profile."
)
super().save(*args, **kwargs)
@ -90,10 +94,14 @@ class StreamProfile(models.Model):
for field_name, new_value in kwargs.items():
if field_name not in allowed_fields:
raise ValidationError(f"Cannot modify {field_name} on a protected profile.")
raise ValidationError(
f"Cannot modify {field_name} on a protected profile."
)
# Ensure user_agent ForeignKey updates correctly
if field_name == "user_agent" and isinstance(new_value, cls._meta.get_field("user_agent").related_model):
if field_name == "user_agent" and isinstance(
new_value, cls._meta.get_field("user_agent").related_model
):
new_value = new_value.pk # Convert object to ID if needed
setattr(instance, field_name, new_value)
@ -122,7 +130,8 @@ class StreamProfile(models.Model):
# Split the command and iterate through each part to apply replacements
cmd = [self.command] + [
self._replace_in_part(part, replacements) for part in self.parameters.split()
self._replace_in_part(part, replacements)
for part in self.parameters.split()
]
return cmd
@ -134,11 +143,13 @@ class StreamProfile(models.Model):
return part
DEFAULT_USER_AGENT_KEY= slugify("Default User-Agent")
DEFAULT_USER_AGENT_KEY = slugify("Default User-Agent")
DEFAULT_STREAM_PROFILE_KEY = slugify("Default Stream Profile")
STREAM_HASH_KEY = slugify("M3U Hash Key")
PREFERRED_REGION_KEY = slugify("Preferred Region")
AUTO_IMPORT_MAPPED_FILES = slugify("Auto-Import Mapped Files")
NETWORK_ACCESS = slugify("Network Access")
class CoreSettings(models.Model):
key = models.CharField(

View file

@ -7,7 +7,7 @@ from rest_framework import permissions
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from .routing import websocket_urlpatterns
from apps.output.views import xc_player_api, xc_get, xc_xmltv
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
from apps.proxy.ts_proxy.views import stream_xc
# Define schema_view for Swagger
@ -40,15 +40,21 @@ urlpatterns = [
# Add proxy apps - Move these before the catch-all
path("proxy/", include(("apps.proxy.urls", "proxy"), namespace="proxy")),
path("proxy", RedirectView.as_view(url="/proxy/", permanent=True)),
# xc
re_path("player_api.php", xc_player_api, name="xc_player_api"),
re_path("panel_api.php", xc_panel_api, name="xc_panel_api"),
re_path("get.php", xc_get, name="xc_get"),
re_path("xmltv.php", xc_xmltv, name="xc_xmltv"),
path(
"<slug:username>/<slug:password>/<int:channel_id>",
"live/<str:username>/<str:password>/<str:channel_id>",
stream_xc,
name="xc_live_stream_endpoint",
),
path(
"<str:username>/<str:password>/<str:channel_id>",
stream_xc,
name="xc_stream_endpoint",
),
# xc
re_path("player_api.php", xc_player_api, name="xc_get"),
re_path("get.php", xc_get, name="xc_get"),
re_path("xmltv.php", xc_xmltv, name="xc_xmltv"),
# Swagger UI
path(
"swagger/",

View file

@ -1,23 +1,58 @@
# dispatcharr/utils.py
import json
import ipaddress
from django.http import JsonResponse
from django.core.exceptions import ValidationError
from core.models import CoreSettings, NETWORK_ACCESS
def json_error_response(message, status=400):
"""Return a standardized error JSON response."""
return JsonResponse({'success': False, 'error': message}, status=status)
return JsonResponse({"success": False, "error": message}, status=status)
def json_success_response(data=None, status=200):
"""Return a standardized success JSON response."""
response = {'success': True}
response = {"success": True}
if data is not None:
response.update(data)
return JsonResponse(response, status=status)
def validate_logo_file(file):
"""Validate uploaded logo file size and MIME type."""
valid_mime_types = ['image/jpeg', 'image/png', 'image/gif']
valid_mime_types = ["image/jpeg", "image/png", "image/gif"]
if file.content_type not in valid_mime_types:
raise ValidationError('Unsupported file type. Allowed types: JPEG, PNG, GIF.')
raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF.")
if file.size > 2 * 1024 * 1024:
raise ValidationError('File too large. Max 2MB.')
raise ValidationError("File too large. Max 2MB.")
def get_client_ip(request):
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
# X-Forwarded-For can be a comma-separated list of IPs
ip = x_forwarded_for.split(",")[0].strip()
else:
ip = request.META.get("REMOTE_ADDR")
return ip
def network_access_allowed(request, settings_key):
network_access = json.loads(CoreSettings.objects.get(key=NETWORK_ACCESS).value)
cidrs = (
network_access[settings_key].split(",")
if settings_key in network_access
else "0.0.0.0/0"
)
network_allowed = False
client_ip = ipaddress.ip_address(get_client_ip(request))
for cidr in cidrs:
network = ipaddress.ip_network(cidr)
if client_ip in network:
network_allowed = True
break
return network_allowed

View file

@ -9,13 +9,16 @@ server {
proxy_read_timeout 300;
client_max_body_size 0; # Allow file uploads up to 128MB
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
# Serve Django via uWSGI
location / {
include uwsgi_params;
uwsgi_pass unix:/app/uwsgi.sock;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
location /assets/ {
@ -55,11 +58,6 @@ server {
location /hdhr {
include uwsgi_params;
uwsgi_pass unix:/app/uwsgi.sock;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
# Serve FFmpeg streams efficiently
@ -78,9 +76,6 @@ server {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
# Route TS proxy requests to the dedicated instance
@ -94,8 +89,5 @@ server {
proxy_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 0;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
}

View file

@ -19,17 +19,13 @@
"@mantine/form": "~8.0.1",
"@mantine/hooks": "~8.0.1",
"@mantine/notifications": "~8.0.1",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.3",
"axios": "^1.8.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.511.0",
"mpegts.js": "^1.8.0",
"prettier": "^3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-draggable": "^4.4.6",
@ -52,6 +48,7 @@
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "^3.5.3",
"vite": "^6.2.0"
}
},
@ -989,32 +986,6 @@
"@swc/counter": "^0.1.3"
}
},
"node_modules/@tabler/icons": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.31.0.tgz",
"integrity": "sha512-dblAdeKY3+GA1U+Q9eziZ0ooVlZMHsE8dqP0RkwvRtEsAULoKOYaCUOcJ4oW1DjWegdxk++UAt2SlQVnmeHv+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.31.0.tgz",
"integrity": "sha512-2rrCM5y/VnaVKnORpDdAua9SEGuJKVqPtWxeQ/vUVsgaUx30LDgBZph7/lterXxDY1IKR6NO//HDhWiifXTi3w==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.31.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@ -1336,12 +1307,6 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
@ -1351,17 +1316,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz",
"integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
@ -1395,19 +1349,6 @@
"concat-map": "0.0.1"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -1469,18 +1410,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -1715,15 +1644,6 @@
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@ -1745,20 +1665,6 @@
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@ -1768,51 +1674,6 @@
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
@ -2169,41 +2030,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/formik": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz",
@ -2253,30 +2079,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@ -2286,19 +2088,6 @@
"node": ">=6"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -2335,18 +2124,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -2357,33 +2134,6 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -2702,42 +2452,12 @@
"global": "^4.4.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/min-document": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
@ -3031,6 +2751,7 @@
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
@ -3074,12 +2795,6 @@
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View file

@ -21,17 +21,13 @@
"@mantine/form": "~8.0.1",
"@mantine/hooks": "~8.0.1",
"@mantine/notifications": "~8.0.1",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-table": "^8.21.2",
"allotment": "^1.20.3",
"axios": "^1.8.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.511.0",
"mpegts.js": "^1.8.0",
"prettier": "^3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-draggable": "^4.4.6",
@ -54,6 +50,7 @@
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "^3.5.3",
"vite": "^6.2.0"
}
}

View file

@ -2,13 +2,13 @@
import React, { useEffect, useState } from 'react';
import usePlaylistsStore from '../store/playlists';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import useStreamsStore from '../store/streams';
import useChannelsStore from '../store/channels';
import useEPGsStore from '../store/epgs';
import { Stack, Button, Group } from '@mantine/core';
import API from '../api';
import { useNavigate } from 'react-router-dom';
import { CircleCheck } from 'lucide-react';
export default function M3URefreshNotification() {
const playlists = usePlaylistsStore((s) => s.playlists);
@ -40,7 +40,7 @@ export default function M3URefreshNotification() {
});
// Special handling for pending setup status
if (data.status === "pending_setup") {
if (data.status === 'pending_setup') {
fetchChannelGroups();
fetchPlaylists();
@ -48,7 +48,8 @@ export default function M3URefreshNotification() {
title: `M3U Setup: ${playlist.name}`,
message: (
<Stack>
{data.message || "M3U groups loaded. Please select groups or refresh M3U to complete setup."}
{data.message ||
'M3U groups loaded. Please select groups or refresh M3U to complete setup.'}
<Group grow>
<Button
size="xs"
@ -77,21 +78,21 @@ export default function M3URefreshNotification() {
</Stack>
),
color: 'orange.5',
autoClose: 5000, // Keep visible a bit longer
autoClose: 5000, // Keep visible a bit longer
});
return;
}
// Check for error status FIRST before doing anything else
if (data.status === "error") {
if (data.status === 'error') {
// Only show the error notification if we have a complete task (progress=100)
// or if it's explicitly flagged as an error
if (data.progress === 100) {
notifications.show({
title: `M3U Processing: ${playlist.name}`,
message: `${data.action || 'Processing'} failed: ${data.error || "Unknown error"}`,
message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`,
color: 'red',
autoClose: 5000, // Keep error visible a bit longer
autoClose: 5000, // Keep error visible a bit longer
});
}
return; // Exit early for any error status
@ -99,7 +100,7 @@ export default function M3URefreshNotification() {
// Check if we already have an error stored for this account, and if so, don't show further notifications
const currentStatus = notificationStatus[data.account];
if (currentStatus && currentStatus.status === "error") {
if (currentStatus && currentStatus.status === 'error') {
// Don't show any other notifications once we've hit an error
return;
}
@ -147,18 +148,18 @@ export default function M3URefreshNotification() {
message,
loading: taskProgress == 0,
autoClose: 2000,
icon: taskProgress == 100 ? <IconCheck /> : null,
icon: taskProgress == 100 ? <CircleCheck /> : null,
});
};
useEffect(() => {
// Reset notificationStatus when playlists change to prevent stale data
if (playlists.length > 0 && Object.keys(notificationStatus).length > 0) {
const validIds = playlists.map(p => p.id);
const validIds = playlists.map((p) => p.id);
const currentIds = Object.keys(notificationStatus).map(Number);
// If we have notification statuses for playlists that no longer exist, reset the state
if (!currentIds.every(id => validIds.includes(id))) {
if (!currentIds.every((id) => validIds.includes(id))) {
setNotificationStatus({});
}
}

View file

@ -1,11 +0,0 @@
// ...existing imports...
const menuItems = [
// existing items go here,
{
key: 'proxy',
label: 'Proxy Manager',
icon: <ApiOutlined />,
path: '/proxy',
},
];

View file

@ -1,82 +0,0 @@
import React, { useState } from 'react';
import { Button, Form, Input, Select, message } from 'antd';
import axios from 'axios';
const { Option } = Select;
const ProxyManager = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const handleSubmit = async (values) => {
setLoading(true);
try {
const { action, ...data } = values;
await axios.post(`/proxy/api/proxy/${action}/`, data);
message.success(`Proxy ${action} successful`);
form.resetFields();
} catch (error) {
message.error(error.response?.data?.error || 'An error occurred');
} finally {
setLoading(false);
}
};
return (
<div className="proxy-manager">
<h2>Proxy Manager</h2>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item
name="type"
label="Proxy Type"
rules={[{ required: true }]}
>
<Select>
<Option value="hls">HLS</Option>
<Option value="ts">TS</Option>
</Select>
</Form.Item>
<Form.Item
name="channel"
label="Channel ID"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
name="url"
label="Stream URL"
rules={[{ required: true, type: 'url' }]}
>
<Input />
</Form.Item>
<Form.Item>
<Button.Group>
<Button
type="primary"
onClick={() => form.submit()}
loading={loading}
>
Start Proxy
</Button>
<Button
danger
onClick={() => {
form.setFieldsValue({ action: 'stop' });
form.submit();
}}
loading={loading}
>
Stop Proxy
</Button>
</Button.Group>
</Form.Item>
</Form>
</div>
);
};
export default ProxyManager;

View file

@ -22,7 +22,6 @@ import {
Box,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import { IconUpload } from '@tabler/icons-react';
const EPG = ({ epg = null, isOpen, onClose }) => {
const epgs = useEPGsStore((state) => state.epgs);
@ -123,7 +122,9 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
value: 'schedules_direct',
},
]}
onChange={(event) => handleSourceTypeChange(event.currentTarget.value)}
onChange={(event) =>
handleSourceTypeChange(event.currentTarget.value)
}
/>
<NumberInput
@ -160,14 +161,20 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
{/* Put checkbox at the same level as Refresh Interval */}
<Box style={{ marginTop: 0 }}>
<Text size="sm" fw={500} mb={3}>Status</Text>
<Text size="xs" c="dimmed" mb={12}>When enabled, this EPG source will auto update.</Text>
<Box style={{
display: 'flex',
alignItems: 'center',
height: '30px', // Reduced height
marginTop: '-4px' // Slight negative margin to move it up
}}>
<Text size="sm" fw={500} mb={3}>
Status
</Text>
<Text size="xs" c="dimmed" mb={12}>
When enabled, this EPG source will auto update.
</Text>
<Box
style={{
display: 'flex',
alignItems: 'center',
height: '30px', // Reduced height
marginTop: '-4px', // Slight negative margin to move it up
}}
>
<Checkbox
id="is_active"
name="is_active"
@ -185,12 +192,10 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
<Divider my="sm" />
<Group justify="end" mt="xl">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
type="submit"
variant="filled"
disabled={form.submitting}
>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="filled" disabled={form.submitting}>
{epg?.id ? 'Update' : 'Create'} EPG Source
</Button>
</Group>

View file

@ -15,7 +15,6 @@ import {
} from '@mantine/core';
import { useWebSocket } from '../../WebSocket';
import usePlaylistsStore from '../../store/playlists';
import { useDebounce } from '../../utils';
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [websocketReady, sendMessage] = useWebSocket();
@ -139,7 +138,10 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
if (!searchPattern || !sampleInput) return sampleInput;
try {
const regex = new RegExp(searchPattern, 'g');
return sampleInput.replace(regex, match => `<mark style="background-color: #ffee58;">${match}</mark>`);
return sampleInput.replace(
regex,
(match) => `<mark style="background-color: #ffee58;">${match}</mark>`
);
} catch (e) {
return sampleInput;
}
@ -213,10 +215,14 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
</Flex>
</form>
<Title order={4} mt={15} mb={10}>Live Regex Demonstration</Title>
<Title order={4} mt={15} mb={10}>
Live Regex Demonstration
</Title>
<Paper shadow="sm" p="xs" radius="md" withBorder mb={8}>
<Text size="sm" weight={500} mb={3}>Sample Text</Text>
<Text size="sm" weight={500} mb={3}>
Sample Text
</Text>
<TextInput
value={sampleInput}
onChange={handleSampleInputChange}
@ -228,7 +234,12 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
<Grid gutter="xs">
<Grid.Col span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Text size="sm" weight={500} mb={3}>Matched Text <Badge size="xs" color="yellow">highlighted</Badge></Text>
<Text size="sm" weight={500} mb={3}>
Matched Text{' '}
<Badge size="xs" color="yellow">
highlighted
</Badge>
</Text>
<Text
size="sm"
dangerouslySetInnerHTML={{
@ -241,8 +252,13 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
<Grid.Col span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Text size="sm" weight={500} mb={3}>Result After Replace</Text>
<Text size="sm" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
<Text size="sm" weight={500} mb={3}>
Result After Replace
</Text>
<Text
size="sm"
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
>
{getLocalReplaceResult()}
</Text>
</Paper>

View file

@ -205,7 +205,7 @@ const User = ({ user = null, isOpen, onClose }) => {
<Group align="flex-end">
<TextInput
label="XC Password"
description="Auto-generated - clear to disable XC API"
description="Clear to disable XC API"
{...form.getInputProps('xc_password')}
key={form.key('xc_password')}
style={{ flex: 1 }}

View file

@ -14,6 +14,7 @@ import {
FileInput,
Space,
} from '@mantine/core';
import { NETWORK_ACCESS_OPTIONS } from '../../constants';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({

View file

@ -300,7 +300,12 @@ const ChannelsTable = ({}) => {
const groupOptions = Object.values(channelGroups)
.filter((group) => activeGroupIds.has(group.id))
.map((group) => group.name);
const debouncedFilters = useDebounce(filters, 500);
const debouncedFilters = useDebounce(filters, 500, () => {
setPagination({
...pagination,
pageIndex: 0,
});
});
/**
* Functions
@ -338,14 +343,8 @@ const ChannelsTable = ({}) => {
e.stopPropagation();
}, []);
// Remove useCallback to ensure we're using the latest setPagination function
const handleFilterChange = (e) => {
const { name, value } = e.target;
// First reset pagination to page 0
setPagination({
...pagination,
pageIndex: 0,
});
// Then update filters
setFilters((prev) => ({
...prev,
@ -354,11 +353,6 @@ const ChannelsTable = ({}) => {
};
const handleGroupChange = (value) => {
// First reset pagination to page 0
setPagination({
...pagination,
pageIndex: 0,
});
// Then update filters
setFilters((prev) => ({
...prev,

View file

@ -19,7 +19,6 @@ import {
Group,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconSquarePlus } from '@tabler/icons-react';
import {
ArrowDownWideNarrow,
ArrowUpDown,
@ -27,6 +26,7 @@ import {
RefreshCcw,
SquareMinus,
SquarePen,
SquarePlus,
} from 'lucide-react';
import dayjs from 'dayjs';
import useSettingsStore from '../../store/settings';
@ -283,7 +283,7 @@ const EPGsTable = () => {
{
header: 'Updated',
accessorKey: 'updated_at',
size: 150,
size: 175,
enableSorting: false,
cell: ({ cell }) => {
const value = cell.getValue();
@ -540,7 +540,7 @@ const EPGsTable = () => {
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editEPG()}

View file

@ -34,8 +34,8 @@ import {
ArrowUpDown,
ArrowUpNarrowWide,
ArrowDownWideNarrow,
SquarePlus,
} from 'lucide-react';
import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons
import dayjs from 'dayjs';
import useSettingsStore from '../../store/settings';
import useLocalStorage from '../../hooks/useLocalStorage';
@ -572,7 +572,7 @@ const M3UTable = () => {
{
header: 'Updated',
accessorKey: 'updated_at',
size: 150,
size: 175,
cell: ({ cell }) => {
const value = cell.getValue();
return value ? (
@ -842,7 +842,7 @@ const M3UTable = () => {
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={14} />}
leftSection={<SquarePlus size={14} />}
variant="light"
size="xs"
onClick={() => editPlaylist()}
@ -854,7 +854,7 @@ const M3UTable = () => {
color: 'white',
}}
>
Add
Add M3U
</Button>
</Tooltip>
</Flex>

View file

@ -18,8 +18,15 @@ import {
Switch,
Stack,
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { SquareMinus, SquarePen, Check, X, Eye, EyeOff } from 'lucide-react';
import {
SquareMinus,
SquarePen,
Check,
X,
Eye,
EyeOff,
SquarePlus,
} from 'lucide-react';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
@ -273,7 +280,7 @@ const StreamProfiles = () => {
</Tooltip>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editStreamProfile()}

View file

@ -36,8 +36,9 @@ import {
MultiSelect,
useMantineTheme,
UnstyledButton,
LoadingOverlay,
Skeleton,
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
@ -197,7 +198,13 @@ const StreamsTable = ({}) => {
channel_group: '',
m3u_account: '',
});
const debouncedFilters = useDebounce(filters, 500);
const debouncedFilters = useDebounce(filters, 500, () => {
// Reset to first page whenever filters change to avoid "Invalid page" errors
setPagination((prev) => ({
...prev,
pageIndex: 0,
}));
});
// Add state to track if stream groups are loaded
const [groupsLoaded, setGroupsLoaded] = useState(false);
@ -306,12 +313,6 @@ const StreamsTable = ({}) => {
...prev,
[name]: value,
}));
// Reset to first page whenever filters change to avoid "Invalid page" errors
setPagination((prev) => ({
...prev,
pageIndex: 0,
}));
};
const handleGroupChange = (value) => {
@ -319,12 +320,6 @@ const StreamsTable = ({}) => {
...prev,
channel_group: value ? value : '',
}));
// Reset to first page whenever filters change to avoid "Invalid page" errors
setPagination((prev) => ({
...prev,
pageIndex: 0,
}));
};
const handleM3UChange = (value) => {
@ -332,12 +327,6 @@ const StreamsTable = ({}) => {
...prev,
m3u_account: value ? value : '',
}));
// Reset to first page whenever filters change to avoid "Invalid page" errors
setPagination((prev) => ({
...prev,
pageIndex: 0,
}));
};
const fetchData = useCallback(async () => {
@ -672,7 +661,7 @@ const StreamsTable = ({}) => {
<Group justify="space-between" style={{ paddingLeft: 10 }}>
<Box>
<Button
leftSection={<IconSquarePlus size={18} />}
leftSection={<SquarePlus size={18} />}
variant={
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
? 'light'
@ -725,7 +714,7 @@ const StreamsTable = ({}) => {
</Button>
<Button
leftSection={<IconSquarePlus size={18} />}
leftSection={<SquarePlus size={18} />}
variant="default"
size="xs"
onClick={createChannelsFromStreams}
@ -736,7 +725,7 @@ const StreamsTable = ({}) => {
</Button>
<Button
leftSection={<IconSquarePlus size={18} />}
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editStream()}
@ -824,6 +813,7 @@ const StreamsTable = ({}) => {
borderRadius: 'var(--mantine-radius-default)',
}}
>
<LoadingOverlay visible={isLoading} />
<CustomTable table={table} />
</Box>

View file

@ -17,8 +17,7 @@ import {
Button,
Stack,
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { SquareMinus, SquarePen, Check, X } from 'lucide-react';
import { SquareMinus, SquarePen, Check, X, SquarePlus } from 'lucide-react';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
@ -211,7 +210,7 @@ const UserAgentsTable = () => {
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editUserAgent()}

View file

@ -49,17 +49,17 @@ html {
}
.divTable.table-size-compact .td {
height: 28px;
min-height: 28px;
font-size: var(--mantine-font-size-sm);
}
.divTable.table-size-default .td {
height: 40px;
min-height: 40px;
font-size: var(--mantine-font-size-md);
}
.divTable.table-size-large .td {
height: 48px;
min-height: 48px;
font-size: var(--mantine-font-size-md);
}

View file

@ -1,11 +1,31 @@
export const USER_LEVELS = {
STREAMER: 0,
READ_ONLY: 1,
STANDARD: 1,
ADMIN: 10,
};
export const USER_LEVEL_LABELS = {
[USER_LEVELS.STREAMER]: 'Streamer',
[USER_LEVELS.READ_ONLY]: 'Read Only',
[USER_LEVELS.STANDARD]: 'Standard User',
[USER_LEVELS.ADMIN]: 'Admin',
};
export const NETWORK_ACCESS_OPTIONS = {
M3U_EPG: {
label: 'M3U / EPG Endpoints',
description: 'Limit access to M3U, EPG, and HDHR URLs',
},
STREAMS: {
label: 'Stream Endpoints',
description:
'Limit network access to stream URLs, including XC stream URLs',
},
XC_API: {
label: 'XC API',
description: 'Limit access to the XC API',
},
UI: {
label: 'UI',
description: 'Limit access to the Dispatcharr UI',
},
};

View file

@ -13,7 +13,7 @@ const ChannelsPage = () => {
return <></>;
}
if (authUser.user_level <= USER_LEVELS.READ_ONLY) {
if (authUser.user_level <= USER_LEVELS.STANDARD) {
return (
<Box style={{ padding: 10 }}>
<ChannelsTable />

View file

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import API from '../api';
import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents';
@ -12,15 +12,17 @@ import {
Group,
MultiSelect,
Select,
Stack,
Switch,
Text,
TextInput,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import UserAgentsTable from '../components/tables/UserAgentsTable';
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
import useLocalStorage from '../hooks/useLocalStorage';
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import { USER_LEVELS, NETWORK_ACCESS_OPTIONS } from '../constants';
const SettingsPage = () => {
const settings = useSettingsStore((s) => s.settings);
@ -28,6 +30,8 @@ const SettingsPage = () => {
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const authUser = useAuthStore((s) => s.user);
const [accordianValue, setAccordianValue] = useState(null);
// UI / local storage settings
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
@ -299,6 +303,14 @@ const SettingsPage = () => {
},
});
const networkAccessForm = useForm({
mode: 'uncontrolled',
initialValues: Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = '0.0.0.0/0';
return acc;
}, {}),
});
useEffect(() => {
if (settings) {
console.log(settings);
@ -329,8 +341,18 @@ const SettingsPage = () => {
},
{}
);
console.log(formValues);
form.setValues(formValues);
const networkAccessSettings = JSON.parse(
settings['network-access'].value || '{}'
);
networkAccessForm.setValues(
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
acc[key] = networkAccessSettings[key];
return acc;
}, {})
);
}
}, [settings]);
@ -353,6 +375,14 @@ const SettingsPage = () => {
}
};
const onNetworkAccessSubmit = async () => {
console.log(networkAccessForm.getValues());
API.updateSetting({
...settings['network-access'],
value: JSON.stringify(networkAccessForm.getValues()),
});
};
const onUISettingsChange = (name, value) => {
switch (name) {
case 'table-size':
@ -368,7 +398,11 @@ const SettingsPage = () => {
}}
>
<Box style={{ width: '100%', maxWidth: 800 }}>
<Accordion variant="separated" defaultValue="ui-settings">
<Accordion
variant="separated"
defaultValue="ui-settings"
onChange={setAccordianValue}
>
{[
<Accordion.Item value="ui-settings">
<Accordion.Control>UI Settings</Accordion.Control>
@ -538,6 +572,54 @@ const SettingsPage = () => {
<StreamProfilesTable />
</Accordion.Panel>
</Accordion.Item>,
<Accordion.Item value="network-access">
<Accordion.Control>
<Box>Network Access</Box>
{accordianValue == 'network-access' && (
<Box>
<Text size="sm">Comma-Delimited CIDR ranges</Text>
</Box>
)}
</Accordion.Control>
<Accordion.Panel>
<form
onSubmit={networkAccessForm.onSubmit(
onNetworkAccessSubmit
)}
>
<Stack gap="sm">
{Object.entries(NETWORK_ACCESS_OPTIONS).map(
([key, config]) => {
return (
<TextInput
label={config.label}
{...networkAccessForm.getInputProps(key)}
key={networkAccessForm.key(key)}
description={config.description}
/>
);
}
)}
<Flex
mih={50}
gap="xs"
justify="flex-end"
align="flex-end"
>
<Button
type="submit"
disabled={networkAccessForm.submitting}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
</form>
</Accordion.Panel>
</Accordion.Item>,
]
: []
)}

View file

@ -1,14 +0,0 @@
import ProxyManager from './components/ProxyManager';
// ...existing code...
const routes = [
...existingRoutes,
{
path: '/proxy',
element: <ProxyManager />,
name: 'Proxy Manager',
},
];
export default routes;

View file

@ -38,12 +38,15 @@ export default {
};
// Custom debounce hook
export function useDebounce(value, delay = 500) {
export function useDebounce(value, delay = 500, callback = null) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
if (callback) {
callback();
}
}, delay);
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change