mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
looooots of updates for user-management, initial commit of access control
This commit is contained in:
parent
6504db3bd4
commit
3f445607e0
40 changed files with 669 additions and 681 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
core/migrations/0013_default_network_access_settings.py
Normal file
24
core/migrations/0013_default_network_access_settings.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
289
frontend/package-lock.json
generated
289
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
// ...existing imports...
|
||||
|
||||
const menuItems = [
|
||||
// existing items go here,
|
||||
{
|
||||
key: 'proxy',
|
||||
label: 'Proxy Manager',
|
||||
icon: <ApiOutlined />,
|
||||
path: '/proxy',
|
||||
},
|
||||
];
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
]
|
||||
: []
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import ProxyManager from './components/ProxyManager';
|
||||
|
||||
// ...existing code...
|
||||
|
||||
const routes = [
|
||||
...existingRoutes,
|
||||
{
|
||||
path: '/proxy',
|
||||
element: <ProxyManager />,
|
||||
name: 'Proxy Manager',
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue