From 3f445607e066b0a14c165e0518a2d2d69486bc64 Mon Sep 17 00:00:00 2001 From: dekzter Date: Sat, 31 May 2025 18:01:46 -0400 Subject: [PATCH] looooots of updates for user-management, initial commit of access control --- apps/accounts/api_urls.py | 6 +- apps/accounts/api_views.py | 33 +- apps/accounts/models.py | 2 +- apps/accounts/permissions.py | 34 +- apps/accounts/serializers.py | 4 +- apps/channels/api_views.py | 23 +- apps/epg/api_views.py | 12 +- apps/hdhr/api_views.py | 6 +- apps/hdhr/views.py | 5 +- apps/m3u/api_views.py | 16 +- apps/output/urls.py | 12 +- apps/output/views.py | 312 +++++++++++++----- apps/proxy/ts_proxy/views.py | 16 +- core/api_views.py | 6 +- .../0013_default_network_access_settings.py | 24 ++ core/models.py | 49 +-- dispatcharr/urls.py | 18 +- dispatcharr/utils.py | 45 ++- docker/nginx.conf | 20 +- frontend/package-lock.json | 289 +--------------- frontend/package.json | 5 +- .../src/components/M3URefreshNotification.jsx | 23 +- frontend/src/components/Navigation.js | 11 - frontend/src/components/ProxyManager.js | 82 ----- frontend/src/components/forms/EPG.jsx | 37 ++- frontend/src/components/forms/M3UProfile.jsx | 30 +- frontend/src/components/forms/User.jsx | 2 +- frontend/src/components/forms/UserAgent.jsx | 1 + .../src/components/tables/ChannelsTable.jsx | 18 +- frontend/src/components/tables/EPGsTable.jsx | 6 +- frontend/src/components/tables/M3UsTable.jsx | 8 +- .../components/tables/StreamProfilesTable.jsx | 13 +- .../src/components/tables/StreamsTable.jsx | 36 +- .../src/components/tables/UserAgentsTable.jsx | 5 +- frontend/src/components/tables/table.css | 6 +- frontend/src/constants.js | 24 +- frontend/src/pages/Channels.jsx | 2 +- frontend/src/pages/Settings.jsx | 90 ++++- frontend/src/routes.js | 14 - frontend/src/utils.js | 5 +- 40 files changed, 669 insertions(+), 681 deletions(-) create mode 100644 core/migrations/0013_default_network_access_settings.py delete mode 100644 frontend/src/components/Navigation.js delete mode 100644 frontend/src/components/ProxyManager.js delete mode 100644 frontend/src/routes.js diff --git a/apps/accounts/api_urls.py b/apps/accounts/api_urls.py index 478fadd0..dda3832c 100644 --- a/apps/accounts/api_urls.py +++ b/apps/accounts/api_urls.py @@ -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 diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 476b1f60..f6b48e55 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -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() diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 313b20f7..cbaa0f5e 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -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) diff --git a/apps/accounts/permissions.py b/apps/accounts/permissions.py index 4cb593c3..62673038 100644 --- a/apps/accounts/permissions.py +++ b/apps/accounts/permissions.py @@ -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], diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 5a9f7cef..5aa81f3e 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -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() diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b16f3afd..38cb5bd4 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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()] diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 67e26abc..f3248677 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -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()] diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 95bacace..178fce5f 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -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 diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 47a53bec..40823259 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -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 diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 571ace28..0ef42272 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -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"] diff --git a/apps/output/urls.py b/apps/output/urls.py index e328b883..8b9c4f3a 100644 --- a/apps/output/urls.py +++ b/apps/output/urls.py @@ -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[^/]+))?/?$', generate_m3u, name='generate_m3u'), - + re_path(r"^m3u(?:/(?P[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"), # Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/` - re_path(r'^epg(?:/(?P[^/]+))?/?$', generate_epg, name='generate_epg'), - + re_path(r"^epg(?:/(?P[^/]+))?/?$", epg_endpoint, name="epg_endpoint"), # Allow both `/stream/` and `/stream//` - re_path(r'^stream/(?P[0-9a-fA-F\-]+)/?$', stream_view, name='stream'), + re_path(r"^stream/(?P[0-9a-fA-F\-]+)/?$", stream_view, name="stream"), ] diff --git a/apps/output/views.py b/apps/output/views.py index f6ee215d..0ba9b211 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -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' ' - ) - xml_lines.append(f" {html.escape(channel_name)}") - xml_lines.append(f" {html.escape(description)}") - xml_lines.append(f" ") + 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' ' + ) + xml_lines.append(f" {html.escape(program['title'])}") + xml_lines.append(f" {html.escape(program['description'])}") + xml_lines.append(f" ") 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 diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 4d632bfd..8d46df5e 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -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) diff --git a/core/api_views.py b/core/api_views.py index 43f88ad4..b3e0c1bb 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -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 diff --git a/core/migrations/0013_default_network_access_settings.py b/core/migrations/0013_default_network_access_settings.py new file mode 100644 index 00000000..be53ba05 --- /dev/null +++ b/core/migrations/0013_default_network_access_settings.py @@ -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), + ] diff --git a/core/models.py b/core/models.py index fe7e9eb5..e251c4b4 100644 --- a/core/models.py +++ b/core/models.py @@ -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( diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index b4b602f6..3e891314 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -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( - "//", + "live///", + stream_xc, + name="xc_live_stream_endpoint", + ), + path( + "//", 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/", diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index e6392c6c..5f75121a 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -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 diff --git a/docker/nginx.conf b/docker/nginx.conf index 65d382c5..db097ede 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -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; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 291a265a..5325ff6c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 8802db2c..1f6c769d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index e669b444..8a6647cb 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -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: ( - {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.'} - - - - - - ); -}; - -export default ProxyManager; \ No newline at end of file diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx index 0c7f78c0..603d0c81 100644 --- a/frontend/src/components/forms/EPG.jsx +++ b/frontend/src/components/forms/EPG.jsx @@ -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) + } /> { {/* Put checkbox at the same level as Refresh Interval */} - Status - When enabled, this EPG source will auto update. - + + Status + + + When enabled, this EPG source will auto update. + + { - - + diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index ff3378a1..ac6adca2 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -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 => `${match}`); + return sampleInput.replace( + regex, + (match) => `${match}` + ); } catch (e) { return sampleInput; } @@ -213,10 +215,14 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { - Live Regex Demonstration + + Live Regex Demonstration + - Sample Text + + Sample Text + { - Matched Text highlighted + + Matched Text{' '} + + highlighted + + { - Result After Replace - + + Result After Replace + + {getLocalReplaceResult()} diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index 77206725..00ea0537 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -205,7 +205,7 @@ const User = ({ user = null, isOpen, onClose }) => { { const formik = useFormik({ diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 6e39de37..3bf71d00 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -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, diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index e5fefc96..6978d005 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -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 = () => { diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx index 9913eaa1..9dc82b5f 100644 --- a/frontend/src/components/tables/StreamProfilesTable.jsx +++ b/frontend/src/components/tables/StreamProfilesTable.jsx @@ -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 = () => { + + + + + , ] : [] )} diff --git a/frontend/src/routes.js b/frontend/src/routes.js deleted file mode 100644 index 93fadefe..00000000 --- a/frontend/src/routes.js +++ /dev/null @@ -1,14 +0,0 @@ -import ProxyManager from './components/ProxyManager'; - -// ...existing code... - -const routes = [ - ...existingRoutes, - { - path: '/proxy', - element: , - name: 'Proxy Manager', - }, -]; - -export default routes; diff --git a/frontend/src/utils.js b/frontend/src/utils.js index 619823c8..a488ce8d 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -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