mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Merge branch 'dev' into FUSE-fs
This commit is contained in:
commit
5223b1862d
52 changed files with 4214 additions and 1017 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -7,8 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942)
|
||||
- Event-driven webhooks and script execution (Connect): Added new Connect feature that enables event-driven execution of custom scripts and webhooks in response to system events. Supports multiple event types including channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect). Event data is available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads. Plugins can now subscribe to events by specifying an `events` array in their action definitions. Includes connection testing endpoint with dummy payloads for validation. (Closes #203)
|
||||
- Cron scheduling support for M3U and EPG refreshes: Added interactive cron expression builder with preset buttons and custom field editors, plus info popover with common cron examples. Refactored backup scheduling to use shared ScheduleInput component for consistency across all scheduling interfaces. (Closes #165)
|
||||
- Channel numbering modes for auto channel sync: Added three channel numbering modes when auto-syncing channels from M3U groups:
|
||||
- **Fixed Start Number** (default): Start at a specified number and increment sequentially
|
||||
- **Use Provider Number**: Use channel numbers from the M3U source (tvg-chno), with configurable fallback if provider number is missing
|
||||
- **Next Available**: Auto-assign starting from 1, skipping all used channel numbers
|
||||
Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433)
|
||||
- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd)
|
||||
|
||||
### Changed
|
||||
|
||||
- Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible.
|
||||
- XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839)
|
||||
|
||||
### Fixed
|
||||
|
||||
- VOD proxy connection counter leak on client disconnect: Fixed a connection leak in the VOD proxy where connection counters were not properly decremented when clients disconnected, causing the connection pool to lose track of available connections. The multi-worker connection manager now correctly handles client disconnection events across all proxy configurations. Includes three key fixes: (1) Replaced GET-check-INCR race condition with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams; (2) Decrement profile counter directly in stream generator exit paths instead of deferring to daemon thread cleanup; (3) Decrement profile counter on create_connection() failure to release reserved slots. (Fixes #962, #971, #451, #533) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- XC profile refresh credential extraction with sub-paths: Fixed credential extraction in `get_transformed_credentials()` to use negative indices anchored to the known tail structure instead of hardcoded indices that broke when server URLs contained sub-paths (e.g., `http://server.com/portal/a/`). This ensures XC accounts with sub-paths in their server URLs work correctly for profile refreshes. (Fixes #945) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique.
|
||||
- Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
|
||||
## [0.19.0] - 2026-02-10
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from django.contrib.auth import authenticate, login, logout
|
||||
import logging
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
|
@ -15,6 +16,8 @@ from .models import User
|
|||
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenObtainPairView(TokenObtainPairView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -25,6 +28,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
username = request.data.get("username", 'unknown')
|
||||
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
|
||||
logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}")
|
||||
log_system_event(
|
||||
event_type='login_failed',
|
||||
user=username,
|
||||
|
|
@ -43,6 +47,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
|
||||
|
||||
try:
|
||||
logger.debug(f"Attempting JWT login for user={username}")
|
||||
response = super().post(request, *args, **kwargs)
|
||||
|
||||
# If login was successful, update last_login and log success
|
||||
|
|
@ -61,6 +66,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
logger.info(f"Login success: user={username} ip={client_ip}")
|
||||
except User.DoesNotExist:
|
||||
pass # User doesn't exist, but login somehow succeeded
|
||||
else:
|
||||
|
|
@ -72,6 +78,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
user_agent=user_agent,
|
||||
reason='Invalid credentials',
|
||||
)
|
||||
logger.info(f"Login failed: user={username} ip={client_ip}")
|
||||
|
||||
return response
|
||||
|
||||
|
|
@ -84,6 +91,7 @@ class TokenObtainPairView(TokenObtainPairView):
|
|||
user_agent=user_agent,
|
||||
reason=f'Authentication error: {str(e)[:100]}',
|
||||
)
|
||||
logger.error(f"Login error for user={username}: {e}")
|
||||
raise # Re-raise the exception to maintain normal error flow
|
||||
|
||||
|
||||
|
|
@ -95,6 +103,7 @@ class TokenRefreshView(TokenRefreshView):
|
|||
from core.utils import log_system_event
|
||||
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
|
||||
logger.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}")
|
||||
log_system_event(
|
||||
event_type='login_failed',
|
||||
user='token_refresh',
|
||||
|
|
@ -167,6 +176,7 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
from core.utils import log_system_event
|
||||
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
|
||||
logger.debug(f"Login attempt via session: user={username} ip={client_ip}")
|
||||
|
||||
if user:
|
||||
login(request, user)
|
||||
|
|
@ -182,6 +192,7 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
logger.info(f"Login success via session: user={username} ip={client_ip}")
|
||||
|
||||
return Response(
|
||||
{
|
||||
|
|
@ -203,6 +214,7 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
user_agent=user_agent,
|
||||
reason='Invalid credentials',
|
||||
)
|
||||
logger.info(f"Login failed via session: user={username} ip={client_ip}")
|
||||
return Response({"error": "Invalid credentials"}, status=400)
|
||||
|
||||
@extend_schema(
|
||||
|
|
@ -222,6 +234,7 @@ class AuthViewSet(viewsets.ViewSet):
|
|||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
logger.info(f"Logout: user={username} ip={client_ip}")
|
||||
|
||||
logout(request)
|
||||
return Response({"message": "Logout successful"})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ urlpatterns = [
|
|||
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
|
||||
path('fuse/', include(('apps.fuse.api_urls', 'fuse'), namespace='fuse')),
|
||||
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
|
||||
path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')),
|
||||
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
|
||||
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
|
||||
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from django_celery_beat.models import PeriodicTask, CrontabSchedule
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
|
||||
from core.models import CoreSettings
|
||||
from core.scheduling import (
|
||||
create_or_update_periodic_task,
|
||||
delete_periodic_task,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -105,98 +109,25 @@ def _sync_periodic_task() -> None:
|
|||
settings = get_schedule_settings()
|
||||
|
||||
if not settings["enabled"]:
|
||||
# Delete the task if it exists
|
||||
task = PeriodicTask.objects.filter(name=BACKUP_SCHEDULE_TASK_NAME).first()
|
||||
if task:
|
||||
old_crontab = task.crontab
|
||||
task.delete()
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
delete_periodic_task(BACKUP_SCHEDULE_TASK_NAME)
|
||||
logger.info("Backup schedule disabled, removed periodic task")
|
||||
return
|
||||
|
||||
# Get old crontab before creating new one
|
||||
old_crontab = None
|
||||
try:
|
||||
old_task = PeriodicTask.objects.get(name=BACKUP_SCHEDULE_TASK_NAME)
|
||||
old_crontab = old_task.crontab
|
||||
except PeriodicTask.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Check if using cron expression (advanced mode)
|
||||
if settings["cron_expression"]:
|
||||
# Parse cron expression: "minute hour day month weekday"
|
||||
try:
|
||||
parts = settings["cron_expression"].split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError("Cron expression must have 5 parts: minute hour day month weekday")
|
||||
|
||||
minute, hour, day_of_month, month_of_year, day_of_week = parts
|
||||
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week=day_of_week,
|
||||
day_of_month=day_of_month,
|
||||
month_of_year=month_of_year,
|
||||
timezone=CoreSettings.get_system_time_zone(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}")
|
||||
raise ValueError(f"Invalid cron expression: {e}")
|
||||
cron_expr = settings["cron_expression"]
|
||||
else:
|
||||
# Use simple frequency-based scheduling
|
||||
# Parse time
|
||||
# Build a cron expression from simple frequency settings
|
||||
hour, minute = settings["time"].split(":")
|
||||
|
||||
# Build crontab based on frequency
|
||||
system_tz = CoreSettings.get_system_time_zone()
|
||||
if settings["frequency"] == "daily":
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week="*",
|
||||
day_of_month="*",
|
||||
month_of_year="*",
|
||||
timezone=system_tz,
|
||||
)
|
||||
cron_expr = f"{minute} {hour} * * *"
|
||||
else: # weekly
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=minute,
|
||||
hour=hour,
|
||||
day_of_week=str(settings["day_of_week"]),
|
||||
day_of_month="*",
|
||||
month_of_year="*",
|
||||
timezone=system_tz,
|
||||
)
|
||||
cron_expr = f"{minute} {hour} * * {settings['day_of_week']}"
|
||||
|
||||
# Create or update the periodic task
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=BACKUP_SCHEDULE_TASK_NAME,
|
||||
defaults={
|
||||
"task": "apps.backups.tasks.scheduled_backup_task",
|
||||
"crontab": crontab,
|
||||
"enabled": True,
|
||||
"kwargs": json.dumps({"retention_count": settings["retention_count"]}),
|
||||
},
|
||||
create_or_update_periodic_task(
|
||||
task_name=BACKUP_SCHEDULE_TASK_NAME,
|
||||
celery_task_path="apps.backups.tasks.scheduled_backup_task",
|
||||
kwargs={"retention_count": settings["retention_count"]},
|
||||
cron_expression=cron_expr,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
# Clean up old crontab if it changed and is orphaned
|
||||
if old_crontab and old_crontab.id != crontab.id:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
logger.info(f"{action} backup schedule: {settings['frequency']} at {settings['time']}")
|
||||
|
||||
|
||||
def _cleanup_orphaned_crontab(crontab_schedule):
|
||||
"""Delete old CrontabSchedule if no other tasks are using it."""
|
||||
if crontab_schedule is None:
|
||||
return
|
||||
|
||||
# Check if any other tasks are using this crontab
|
||||
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
|
||||
logger.debug(f"CrontabSchedule {crontab_schedule.id} still in use, not deleting")
|
||||
return
|
||||
|
||||
logger.debug(f"Cleaning up orphaned CrontabSchedule: {crontab_schedule.id}")
|
||||
crontab_schedule.delete()
|
||||
|
|
|
|||
0
apps/connect/__init__.py
Normal file
0
apps/connect/__init__.py
Normal file
17
apps/connect/api_urls.py
Normal file
17
apps/connect/api_urls.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import (
|
||||
IntegrationViewSet,
|
||||
EventSubscriptionViewSet,
|
||||
DeliveryLogViewSet,
|
||||
)
|
||||
|
||||
app_name = 'connect'
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'integrations', IntegrationViewSet, basename='integration')
|
||||
router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription')
|
||||
router.register(r'logs', DeliveryLogViewSet, basename='delivery-log')
|
||||
|
||||
urlpatterns = []
|
||||
urlpatterns += router.urls
|
||||
196
apps/connect/api_views.py
Normal file
196
apps/connect/api_views.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.utils import timezone
|
||||
from .models import Integration, EventSubscription, DeliveryLog
|
||||
from .serializers import (
|
||||
IntegrationSerializer,
|
||||
EventSubscriptionSerializer,
|
||||
DeliveryLogSerializer,
|
||||
)
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
permission_classes_by_action,
|
||||
IsAdmin,
|
||||
)
|
||||
from .handlers.webhook import WebhookHandler
|
||||
from .handlers.script import ScriptHandler
|
||||
|
||||
|
||||
class IntegrationViewSet(viewsets.ModelViewSet):
|
||||
queryset = Integration.objects.all()
|
||||
serializer_class = IntegrationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
perms = permission_classes_by_action[self.action]
|
||||
except KeyError:
|
||||
# Respect view/action-specific permission_classes if provided; fallback to Authenticated
|
||||
perms = getattr(self, "permission_classes", [Authenticated])
|
||||
return [perm() for perm in perms]
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="subscriptions")
|
||||
def list_subscriptions(self, request, pk=None):
|
||||
qs = EventSubscription.objects.filter(integration_id=pk)
|
||||
serializer = EventSubscriptionSerializer(qs, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=["put"], url_path=r"subscriptions/set")
|
||||
def set_subscriptions(self, request, pk=None):
|
||||
"""
|
||||
Replace the integration's subscriptions with the provided list.
|
||||
Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...]
|
||||
Any existing subscriptions not in the list will be deleted; missing ones will be created/updated.
|
||||
"""
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
except Integration.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
data = request.data
|
||||
if not isinstance(data, list):
|
||||
return Response(
|
||||
{"detail": "Expected a list of subscriptions"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate incoming items using serializer (without integration field)
|
||||
# We'll attach the integration explicitly
|
||||
valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES)
|
||||
incoming = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
return Response(
|
||||
{"detail": "Each subscription must be an object"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
event = item.get("event")
|
||||
if event not in valid_events:
|
||||
return Response(
|
||||
{"detail": f"Invalid event: {event}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
incoming.append(
|
||||
{
|
||||
"event": event,
|
||||
"enabled": bool(item.get("enabled", True)),
|
||||
"payload_template": item.get("payload_template"),
|
||||
}
|
||||
)
|
||||
|
||||
incoming_events = {s["event"] for s in incoming}
|
||||
|
||||
# Delete subscriptions that are no longer present
|
||||
EventSubscription.objects.filter(integration=integration).exclude(
|
||||
event__in=incoming_events
|
||||
).delete()
|
||||
|
||||
# Upsert incoming subscriptions
|
||||
updated = []
|
||||
for sub in incoming:
|
||||
obj, _created = EventSubscription.objects.update_or_create(
|
||||
integration=integration,
|
||||
event=sub["event"],
|
||||
defaults={
|
||||
"enabled": sub["enabled"],
|
||||
"payload_template": sub.get("payload_template"),
|
||||
},
|
||||
)
|
||||
updated.append(obj)
|
||||
|
||||
serializer = EventSubscriptionSerializer(updated, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin])
|
||||
def test(self, request, pk=None):
|
||||
"""
|
||||
Execute a saved integration (connect) with a dummy payload to verify configuration.
|
||||
"""
|
||||
try:
|
||||
integration = Integration.objects.get(pk=pk)
|
||||
except Integration.DoesNotExist:
|
||||
return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Build a dummy payload similar to system events
|
||||
now = timezone.now().isoformat()
|
||||
dummy_payload = {
|
||||
"event": "test",
|
||||
"timestamp": now,
|
||||
"channel_name": "Test Channel",
|
||||
"stream_name": "Test Stream",
|
||||
"stream_url": "http://example.com/stream.m3u8",
|
||||
"channel_url": "http://example.com/stream.m3u8",
|
||||
"provider_name": "Test Provider",
|
||||
"profile_used": "Default",
|
||||
"test": True,
|
||||
}
|
||||
|
||||
# Choose handler based on saved type
|
||||
if integration.type == "webhook":
|
||||
handler = WebhookHandler(integration, None, dummy_payload)
|
||||
elif integration.type == "script":
|
||||
handler = ScriptHandler(integration, None, dummy_payload)
|
||||
else:
|
||||
return Response(
|
||||
{"success": False, "error": f"Unsupported integration type: {integration.type}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
result = handler.execute()
|
||||
return Response(
|
||||
{
|
||||
"success": bool(result.get("success")),
|
||||
"type": integration.type,
|
||||
"request_payload": dummy_payload,
|
||||
"result": result,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"type": integration.type,
|
||||
"request_payload": dummy_payload,
|
||||
"error": str(e),
|
||||
},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
|
||||
class EventSubscriptionViewSet(viewsets.ModelViewSet):
|
||||
queryset = EventSubscription.objects.all()
|
||||
serializer_class = EventSubscriptionSerializer
|
||||
|
||||
|
||||
class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = DeliveryLog.objects.all().order_by("-created_at")
|
||||
serializer_class = DeliveryLogSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
# Support server-side pagination with page_size query param
|
||||
class ConnectLogsPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 250
|
||||
|
||||
pagination_class = ConnectLogsPagination
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
|
||||
# Optional filters: integration id and type
|
||||
integration_id = self.request.query_params.get("integration")
|
||||
if integration_id:
|
||||
qs = qs.filter(subscription__integration_id=integration_id)
|
||||
|
||||
integration_type = self.request.query_params.get("type")
|
||||
if integration_type:
|
||||
qs = qs.filter(subscription__integration__type=integration_type)
|
||||
|
||||
return qs
|
||||
8
apps/connect/apps.py
Normal file
8
apps/connect/apps.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ConnectConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.connect'
|
||||
verbose_name = "Connect Integrations"
|
||||
label = 'dispatcharr_connect'
|
||||
0
apps/connect/handlers/__init__.py
Normal file
0
apps/connect/handlers/__init__.py
Normal file
0
apps/connect/handlers/api.py
Normal file
0
apps/connect/handlers/api.py
Normal file
12
apps/connect/handlers/base.py
Normal file
12
apps/connect/handlers/base.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# connect/handlers/base.py
|
||||
import abc
|
||||
|
||||
class IntegrationHandler(abc.ABC):
|
||||
def __init__(self, integration, subscription, payload):
|
||||
self.integration = integration
|
||||
self.subscription = subscription
|
||||
self.payload = payload
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute(self):
|
||||
pass
|
||||
81
apps/connect/handlers/script.py
Normal file
81
apps/connect/handlers/script.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# connect/handlers/script.py
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
from django.conf import settings
|
||||
from .base import IntegrationHandler
|
||||
|
||||
|
||||
def _is_path_allowed(real_path: str) -> bool:
|
||||
# Ensure path is within one of the allowed directories
|
||||
for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []):
|
||||
base_abs = os.path.abspath(base) + os.sep
|
||||
if real_path.startswith(base_abs):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ScriptHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
raw_path = self.integration.config.get("path")
|
||||
if not raw_path:
|
||||
raise ValueError("Missing 'path' in integration config")
|
||||
|
||||
# Resolve and validate path
|
||||
real_path = os.path.abspath(os.path.realpath(raw_path))
|
||||
|
||||
if not os.path.exists(real_path):
|
||||
raise FileNotFoundError(f"Script not found: {real_path}")
|
||||
|
||||
if not _is_path_allowed(real_path):
|
||||
raise PermissionError(
|
||||
f"Script path '{real_path}' not within allowed directories: "
|
||||
f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}"
|
||||
)
|
||||
|
||||
if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True):
|
||||
if not os.access(real_path, os.X_OK):
|
||||
raise PermissionError(f"Script is not executable: {real_path}")
|
||||
|
||||
if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True):
|
||||
st = os.stat(real_path)
|
||||
if st.st_mode & stat.S_IWOTH:
|
||||
raise PermissionError(
|
||||
f"Refusing to execute world-writable script: {real_path}"
|
||||
)
|
||||
|
||||
# Build a sanitized minimal environment; avoid inheriting secrets
|
||||
env = {
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
for key, value in (self.payload or {}).items():
|
||||
env_key = f"DISPATCHARR_{str(key).upper()}"
|
||||
env[env_key] = "" if value is None else str(value)
|
||||
|
||||
# Run with a timeout to prevent hanging scripts
|
||||
timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10)
|
||||
max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536)
|
||||
|
||||
result = subprocess.run(
|
||||
[real_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
cwd=os.path.dirname(real_path) or None,
|
||||
)
|
||||
|
||||
# Truncate outputs to avoid excessive memory/logging
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
if len(stdout) > max_out:
|
||||
stdout = stdout[:max_out] + "... [truncated]"
|
||||
if len(stderr) > max_out:
|
||||
stderr = stderr[:max_out] + "... [truncated]"
|
||||
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"success": result.returncode == 0,
|
||||
}
|
||||
10
apps/connect/handlers/webhook.py
Normal file
10
apps/connect/handlers/webhook.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# connect/handlers/webhook.py
|
||||
import requests
|
||||
from .base import IntegrationHandler
|
||||
|
||||
class WebhookHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
url = self.integration.config.get("url")
|
||||
headers = self.integration.config.get("headers", {})
|
||||
response = requests.post(url, json=self.payload, headers=headers, timeout=10)
|
||||
return {"status_code": response.status_code, "body": response.text, "success": response.ok}
|
||||
52
apps/connect/migrations/0001_initial.py
Normal file
52
apps/connect/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 5.2.9 on 2026-01-27 21:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EventSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Integration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)),
|
||||
('config', models.JSONField(default=dict)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeliveryLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)),
|
||||
('request_payload', models.JSONField(blank=True, default=dict)),
|
||||
('response_payload', models.JSONField(blank=True, default=dict)),
|
||||
('error_message', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventsubscription',
|
||||
name='integration',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'),
|
||||
),
|
||||
]
|
||||
0
apps/connect/migrations/__init__.py
Normal file
0
apps/connect/migrations/__init__.py
Normal file
47
apps/connect/models.py
Normal file
47
apps/connect/models.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from django.db import models
|
||||
|
||||
SUPPORTED_EVENTS = {
|
||||
"channel_start": "Channel Started",
|
||||
"channel_stop": "Channel Stopped",
|
||||
"channel_reconnect": "Channel Reconnected",
|
||||
"channel_error": "Channel Error",
|
||||
"channel_failover": "Channel Failover",
|
||||
"stream_switch": "Stream Switch",
|
||||
"recording_start": "Recording Started",
|
||||
"recording_end": "Recording Ended",
|
||||
"epg_refresh": "EPG Refreshed",
|
||||
"m3u_refresh": "M3U Refreshed",
|
||||
"client_connect": "Client Connected",
|
||||
"client_disconnect": "Client Disconnected",
|
||||
"login_failed": "Login Failed",
|
||||
"epg_blocked": "EPG Blocked",
|
||||
"m3u_blocked": "M3U Blocked",
|
||||
}
|
||||
|
||||
class Integration(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
("webhook", "Webhook"),
|
||||
("api", "API"),
|
||||
("script", "Custom Script"),
|
||||
]
|
||||
name = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=50, choices=TYPE_CHOICES)
|
||||
config = models.JSONField(default=dict)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class EventSubscription(models.Model):
|
||||
EVENT_CHOICES = list(SUPPORTED_EVENTS.items())
|
||||
event = models.CharField(max_length=100, choices=EVENT_CHOICES)
|
||||
integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions")
|
||||
enabled = models.BooleanField(default=True)
|
||||
payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload")
|
||||
|
||||
class DeliveryLog(models.Model):
|
||||
subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs")
|
||||
status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")])
|
||||
request_payload = models.JSONField(default=dict, blank=True)
|
||||
response_payload = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
68
apps/connect/serializers.py
Normal file
68
apps/connect/serializers.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Integration, EventSubscription, DeliveryLog
|
||||
import os
|
||||
|
||||
|
||||
class EventSubscriptionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = EventSubscription
|
||||
fields = [
|
||||
"id",
|
||||
"event",
|
||||
"enabled",
|
||||
"payload_template",
|
||||
"integration",
|
||||
]
|
||||
|
||||
|
||||
class IntegrationSerializer(serializers.ModelSerializer):
|
||||
subscriptions = EventSubscriptionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Integration
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"config",
|
||||
"enabled",
|
||||
"created_at",
|
||||
"subscriptions",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None)
|
||||
config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {})
|
||||
|
||||
if type == "script":
|
||||
path = (config or {}).get("path")
|
||||
if not path or not isinstance(path, str):
|
||||
raise serializers.ValidationError({"config": "Script config must include a 'path' string"})
|
||||
|
||||
real_path = os.path.abspath(os.path.realpath(path))
|
||||
if not os.path.exists(real_path):
|
||||
raise serializers.ValidationError({"config": f"Script path does not exist: {path}"})
|
||||
elif type == "webhook":
|
||||
url = (config or {}).get("url")
|
||||
if not url or not isinstance(url, str):
|
||||
raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"})
|
||||
else:
|
||||
raise serializers.ValidationError({"type": "Unsupported integration type"})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class DeliveryLogSerializer(serializers.ModelSerializer):
|
||||
subscription = EventSubscriptionSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeliveryLog
|
||||
fields = [
|
||||
"id",
|
||||
"subscription",
|
||||
"status",
|
||||
"request_payload",
|
||||
"response_payload",
|
||||
"error_message",
|
||||
"created_at",
|
||||
]
|
||||
115
apps/connect/utils.py
Normal file
115
apps/connect/utils.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# connect/utils.py
|
||||
import logging, json
|
||||
from django.template import Template, Context
|
||||
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
|
||||
from .handlers.webhook import WebhookHandler
|
||||
from .handlers.script import ScriptHandler
|
||||
from apps.plugins.loader import PluginManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HANDLERS = {
|
||||
"webhook": WebhookHandler,
|
||||
"script": ScriptHandler,
|
||||
}
|
||||
|
||||
|
||||
def trigger_event(event_name, payload):
|
||||
if event_name not in SUPPORTED_EVENTS:
|
||||
logger.debug(f"Unsupported event '{event_name}' - skipping")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}"
|
||||
)
|
||||
subscriptions = EventSubscription.objects.filter(
|
||||
event=event_name, enabled=True
|
||||
).select_related("integration")
|
||||
|
||||
count = subscriptions.count()
|
||||
logger.info(f"Found {count} connect subscription(s) for event '{event_name}'")
|
||||
|
||||
# First, fetch all subscriptions and trigger
|
||||
for sub in subscriptions:
|
||||
integration = sub.integration
|
||||
if not integration.enabled:
|
||||
logger.debug(
|
||||
f"Skipping disabled integration id={integration.id} name={integration.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
# apply optional payload template
|
||||
final_payload = payload
|
||||
if sub.payload_template:
|
||||
try:
|
||||
template = Template(sub.payload_template)
|
||||
rendered = template.render(Context(payload))
|
||||
final_payload = {"message": rendered}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Payload template render failed for subscription id={sub.id}: {e}"
|
||||
)
|
||||
final_payload = payload
|
||||
|
||||
handler_cls = HANDLERS.get(integration.type)
|
||||
if not handler_cls:
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="failed",
|
||||
request_payload=final_payload,
|
||||
error_message=f"No handler for integration type '{integration.type}'",
|
||||
)
|
||||
logger.error(
|
||||
f"No handler for integration type '{integration.type}' (integration id={integration.id})"
|
||||
)
|
||||
continue
|
||||
|
||||
handler = handler_cls(integration, sub, final_payload)
|
||||
logger.debug(
|
||||
f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = handler.execute()
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="success" if result.get("success") else "failed",
|
||||
request_payload=final_payload,
|
||||
response_payload=result,
|
||||
)
|
||||
logger.info(
|
||||
f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'"
|
||||
)
|
||||
except Exception as e:
|
||||
DeliveryLog.objects.create(
|
||||
subscription=sub,
|
||||
status="failed",
|
||||
request_payload=final_payload,
|
||||
error_message=str(e),
|
||||
)
|
||||
logger.error(
|
||||
f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}"
|
||||
)
|
||||
|
||||
pm = PluginManager.get()
|
||||
pm.discover_plugins(sync_db=False, use_cache=True)
|
||||
plugins = pm.list_plugins()
|
||||
|
||||
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'")
|
||||
for plugin in plugins:
|
||||
if not plugin["enabled"]:
|
||||
logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}")
|
||||
continue
|
||||
|
||||
logger.debug(json.dumps(plugin))
|
||||
for action in plugin["actions"]:
|
||||
if "events" in action and event_name in action["events"]:
|
||||
key = plugin["key"]
|
||||
params = {"event": event_name, "payload": payload}
|
||||
action_name = action.get("label") or action.get("id")
|
||||
action_id = action.get("id")
|
||||
logger.debug(
|
||||
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}"
|
||||
)
|
||||
if action_id:
|
||||
pm.run_action(key, action_id, params)
|
||||
|
|
@ -31,7 +31,9 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
API endpoint that allows EPG sources to be viewed or edited.
|
||||
"""
|
||||
|
||||
queryset = EPGSource.objects.all()
|
||||
queryset = EPGSource.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).all()
|
||||
serializer_class = EPGSourceSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
allow_null=True,
|
||||
validators=[validate_flexible_url]
|
||||
)
|
||||
cron_expression = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
|
||||
class Meta:
|
||||
model = EPGSource
|
||||
|
|
@ -24,6 +25,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
'is_active',
|
||||
'file_path',
|
||||
'refresh_interval',
|
||||
'cron_expression',
|
||||
'priority',
|
||||
'status',
|
||||
'last_message',
|
||||
|
|
@ -37,6 +39,34 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
|
||||
return obj.epgs.count()
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
|
||||
# But first check if we have a transient _cron_expression (from create/update before signal runs)
|
||||
cron_expr = ''
|
||||
if hasattr(instance, '_cron_expression'):
|
||||
cron_expr = instance._cron_expression
|
||||
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
|
||||
ct = instance.refresh_task.crontab
|
||||
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
|
||||
data['cron_expression'] = cron_expr
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
cron_expr = validated_data.pop('cron_expression', '')
|
||||
instance._cron_expression = cron_expr
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
cron_expr = validated_data.pop('cron_expression', '')
|
||||
instance = EPGSource(**validated_data)
|
||||
instance._cron_expression = cron_expr
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
class ProgramDataSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProgramData
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
|
|||
from django.dispatch import receiver
|
||||
from .models import EPGSource, EPGData
|
||||
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
|
||||
from django_celery_beat.models import PeriodicTask, IntervalSchedule
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
from core.utils import is_protected_path, send_websocket_update
|
||||
import json
|
||||
import logging
|
||||
|
|
@ -74,6 +74,7 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
|
|||
"""
|
||||
Create or update a Celery Beat periodic task when an EPGSource is created/updated.
|
||||
Skip creating tasks for dummy EPG sources as they don't need refreshing.
|
||||
Supports both interval-based and cron-based scheduling via the shared utility.
|
||||
"""
|
||||
# Skip task creation for dummy EPGs
|
||||
if instance.source_type == 'dummy':
|
||||
|
|
@ -84,38 +85,23 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
|
|||
return
|
||||
|
||||
task_name = f"epg_source-refresh-{instance.id}"
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=int(instance.refresh_interval),
|
||||
period=IntervalSchedule.HOURS
|
||||
should_be_enabled = instance.is_active
|
||||
|
||||
# Read cron_expression from transient attribute set by the serializer
|
||||
cron_expr = getattr(instance, "_cron_expression", "")
|
||||
|
||||
task = create_or_update_periodic_task(
|
||||
task_name=task_name,
|
||||
celery_task_path="apps.epg.tasks.refresh_epg_data",
|
||||
kwargs={"source_id": instance.id},
|
||||
interval_hours=int(instance.refresh_interval),
|
||||
cron_expression=cron_expr,
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
|
||||
task, created = PeriodicTask.objects.get_or_create(name=task_name, defaults={
|
||||
"interval": interval,
|
||||
"task": "apps.epg.tasks.refresh_epg_data",
|
||||
"kwargs": json.dumps({"source_id": instance.id}),
|
||||
"enabled": instance.refresh_interval != 0 and instance.is_active,
|
||||
})
|
||||
|
||||
update_fields = []
|
||||
if created:
|
||||
task.interval = interval
|
||||
|
||||
if task.interval != interval:
|
||||
task.interval = interval
|
||||
update_fields.append("interval")
|
||||
|
||||
# Check both refresh_interval and is_active to determine if task should be enabled
|
||||
should_be_enabled = instance.refresh_interval != 0 and instance.is_active
|
||||
if task.enabled != should_be_enabled:
|
||||
task.enabled = should_be_enabled
|
||||
update_fields.append("enabled")
|
||||
|
||||
if update_fields:
|
||||
task.save(update_fields=update_fields)
|
||||
|
||||
if instance.refresh_task != task:
|
||||
instance.refresh_task = task
|
||||
instance.save(update_fields=["refresh_task"]) # Fixed field name
|
||||
instance.save(update_fields=["refresh_task"])
|
||||
|
||||
@receiver(post_delete, sender=EPGSource)
|
||||
def delete_refresh_task(sender, instance, **kwargs):
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ import json
|
|||
class M3UAccountViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U accounts"""
|
||||
|
||||
queryset = M3UAccount.objects.prefetch_related("channel_group")
|
||||
queryset = M3UAccount.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).prefetch_related("channel_group")
|
||||
serializer_class = M3UAccountSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True)
|
||||
auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True)
|
||||
auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True)
|
||||
cron_expression = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
model = M3UAccount
|
||||
|
|
@ -158,6 +159,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
"locked",
|
||||
"channel_groups",
|
||||
"refresh_interval",
|
||||
"cron_expression",
|
||||
"custom_properties",
|
||||
"account_type",
|
||||
"username",
|
||||
|
|
@ -188,9 +190,23 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True)
|
||||
data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True)
|
||||
data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True)
|
||||
|
||||
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
|
||||
# But first check if we have a transient _cron_expression (from create/update before signal runs)
|
||||
cron_expr = ""
|
||||
if hasattr(instance, '_cron_expression'):
|
||||
cron_expr = instance._cron_expression
|
||||
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
|
||||
ct = instance.refresh_task.crontab
|
||||
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
|
||||
data["cron_expression"] = cron_expr
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Pop cron_expression before it reaches model fields
|
||||
cron_expr = validated_data.pop("cron_expression", "")
|
||||
instance._cron_expression = cron_expr
|
||||
|
||||
# Handle enable_vod preference and auto_enable_new_groups settings
|
||||
enable_vod = validated_data.pop("enable_vod", None)
|
||||
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None)
|
||||
|
|
@ -244,6 +260,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
# Pop cron_expression — it's not a model field
|
||||
cron_expr = validated_data.pop("cron_expression", "")
|
||||
|
||||
# Handle enable_vod preference and auto_enable_new_groups settings during creation
|
||||
enable_vod = validated_data.pop("enable_vod", False)
|
||||
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True)
|
||||
|
|
@ -260,7 +279,11 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
|
||||
validated_data["custom_properties"] = custom_props
|
||||
|
||||
return super().create(validated_data)
|
||||
# Build instance manually so we can attach transient attr before save triggers signal
|
||||
instance = M3UAccount(**validated_data)
|
||||
instance._cron_expression = cron_expr
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def get_filters(self, obj):
|
||||
filters = obj.filters.order_by("order")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from django.db.models.signals import post_save, post_delete, pre_save
|
|||
from django.dispatch import receiver
|
||||
from .models import M3UAccount
|
||||
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
|
||||
from django_celery_beat.models import PeriodicTask, IntervalSchedule
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
import json
|
||||
import logging
|
||||
|
||||
|
|
@ -23,48 +23,26 @@ def refresh_account_on_save(sender, instance, created, **kwargs):
|
|||
def create_or_update_refresh_task(sender, instance, **kwargs):
|
||||
"""
|
||||
Create or update a Celery Beat periodic task when an M3UAccount is created/updated.
|
||||
Supports both interval-based and cron-based scheduling via the shared utility.
|
||||
"""
|
||||
task_name = f"m3u_account-refresh-{instance.id}"
|
||||
should_be_enabled = instance.is_active
|
||||
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=int(instance.refresh_interval),
|
||||
period=IntervalSchedule.HOURS
|
||||
# Read cron_expression from transient attribute set by the serializer
|
||||
cron_expr = getattr(instance, "_cron_expression", "")
|
||||
|
||||
task = create_or_update_periodic_task(
|
||||
task_name=task_name,
|
||||
celery_task_path="apps.m3u.tasks.refresh_single_m3u_account",
|
||||
kwargs={"account_id": instance.id},
|
||||
interval_hours=int(instance.refresh_interval),
|
||||
cron_expression=cron_expr,
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
|
||||
# Task should be enabled only if refresh_interval != 0 AND account is active
|
||||
should_be_enabled = (instance.refresh_interval != 0) and instance.is_active
|
||||
|
||||
# First check if the task already exists to avoid validation errors
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
# Task exists, just update it
|
||||
updated_fields = []
|
||||
|
||||
if task.enabled != should_be_enabled:
|
||||
task.enabled = should_be_enabled
|
||||
updated_fields.append("enabled")
|
||||
|
||||
if task.interval != interval:
|
||||
task.interval = interval
|
||||
updated_fields.append("interval")
|
||||
|
||||
if updated_fields:
|
||||
task.save(update_fields=updated_fields)
|
||||
|
||||
# Ensure instance has the task
|
||||
if instance.refresh_task_id != task.id:
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
|
||||
|
||||
except PeriodicTask.DoesNotExist:
|
||||
# Create new task if it doesn't exist
|
||||
refresh_task = PeriodicTask.objects.create(
|
||||
name=task_name,
|
||||
interval=interval,
|
||||
task="apps.m3u.tasks.refresh_single_m3u_account",
|
||||
kwargs=json.dumps({"account_id": instance.id}),
|
||||
enabled=should_be_enabled,
|
||||
)
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task)
|
||||
# Ensure instance has the task linked
|
||||
if instance.refresh_task_id != task.id:
|
||||
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
|
||||
|
||||
@receiver(post_delete, sender=M3UAccount)
|
||||
def delete_refresh_task(sender, instance, **kwargs):
|
||||
|
|
|
|||
|
|
@ -1648,6 +1648,13 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
channels_updated = 0
|
||||
channels_deleted = 0
|
||||
|
||||
# Get all channel numbers that are already in use by other channels (not auto-created by this account)
|
||||
used_numbers = set(
|
||||
Channel.objects.exclude(
|
||||
auto_created=True, auto_created_by=account
|
||||
).values_list("channel_number", flat=True)
|
||||
)
|
||||
|
||||
for group_relation in auto_sync_groups:
|
||||
channel_group = group_relation.channel_group
|
||||
start_number = group_relation.auto_sync_channel_start or 1.0
|
||||
|
|
@ -1665,6 +1672,8 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
stream_profile_id = None
|
||||
custom_logo_id = None
|
||||
custom_epg_id = None # New option: select specific EPG source (takes priority over force_dummy_epg)
|
||||
channel_numbering_mode = "fixed" # Default mode
|
||||
channel_numbering_fallback = 1 # Default fallback for provider mode
|
||||
if group_relation.custom_properties:
|
||||
group_custom_props = group_relation.custom_properties
|
||||
force_dummy_epg = group_custom_props.get("force_dummy_epg", False)
|
||||
|
|
@ -1682,6 +1691,8 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
)
|
||||
stream_profile_id = group_custom_props.get("stream_profile_id")
|
||||
custom_logo_id = group_custom_props.get("custom_logo_id")
|
||||
channel_numbering_mode = group_custom_props.get("channel_numbering_mode", "fixed")
|
||||
channel_numbering_fallback = group_custom_props.get("channel_numbering_fallback", 1)
|
||||
|
||||
# Determine which group to use for created channels
|
||||
target_group = channel_group
|
||||
|
|
@ -1697,7 +1708,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
)
|
||||
|
||||
logger.info(
|
||||
f"Processing auto sync for group: {channel_group.name} (start: {start_number})"
|
||||
f"Processing auto sync for group: {channel_group.name} (mode: {channel_numbering_mode}, start: {start_number})"
|
||||
)
|
||||
|
||||
# Get all current streams in this group for this M3U account, filter out stale streams
|
||||
|
|
@ -1837,21 +1848,35 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
channels_to_renumber = []
|
||||
temp_channel_number = start_number
|
||||
|
||||
# Get all channel numbers that are already in use by other channels (not auto-created by this account)
|
||||
used_numbers = set(
|
||||
Channel.objects.exclude(
|
||||
auto_created=True, auto_created_by=account
|
||||
).values_list("channel_number", flat=True)
|
||||
)
|
||||
|
||||
for stream in current_streams:
|
||||
if stream.id in existing_channel_map:
|
||||
channel = existing_channel_map[stream.id]
|
||||
|
||||
# Find next available number starting from temp_channel_number
|
||||
target_number = temp_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
# Determine target number based on numbering mode
|
||||
if channel_numbering_mode == "provider":
|
||||
# Use provider number if available, otherwise use fallback with next available logic
|
||||
if stream.stream_chno is not None:
|
||||
target_number = stream.stream_chno
|
||||
# If provider number is already used, find next available
|
||||
if target_number in used_numbers:
|
||||
target_number = channel_numbering_fallback
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
else:
|
||||
# No provider number, use fallback and find next available
|
||||
target_number = channel_numbering_fallback
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
elif channel_numbering_mode == "next_available":
|
||||
# Find next available starting from 1
|
||||
target_number = 1
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
else: # fixed mode (default)
|
||||
# Find next available number starting from temp_channel_number
|
||||
target_number = temp_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
|
||||
# Add this number to used_numbers so we don't reuse it in this batch
|
||||
used_numbers.add(target_number)
|
||||
|
|
@ -1863,9 +1888,11 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
f"Will renumber channel '{channel.name}' to {target_number}"
|
||||
)
|
||||
|
||||
temp_channel_number += 1.0
|
||||
if temp_channel_number % 1 != 0: # Has decimal
|
||||
temp_channel_number = int(temp_channel_number) + 1.0
|
||||
# Only increment temp_channel_number in fixed mode
|
||||
if channel_numbering_mode == "fixed":
|
||||
temp_channel_number += 1.0
|
||||
if temp_channel_number % 1 != 0: # Has decimal
|
||||
temp_channel_number = int(temp_channel_number) + 1.0
|
||||
|
||||
# Bulk update channel numbers if any need renumbering
|
||||
if channels_to_renumber:
|
||||
|
|
@ -2060,10 +2087,31 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
|
||||
else:
|
||||
# Create new channel
|
||||
# Find next available channel number
|
||||
target_number = current_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
# Determine channel number based on numbering mode
|
||||
if channel_numbering_mode == "provider":
|
||||
# Use provider number if available, otherwise use fallback with next available logic
|
||||
if stream.stream_chno is not None:
|
||||
target_number = stream.stream_chno
|
||||
# If provider number is already used, find next available from fallback
|
||||
if target_number in used_numbers:
|
||||
target_number = channel_numbering_fallback
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
else:
|
||||
# No provider number, use fallback and find next available
|
||||
target_number = channel_numbering_fallback
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
elif channel_numbering_mode == "next_available":
|
||||
# Find next available starting from 1
|
||||
target_number = 1
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
else: # fixed mode (default)
|
||||
# Find next available channel number starting from current_channel_number
|
||||
target_number = current_channel_number
|
||||
while target_number in used_numbers:
|
||||
target_number += 1
|
||||
|
||||
# Add this number to used_numbers
|
||||
used_numbers.add(target_number)
|
||||
|
|
@ -2190,10 +2238,11 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
f"Created auto channel: {channel.channel_number} - {channel.name}"
|
||||
)
|
||||
|
||||
# Increment channel number for next iteration
|
||||
current_channel_number += 1.0
|
||||
if current_channel_number % 1 != 0: # Has decimal
|
||||
current_channel_number = int(current_channel_number) + 1.0
|
||||
# Increment channel number for next iteration (only in fixed mode)
|
||||
if channel_numbering_mode == "fixed":
|
||||
current_channel_number += 1.0
|
||||
if current_channel_number % 1 != 0: # Has decimal
|
||||
current_channel_number = int(current_channel_number) + 1.0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -2305,10 +2354,12 @@ def get_transformed_credentials(account, profile=None):
|
|||
parsed_url = urllib.parse.urlparse(transformed_complete_url)
|
||||
path_parts = [part for part in parsed_url.path.split('/') if part]
|
||||
|
||||
if len(path_parts) >= 2:
|
||||
# Extract username and password from path
|
||||
transformed_username = path_parts[1]
|
||||
transformed_password = path_parts[2]
|
||||
if len(path_parts) >= 4 and path_parts[-1] == '1234.ts':
|
||||
# Extract username and password from the known structure:
|
||||
# .../{live}/{username}/{password}/1234.ts
|
||||
# Using negative indices so sub-paths in the server URL don't shift extraction
|
||||
transformed_username = path_parts[-3]
|
||||
transformed_password = path_parts[-2]
|
||||
|
||||
# Rebuild server URL without the username/password path
|
||||
transformed_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
|
|
|
|||
|
|
@ -183,8 +183,9 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
# Check if this is an XC API request (has username/password in GET params and user is authenticated)
|
||||
xc_username = request.GET.get('username')
|
||||
xc_password = request.GET.get('password')
|
||||
is_xc_request = user is not None and xc_username and xc_password
|
||||
|
||||
if user is not None and xc_username and xc_password:
|
||||
if is_xc_request:
|
||||
# This is an XC API request - use XC-style EPG URL
|
||||
base_url = build_absolute_uri_with_port(request, '')
|
||||
epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}"
|
||||
|
|
@ -254,8 +255,12 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n'
|
||||
)
|
||||
|
||||
# Determine the stream URL based on the direct parameter
|
||||
if use_direct_urls:
|
||||
# Determine the stream URL based on request type
|
||||
if is_xc_request:
|
||||
# XC API request - use XC-style stream URL format
|
||||
base_url = build_absolute_uri_with_port(request, '')
|
||||
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}"
|
||||
elif use_direct_urls:
|
||||
# Try to get the first stream's direct URL
|
||||
first_stream = channel.streams.order_by('channelstream__order').first()
|
||||
if first_stream and first_stream.url:
|
||||
|
|
@ -506,6 +511,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
output_timezone_value = custom_properties.get('output_timezone', '') # Optional: display times in different timezone
|
||||
program_duration = custom_properties.get('program_duration', 180) # Minutes
|
||||
title_template = custom_properties.get('title_template', '')
|
||||
subtitle_template = custom_properties.get('subtitle_template', '')
|
||||
description_template = custom_properties.get('description_template', '')
|
||||
|
||||
# Templates for upcoming/ended programs
|
||||
|
|
@ -911,6 +917,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
title_parts.append(all_groups['title'])
|
||||
main_event_title = ' - '.join(title_parts) if title_parts else channel_name
|
||||
|
||||
if subtitle_template:
|
||||
main_event_subtitle = format_template(subtitle_template, all_groups)
|
||||
else:
|
||||
main_event_subtitle = None
|
||||
|
||||
if description_template:
|
||||
main_event_description = format_template(description_template, all_groups)
|
||||
else:
|
||||
|
|
@ -961,6 +972,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": program_start_utc,
|
||||
"end_time": program_end_utc,
|
||||
"title": upcoming_title,
|
||||
"sub_title": None, # No subtitle for filler programs
|
||||
"description": upcoming_description,
|
||||
"custom_properties": program_custom_properties,
|
||||
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
|
||||
|
|
@ -1000,6 +1012,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": event_start_utc,
|
||||
"end_time": event_end_utc,
|
||||
"title": main_event_title,
|
||||
"sub_title": main_event_subtitle,
|
||||
"description": main_event_description,
|
||||
"custom_properties": main_event_custom_properties,
|
||||
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
|
||||
|
|
@ -1044,6 +1057,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": program_start_utc,
|
||||
"end_time": program_end_utc,
|
||||
"title": ended_title,
|
||||
"sub_title": None, # No subtitle for filler programs
|
||||
"description": ended_description,
|
||||
"custom_properties": program_custom_properties,
|
||||
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
|
||||
|
|
@ -1104,6 +1118,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": program_start_utc,
|
||||
"end_time": program_end_utc,
|
||||
"title": program_title,
|
||||
"sub_title": None, # No subtitle for filler programs
|
||||
"description": program_description,
|
||||
"custom_properties": program_custom_properties,
|
||||
"channel_logo_url": channel_logo_url,
|
||||
|
|
@ -1131,6 +1146,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
title_parts.append(all_groups['title'])
|
||||
title = ' - '.join(title_parts) if title_parts else channel_name
|
||||
|
||||
if subtitle_template:
|
||||
subtitle = format_template(subtitle_template, all_groups)
|
||||
else:
|
||||
subtitle = None
|
||||
|
||||
if description_template:
|
||||
description = format_template(description_template, all_groups)
|
||||
else:
|
||||
|
|
@ -1167,6 +1187,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust
|
|||
"start_time": program_start_utc,
|
||||
"end_time": program_end_utc,
|
||||
"title": title,
|
||||
"sub_title": subtitle,
|
||||
"description": description,
|
||||
"custom_properties": program_custom_properties,
|
||||
"channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation
|
||||
|
|
@ -1206,6 +1227,11 @@ def generate_dummy_epg(
|
|||
f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(program["channel_id"])}">'
|
||||
)
|
||||
xml_lines.append(f" <title>{html.escape(program['title'])}</title>")
|
||||
|
||||
# Add subtitle if available
|
||||
if program.get('sub_title'):
|
||||
xml_lines.append(f" <sub-title>{html.escape(program['sub_title'])}</sub-title>")
|
||||
|
||||
xml_lines.append(f" <desc>{html.escape(program['description'])}</desc>")
|
||||
|
||||
# Add custom_properties if present
|
||||
|
|
@ -1525,6 +1551,11 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
# Create program entry with escaped channel name
|
||||
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
|
||||
yield f" <title>{html.escape(program['title'])}</title>\n"
|
||||
|
||||
# Add subtitle if available
|
||||
if program.get('sub_title'):
|
||||
yield f" <sub-title>{html.escape(program['sub_title'])}</sub-title>\n"
|
||||
|
||||
yield f" <desc>{html.escape(program['description'])}</desc>\n"
|
||||
|
||||
# Add custom_properties if present
|
||||
|
|
@ -1574,6 +1605,11 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
|
||||
yield f' <programme start="{start_str}" stop="{stop_str}" channel="{html.escape(channel_id)}">\n'
|
||||
yield f" <title>{html.escape(program['title'])}</title>\n"
|
||||
|
||||
# Add subtitle if available
|
||||
if program.get('sub_title'):
|
||||
yield f" <sub-title>{html.escape(program['sub_title'])}</sub-title>\n"
|
||||
|
||||
yield f" <desc>{html.escape(program['description'])}</desc>\n"
|
||||
|
||||
# Add custom_properties if present
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ class PluginActionSerializer(serializers.Serializer):
|
|||
button_label = serializers.CharField(required=False, allow_blank=True)
|
||||
button_variant = serializers.CharField(required=False, allow_blank=True)
|
||||
button_color = serializers.CharField(required=False, allow_blank=True)
|
||||
events = serializers.ListField(
|
||||
child=serializers.CharField(), required=False, allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
class PluginFieldOptionSerializer(serializers.Serializer):
|
||||
|
|
|
|||
|
|
@ -459,13 +459,12 @@ class VODConnectionManager:
|
|||
return False
|
||||
|
||||
try:
|
||||
# Check profile connection limits using standardized key
|
||||
if not self._check_profile_limits(m3u_profile):
|
||||
# Atomically check and reserve a profile connection slot (INCR-first)
|
||||
if not self._check_and_reserve_profile_slot(m3u_profile):
|
||||
logger.warning(f"Profile {m3u_profile.name} connection limit exceeded")
|
||||
return False
|
||||
|
||||
connection_key = self._get_connection_key(content_type, content_uuid, client_id)
|
||||
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
|
||||
content_connections_key = self._get_content_connections_key(content_type, content_uuid)
|
||||
|
||||
# Check if connection already exists to prevent duplicate counting
|
||||
|
|
@ -473,6 +472,9 @@ class VODConnectionManager:
|
|||
logger.info(f"Connection already exists for {client_id} - {content_type} {content_name}")
|
||||
# Update activity but don't increment profile counter
|
||||
self.redis_client.hset(connection_key, "last_activity", str(time.time()))
|
||||
# Roll back the reservation — connection already counted
|
||||
if m3u_profile.max_streams > 0:
|
||||
self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id))
|
||||
return True
|
||||
|
||||
# Connection data
|
||||
|
|
@ -499,8 +501,7 @@ class VODConnectionManager:
|
|||
pipe.hset(connection_key, mapping=connection_data)
|
||||
pipe.expire(connection_key, self.connection_ttl)
|
||||
|
||||
# Increment profile connections using standardized method
|
||||
pipe.incr(profile_connections_key)
|
||||
# Profile counter already incremented atomically above — no pipe.incr needed
|
||||
|
||||
# Add to content connections set
|
||||
pipe.sadd(content_connections_key, client_id)
|
||||
|
|
@ -513,6 +514,9 @@ class VODConnectionManager:
|
|||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Roll back the profile reservation on failure
|
||||
if m3u_profile.max_streams > 0:
|
||||
self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id))
|
||||
logger.error(f"Error creating VOD connection: {e}")
|
||||
return False
|
||||
|
||||
|
|
@ -531,6 +535,41 @@ class VODConnectionManager:
|
|||
logger.error(f"Error checking profile limits: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_reserve_profile_slot(self, m3u_profile: M3UAccountProfile) -> bool:
|
||||
"""
|
||||
Atomically check and reserve a connection slot for the given profile.
|
||||
|
||||
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
|
||||
condition where separate GET > check > INCR operations could allow
|
||||
concurrent requests to both pass the capacity check.
|
||||
|
||||
For profiles with max_streams=0 (unlimited), no reservation is needed.
|
||||
|
||||
Returns:
|
||||
bool: True if slot was reserved (or unlimited), False if at capacity
|
||||
"""
|
||||
if m3u_profile.max_streams == 0: # Unlimited
|
||||
return True
|
||||
|
||||
try:
|
||||
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
|
||||
|
||||
# Atomically increment first — single Redis command eliminates race window
|
||||
new_count = self.redis_client.incr(profile_connections_key)
|
||||
|
||||
if new_count <= m3u_profile.max_streams:
|
||||
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}")
|
||||
return True
|
||||
|
||||
# Over capacity — roll back the increment
|
||||
self.redis_client.decr(profile_connections_key)
|
||||
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reserving profile slot: {e}")
|
||||
return False
|
||||
|
||||
def update_connection_activity(self, content_type: str, content_uuid: str,
|
||||
client_id: str, bytes_sent: int = 0,
|
||||
position_seconds: int = 0) -> bool:
|
||||
|
|
|
|||
|
|
@ -436,8 +436,22 @@ class RedisBackedVODConnection:
|
|||
f"length_is_total={state.content_length_is_total}, type={state.content_type}"
|
||||
)
|
||||
|
||||
# Save updated state
|
||||
self._save_connection_state(state)
|
||||
# Save updated state under lock to avoid overwriting concurrent
|
||||
# active_streams changes (e.g., another stream's GeneratorExit decrement)
|
||||
if self._acquire_lock():
|
||||
try:
|
||||
current = self._get_connection_state()
|
||||
if current:
|
||||
# Preserve the current active_streams value — it may have been
|
||||
# modified by concurrent increment/decrement operations while
|
||||
# waiting for the upstream HTTP response.
|
||||
state.active_streams = current.active_streams
|
||||
self._save_connection_state(state)
|
||||
finally:
|
||||
self._release_lock()
|
||||
else:
|
||||
# Fallback: save without lock but skip active_streams to avoid overwrite
|
||||
logger.warning(f"[{self.session_id}] Could not acquire lock for get_stream state save")
|
||||
|
||||
self.local_response = response
|
||||
return response
|
||||
|
|
@ -486,35 +500,44 @@ class RedisBackedVODConnection:
|
|||
return range_header
|
||||
|
||||
def increment_active_streams(self):
|
||||
"""Increment active streams count in Redis"""
|
||||
"""Increment active streams count in Redis. Returns new active_streams count, or 0 on failure."""
|
||||
if not self._acquire_lock():
|
||||
return False
|
||||
logger.warning(f"[{self.session_id}] INCR-AS failed: could not acquire lock")
|
||||
return 0
|
||||
|
||||
try:
|
||||
state = self._get_connection_state()
|
||||
if state:
|
||||
old = state.active_streams
|
||||
state.active_streams += 1
|
||||
state.last_activity = time.time()
|
||||
self._save_connection_state(state)
|
||||
logger.debug(f"[{self.session_id}] Active streams incremented to {state.active_streams}")
|
||||
return True
|
||||
return False
|
||||
logger.debug(f"[{self.session_id}] INCR-AS {old} -> {state.active_streams}")
|
||||
return state.active_streams
|
||||
logger.warning(f"[{self.session_id}] INCR-AS failed: no state")
|
||||
return 0
|
||||
finally:
|
||||
self._release_lock()
|
||||
|
||||
def decrement_active_streams(self):
|
||||
"""Decrement active streams count in Redis"""
|
||||
if not self._acquire_lock():
|
||||
logger.warning(f"[{self.session_id}] DECR-AS failed: could not acquire lock")
|
||||
return False
|
||||
|
||||
try:
|
||||
state = self._get_connection_state()
|
||||
if state and state.active_streams > 0:
|
||||
old = state.active_streams
|
||||
state.active_streams -= 1
|
||||
state.last_activity = time.time()
|
||||
self._save_connection_state(state)
|
||||
logger.debug(f"[{self.session_id}] Active streams decremented to {state.active_streams}")
|
||||
logger.debug(f"[{self.session_id}] DECR-AS {old} -> {state.active_streams}")
|
||||
return True
|
||||
if not state:
|
||||
logger.warning(f"[{self.session_id}] DECR-AS failed: no state")
|
||||
else:
|
||||
logger.warning(f"[{self.session_id}] DECR-AS failed: active_streams already {state.active_streams}")
|
||||
return False
|
||||
finally:
|
||||
self._release_lock()
|
||||
|
|
@ -748,6 +771,41 @@ class MultiWorkerVODConnectionManager:
|
|||
logger.error(f"Error checking profile limits: {e}")
|
||||
return False
|
||||
|
||||
def _check_and_reserve_profile_slot(self, m3u_profile) -> bool:
|
||||
"""
|
||||
Atomically check and reserve a connection slot for the given profile.
|
||||
|
||||
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
|
||||
condition where separate GET > check > INCR operations could allow
|
||||
concurrent requests to both pass the capacity check.
|
||||
|
||||
For profiles with max_streams=0 (unlimited), no reservation is needed.
|
||||
|
||||
Returns:
|
||||
bool: True if slot was reserved (or unlimited), False if at capacity
|
||||
"""
|
||||
if m3u_profile.max_streams == 0: # Unlimited
|
||||
return True
|
||||
|
||||
try:
|
||||
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
|
||||
|
||||
# Atomically increment first — single Redis command eliminates race window
|
||||
new_count = self.redis_client.incr(profile_connections_key)
|
||||
|
||||
if new_count <= m3u_profile.max_streams:
|
||||
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}")
|
||||
return True
|
||||
|
||||
# Over capacity — roll back the increment
|
||||
self.redis_client.decr(profile_connections_key)
|
||||
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reserving profile slot: {e}")
|
||||
return False
|
||||
|
||||
def _increment_profile_connections(self, m3u_profile):
|
||||
"""Increment profile connection count"""
|
||||
try:
|
||||
|
|
@ -834,10 +892,11 @@ class MultiWorkerVODConnectionManager:
|
|||
if not existing_state:
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Creating new Redis-backed connection")
|
||||
|
||||
# Check profile limits before creating new connection
|
||||
if not self._check_profile_limits(m3u_profile):
|
||||
# Atomically check and reserve a profile connection slot (INCR-first)
|
||||
if not self._check_and_reserve_profile_slot(m3u_profile):
|
||||
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded")
|
||||
return HttpResponse("Connection limit exceeded for profile", status=429)
|
||||
profile_connections_incremented = True
|
||||
|
||||
# Apply timeshift parameters
|
||||
modified_stream_url = self._apply_timeshift_parameters(stream_url, utc_start, utc_end, offset)
|
||||
|
|
@ -880,16 +939,43 @@ class MultiWorkerVODConnectionManager:
|
|||
worker_id=self.worker_id
|
||||
):
|
||||
logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection")
|
||||
# Roll back the profile slot reservation since connection failed
|
||||
self._decrement_profile_connections(m3u_profile.id)
|
||||
profile_connections_incremented = False
|
||||
return HttpResponse("Failed to create connection", status=500)
|
||||
|
||||
# Increment profile connections after successful connection creation
|
||||
self._increment_profile_connections(m3u_profile)
|
||||
profile_connections_incremented = True
|
||||
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata")
|
||||
else:
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Using existing Redis-backed connection")
|
||||
|
||||
# Immediately increment active_streams to prevent cleanup race condition.
|
||||
# Without this, stream's GeneratorExit can see active_streams=0
|
||||
# and DECR the profile counter before the new generator starts.
|
||||
if matching_session_id:
|
||||
# Idle session reuse: active_streams already incremented at line 776
|
||||
# Always need to re-reserve profile slot (GeneratorExit DECRed it)
|
||||
if not self._check_and_reserve_profile_slot(m3u_profile):
|
||||
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on session reuse")
|
||||
redis_connection.decrement_active_streams()
|
||||
return HttpResponse("Connection limit exceeded for profile", status=429)
|
||||
profile_connections_incremented = True
|
||||
else:
|
||||
# Concurrent/reconnect: increment active_streams now (not in generator)
|
||||
new_count = redis_connection.increment_active_streams()
|
||||
if new_count == 1:
|
||||
# 0→1 transition: previous stream's GeneratorExit already DECRed
|
||||
# the profile counter, need to re-reserve the slot
|
||||
if not self._check_and_reserve_profile_slot(m3u_profile):
|
||||
logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on reconnect")
|
||||
redis_connection.decrement_active_streams()
|
||||
return HttpResponse("Connection limit exceeded for profile", status=429)
|
||||
profile_connections_incremented = True
|
||||
elif new_count == 0:
|
||||
logger.error(f"[{client_id}] Failed to increment active streams")
|
||||
return HttpResponse("Failed to reserve stream", status=500)
|
||||
# else: new_count > 1, another stream is already active and profile
|
||||
# counter already reflects it — no INCR needed
|
||||
|
||||
# Transfer ownership to current worker and update session activity
|
||||
if redis_connection._acquire_lock():
|
||||
try:
|
||||
|
|
@ -924,27 +1010,12 @@ class MultiWorkerVODConnectionManager:
|
|||
|
||||
if upstream_response is None:
|
||||
logger.warning(f"[{client_id}] Worker {self.worker_id} - Range not satisfiable")
|
||||
if stream_reserved:
|
||||
try:
|
||||
redis_connection.decrement_active_streams()
|
||||
except Exception as decrement_error:
|
||||
logger.warning(f"[{client_id}] Error releasing reserved stream slot: {decrement_error}")
|
||||
finally:
|
||||
stream_reserved = False
|
||||
|
||||
# If this request created a new profile-backed connection, ensure cleanup/decrement.
|
||||
if existing_state:
|
||||
# Roll back the active_streams increment from the else branch
|
||||
redis_connection.decrement_active_streams()
|
||||
if profile_connections_incremented:
|
||||
try:
|
||||
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"[{client_id}] Error cleaning up after 416: {cleanup_error}")
|
||||
elif matching_session_id:
|
||||
# For reused sessions, only clean up if no streams remain.
|
||||
try:
|
||||
if not redis_connection.has_active_streams():
|
||||
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"[{client_id}] Error cleaning up reused session after 416: {cleanup_error}")
|
||||
self._decrement_profile_connections(m3u_profile.id)
|
||||
profile_connections_incremented = False
|
||||
return HttpResponse("Requested Range Not Satisfiable", status=416)
|
||||
|
||||
# Get connection headers
|
||||
|
|
@ -958,13 +1029,14 @@ class MultiWorkerVODConnectionManager:
|
|||
try:
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Starting Redis-backed stream")
|
||||
|
||||
if not decrement_needed:
|
||||
if redis_connection.increment_active_streams():
|
||||
decrement_needed = True
|
||||
else:
|
||||
logger.warning(
|
||||
f"[{client_id}] Worker {self.worker_id} - Failed to increment active streams at stream start"
|
||||
)
|
||||
# Increment active streams only for brand-new connections.
|
||||
# For existing connections (session reuse or concurrent requests),
|
||||
# active_streams was already incremented in the else branch above
|
||||
# to prevent cleanup race conditions with GeneratorExit.
|
||||
if not existing_state:
|
||||
redis_connection.increment_active_streams()
|
||||
else:
|
||||
logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path")
|
||||
|
||||
bytes_sent = 0
|
||||
bytes_since_activity_check = 0
|
||||
|
|
@ -1016,7 +1088,24 @@ class MultiWorkerVODConnectionManager:
|
|||
|
||||
# Schedule smart cleanup if no active streams after normal completion
|
||||
if not redis_connection.has_active_streams():
|
||||
self._schedule_idle_cleanup(effective_session_id, client_id)
|
||||
# Decrement profile counter immediately — don't defer to daemon thread
|
||||
state = redis_connection._get_connection_state()
|
||||
profile_id = state.m3u_profile_id if state else m3u_profile.id
|
||||
if profile_id:
|
||||
self._decrement_profile_connections(profile_id)
|
||||
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on normal completion")
|
||||
|
||||
def delayed_cleanup():
|
||||
time.sleep(1) # Wait 1 second
|
||||
# Smart cleanup: check active streams and ownership
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion")
|
||||
# No connection_manager — profile already decremented above
|
||||
redis_connection.cleanup(current_worker_id=self.worker_id)
|
||||
|
||||
import threading
|
||||
cleanup_thread = threading.Thread(target=delayed_cleanup)
|
||||
cleanup_thread.daemon = True
|
||||
cleanup_thread.start()
|
||||
|
||||
except GeneratorExit:
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Client disconnected from Redis-backed stream")
|
||||
|
|
@ -1027,16 +1116,41 @@ class MultiWorkerVODConnectionManager:
|
|||
|
||||
# Schedule smart cleanup if no active streams
|
||||
if not redis_connection.has_active_streams():
|
||||
self._schedule_idle_cleanup(effective_session_id, client_id)
|
||||
# Decrement profile counter immediately — don't defer to daemon thread
|
||||
state = redis_connection._get_connection_state()
|
||||
profile_id = state.m3u_profile_id if state else m3u_profile.id
|
||||
if profile_id:
|
||||
self._decrement_profile_connections(profile_id)
|
||||
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on client disconnect")
|
||||
|
||||
def delayed_cleanup():
|
||||
time.sleep(1) # Wait 1 second
|
||||
# Smart cleanup: check active streams and ownership
|
||||
logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect")
|
||||
# No connection_manager — profile already decremented above
|
||||
redis_connection.cleanup(current_worker_id=self.worker_id)
|
||||
|
||||
import threading
|
||||
cleanup_thread = threading.Thread(target=delayed_cleanup)
|
||||
cleanup_thread.daemon = True
|
||||
cleanup_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[{client_id}] Worker {self.worker_id} - Error in Redis-backed stream: {e}")
|
||||
if decrement_needed and not decremented:
|
||||
redis_connection.decrement_active_streams()
|
||||
decremented = True
|
||||
decrement_needed = False
|
||||
# Smart cleanup on error - immediate cleanup since we're in error state
|
||||
redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id)
|
||||
|
||||
# Decrement profile counter immediately if no other active streams
|
||||
if not redis_connection.has_active_streams():
|
||||
state = redis_connection._get_connection_state()
|
||||
profile_id = state.m3u_profile_id if state else m3u_profile.id
|
||||
if profile_id:
|
||||
self._decrement_profile_connections(profile_id)
|
||||
logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on stream error")
|
||||
# Smart cleanup on error - immediate cleanup since we're in error state
|
||||
# No connection_manager — profile already decremented above
|
||||
redis_connection.cleanup(current_worker_id=self.worker_id)
|
||||
yield b"Error: Stream interrupted"
|
||||
|
||||
finally:
|
||||
|
|
|
|||
203
core/scheduling.py
Normal file
203
core/scheduling.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""
|
||||
Reusable scheduling utilities for creating/updating/deleting
|
||||
Celery Beat periodic tasks with interval or cron-based schedules.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask
|
||||
|
||||
from core.models import CoreSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_cron_expression(cron_expression):
|
||||
"""
|
||||
Parse a 5-part cron expression into its components.
|
||||
|
||||
Args:
|
||||
cron_expression: A string like "0 3 * * *"
|
||||
|
||||
Returns:
|
||||
dict with keys: minute, hour, day_of_month, month_of_year, day_of_week
|
||||
|
||||
Raises:
|
||||
ValueError: If the expression is not valid 5-part cron.
|
||||
"""
|
||||
parts = cron_expression.strip().split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError(
|
||||
"Cron expression must have 5 parts: minute hour day month weekday"
|
||||
)
|
||||
return {
|
||||
"minute": parts[0],
|
||||
"hour": parts[1],
|
||||
"day_of_month": parts[2],
|
||||
"month_of_year": parts[3],
|
||||
"day_of_week": parts[4],
|
||||
}
|
||||
|
||||
|
||||
def create_or_update_periodic_task(
|
||||
task_name,
|
||||
celery_task_path,
|
||||
kwargs=None,
|
||||
interval_hours=0,
|
||||
cron_expression="",
|
||||
enabled=True,
|
||||
):
|
||||
"""
|
||||
Create or update a Celery Beat PeriodicTask. Supports both interval
|
||||
(hours) and cron-based scheduling.
|
||||
|
||||
When *cron_expression* is provided and non-empty it takes precedence
|
||||
over *interval_hours*. An interval_hours of 0 (with no cron) means
|
||||
the task is disabled.
|
||||
|
||||
Args:
|
||||
task_name: Unique PeriodicTask name.
|
||||
celery_task_path: Dotted path to the Celery task function.
|
||||
kwargs: dict of keyword arguments passed to the task.
|
||||
interval_hours: Interval in hours (0 = disabled when no cron).
|
||||
cron_expression: 5-part cron string (empty = use interval).
|
||||
enabled: Whether the task should be enabled.
|
||||
|
||||
Returns:
|
||||
The PeriodicTask instance (created or updated).
|
||||
"""
|
||||
task_kwargs = json.dumps(kwargs or {})
|
||||
|
||||
# Determine effective enabled state
|
||||
use_cron = bool(cron_expression and cron_expression.strip())
|
||||
should_be_enabled = enabled and (use_cron or interval_hours > 0)
|
||||
|
||||
# Retrieve existing task (if any) to track old schedule objects
|
||||
old_interval = None
|
||||
old_crontab = None
|
||||
try:
|
||||
existing = PeriodicTask.objects.get(name=task_name)
|
||||
old_interval = existing.interval
|
||||
old_crontab = existing.crontab
|
||||
except PeriodicTask.DoesNotExist:
|
||||
existing = None
|
||||
|
||||
if use_cron:
|
||||
# ---- Cron-based schedule ----
|
||||
cron_parts = parse_cron_expression(cron_expression)
|
||||
system_tz = CoreSettings.get_system_time_zone()
|
||||
|
||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute=cron_parts["minute"],
|
||||
hour=cron_parts["hour"],
|
||||
day_of_week=cron_parts["day_of_week"],
|
||||
day_of_month=cron_parts["day_of_month"],
|
||||
month_of_year=cron_parts["month_of_year"],
|
||||
timezone=system_tz,
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"task": celery_task_path,
|
||||
"crontab": crontab,
|
||||
"interval": None,
|
||||
"enabled": should_be_enabled,
|
||||
"kwargs": task_kwargs,
|
||||
}
|
||||
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=task_name, defaults=defaults
|
||||
)
|
||||
|
||||
# Clean up old interval if we switched from interval → cron
|
||||
if old_interval:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
# Clean up old crontab if it changed
|
||||
if old_crontab and old_crontab.id != crontab.id:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
else:
|
||||
# ---- Interval-based schedule ----
|
||||
interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=max(int(interval_hours), 1) if interval_hours else 1,
|
||||
period=IntervalSchedule.HOURS,
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"task": celery_task_path,
|
||||
"interval": interval,
|
||||
"crontab": None,
|
||||
"enabled": should_be_enabled,
|
||||
"kwargs": task_kwargs,
|
||||
}
|
||||
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=task_name, defaults=defaults
|
||||
)
|
||||
|
||||
# Clean up old crontab if we switched from cron → interval
|
||||
if old_crontab:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
# Clean up old interval if it changed
|
||||
if old_interval and old_interval.id != interval.id:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
mode = "cron" if use_cron else "interval"
|
||||
logger.info(f"{action} periodic task '{task_name}' ({mode}, enabled={should_be_enabled})")
|
||||
return task
|
||||
|
||||
|
||||
def delete_periodic_task(task_name):
|
||||
"""
|
||||
Delete a PeriodicTask by name and clean up orphaned schedules.
|
||||
|
||||
Args:
|
||||
task_name: The unique name of the PeriodicTask.
|
||||
|
||||
Returns:
|
||||
True if a task was found and deleted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
task = PeriodicTask.objects.get(name=task_name)
|
||||
except PeriodicTask.DoesNotExist:
|
||||
logger.warning(f"No PeriodicTask found with name '{task_name}'")
|
||||
return False
|
||||
|
||||
old_interval = task.interval
|
||||
old_crontab = task.crontab
|
||||
task_id = task.id
|
||||
|
||||
task.delete()
|
||||
logger.info(f"Deleted periodic task '{task_name}' (id={task_id})")
|
||||
|
||||
if old_interval:
|
||||
_cleanup_orphaned_interval(old_interval)
|
||||
if old_crontab:
|
||||
_cleanup_orphaned_crontab(old_crontab)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cleanup_orphaned_interval(interval_schedule):
|
||||
"""Delete an IntervalSchedule if no PeriodicTasks reference it."""
|
||||
if interval_schedule is None:
|
||||
return
|
||||
if PeriodicTask.objects.filter(interval=interval_schedule).exists():
|
||||
return
|
||||
logger.debug(f"Cleaning up orphaned IntervalSchedule {interval_schedule.id}")
|
||||
interval_schedule.delete()
|
||||
|
||||
|
||||
def _cleanup_orphaned_crontab(crontab_schedule):
|
||||
"""Delete a CrontabSchedule if no PeriodicTasks reference it."""
|
||||
if crontab_schedule is None:
|
||||
return
|
||||
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
|
||||
return
|
||||
logger.debug(f"Cleaning up orphaned CrontabSchedule {crontab_schedule.id}")
|
||||
crontab_schedule.delete()
|
||||
|
|
@ -397,6 +397,78 @@ def validate_flexible_url(value):
|
|||
# If it doesn't match our flexible patterns, raise the original error
|
||||
raise ValidationError("Enter a valid URL.")
|
||||
|
||||
def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details):
|
||||
try:
|
||||
from apps.connect.utils import trigger_event
|
||||
from apps.channels.models import Channel, Stream
|
||||
from core.models import StreamProfile
|
||||
from core.utils import RedisClient
|
||||
|
||||
payload = {}
|
||||
|
||||
channel_obj = None
|
||||
if channel_id:
|
||||
try:
|
||||
channel_obj = Channel.objects.get(uuid=channel_id)
|
||||
payload["channel_name"] = channel_obj.name
|
||||
except Exception:
|
||||
payload["channel_name"] = channel_name or None
|
||||
else:
|
||||
payload["channel_name"] = channel_name or None
|
||||
|
||||
# Resolve current stream info
|
||||
stream_id = details.get("stream_id")
|
||||
stream_obj = None
|
||||
if not stream_id and channel_obj:
|
||||
try:
|
||||
redis = RedisClient.get_client()
|
||||
sid = redis.get(f"channel_stream:{channel_obj.id}")
|
||||
if sid:
|
||||
stream_id = int(sid)
|
||||
except Exception:
|
||||
stream_id = None
|
||||
|
||||
if stream_id:
|
||||
try:
|
||||
stream_obj = Stream.objects.get(id=stream_id)
|
||||
except Exception:
|
||||
stream_obj = None
|
||||
|
||||
# Populate stream details
|
||||
payload["stream_name"] = getattr(stream_obj, "name", None)
|
||||
payload["stream_url"] = getattr(stream_obj, "url", None)
|
||||
|
||||
# Channel URL: use stream URL as best-effort
|
||||
payload["channel_url"] = payload.get("stream_url")
|
||||
|
||||
# Provider name from M3U account
|
||||
provider_name = None
|
||||
try:
|
||||
if stream_obj and stream_obj.m3u_account:
|
||||
provider_name = stream_obj.m3u_account.name
|
||||
except Exception:
|
||||
provider_name = None
|
||||
payload["provider_name"] = provider_name
|
||||
|
||||
# Profile used
|
||||
profile_used = None
|
||||
try:
|
||||
if stream_id:
|
||||
redis = RedisClient.get_client()
|
||||
pid = redis.get(f"stream_profile:{stream_id}")
|
||||
if pid:
|
||||
profile = StreamProfile.objects.filter(id=int(pid)).first()
|
||||
profile_used = profile.name if profile else None
|
||||
except Exception:
|
||||
profile_used = None
|
||||
|
||||
payload["profile_used"] = profile_used
|
||||
|
||||
trigger_event(event_type, payload)
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail main path if connect dispatch fails
|
||||
pass
|
||||
|
||||
def log_system_event(event_type, channel_id=None, channel_name=None, **details):
|
||||
"""
|
||||
|
|
@ -423,6 +495,9 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
|
|||
details=details
|
||||
)
|
||||
|
||||
# Trigger connect integrations for specific events
|
||||
dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details)
|
||||
|
||||
# Get max events from settings (default 100)
|
||||
try:
|
||||
from .models import CoreSettings
|
||||
|
|
@ -517,4 +592,3 @@ def send_notification_dismissed(notification_key):
|
|||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification dismissed event: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ INSTALLED_APPS = [
|
|||
"apps.proxy.ts_proxy",
|
||||
"apps.fuse",
|
||||
"apps.vod.apps.VODConfig",
|
||||
"apps.connect.apps.ConnectConfig",
|
||||
"core",
|
||||
"daphne",
|
||||
"drf_spectacular",
|
||||
|
|
@ -412,3 +413,18 @@ LOGGING = {
|
|||
"level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO'
|
||||
},
|
||||
}
|
||||
|
||||
# Connect script execution safety settings
|
||||
# Allowed base directories for custom scripts; real paths must be inside
|
||||
_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/plugins")
|
||||
CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p]
|
||||
|
||||
# Max execution time (seconds) for scripts
|
||||
CONNECT_SCRIPT_TIMEOUT = int(os.environ.get("DISPATCHARR_SCRIPT_TIMEOUT", "10"))
|
||||
|
||||
# Truncate stdout/stderr to this many characters to avoid large outputs
|
||||
CONNECT_SCRIPT_MAX_OUTPUT = int(os.environ.get("DISPATCHARR_SCRIPT_MAX_OUTPUT", "65536"))
|
||||
|
||||
# Require executable bit and disallow world-writable files
|
||||
CONNECT_SCRIPT_REQUIRE_EXECUTABLE = True
|
||||
CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE = True
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ services:
|
|||
# Process Priority Configuration (Optional)
|
||||
#- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19)
|
||||
|
||||
# Legacy CPU Support (Optional)
|
||||
# Uncomment to enable legacy NumPy build for older CPUs (circa 2009)
|
||||
# that lack support for newer baseline CPU features:
|
||||
#- USE_LEGACY_NUMPY=true
|
||||
|
||||
# Django Configuration
|
||||
- DJANGO_SETTINGS_MODULE=dispatcharr.settings
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
|
|
|||
|
|
@ -4,15 +4,32 @@ set -e
|
|||
cd /app
|
||||
source /dispatcharrpy/bin/activate
|
||||
|
||||
# Function to echo with timestamp
|
||||
echo_with_timestamp() {
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||
}
|
||||
|
||||
# Wait for Django secret key
|
||||
echo 'Waiting for Django secret key...'
|
||||
while [ ! -f /data/jwt ]; do sleep 1; done
|
||||
export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)"
|
||||
|
||||
# --- NumPy version switching for legacy hardware ---
|
||||
if [ "$USE_LEGACY_NUMPY" = "true" ]; then
|
||||
# Check if NumPy was compiled with baseline support
|
||||
if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then
|
||||
echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..."
|
||||
uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl
|
||||
echo_with_timestamp "✅ Legacy NumPy installed"
|
||||
else
|
||||
echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Wait for migrations to complete (check that NO unapplied migrations remain)
|
||||
echo 'Waiting for migrations to complete...'
|
||||
until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do
|
||||
echo 'Migrations not ready yet, waiting...'
|
||||
echo_with_timestamp 'Migrations not ready yet, waiting...'
|
||||
sleep 2
|
||||
done
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import Stats from './pages/Stats';
|
|||
import DVR from './pages/DVR';
|
||||
import Settings from './pages/Settings';
|
||||
import PluginsPage from './pages/Plugins';
|
||||
import ConnectPage from './pages/Connect';
|
||||
import ConnectLogsPage from './pages/ConnectLogs';
|
||||
import Users from './pages/Users';
|
||||
import LogosPage from './pages/Logos';
|
||||
import VODsPage from './pages/VODs';
|
||||
|
|
@ -152,6 +154,11 @@ const App = () => {
|
|||
<Route path="/dvr" element={<DVR />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/plugins" element={<PluginsPage />} />
|
||||
<Route path="/connect" element={<ConnectPage />} />
|
||||
<Route
|
||||
path="/connect/logs"
|
||||
element={<ConnectLogsPage />}
|
||||
/>
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/logos" element={<LogosPage />} />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { notifications } from '@mantine/notifications';
|
|||
import useChannelsTableStore from './store/channelsTable';
|
||||
import useStreamsTableStore from './store/streamsTable';
|
||||
import useUsersStore from './store/users';
|
||||
import useConnectStore from './store/connect';
|
||||
|
||||
// If needed, you can set a base host or keep it empty if relative requests
|
||||
const host = import.meta.env.DEV
|
||||
|
|
@ -178,9 +179,34 @@ export default class API {
|
|||
|
||||
static async getChannels() {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/channels/`);
|
||||
// Paginate through channels to avoid heavy single response
|
||||
const pageSize = 200;
|
||||
let page = 1;
|
||||
let allChannels = [];
|
||||
|
||||
return response;
|
||||
while (true) {
|
||||
const data = await request(
|
||||
`${host}/api/channels/channels/?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
|
||||
// Backward compatibility: if endpoint returns an array (legacy), just return it
|
||||
if (Array.isArray(data)) {
|
||||
allChannels = data;
|
||||
break;
|
||||
}
|
||||
|
||||
const results = Array.isArray(data?.results) ? data.results : [];
|
||||
allChannels = allChannels.concat(results);
|
||||
|
||||
const hasMore = Boolean(data?.next);
|
||||
if (!hasMore || results.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return allChannels;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve channels', e);
|
||||
}
|
||||
|
|
@ -3105,4 +3131,124 @@ export default class API {
|
|||
errorNotification('Failed to dismiss all notifications', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getConnectIntegrations() {
|
||||
try {
|
||||
return await request(`${host}/api/connect/integrations/`);
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch connect integrations', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async createConnectIntegration(values) {
|
||||
try {
|
||||
const response = await request(`${host}/api/connect/integrations/`, {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
});
|
||||
|
||||
useConnectStore.getState().addIntegration(response);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create integration', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateConnectIntegration(id, values) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/connect/integrations/${id}/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: values,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.id) {
|
||||
useConnectStore.getState().updateIntegration(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update integration', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteConnectIntegration(id) {
|
||||
try {
|
||||
await request(`${host}/api/connect/integrations/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
useConnectStore.getState().removeIntegration(id);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete integration', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async createConnectSubscription(values) {
|
||||
try {
|
||||
await request(`${host}/api/connect/subscriptions/`, {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create subscription', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async listConnectSubscriptions(integrationId) {
|
||||
try {
|
||||
return await request(
|
||||
`${host}/api/connect/integrations/${integrationId}/subscriptions/`
|
||||
);
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch subscriptions', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async setConnectSubscriptions(integrationId, subscriptions) {
|
||||
// subscriptions: [{ event, enabled, payload_template }]
|
||||
console.log(subscriptions);
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/connect/integrations/${integrationId}/subscriptions/set/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: subscriptions,
|
||||
}
|
||||
);
|
||||
|
||||
useConnectStore
|
||||
.getState()
|
||||
.updateIntegrationSubscriptions(integrationId, response);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to set subscriptions', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async getConnectLogs(params = {}) {
|
||||
try {
|
||||
const search = new URLSearchParams();
|
||||
if (params.page) search.set('page', params.page);
|
||||
if (params.page_size) search.set('page_size', params.page_size);
|
||||
if (params.type) search.set('type', params.type);
|
||||
if (params.integration) search.set('integration', params.integration);
|
||||
|
||||
return await request(
|
||||
`${host}/api/connect/logs/${search.toString() ? `?${search.toString()}` : ''}`
|
||||
);
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch connect logs', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ import {
|
|||
LogOut,
|
||||
User,
|
||||
FileImage,
|
||||
Webhook,
|
||||
Logs,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MonitorCog,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar,
|
||||
|
|
@ -27,6 +32,7 @@ import {
|
|||
TextInput,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
ScrollArea,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import logo from '../images/logo.png';
|
||||
|
|
@ -70,6 +76,74 @@ const NavLink = ({ item, isActive, collapsed }) => {
|
|||
);
|
||||
};
|
||||
|
||||
function NavGroup({ label, icon, paths, location, collapsed }) {
|
||||
const [open, setOpen] = useState(() =>
|
||||
location.pathname.startsWith('/connect')
|
||||
);
|
||||
|
||||
const parentActive = paths
|
||||
.map((path) => path.path)
|
||||
.includes(location.pathname);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{ width: '100%', paddingRight: 2 }}
|
||||
className={open ? 'navgroup-open' : ''}
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed && (
|
||||
<Group justify="space-between" style={{ width: '100%' }}>
|
||||
<Text
|
||||
sx={{
|
||||
opacity: open ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: open ? 0 : 150,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<Box alignItems="center" style={{ display: 'flex' }}>
|
||||
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
|
||||
{open && (
|
||||
<Box style={{ paddingTop: 10 }}>
|
||||
<Stack gap="xs" pl={open ? 0 : 'lg'}>
|
||||
{paths.map((child) => {
|
||||
const active = location.pathname === child.path;
|
||||
return (
|
||||
<Box
|
||||
style={{ paddingLeft: collapsed ? 0 : 35 }}
|
||||
key={child.path}
|
||||
>
|
||||
<NavLink
|
||||
key={child.path}
|
||||
item={child}
|
||||
isActive={active}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
||||
const location = useLocation();
|
||||
|
||||
|
|
@ -111,19 +185,41 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
|
||||
{ label: 'Plugins', icon: <PlugZap size={20} />, path: '/plugins' },
|
||||
{
|
||||
label: 'Users',
|
||||
icon: <User size={20} />,
|
||||
path: '/users',
|
||||
},
|
||||
{
|
||||
label: 'Logo Manager',
|
||||
icon: <FileImage size={20} />,
|
||||
path: '/logos',
|
||||
label: 'Connect',
|
||||
icon: <Webhook size={20} />,
|
||||
paths: [
|
||||
{
|
||||
label: 'Connections',
|
||||
icon: <Webhook size={20} />,
|
||||
path: '/connect',
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
icon: <Logs size={20} />,
|
||||
path: '/connect/logs',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <LucideSettings size={20} />,
|
||||
path: '/settings',
|
||||
paths: [
|
||||
{
|
||||
label: 'Users',
|
||||
icon: <User size={20} />,
|
||||
path: '/users',
|
||||
},
|
||||
{
|
||||
label: 'Logo Manager',
|
||||
icon: <FileImage size={20} />,
|
||||
path: '/logos',
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
icon: <MonitorCog size={20} />,
|
||||
path: '/settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [
|
||||
|
|
@ -205,20 +301,44 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
</Group>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<Stack gap="xs" mt="lg">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
<ScrollArea h="100%" type="scroll" scrollbars="y">
|
||||
<Stack
|
||||
gap="xs"
|
||||
mt="lg"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}}
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
if (item.paths) {
|
||||
return (
|
||||
<NavGroup
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
paths={item.paths}
|
||||
location={location}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Profile Section */}
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import ConfirmationDialog from '../ConfirmationDialog';
|
|||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
import useWarningsStore from '../../store/warnings';
|
||||
import { CustomTable, useTable } from '../tables/CustomTable';
|
||||
import { validateCronExpression } from '../../utils/cronUtils';
|
||||
import ScheduleInput from '../forms/ScheduleInput';
|
||||
|
||||
const RowActions = ({
|
||||
row,
|
||||
|
|
@ -113,95 +115,6 @@ function getDefaultTimeZone() {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
function validateCronExpression(expression) {
|
||||
if (!expression || expression.trim() === '') {
|
||||
return { valid: false, error: 'Cron expression is required' };
|
||||
}
|
||||
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'Cron expression must have exactly 5 parts: minute hour day month weekday',
|
||||
};
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
// Validate each part (allowing *, */N steps, ranges, lists, steps)
|
||||
// Supports: *, */2, 5, 1-5, 1-5/2, 1,3,5, etc.
|
||||
const cronPartRegex =
|
||||
/^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
|
||||
|
||||
if (!cronPartRegex.test(minute)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid minute field (0-59, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(hour)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid hour field (0-23, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(dayOfMonth)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid day field (1-31, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(month)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid month field (1-12, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
if (!cronPartRegex.test(dayOfWeek)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Invalid weekday field (0-6, *, or cron syntax)',
|
||||
};
|
||||
}
|
||||
|
||||
// Additional range validation for numeric values
|
||||
const validateRange = (value, min, max, name) => {
|
||||
// Skip if it's * or contains special characters
|
||||
if (
|
||||
value === '*' ||
|
||||
value.includes('/') ||
|
||||
value.includes('-') ||
|
||||
value.includes(',')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
return `${name} must be between ${min} and ${max}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const minuteError = validateRange(minute, 0, 59, 'Minute');
|
||||
if (minuteError) return { valid: false, error: minuteError };
|
||||
|
||||
const hourError = validateRange(hour, 0, 23, 'Hour');
|
||||
if (hourError) return { valid: false, error: hourError };
|
||||
|
||||
const dayError = validateRange(dayOfMonth, 1, 31, 'Day');
|
||||
if (dayError) return { valid: false, error: dayError };
|
||||
|
||||
const monthError = validateRange(month, 1, 12, 'Month');
|
||||
if (monthError) return { valid: false, error: monthError };
|
||||
|
||||
const weekdayError = validateRange(dayOfWeek, 0, 6, 'Weekday');
|
||||
if (weekdayError) return { valid: false, error: weekdayError };
|
||||
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: '0', label: 'Sunday' },
|
||||
{ value: '1', label: 'Monday' },
|
||||
|
|
@ -262,8 +175,7 @@ export default function BackupManager() {
|
|||
const [scheduleLoading, setScheduleLoading] = useState(false);
|
||||
const [scheduleSaving, setScheduleSaving] = useState(false);
|
||||
const [scheduleChanged, setScheduleChanged] = useState(false);
|
||||
const [advancedMode, setAdvancedMode] = useState(false);
|
||||
const [cronError, setCronError] = useState(null);
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
// For 12-hour display mode
|
||||
const [displayTime, setDisplayTime] = useState('3:00');
|
||||
|
|
@ -373,12 +285,8 @@ export default function BackupManager() {
|
|||
try {
|
||||
const settings = await API.getBackupSchedule();
|
||||
|
||||
// Check if using cron expression (advanced mode)
|
||||
if (settings.cron_expression) {
|
||||
setAdvancedMode(true);
|
||||
}
|
||||
|
||||
setSchedule(settings);
|
||||
setScheduleType(settings.cron_expression ? 'cron' : 'interval');
|
||||
|
||||
// Initialize 12-hour display values
|
||||
const { time, period } = to12Hour(settings.time);
|
||||
|
|
@ -398,25 +306,9 @@ export default function BackupManager() {
|
|||
loadSchedule();
|
||||
}, []);
|
||||
|
||||
// Validate cron expression when switching to advanced mode
|
||||
useEffect(() => {
|
||||
if (advancedMode && schedule.cron_expression) {
|
||||
const validation = validateCronExpression(schedule.cron_expression);
|
||||
setCronError(validation.valid ? null : validation.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
}, [advancedMode, schedule.cron_expression]);
|
||||
|
||||
const handleScheduleChange = (field, value) => {
|
||||
setSchedule((prev) => ({ ...prev, [field]: value }));
|
||||
setScheduleChanged(true);
|
||||
|
||||
// Validate cron expression if in advanced mode
|
||||
if (field === 'cron_expression' && advancedMode) {
|
||||
const validation = validateCronExpression(value);
|
||||
setCronError(validation.valid ? null : validation.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle time changes in 12-hour mode
|
||||
|
|
@ -442,9 +334,11 @@ export default function BackupManager() {
|
|||
const handleSaveSchedule = async () => {
|
||||
setScheduleSaving(true);
|
||||
try {
|
||||
const scheduleToSave = advancedMode
|
||||
? schedule
|
||||
: { ...schedule, cron_expression: '' };
|
||||
// Clear cron_expression if not in cron mode
|
||||
const scheduleToSave =
|
||||
scheduleType === 'cron'
|
||||
? schedule
|
||||
: { ...schedule, cron_expression: '' };
|
||||
|
||||
const updated = await API.updateBackupSchedule(scheduleToSave);
|
||||
setSchedule(updated);
|
||||
|
|
@ -603,207 +497,161 @@ export default function BackupManager() {
|
|||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
Advanced (Cron Expression)
|
||||
</Text>
|
||||
<Switch
|
||||
checked={advancedMode}
|
||||
onChange={(e) => setAdvancedMode(e.currentTarget.checked)}
|
||||
label={advancedMode ? 'Enabled' : 'Disabled'}
|
||||
disabled={!schedule.enabled}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={(type) => {
|
||||
setScheduleType(type);
|
||||
if (type !== 'cron') {
|
||||
handleScheduleChange('cron_expression', '');
|
||||
}
|
||||
}}
|
||||
cronValue={schedule.cron_expression}
|
||||
onCronChange={(expr) => handleScheduleChange('cron_expression', expr)}
|
||||
disabled={!schedule.enabled}
|
||||
switchToCronLabel="Use custom cron schedule"
|
||||
switchToIntervalLabel="Use simple schedule"
|
||||
>
|
||||
{/* Simple mode: frequency / time / day selectors */}
|
||||
<Stack gap="sm">
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
label="Frequency"
|
||||
value={schedule.frequency}
|
||||
onChange={(value) => handleScheduleChange('frequency', value)}
|
||||
data={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
{schedule.frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day"
|
||||
value={String(schedule.day_of_week)}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('day_of_week', parseInt(value, 10))
|
||||
}
|
||||
data={DAYS_OF_WEEK}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
)}
|
||||
{is12Hour ? (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={displayTime ? displayTime.split(':')[0] : '12'}
|
||||
onChange={(value) => {
|
||||
const minute = displayTime
|
||||
? displayTime.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange12h(`${value}:${minute}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 12 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: String(i + 1),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={displayTime ? displayTime.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = displayTime
|
||||
? displayTime.split(':')[0]
|
||||
: '12';
|
||||
handleTimeChange12h(`${hour}:${value}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Period"
|
||||
value={timePeriod}
|
||||
onChange={(value) => handleTimeChange12h(null, value)}
|
||||
data={[
|
||||
{ value: 'AM', label: 'AM' },
|
||||
{ value: 'PM', label: 'PM' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={schedule.time ? schedule.time.split(':')[0] : '00'}
|
||||
onChange={(value) => {
|
||||
const minute = schedule.time
|
||||
? schedule.time.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange24h(`${value}:${minute}`);
|
||||
}}
|
||||
data={Array.from({ length: 24 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={schedule.time ? schedule.time.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = schedule.time
|
||||
? schedule.time.split(':')[0]
|
||||
: '00';
|
||||
handleTimeChange24h(`${hour}:${value}`);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</ScheduleInput>
|
||||
|
||||
{scheduleLoading ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<>
|
||||
{advancedMode ? (
|
||||
<>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Cron Expression"
|
||||
value={schedule.cron_expression}
|
||||
onChange={(e) =>
|
||||
handleScheduleChange(
|
||||
'cron_expression',
|
||||
e.currentTarget.value
|
||||
)
|
||||
}
|
||||
placeholder="0 3 * * *"
|
||||
description="Format: minute hour day month weekday (e.g., '0 3 * * *' = 3:00 AM daily)"
|
||||
disabled={!schedule.enabled}
|
||||
error={cronError}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
Examples: <br />• <code>0 3 * * *</code> - Every day at 3:00
|
||||
AM
|
||||
<br />• <code>0 2 * * 0</code> - Every Sunday at 2:00 AM
|
||||
<br />• <code>0 */6 * * *</code> - Every 6 hours
|
||||
<br />• <code>30 14 1 * *</code> - 1st of every month at
|
||||
2:30 PM
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group grow align="flex-end">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={!scheduleChanged || (advancedMode && cronError)}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
label="Frequency"
|
||||
value={schedule.frequency}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('frequency', value)
|
||||
}
|
||||
data={[
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
{schedule.frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day"
|
||||
value={String(schedule.day_of_week)}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('day_of_week', parseInt(value, 10))
|
||||
}
|
||||
data={DAYS_OF_WEEK}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
)}
|
||||
{is12Hour ? (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={displayTime ? displayTime.split(':')[0] : '12'}
|
||||
onChange={(value) => {
|
||||
const minute = displayTime
|
||||
? displayTime.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange12h(`${value}:${minute}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 12 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: String(i + 1),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={displayTime ? displayTime.split(':')[1] : '00'}
|
||||
onChange={(value) => {
|
||||
const hour = displayTime
|
||||
? displayTime.split(':')[0]
|
||||
: '12';
|
||||
handleTimeChange12h(`${hour}:${value}`, null);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Period"
|
||||
value={timePeriod}
|
||||
onChange={(value) => handleTimeChange12h(null, value)}
|
||||
data={[
|
||||
{ value: 'AM', label: 'AM' },
|
||||
{ value: 'PM', label: 'PM' },
|
||||
]}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label="Hour"
|
||||
value={
|
||||
schedule.time ? schedule.time.split(':')[0] : '00'
|
||||
}
|
||||
onChange={(value) => {
|
||||
const minute = schedule.time
|
||||
? schedule.time.split(':')[1]
|
||||
: '00';
|
||||
handleTimeChange24h(`${value}:${minute}`);
|
||||
}}
|
||||
data={Array.from({ length: 24 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Minute"
|
||||
value={
|
||||
schedule.time ? schedule.time.split(':')[1] : '00'
|
||||
}
|
||||
onChange={(value) => {
|
||||
const hour = schedule.time
|
||||
? schedule.time.split(':')[0]
|
||||
: '00';
|
||||
handleTimeChange24h(`${hour}:${value}`);
|
||||
}}
|
||||
data={Array.from({ length: 60 }, (_, i) => ({
|
||||
value: String(i).padStart(2, '0'),
|
||||
label: String(i).padStart(2, '0'),
|
||||
}))}
|
||||
disabled={!schedule.enabled}
|
||||
searchable
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-end" gap="xs">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={!scheduleChanged}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
<Group grow align="flex-end" gap="xs">
|
||||
<NumberInput
|
||||
label="Retention"
|
||||
description="0 = keep all"
|
||||
value={schedule.retention_count}
|
||||
onChange={(value) =>
|
||||
handleScheduleChange('retention_count', value || 0)
|
||||
}
|
||||
min={0}
|
||||
disabled={!schedule.enabled}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveSchedule}
|
||||
loading={scheduleSaving}
|
||||
disabled={
|
||||
!scheduleChanged ||
|
||||
(scheduleType === 'cron' &&
|
||||
schedule.cron_expression &&
|
||||
!validateCronExpression(schedule.cron_expression).valid)
|
||||
}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Timezone info - only show in simple mode */}
|
||||
{!advancedMode && schedule.enabled && schedule.time && (
|
||||
{scheduleType !== 'cron' && schedule.enabled && schedule.time && (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
System Timezone: {userTimezone} • Backup will run at{' '}
|
||||
{schedule.time} {userTimezone}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ import {
|
|||
Switch,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
Badge,
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
|
||||
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
|
||||
import { SUBSCRIPTION_EVENTS } from '../../constants.js';
|
||||
|
||||
const PluginFieldList = ({ plugin, settings, updateField }) => {
|
||||
return plugin.fields.map((f) => (
|
||||
|
|
@ -44,6 +46,14 @@ const PluginActionList = ({
|
|||
{action.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" style={{ paddingTop: 10 }}>
|
||||
Event Triggers
|
||||
</Text>
|
||||
{action.events.map((event) => (
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{SUBSCRIPTION_EVENTS[event] || event}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
loading={runningActionId === action.id}
|
||||
|
|
|
|||
230
frontend/src/components/forms/Connection.jsx
Normal file
230
frontend/src/components/forms/Connection.jsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Flex,
|
||||
TextInput,
|
||||
Box,
|
||||
Checkbox,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { SUBSCRIPTION_EVENTS } from '../../constants';
|
||||
|
||||
const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map(
|
||||
([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
})
|
||||
);
|
||||
|
||||
const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedEvents, setSelectedEvents] = useState([]);
|
||||
const [apiError, setApiError] = useState('');
|
||||
|
||||
// One-time form
|
||||
const form = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
name: connection?.name || '',
|
||||
type: connection?.type || 'webhook',
|
||||
url: connection?.config?.url || '',
|
||||
script_path: connection?.config?.path || '',
|
||||
enabled: connection?.enabled ?? true,
|
||||
},
|
||||
validate: {
|
||||
name: isNotEmpty('Provide a name'),
|
||||
type: isNotEmpty('Select a type'),
|
||||
url: (value, values) => {
|
||||
if (values.type === 'webhook' && !value.trim()) {
|
||||
return 'Provide a webhook URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
script_path: (value, values) => {
|
||||
if (values.type === 'script' && !value.trim()) {
|
||||
return 'Provide a script path';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
const values = {
|
||||
name: connection.name,
|
||||
type: connection.type,
|
||||
url: connection.config?.url,
|
||||
script_path: connection.config?.path,
|
||||
enabled: connection.enabled,
|
||||
};
|
||||
form.setValues(values);
|
||||
setSelectedEvents(
|
||||
connection.subscriptions.reduce((acc, sub) => {
|
||||
if (sub.enabled) acc.push(sub.event);
|
||||
return acc;
|
||||
}, [])
|
||||
);
|
||||
} else {
|
||||
form.reset();
|
||||
setSelectedEvents([]);
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
const handleClose = () => {
|
||||
setApiError('');
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
console.log(values);
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setApiError('');
|
||||
const config =
|
||||
values.type === 'webhook'
|
||||
? { url: values.url }
|
||||
: { path: values.script_path };
|
||||
|
||||
if (connection) {
|
||||
await API.updateConnectIntegration(connection.id, {
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
config,
|
||||
enabled: values.enabled,
|
||||
});
|
||||
} else {
|
||||
connection = await API.createConnectIntegration({
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
config,
|
||||
enabled: values.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
await API.setConnectSubscriptions(
|
||||
connection.id,
|
||||
Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({
|
||||
event,
|
||||
enabled: selectedEvents.includes(event),
|
||||
}))
|
||||
);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create/update connection', error);
|
||||
// Try to map server-side validation errors to form fields
|
||||
const body = error?.body;
|
||||
|
||||
if (body && typeof body === 'object') {
|
||||
const fieldErrors = {};
|
||||
if (body.name) {
|
||||
fieldErrors.name = body.name;
|
||||
}
|
||||
if (body.type) {
|
||||
fieldErrors.type = body.type;
|
||||
}
|
||||
if (body.config) {
|
||||
if (values.type === 'webhook') {
|
||||
fieldErrors.url = msg;
|
||||
} else {
|
||||
fieldErrors.script_path = msg;
|
||||
}
|
||||
}
|
||||
|
||||
const nonField = body.non_field_errors || body.detail;
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
form.setErrors(fieldErrors);
|
||||
}
|
||||
if (nonField) setApiError(nonField);
|
||||
if (!nonField && Object.keys(fieldErrors).length === 0) {
|
||||
setApiError(body);
|
||||
}
|
||||
} else {
|
||||
setApiError(error?.message || 'Unknown error');
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEvent = (event) => {
|
||||
setSelectedEvents((prev) =>
|
||||
prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event]
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} size="lg" onClose={handleClose} title="Connection">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{apiError ? (
|
||||
<Text c="red" size="sm">
|
||||
{apiError}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
<Select
|
||||
{...form.getInputProps('type')}
|
||||
key={form.key('type')}
|
||||
label="Connection Type"
|
||||
data={[
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'script', label: 'Custom Script' },
|
||||
]}
|
||||
/>
|
||||
{form.getValues().type === 'webhook' ? (
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Script Path"
|
||||
{...form.getInputProps('script_path')}
|
||||
key={form.key('script_path')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text size="sm" weight={500} mb={5}>
|
||||
Event Triggers
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={3}>
|
||||
{EVENT_OPTIONS.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
checked={selectedEvents.includes(opt.value)}
|
||||
onChange={() => toggleEvent(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionForm;
|
||||
432
frontend/src/components/forms/CronBuilder.jsx
Normal file
432
frontend/src/components/forms/CronBuilder.jsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
/**
|
||||
* Cron Expression Builder Modal
|
||||
*
|
||||
* Provides an easy interface to build cron expressions with:
|
||||
* - Quick preset buttons for common schedules
|
||||
* - Simple hour/minute/day selectors
|
||||
* - Preview of next run times
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Select,
|
||||
NumberInput,
|
||||
Text,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
TextInput,
|
||||
Paper,
|
||||
Tabs,
|
||||
Code,
|
||||
} from '@mantine/core';
|
||||
import { Clock, Calendar } from 'lucide-react';
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
label: 'Every hour',
|
||||
value: '0 * * * *',
|
||||
description: 'At the start of every hour',
|
||||
},
|
||||
{
|
||||
label: 'Every 6 hours',
|
||||
value: '0 */6 * * *',
|
||||
description: 'Every 6 hours starting at midnight',
|
||||
},
|
||||
{
|
||||
label: 'Every 12 hours',
|
||||
value: '0 */12 * * *',
|
||||
description: 'Twice daily at midnight and noon',
|
||||
},
|
||||
{
|
||||
label: 'Daily at midnight',
|
||||
value: '0 0 * * *',
|
||||
description: 'Once per day at 12:00 AM',
|
||||
},
|
||||
{
|
||||
label: 'Daily at 3 AM',
|
||||
value: '0 3 * * *',
|
||||
description: 'Once per day at 3:00 AM',
|
||||
},
|
||||
{
|
||||
label: 'Daily at noon',
|
||||
value: '0 12 * * *',
|
||||
description: 'Once per day at 12:00 PM',
|
||||
},
|
||||
{
|
||||
label: 'Weekly (Sunday midnight)',
|
||||
value: '0 0 * * 0',
|
||||
description: 'Once per week on Sunday',
|
||||
},
|
||||
{
|
||||
label: 'Weekly (Monday 3 AM)',
|
||||
value: '0 3 * * 1',
|
||||
description: 'Once per week on Monday',
|
||||
},
|
||||
{
|
||||
label: 'Monthly (1st at 2:30 AM)',
|
||||
value: '30 2 1 * *',
|
||||
description: 'First day of each month',
|
||||
},
|
||||
];
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: '*', label: 'Every day' },
|
||||
{ value: '0', label: 'Sunday' },
|
||||
{ value: '1', label: 'Monday' },
|
||||
{ value: '2', label: 'Tuesday' },
|
||||
{ value: '3', label: 'Wednesday' },
|
||||
{ value: '4', label: 'Thursday' },
|
||||
{ value: '5', label: 'Friday' },
|
||||
{ value: '6', label: 'Saturday' },
|
||||
];
|
||||
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: 'hourly', label: 'Hourly' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
];
|
||||
|
||||
export default function CronBuilder({
|
||||
opened,
|
||||
onClose,
|
||||
onApply,
|
||||
currentValue = '',
|
||||
}) {
|
||||
const [mode, setMode] = useState('simple'); // 'simple' or 'advanced'
|
||||
const [frequency, setFrequency] = useState('daily');
|
||||
const [hour, setHour] = useState(3);
|
||||
const [minute, setMinute] = useState(0);
|
||||
const [dayOfWeek, setDayOfWeek] = useState('*');
|
||||
const [dayOfMonth, setDayOfMonth] = useState(1);
|
||||
const [generatedCron, setGeneratedCron] = useState('0 3 * * *');
|
||||
const [manualCron, setManualCron] = useState('* * * * *');
|
||||
|
||||
// Initialize manualCron from currentValue when modal opens
|
||||
useEffect(() => {
|
||||
if (opened && currentValue) {
|
||||
setManualCron(currentValue);
|
||||
}
|
||||
}, [opened, currentValue]);
|
||||
|
||||
// Update generated cron when inputs change
|
||||
useEffect(() => {
|
||||
let cron = '';
|
||||
switch (frequency) {
|
||||
case 'hourly':
|
||||
cron = `${minute} * * * *`;
|
||||
break;
|
||||
case 'daily':
|
||||
cron = `${minute} ${hour} * * *`;
|
||||
break;
|
||||
case 'weekly':
|
||||
cron = `${minute} ${hour} * * ${dayOfWeek === '*' ? '0' : dayOfWeek}`;
|
||||
break;
|
||||
case 'monthly':
|
||||
cron = `${minute} ${hour} ${dayOfMonth} * *`;
|
||||
break;
|
||||
}
|
||||
setGeneratedCron(cron);
|
||||
}, [frequency, hour, minute, dayOfWeek, dayOfMonth]);
|
||||
|
||||
const handlePresetClick = (cron) => {
|
||||
setGeneratedCron(cron);
|
||||
setManualCron(cron);
|
||||
|
||||
// Parse the cron expression and update form fields
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length === 5) {
|
||||
const [min, hr, day, _month, weekday] = parts;
|
||||
|
||||
setMinute(parseInt(min) || 0);
|
||||
|
||||
// Determine frequency based on pattern
|
||||
if (hr === '*') {
|
||||
setFrequency('hourly');
|
||||
} else if (day !== '*' && day !== '1') {
|
||||
// Has specific day of month
|
||||
setFrequency('monthly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfMonth(parseInt(day) || 1);
|
||||
} else if (weekday !== '*') {
|
||||
// Has specific day of week
|
||||
setFrequency('weekly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfWeek(weekday);
|
||||
} else if (day === '1') {
|
||||
// Monthly on 1st
|
||||
setFrequency('monthly');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
setDayOfMonth(1);
|
||||
} else {
|
||||
// Daily
|
||||
setFrequency('daily');
|
||||
setHour(parseInt(hr.replace('*/', '').replace('*', '0')) || 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const cronToApply = mode === 'advanced' ? manualCron : generatedCron;
|
||||
onApply(cronToApply);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Cron Expression Builder"
|
||||
size="xl"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Tabs value={mode} onChange={setMode}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="simple">Simple</Tabs.Tab>
|
||||
<Tabs.Tab value="advanced">Advanced</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="simple" pt="md">
|
||||
<Stack gap="md">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Quick Presets
|
||||
</Text>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
{PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => handlePresetClick(preset.value)}
|
||||
style={{
|
||||
height: '75px',
|
||||
padding: '8px',
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
inner: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
label: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={500} mb={2}>
|
||||
{preset.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{preset.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="dot"
|
||||
color="gray"
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
>
|
||||
{preset.value}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
|
||||
<Divider label="OR Build Custom" labelPosition="center" />
|
||||
|
||||
{/* Custom Builder */}
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">
|
||||
Custom Schedule
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select
|
||||
label="Frequency"
|
||||
data={FREQUENCY_OPTIONS}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
leftSection={<Calendar size={16} />}
|
||||
/>
|
||||
|
||||
{frequency !== 'hourly' && (
|
||||
<NumberInput
|
||||
label="Hour (0-23)"
|
||||
value={hour}
|
||||
onChange={setHour}
|
||||
min={0}
|
||||
max={23}
|
||||
leftSection={<Clock size={16} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label="Minute (0-59)"
|
||||
value={minute}
|
||||
onChange={setMinute}
|
||||
min={0}
|
||||
max={59}
|
||||
leftSection={<Clock size={16} />}
|
||||
/>
|
||||
|
||||
{frequency === 'weekly' && (
|
||||
<Select
|
||||
label="Day of Week"
|
||||
data={DAYS_OF_WEEK}
|
||||
value={dayOfWeek}
|
||||
onChange={setDayOfWeek}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency === 'monthly' && (
|
||||
<NumberInput
|
||||
label="Day of Month (1-31)"
|
||||
value={dayOfMonth}
|
||||
onChange={setDayOfMonth}
|
||||
min={1}
|
||||
max={31}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="advanced" pt="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Build advanced cron expressions with comma-separated values
|
||||
(e.g., <Code>2,4,16</Code>), ranges (e.g., <Code>9-17</Code>),
|
||||
or steps (e.g., <Code>*/15</Code>).
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput
|
||||
label="Minute (0-59)"
|
||||
placeholder="*, 0, */15, 0,15,30,45"
|
||||
value={manualCron.split(' ')[0] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[0] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Hour (0-23)"
|
||||
placeholder="*, 0, 9-17, */6, 2,4,16"
|
||||
value={manualCron.split(' ')[1] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[1] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Day of Month (1-31)"
|
||||
placeholder="*, 1, 1-15, */2, 1,15"
|
||||
value={manualCron.split(' ')[2] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[2] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Month (1-12)"
|
||||
placeholder="*, 1, 1-6, */3, 6,12"
|
||||
value={manualCron.split(' ')[3] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[3] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Day of Week (0-6, Sun-Sat)"
|
||||
placeholder="*, 0, 1-5, 0,6"
|
||||
value={manualCron.split(' ')[4] || '*'}
|
||||
onChange={(e) => {
|
||||
const parts =
|
||||
manualCron.split(' ').length >= 5
|
||||
? manualCron.split(' ')
|
||||
: ['*', '*', '*', '*', '*'];
|
||||
parts[4] = e.currentTarget.value || '*';
|
||||
setManualCron(parts.join(' '));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Examples: <Code>0 4,10,16 * * *</Code> at 4 AM, 10 AM, and 4 PM
|
||||
• <Code>0 9-17 * * 1-5</Code> hourly 9 AM-5 PM Mon-Fri
|
||||
• <Code>*/15 * * * *</Code> every 15 minutes
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* Generated Expression */}
|
||||
<Paper withBorder p="md" bg="dark.6">
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Expression:
|
||||
</Text>
|
||||
<Badge size="lg" variant="filled" color="blue">
|
||||
{mode === 'advanced' ? manualCron : generatedCron}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Actions */}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApply}>Apply Expression</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
|
|
@ -8,12 +10,14 @@ import {
|
|||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Popover,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
|
|
@ -26,6 +30,30 @@ import timezone from 'dayjs/plugin/timezone';
|
|||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
// Helper component for labels with info popover
|
||||
const LabelWithInfo = ({ label, info, required }) => (
|
||||
<Group spacing={4} align="center">
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
{required && (
|
||||
<Text component="span" c="red" ml={4}>
|
||||
*
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Popover width={300} position="top" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon size="xs" variant="subtle" color="gray">
|
||||
<Info size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text size="xs">{info}</Text>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
||||
// Get all EPGs from the store
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
|
|
@ -43,6 +71,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
const [datePattern, setDatePattern] = useState('');
|
||||
const [sampleTitle, setSampleTitle] = useState('');
|
||||
const [titleTemplate, setTitleTemplate] = useState('');
|
||||
const [subtitleTemplate, setSubtitleTemplate] = useState('');
|
||||
const [descriptionTemplate, setDescriptionTemplate] = useState('');
|
||||
const [upcomingTitleTemplate, setUpcomingTitleTemplate] = useState('');
|
||||
const [upcomingDescriptionTemplate, setUpcomingDescriptionTemplate] =
|
||||
|
|
@ -71,6 +100,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
program_duration: 180,
|
||||
sample_title: '',
|
||||
title_template: '',
|
||||
subtitle_template: '',
|
||||
description_template: '',
|
||||
upcoming_title_template: '',
|
||||
upcoming_description_template: '',
|
||||
|
|
@ -125,6 +155,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
dateGroups: {},
|
||||
calculatedPlaceholders: {},
|
||||
formattedTitle: '',
|
||||
formattedSubtitle: '',
|
||||
formattedDescription: '',
|
||||
formattedUpcomingTitle: '',
|
||||
formattedUpcomingDescription: '',
|
||||
|
|
@ -459,6 +490,14 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
);
|
||||
}
|
||||
|
||||
// Format subtitle template
|
||||
if (subtitleTemplate && (result.titleMatch || result.timeMatch)) {
|
||||
result.formattedSubtitle = subtitleTemplate.replace(
|
||||
/\{(\w+)\}/g,
|
||||
(match, key) => allGroups[key] || match
|
||||
);
|
||||
}
|
||||
|
||||
// Format description template
|
||||
if (descriptionTemplate && (result.titleMatch || result.timeMatch)) {
|
||||
result.formattedDescription = descriptionTemplate.replace(
|
||||
|
|
@ -533,6 +572,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
datePattern,
|
||||
sampleTitle,
|
||||
titleTemplate,
|
||||
subtitleTemplate,
|
||||
descriptionTemplate,
|
||||
upcomingTitleTemplate,
|
||||
upcomingDescriptionTemplate,
|
||||
|
|
@ -565,6 +605,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
program_duration: custom.program_duration || 180,
|
||||
sample_title: custom.sample_title || '',
|
||||
title_template: custom.title_template || '',
|
||||
subtitle_template: custom.subtitle_template || '',
|
||||
description_template: custom.description_template || '',
|
||||
upcoming_title_template: custom.upcoming_title_template || '',
|
||||
upcoming_description_template:
|
||||
|
|
@ -591,6 +632,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
setDatePattern(custom.date_pattern || '');
|
||||
setSampleTitle(custom.sample_title || '');
|
||||
setTitleTemplate(custom.title_template || '');
|
||||
setSubtitleTemplate(custom.subtitle_template || '');
|
||||
setDescriptionTemplate(custom.description_template || '');
|
||||
setUpcomingTitleTemplate(custom.upcoming_title_template || '');
|
||||
setUpcomingDescriptionTemplate(
|
||||
|
|
@ -611,6 +653,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
setDatePattern('');
|
||||
setSampleTitle('');
|
||||
setTitleTemplate('');
|
||||
setSubtitleTemplate('');
|
||||
setDescriptionTemplate('');
|
||||
setUpcomingTitleTemplate('');
|
||||
setUpcomingDescriptionTemplate('');
|
||||
|
|
@ -682,6 +725,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
program_duration: custom.program_duration || 180,
|
||||
sample_title: custom.sample_title || '',
|
||||
title_template: custom.title_template || '',
|
||||
subtitle_template: custom.subtitle_template || '',
|
||||
description_template: custom.description_template || '',
|
||||
upcoming_title_template: custom.upcoming_title_template || '',
|
||||
upcoming_description_template:
|
||||
|
|
@ -708,6 +752,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
setDatePattern(custom.date_pattern || '');
|
||||
setSampleTitle(custom.sample_title || '');
|
||||
setTitleTemplate(custom.title_template || '');
|
||||
setSubtitleTemplate(custom.subtitle_template || '');
|
||||
setDescriptionTemplate(custom.description_template || '');
|
||||
setUpcomingTitleTemplate(custom.upcoming_title_template || '');
|
||||
setUpcomingDescriptionTemplate(custom.upcoming_description_template || '');
|
||||
|
|
@ -805,341 +850,494 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
{...form.getInputProps('name')}
|
||||
/>
|
||||
|
||||
{/* Pattern Configuration */}
|
||||
<Divider label="Pattern Configuration" labelPosition="center" />
|
||||
{/* Accordion for organized sections */}
|
||||
<Accordion defaultValue="patterns" variant="separated">
|
||||
<Accordion.Item value="patterns">
|
||||
<Accordion.Control>Pattern Configuration</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack spacing="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Define regex patterns to extract information from channel
|
||||
titles or stream names. Use named capture groups like
|
||||
(?<groupname>pattern).
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Define regex patterns to extract information from channel titles or
|
||||
stream names. Use named capture groups like
|
||||
(?<groupname>pattern).
|
||||
</Text>
|
||||
<Select
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Name Source"
|
||||
info="Choose whether to parse the channel name or a stream name assigned to the channel"
|
||||
required
|
||||
/>
|
||||
}
|
||||
required
|
||||
withAsterisk={false}
|
||||
data={[
|
||||
{ value: 'channel', label: 'Channel Name' },
|
||||
{ value: 'stream', label: 'Stream Name' },
|
||||
]}
|
||||
{...form.getInputProps('custom_properties.name_source')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Name Source"
|
||||
description="Choose whether to parse the channel name or a stream name assigned to the channel"
|
||||
required
|
||||
data={[
|
||||
{ value: 'channel', label: 'Channel Name' },
|
||||
{ value: 'stream', label: 'Stream Name' },
|
||||
]}
|
||||
{...form.getInputProps('custom_properties.name_source')}
|
||||
/>
|
||||
{form.values.custom_properties?.name_source === 'stream' && (
|
||||
<NumberInput
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Stream Index"
|
||||
info="Which stream to use (1 = first stream, 2 = second stream, etc.)"
|
||||
/>
|
||||
}
|
||||
placeholder="1"
|
||||
min={1}
|
||||
max={100}
|
||||
{...form.getInputProps('custom_properties.stream_index')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.values.custom_properties?.name_source === 'stream' && (
|
||||
<NumberInput
|
||||
label="Stream Index"
|
||||
description="Which stream to use (1 = first stream, 2 = second stream, etc.)"
|
||||
placeholder="1"
|
||||
min={1}
|
||||
max={100}
|
||||
{...form.getInputProps('custom_properties.stream_index')}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
id="title_pattern"
|
||||
name="title_pattern"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Title Pattern"
|
||||
info="Regex pattern to extract title information (e.g., team names, league). Example: (?<league>\w+) \d+: (?<team1>.*) VS (?<team2>.*)"
|
||||
required
|
||||
/>
|
||||
}
|
||||
placeholder="(?<league>\w+) \d+: (?<team1>.*) VS (?<team2>.*)"
|
||||
required
|
||||
withAsterisk={false}
|
||||
value={titlePattern}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setTitlePattern(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.title_pattern',
|
||||
value
|
||||
);
|
||||
}}
|
||||
error={form.errors['custom_properties.title_pattern']}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="title_pattern"
|
||||
name="title_pattern"
|
||||
label="Title Pattern"
|
||||
description="Regex pattern to extract title information (e.g., team names, league). Example: (?<league>\w+) \d+: (?<team1>.*) VS (?<team2>.*)"
|
||||
placeholder="(?<league>\w+) \d+: (?<team1>.*) VS (?<team2>.*)"
|
||||
required
|
||||
value={titlePattern}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setTitlePattern(value);
|
||||
form.setFieldValue('custom_properties.title_pattern', value);
|
||||
}}
|
||||
error={form.errors['custom_properties.title_pattern']}
|
||||
/>
|
||||
<TextInput
|
||||
id="time_pattern"
|
||||
name="time_pattern"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Time Pattern (Optional)"
|
||||
info="Extract time from channel titles. Required groups: 'hour' (1-12 or 0-23), 'minute' (0-59), 'ampm' (AM/PM - optional for 24-hour). Examples: @ (?<hour>\d+):(?<minute>\d+)(?<ampm>AM|PM) for '8:30PM' OR @ (?<hour>\d{1,2}):(?<minute>\d{2}) for '20:30'"
|
||||
/>
|
||||
}
|
||||
placeholder="@ (?<hour>\d+):(?<minute>\d+)(?<ampm>AM|PM)"
|
||||
value={timePattern}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setTimePattern(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.time_pattern',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="time_pattern"
|
||||
name="time_pattern"
|
||||
label="Time Pattern (Optional)"
|
||||
description="Extract time from channel titles. Required groups: 'hour' (1-12 or 0-23), 'minute' (0-59), 'ampm' (AM/PM - optional for 24-hour). Examples: @ (?<hour>\d+):(?<minute>\d+)(?<ampm>AM|PM) for '8:30PM' OR @ (?<hour>\d{1,2}):(?<minute>\d{2}) for '20:30'"
|
||||
placeholder="@ (?<hour>\d+):(?<minute>\d+)(?<ampm>AM|PM)"
|
||||
value={timePattern}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setTimePattern(value);
|
||||
form.setFieldValue('custom_properties.time_pattern', value);
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
id="date_pattern"
|
||||
name="date_pattern"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Date Pattern (Optional)"
|
||||
info="Extract date from channel titles. Groups: 'month' (name or number), 'day', 'year' (optional, defaults to current year). Examples: @ (?<month>\w+) (?<day>\d+) for 'Oct 17' OR (?<month>\d+)/(?<day>\d+)/(?<year>\d+) for '10/17/2025'"
|
||||
/>
|
||||
}
|
||||
placeholder="@ (?<month>\w+) (?<day>\d+)"
|
||||
value={datePattern}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setDatePattern(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.date_pattern',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<TextInput
|
||||
id="date_pattern"
|
||||
name="date_pattern"
|
||||
label="Date Pattern (Optional)"
|
||||
description="Extract date from channel titles. Groups: 'month' (name or number), 'day', 'year' (optional, defaults to current year). Examples: @ (?<month>\w+) (?<day>\d+) for 'Oct 17' OR (?<month>\d+)/(?<day>\d+)/(?<year>\d+) for '10/17/2025'"
|
||||
placeholder="@ (?<month>\w+) (?<day>\d+)"
|
||||
value={datePattern}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setDatePattern(value);
|
||||
form.setFieldValue('custom_properties.date_pattern', value);
|
||||
}}
|
||||
/>
|
||||
<Accordion.Item value="templates">
|
||||
<Accordion.Control>Output Templates</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack spacing="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Use extracted groups from your patterns to format EPG titles
|
||||
and descriptions. Reference groups using {'{groupname}'}{' '}
|
||||
syntax. For cleaner URLs, use {'{groupname_normalize}'} to
|
||||
get alphanumeric-only lowercase versions.
|
||||
</Text>
|
||||
|
||||
{/* Output Templates */}
|
||||
<Divider label="Output Templates (Optional)" labelPosition="center" />
|
||||
<TextInput
|
||||
id="title_template"
|
||||
name="title_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Title Template"
|
||||
info="Format the EPG title using extracted groups. Use {starttime} (12-hour: '10 PM'), {starttime24} (24-hour: '22:00'), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {league} - {team1} vs {team2} ({starttime}-{endtime})"
|
||||
/>
|
||||
}
|
||||
placeholder="{league} - {team1} vs {team2}"
|
||||
value={titleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setTitleTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.title_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Use extracted groups from your patterns to format EPG titles and
|
||||
descriptions. Reference groups using {'{groupname}'} syntax. For
|
||||
cleaner URLs, use {'{groupname_normalize}'} to get alphanumeric-only
|
||||
lowercase versions.
|
||||
</Text>
|
||||
<TextInput
|
||||
id="subtitle_template"
|
||||
name="subtitle_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Subtitle Template (Optional)"
|
||||
info="Format the EPG subtitle using extracted groups. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Example: {starttime} - {endtime}"
|
||||
/>
|
||||
}
|
||||
placeholder="{starttime} - {endtime}"
|
||||
value={subtitleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSubtitleTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.subtitle_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="title_template"
|
||||
name="title_template"
|
||||
label="Title Template"
|
||||
description="Format the EPG title using extracted groups. Use {starttime} (12-hour: '10 PM'), {starttime24} (24-hour: '22:00'), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {league} - {team1} vs {team2} ({starttime}-{endtime})"
|
||||
placeholder="{league} - {team1} vs {team2}"
|
||||
value={titleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setTitleTemplate(value);
|
||||
form.setFieldValue('custom_properties.title_template', value);
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
id="description_template"
|
||||
name="description_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Description Template"
|
||||
info="Format the EPG description using extracted groups. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: Watch {team1} take on {team2} on {date} from {starttime} to {endtime}!"
|
||||
/>
|
||||
}
|
||||
placeholder="Watch {team1} take on {team2} in this exciting {league} matchup from {starttime} to {endtime}!"
|
||||
minRows={2}
|
||||
value={descriptionTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setDescriptionTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.description_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Textarea
|
||||
id="description_template"
|
||||
name="description_template"
|
||||
label="Description Template"
|
||||
description="Format the EPG description using extracted groups. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: Watch {team1} take on {team2} on {date} from {starttime} to {endtime}!"
|
||||
placeholder="Watch {team1} take on {team2} in this exciting {league} matchup from {starttime} to {endtime}!"
|
||||
minRows={2}
|
||||
value={descriptionTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setDescriptionTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.description_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Accordion.Item value="upcoming-ended">
|
||||
<Accordion.Control>Upcoming/Ended Templates</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack spacing="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Customize how programs appear before and after the event. If
|
||||
left empty, will use the main title/description with
|
||||
"Upcoming:" or "Ended:" prefix.
|
||||
</Text>
|
||||
|
||||
{/* Upcoming/Ended Templates */}
|
||||
<Divider
|
||||
label="Upcoming/Ended Templates (Optional)"
|
||||
labelPosition="center"
|
||||
/>
|
||||
<TextInput
|
||||
id="upcoming_title_template"
|
||||
name="upcoming_title_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Upcoming Title Template"
|
||||
info="Title for programs before the event starts. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {team1} vs {team2} starting at {starttime}."
|
||||
/>
|
||||
}
|
||||
placeholder="{team1} vs {team2} starting at {starttime}."
|
||||
value={upcomingTitleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpcomingTitleTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.upcoming_title_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Customize how programs appear before and after the event. If left
|
||||
empty, will use the main title/description with "Upcoming:" or
|
||||
"Ended:" prefix.
|
||||
</Text>
|
||||
<Textarea
|
||||
id="upcoming_description_template"
|
||||
name="upcoming_description_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Upcoming Description Template"
|
||||
info="Description for programs before the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: Upcoming: Watch the {league} match up where the {team1} take on the {team2} on {date} from {starttime} to {endtime}!"
|
||||
/>
|
||||
}
|
||||
placeholder="Upcoming: Watch the {league} match up where the {team1} take on the {team2} from {starttime} to {endtime}!"
|
||||
minRows={2}
|
||||
value={upcomingDescriptionTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpcomingDescriptionTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.upcoming_description_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="upcoming_title_template"
|
||||
name="upcoming_title_template"
|
||||
label="Upcoming Title Template"
|
||||
description="Title for programs before the event starts. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {team1} vs {team2} starting at {starttime}."
|
||||
placeholder="{team1} vs {team2} starting at {starttime}."
|
||||
value={upcomingTitleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpcomingTitleTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.upcoming_title_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
id="ended_title_template"
|
||||
name="ended_title_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Ended Title Template"
|
||||
info="Title for programs after the event has ended. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {team1} vs {team2} started at {starttime}."
|
||||
/>
|
||||
}
|
||||
placeholder="{team1} vs {team2} started at {starttime}."
|
||||
value={endedTitleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setEndedTitleTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.ended_title_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="upcoming_description_template"
|
||||
name="upcoming_description_template"
|
||||
label="Upcoming Description Template"
|
||||
description="Description for programs before the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: Upcoming: Watch the {league} match up where the {team1} take on the {team2} on {date} from {starttime} to {endtime}!"
|
||||
placeholder="Upcoming: Watch the {league} match up where the {team1} take on the {team2} from {starttime} to {endtime}!"
|
||||
minRows={2}
|
||||
value={upcomingDescriptionTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setUpcomingDescriptionTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.upcoming_description_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
id="ended_description_template"
|
||||
name="ended_description_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Ended Description Template"
|
||||
info="Description for programs after the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: The {league} match between {team1} and {team2} on {date} ran from {starttime} to {endtime}."
|
||||
/>
|
||||
}
|
||||
placeholder="The {league} match between {team1} and {team2} ran from {starttime} to {endtime}."
|
||||
minRows={2}
|
||||
value={endedDescriptionTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setEndedDescriptionTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.ended_description_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<TextInput
|
||||
id="ended_title_template"
|
||||
name="ended_title_template"
|
||||
label="Ended Title Template"
|
||||
description="Title for programs after the event has ended. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: {team1} vs {team2} started at {starttime}."
|
||||
placeholder="{team1} vs {team2} started at {starttime}."
|
||||
value={endedTitleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setEndedTitleTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.ended_title_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Accordion.Item value="fallback">
|
||||
<Accordion.Control>Fallback Templates</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack spacing="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
When patterns don't match the channel/stream name, use these
|
||||
custom fallback templates instead of the default placeholder
|
||||
messages. Leave empty to use the built-in humorous fallback
|
||||
descriptions.
|
||||
</Text>
|
||||
|
||||
<Textarea
|
||||
id="ended_description_template"
|
||||
name="ended_description_template"
|
||||
label="Ended Description Template"
|
||||
description="Description for programs after the event. Use {starttime} (12-hour), {starttime24} (24-hour), {endtime} (12-hour end), {endtime24} (24-hour end), {date} (YYYY-MM-DD), {month}, {day}, or {year}. Date/time placeholders respect Output Timezone settings. Example: The {league} match between {team1} and {team2} on {date} ran from {starttime} to {endtime}."
|
||||
placeholder="The {league} match between {team1} and {team2} ran from {starttime} to {endtime}."
|
||||
minRows={2}
|
||||
value={endedDescriptionTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setEndedDescriptionTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.ended_description_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
id="fallback_title_template"
|
||||
name="fallback_title_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Fallback Title Template"
|
||||
info="Custom title when patterns don't match. If empty, uses the channel/stream name."
|
||||
/>
|
||||
}
|
||||
placeholder="No EPG data available"
|
||||
value={fallbackTitleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFallbackTitleTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.fallback_title_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Fallback Templates */}
|
||||
<Divider
|
||||
label="Fallback Templates (Optional)"
|
||||
labelPosition="center"
|
||||
/>
|
||||
<Textarea
|
||||
id="fallback_description_template"
|
||||
name="fallback_description_template"
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Fallback Description Template"
|
||||
info="Custom description when patterns don't match. If empty, uses built-in placeholder messages."
|
||||
/>
|
||||
}
|
||||
placeholder="EPG information is currently unavailable for this channel."
|
||||
minRows={2}
|
||||
value={fallbackDescriptionTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFallbackDescriptionTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.fallback_description_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
When patterns don't match the channel/stream name, use these custom
|
||||
fallback templates instead of the default placeholder messages.
|
||||
Leave empty to use the built-in humorous fallback descriptions.
|
||||
</Text>
|
||||
<Accordion.Item value="settings">
|
||||
<Accordion.Control>EPG Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack spacing="md">
|
||||
<Select
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Event Timezone"
|
||||
info="The timezone of the event times in your channel titles. DST (Daylight Saving Time) is handled automatically! All timezones supported by pytz are available."
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
loadingTimezones
|
||||
? 'Loading timezones...'
|
||||
: 'Select timezone'
|
||||
}
|
||||
data={timezoneOptions}
|
||||
searchable
|
||||
disabled={loadingTimezones}
|
||||
{...form.getInputProps('custom_properties.timezone')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="fallback_title_template"
|
||||
name="fallback_title_template"
|
||||
label="Fallback Title Template"
|
||||
description="Custom title when patterns don't match. If empty, uses the channel/stream name."
|
||||
placeholder="No EPG data available"
|
||||
value={fallbackTitleTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFallbackTitleTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.fallback_title_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Output Timezone (Optional)"
|
||||
info="Display times in a different timezone than the event timezone. Leave empty to use the event timezone. Example: Event at 10 PM ET displayed as 9 PM CT."
|
||||
/>
|
||||
}
|
||||
placeholder="Same as event timezone"
|
||||
data={timezoneOptions}
|
||||
searchable
|
||||
clearable
|
||||
disabled={loadingTimezones}
|
||||
{...form.getInputProps('custom_properties.output_timezone')}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
id="fallback_description_template"
|
||||
name="fallback_description_template"
|
||||
label="Fallback Description Template"
|
||||
description="Custom description when patterns don't match. If empty, uses built-in placeholder messages."
|
||||
placeholder="EPG information is currently unavailable for this channel."
|
||||
minRows={2}
|
||||
value={fallbackDescriptionTemplate}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFallbackDescriptionTemplate(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.fallback_description_template',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Program Duration (minutes)"
|
||||
info="Default duration for each program"
|
||||
/>
|
||||
}
|
||||
placeholder="180"
|
||||
min={1}
|
||||
max={1440}
|
||||
{...form.getInputProps(
|
||||
'custom_properties.program_duration'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* EPG Settings */}
|
||||
<Divider label="EPG Settings" labelPosition="center" />
|
||||
<TextInput
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Categories (Optional)"
|
||||
info="EPG categories for these programs. Use commas to separate multiple (e.g., Sports, Live, HD). Note: Only added to the main event, not upcoming/ended filler programs."
|
||||
/>
|
||||
}
|
||||
placeholder="Sports, Live"
|
||||
{...form.getInputProps('custom_properties.category')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Event Timezone"
|
||||
description="The timezone of the event times in your channel titles. DST (Daylight Saving Time) is handled automatically! All timezones supported by pytz are available."
|
||||
placeholder={
|
||||
loadingTimezones ? 'Loading timezones...' : 'Select timezone'
|
||||
}
|
||||
data={timezoneOptions}
|
||||
searchable
|
||||
disabled={loadingTimezones}
|
||||
{...form.getInputProps('custom_properties.timezone')}
|
||||
/>
|
||||
<TextInput
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Channel Logo URL (Optional)"
|
||||
info="Build a URL for the channel logo using regex groups. Example: https://example.com/logos/{league_normalize}/{team1_normalize}.png. Use {groupname_normalize} for cleaner URLs (alphanumeric-only, lowercase). This will be used as the channel <icon> in the EPG output."
|
||||
/>
|
||||
}
|
||||
placeholder="https://example.com/logos/{league_normalize}/{team1_normalize}.png"
|
||||
value={channelLogoUrl}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setChannelLogoUrl(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.channel_logo_url',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Output Timezone (Optional)"
|
||||
description="Display times in a different timezone than the event timezone. Leave empty to use the event timezone. Example: Event at 10 PM ET displayed as 9 PM CT."
|
||||
placeholder="Same as event timezone"
|
||||
data={timezoneOptions}
|
||||
searchable
|
||||
clearable
|
||||
disabled={loadingTimezones}
|
||||
{...form.getInputProps('custom_properties.output_timezone')}
|
||||
/>
|
||||
<TextInput
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Program Poster URL (Optional)"
|
||||
info="Build a URL for the program poster/icon using regex groups. Example: https://example.com/posters/{team1_normalize}-vs-{team2_normalize}.jpg. Use {groupname_normalize} for cleaner URLs (alphanumeric-only, lowercase). This will be used as the program <icon> in the EPG output."
|
||||
/>
|
||||
}
|
||||
placeholder="https://example.com/posters/{team1_normalize}-vs-{team2_normalize}.jpg"
|
||||
value={programPosterUrl}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setProgramPosterUrl(value);
|
||||
form.setFieldValue(
|
||||
'custom_properties.program_poster_url',
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Program Duration (minutes)"
|
||||
description="Default duration for each program"
|
||||
placeholder="180"
|
||||
min={1}
|
||||
max={1440}
|
||||
{...form.getInputProps('custom_properties.program_duration')}
|
||||
/>
|
||||
<Checkbox
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Include Date Tag"
|
||||
info="Include the <date> tag in EPG output with the program's start date (YYYY-MM-DD format). Added to all programs."
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps('custom_properties.include_date', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Categories (Optional)"
|
||||
description="EPG categories for these programs. Use commas to separate multiple (e.g., Sports, Live, HD). Note: Only added to the main event, not upcoming/ended filler programs."
|
||||
placeholder="Sports, Live"
|
||||
{...form.getInputProps('custom_properties.category')}
|
||||
/>
|
||||
<Checkbox
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Include Live Tag"
|
||||
info="Mark programs as live content with the <live /> tag in EPG output. Note: Only added to the main event, not upcoming/ended filler programs."
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps('custom_properties.include_live', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Channel Logo URL (Optional)"
|
||||
description="Build a URL for the channel logo using regex groups. Example: https://example.com/logos/{league_normalize}/{team1_normalize}.png. Use {groupname_normalize} for cleaner URLs (alphanumeric-only, lowercase). This will be used as the channel <icon> in the EPG output."
|
||||
placeholder="https://example.com/logos/{league_normalize}/{team1_normalize}.png"
|
||||
value={channelLogoUrl}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setChannelLogoUrl(value);
|
||||
form.setFieldValue('custom_properties.channel_logo_url', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Program Poster URL (Optional)"
|
||||
description="Build a URL for the program poster/icon using regex groups. Example: https://example.com/posters/{team1_normalize}-vs-{team2_normalize}.jpg. Use {groupname_normalize} for cleaner URLs (alphanumeric-only, lowercase). This will be used as the program <icon> in the EPG output."
|
||||
placeholder="https://example.com/posters/{team1_normalize}-vs-{team2_normalize}.jpg"
|
||||
value={programPosterUrl}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setProgramPosterUrl(value);
|
||||
form.setFieldValue('custom_properties.program_poster_url', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Include Date Tag"
|
||||
description="Include the <date> tag in EPG output with the program's start date (YYYY-MM-DD format). Added to all programs."
|
||||
{...form.getInputProps('custom_properties.include_date', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Include Live Tag"
|
||||
description="Mark programs as live content with the <live /> tag in EPG output. Note: Only added to the main event, not upcoming/ended filler programs."
|
||||
{...form.getInputProps('custom_properties.include_live', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Include New Tag"
|
||||
description="Mark programs as new content with the <new /> tag in EPG output. Note: Only added to the main event, not upcoming/ended filler programs."
|
||||
{...form.getInputProps('custom_properties.include_new', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
<Checkbox
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Include New Tag"
|
||||
info="Mark programs as new content with the <new /> tag in EPG output. Note: Only added to the main event, not upcoming/ended filler programs."
|
||||
/>
|
||||
}
|
||||
{...form.getInputProps('custom_properties.include_new', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
{/* Testing & Preview */}
|
||||
<Divider label="Test Your Configuration" labelPosition="center" />
|
||||
|
|
@ -1155,8 +1353,12 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
<TextInput
|
||||
id="sample_title"
|
||||
name="sample_title"
|
||||
label={`Sample ${form.values.custom_properties?.name_source === 'stream' ? 'Stream' : 'Channel'} Name`}
|
||||
description={`Enter a sample ${form.values.custom_properties?.name_source === 'stream' ? 'stream name' : 'channel name'} to test pattern matching and see the formatted output`}
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label={`Sample ${form.values.custom_properties?.name_source === 'stream' ? 'Stream' : 'Channel'} Name`}
|
||||
info={`Enter a sample ${form.values.custom_properties?.name_source === 'stream' ? 'stream name' : 'channel name'} to test pattern matching and see the formatted output`}
|
||||
/>
|
||||
}
|
||||
placeholder="League 01: Team 1 VS Team 2 @ Oct 17 8:00PM ET"
|
||||
value={sampleTitle}
|
||||
onChange={(e) => {
|
||||
|
|
@ -1371,6 +1573,18 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
</>
|
||||
)}
|
||||
|
||||
{subtitleTemplate && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
EPG Subtitle:
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{patternValidation.formattedSubtitle ||
|
||||
'(no matching groups)'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{descriptionTemplate && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
|
|
@ -1464,6 +1678,7 @@ const DummyEPGForm = ({ epg, isOpen, onClose }) => {
|
|||
)}
|
||||
|
||||
{!titleTemplate &&
|
||||
!subtitleTemplate &&
|
||||
!descriptionTemplate &&
|
||||
!upcomingTitleTemplate &&
|
||||
!upcomingDescriptionTemplate &&
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import ScheduleInput from './ScheduleInput';
|
||||
|
||||
const EPG = ({ epg = null, isOpen, onClose }) => {
|
||||
const [sourceType, setSourceType] = useState('xmltv');
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
|
|
@ -29,6 +31,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
api_key: '',
|
||||
is_active: true,
|
||||
refresh_interval: 24,
|
||||
cron_expression: '',
|
||||
priority: 0,
|
||||
},
|
||||
|
||||
|
|
@ -41,6 +44,17 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
|
||||
// Determine which schedule type is active based on field values
|
||||
const hasCronExpression =
|
||||
values.cron_expression && values.cron_expression.trim() !== '';
|
||||
|
||||
// Clear the field that isn't active based on actual field values
|
||||
if (hasCronExpression) {
|
||||
values.refresh_interval = 0;
|
||||
} else {
|
||||
values.cron_expression = '';
|
||||
}
|
||||
|
||||
if (epg?.id) {
|
||||
// Validate that we have a valid EPG object before updating
|
||||
if (!epg || typeof epg !== 'object' || !epg.id) {
|
||||
|
|
@ -70,13 +84,21 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
api_key: epg.api_key,
|
||||
is_active: epg.is_active,
|
||||
refresh_interval: epg.refresh_interval,
|
||||
cron_expression: epg.cron_expression || '',
|
||||
priority: epg.priority ?? 0,
|
||||
};
|
||||
form.setValues(values);
|
||||
setSourceType(epg.source_type);
|
||||
// Determine schedule type from existing data - check both fields
|
||||
setScheduleType(
|
||||
epg.cron_expression && epg.cron_expression.trim() !== ''
|
||||
? 'cron'
|
||||
: 'interval'
|
||||
);
|
||||
} else {
|
||||
form.reset();
|
||||
setSourceType('xmltv');
|
||||
setScheduleType('interval');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [epg]);
|
||||
|
|
@ -92,127 +114,136 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="EPG Source" size={700}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
{/* Left Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this EPG source"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
|
||||
<NativeSelect
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
description="Format of the EPG data source"
|
||||
{...form.getInputProps('source_type')}
|
||||
key={form.key('source_type')}
|
||||
data={[
|
||||
{
|
||||
label: 'XMLTV',
|
||||
value: 'xmltv',
|
||||
},
|
||||
{
|
||||
label: 'Schedules Direct',
|
||||
value: 'schedules_direct',
|
||||
},
|
||||
]}
|
||||
onChange={(event) =>
|
||||
handleSourceTypeChange(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description="How often to refresh EPG data (0 to disable)"
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
min={0}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
description="Direct URL to the XMLTV file or API endpoint"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
|
||||
{sourceType === 'schedules_direct' && (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={onClose} title="EPG Source" size={700}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
{/* Left Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
description="API key for services that require authentication"
|
||||
{...form.getInputProps('api_key')}
|
||||
key={form.key('api_key')}
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
description="Unique identifier for this EPG source"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={999}
|
||||
label="Priority"
|
||||
description="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
|
||||
{...form.getInputProps('priority')}
|
||||
key={form.key('priority')}
|
||||
/>
|
||||
<NativeSelect
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
description="Format of the EPG data source"
|
||||
{...form.getInputProps('source_type')}
|
||||
key={form.key('source_type')}
|
||||
data={[
|
||||
{
|
||||
label: 'XMLTV',
|
||||
value: 'xmltv',
|
||||
},
|
||||
{
|
||||
label: 'Schedules Direct',
|
||||
value: 'schedules_direct',
|
||||
},
|
||||
]}
|
||||
onChange={(event) =>
|
||||
handleSourceTypeChange(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 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',
|
||||
marginTop: '-4px',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
label="Enable this EPG source"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={setScheduleType}
|
||||
intervalValue={form.getValues().refresh_interval}
|
||||
onIntervalChange={(v) =>
|
||||
form.setFieldValue('refresh_interval', v)
|
||||
}
|
||||
cronValue={form.getValues().cron_expression}
|
||||
onCronChange={(expr) =>
|
||||
form.setFieldValue('cron_expression', expr)
|
||||
}
|
||||
intervalLabel="Refresh Interval (hours)"
|
||||
intervalDescription="How often to refresh EPG data (0 to disable)"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
description="Direct URL to the XMLTV file or API endpoint"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
|
||||
{sourceType === 'schedules_direct' && (
|
||||
<TextInput
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
description="API key for services that require authentication"
|
||||
{...form.getInputProps('api_key')}
|
||||
key={form.key('api_key')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={999}
|
||||
label="Priority"
|
||||
description="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
|
||||
{...form.getInputProps('priority')}
|
||||
key={form.key('priority')}
|
||||
/>
|
||||
|
||||
{/* 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',
|
||||
marginTop: '-4px',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
label="Enable this EPG source"
|
||||
{...form.getInputProps('is_active', { type: 'checkbox' })}
|
||||
key={form.key('is_active')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Full Width Section */}
|
||||
<Box mt="md">
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="end" mt="xl">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="filled" disabled={form.submitting}>
|
||||
{epg?.id ? 'Update' : 'Create'} EPG Source
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Full Width Section */}
|
||||
<Box mt="md">
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="end" mt="xl">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="filled" disabled={form.submitting}>
|
||||
{epg?.id ? 'Update' : 'Create'} EPG Source
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -314,17 +314,119 @@ const LiveGroupFilter = ({
|
|||
|
||||
{group.auto_channel_sync && group.enabled && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="Start Channel #"
|
||||
value={group.auto_sync_channel_start}
|
||||
onChange={(value) =>
|
||||
updateChannelStart(group.channel_group, value)
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
<div>
|
||||
<strong>Fixed:</strong> Start at a specific number
|
||||
and increment
|
||||
</div>
|
||||
<div>
|
||||
<strong>Provider:</strong> Use channel numbers
|
||||
from the M3U source
|
||||
</div>
|
||||
<div>
|
||||
<strong>Next Available:</strong> Auto-assign
|
||||
starting from 1, skipping used numbers
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={1}
|
||||
/>
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
openDelay={500}
|
||||
>
|
||||
<Select
|
||||
label="Channel Numbering Mode"
|
||||
placeholder="Select mode..."
|
||||
value={
|
||||
group.custom_properties?.channel_numbering_mode ||
|
||||
'fixed'
|
||||
}
|
||||
onChange={(value) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (
|
||||
state.channel_group === group.channel_group
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
channel_numbering_mode: value || 'fixed',
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
value: 'fixed',
|
||||
label: 'Fixed Start Number',
|
||||
},
|
||||
{
|
||||
value: 'provider',
|
||||
label: 'Use Provider Number',
|
||||
},
|
||||
{
|
||||
value: 'next_available',
|
||||
label: 'Next Available',
|
||||
},
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{(!group.custom_properties?.channel_numbering_mode ||
|
||||
group.custom_properties?.channel_numbering_mode ===
|
||||
'fixed') && (
|
||||
<NumberInput
|
||||
label="Start Channel #"
|
||||
value={group.auto_sync_channel_start}
|
||||
onChange={(value) =>
|
||||
updateChannelStart(group.channel_group, value)
|
||||
}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{group.custom_properties?.channel_numbering_mode ===
|
||||
'provider' && (
|
||||
<NumberInput
|
||||
label="Fallback Channel # (if provider # missing)"
|
||||
value={
|
||||
group.custom_properties
|
||||
?.channel_numbering_fallback || 1
|
||||
}
|
||||
onChange={(value) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (
|
||||
state.channel_group === group.channel_group
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
custom_properties: {
|
||||
...state.custom_properties,
|
||||
channel_numbering_fallback: value || 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Auto Channel Sync Options Multi-Select */}
|
||||
<MultiSelect
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { isNotEmpty, useForm } from '@mantine/form';
|
|||
import useEPGsStore from '../../store/epgs';
|
||||
import useVODStore from '../../store/useVODStore';
|
||||
import M3UFilters from './M3UFilters';
|
||||
import ScheduleInput from './ScheduleInput';
|
||||
|
||||
const M3U = ({
|
||||
m3uAccount = null,
|
||||
|
|
@ -49,6 +50,7 @@ const M3U = ({
|
|||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState('');
|
||||
const [showCredentialFields, setShowCredentialFields] = useState(false);
|
||||
const [scheduleType, setScheduleType] = useState('interval');
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
|
|
@ -59,6 +61,7 @@ const M3U = ({
|
|||
is_active: true,
|
||||
max_streams: 0,
|
||||
refresh_interval: 24,
|
||||
cron_expression: '',
|
||||
account_type: 'XC',
|
||||
create_epg: false,
|
||||
username: '',
|
||||
|
|
@ -71,12 +74,10 @@ const M3U = ({
|
|||
validate: {
|
||||
name: isNotEmpty('Please select a name'),
|
||||
user_agent: isNotEmpty('Please select a user-agent'),
|
||||
refresh_interval: isNotEmpty('Please specify a refresh interval'),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log(m3uAccount);
|
||||
if (m3uAccount) {
|
||||
setPlaylist(m3uAccount);
|
||||
form.setValues({
|
||||
|
|
@ -86,6 +87,7 @@ const M3U = ({
|
|||
user_agent: m3uAccount.user_agent ? `${m3uAccount.user_agent}` : '0',
|
||||
is_active: m3uAccount.is_active,
|
||||
refresh_interval: m3uAccount.refresh_interval,
|
||||
cron_expression: m3uAccount.cron_expression || '',
|
||||
account_type: m3uAccount.account_type,
|
||||
username: m3uAccount.username ?? '',
|
||||
password: '',
|
||||
|
|
@ -101,6 +103,13 @@ const M3U = ({
|
|||
enable_vod: m3uAccount.enable_vod || false,
|
||||
});
|
||||
|
||||
// Determine schedule type from existing data
|
||||
setScheduleType(
|
||||
m3uAccount.cron_expression && m3uAccount.cron_expression.trim() !== ''
|
||||
? 'cron'
|
||||
: 'interval'
|
||||
);
|
||||
|
||||
if (m3uAccount.account_type == 'XC') {
|
||||
setShowCredentialFields(true);
|
||||
} else {
|
||||
|
|
@ -109,6 +118,7 @@ const M3U = ({
|
|||
} else {
|
||||
setPlaylist(null);
|
||||
form.reset();
|
||||
setScheduleType('interval');
|
||||
}
|
||||
}, [m3uAccount]);
|
||||
|
||||
|
|
@ -121,6 +131,17 @@ const M3U = ({
|
|||
const onSubmit = async () => {
|
||||
const { create_epg, ...values } = form.getValues();
|
||||
|
||||
// Determine which schedule type is active based on field values
|
||||
const hasCronExpression =
|
||||
values.cron_expression && values.cron_expression.trim() !== '';
|
||||
|
||||
// Clear the field that isn't active based on actual field values
|
||||
if (hasCronExpression) {
|
||||
values.refresh_interval = 0;
|
||||
} else {
|
||||
values.cron_expression = '';
|
||||
}
|
||||
|
||||
if (values.account_type == 'XC' && values.password == '') {
|
||||
// If account XC and no password input, assuming no password change
|
||||
// from previously stored value.
|
||||
|
|
@ -148,7 +169,7 @@ const M3U = ({
|
|||
API.addEPG({
|
||||
name: values.name,
|
||||
source_type: 'xmltv',
|
||||
url: `${values.server_url}/xmltv.php?username=${values.username}&password=${values.password}`,
|
||||
url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`,
|
||||
api_key: '',
|
||||
is_active: true,
|
||||
refresh_interval: 24,
|
||||
|
|
@ -377,17 +398,25 @@ const M3U = ({
|
|||
)}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Refresh Interval (hours)"
|
||||
description={
|
||||
<ScheduleInput
|
||||
scheduleType={scheduleType}
|
||||
onScheduleTypeChange={setScheduleType}
|
||||
intervalValue={form.getValues().refresh_interval}
|
||||
onIntervalChange={(v) =>
|
||||
form.setFieldValue('refresh_interval', v)
|
||||
}
|
||||
cronValue={form.getValues().cron_expression}
|
||||
onCronChange={(expr) =>
|
||||
form.setFieldValue('cron_expression', expr)
|
||||
}
|
||||
intervalLabel="Refresh Interval (hours)"
|
||||
intervalDescription={
|
||||
<>
|
||||
How often to automatically refresh M3U data
|
||||
<br />
|
||||
(0 to disable automatic refreshes)
|
||||
</>
|
||||
}
|
||||
{...form.getInputProps('refresh_interval')}
|
||||
key={form.key('refresh_interval')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
|
|
|
|||
229
frontend/src/components/forms/ScheduleInput.jsx
Normal file
229
frontend/src/components/forms/ScheduleInput.jsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Reusable Schedule Input
|
||||
*
|
||||
* Shows the active scheduling mode with a subtle text link to switch.
|
||||
* Interval mode is the default; a small "Use cron schedule" link beneath
|
||||
* toggles to cron mode, and vice-versa.
|
||||
*
|
||||
* For M3U / EPG (default interval NumberInput):
|
||||
* <ScheduleInput
|
||||
* scheduleType={scheduleType}
|
||||
* onScheduleTypeChange={setScheduleType}
|
||||
* intervalValue={form.getValues().refresh_interval}
|
||||
* onIntervalChange={(v) => form.setFieldValue('refresh_interval', v)}
|
||||
* cronValue={form.getValues().cron_expression}
|
||||
* onCronChange={(v) => form.setFieldValue('cron_expression', v)}
|
||||
* />
|
||||
*
|
||||
* For Backups (custom simple-mode UI via children):
|
||||
* <ScheduleInput
|
||||
* scheduleType={scheduleType}
|
||||
* onScheduleTypeChange={setScheduleType}
|
||||
* cronValue={schedule.cron_expression}
|
||||
* onCronChange={(v) => handleScheduleChange('cron_expression', v)}
|
||||
* switchToCronLabel="Use custom cron schedule"
|
||||
* switchToIntervalLabel="Use simple schedule"
|
||||
* >
|
||||
* ...frequency / time / day selectors...
|
||||
* </ScheduleInput>
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Anchor,
|
||||
Stack,
|
||||
Text,
|
||||
Code,
|
||||
Popover,
|
||||
ActionIcon,
|
||||
Group,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import { validateCronExpression } from '../../utils/cronUtils';
|
||||
import CronBuilder from './CronBuilder';
|
||||
|
||||
export default function ScheduleInput({
|
||||
// Schedule type
|
||||
scheduleType = 'interval',
|
||||
onScheduleTypeChange,
|
||||
|
||||
// Cron
|
||||
cronValue = '',
|
||||
onCronChange,
|
||||
|
||||
// Default interval input (used when children not provided)
|
||||
intervalValue = 0,
|
||||
onIntervalChange,
|
||||
intervalLabel = 'Refresh Interval (hours)',
|
||||
intervalDescription = 'How often to refresh (0 to disable)',
|
||||
min = 0,
|
||||
|
||||
// Custom simple-mode content (replaces the default NumberInput)
|
||||
children,
|
||||
|
||||
// Link text for toggling
|
||||
switchToCronLabel = 'Use cron schedule',
|
||||
switchToIntervalLabel = 'Use interval schedule',
|
||||
|
||||
disabled = false,
|
||||
}) {
|
||||
const [cronError, setCronError] = useState(null);
|
||||
const [builderOpened, setBuilderOpened] = useState(false);
|
||||
|
||||
// Validate cron whenever it changes
|
||||
useEffect(() => {
|
||||
if (scheduleType === 'cron' && cronValue) {
|
||||
const v = validateCronExpression(cronValue);
|
||||
setCronError(v.valid ? null : v.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
}, [scheduleType, cronValue]);
|
||||
|
||||
const switchToCron = (e) => {
|
||||
e.preventDefault();
|
||||
onScheduleTypeChange('cron');
|
||||
};
|
||||
|
||||
const switchToInterval = (e) => {
|
||||
e.preventDefault();
|
||||
onScheduleTypeChange('interval');
|
||||
onCronChange('');
|
||||
setCronError(null);
|
||||
};
|
||||
|
||||
const handleCronChange = (val) => {
|
||||
onCronChange(val);
|
||||
if (val) {
|
||||
const v = validateCronExpression(val);
|
||||
setCronError(v.valid ? null : v.error);
|
||||
} else {
|
||||
setCronError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuilderApply = (cron) => {
|
||||
handleCronChange(cron);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{scheduleType === 'cron' ? (
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
Cron Expression
|
||||
<Popover width={320} position="top" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="subtle" size="xs" color="gray">
|
||||
<Info size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="sm">
|
||||
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||
COMMON EXAMPLES
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
Every day at 3 AM:
|
||||
</Text>
|
||||
<Code size="xs">0 3 * * *</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
At 4 AM, 10 AM, 4 PM:
|
||||
</Text>
|
||||
<Code size="xs">0 4,10,16 * * *</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
Sundays at 2 AM:
|
||||
</Text>
|
||||
<Code size="xs">0 2 * * 0</Code>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
1st of month at 2:30 PM:
|
||||
</Text>
|
||||
<Code size="xs">30 14 1 * *</Code>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
}
|
||||
placeholder="0 3 * * *"
|
||||
description="minute hour day month weekday"
|
||||
value={cronValue}
|
||||
onChange={(e) => handleCronChange(e.currentTarget.value)}
|
||||
error={cronError}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!disabled && (
|
||||
<Group gap="sm">
|
||||
<Anchor size="xs" onClick={switchToInterval}>
|
||||
{switchToIntervalLabel}
|
||||
</Anchor>
|
||||
<Anchor size="xs" onClick={() => setBuilderOpened(true)}>
|
||||
Open Cron Builder
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
<CronBuilder
|
||||
opened={builderOpened}
|
||||
onClose={() => setBuilderOpened(false)}
|
||||
onApply={handleBuilderApply}
|
||||
currentValue={cronValue}
|
||||
/>
|
||||
</Stack>
|
||||
) : children ? (
|
||||
<Stack gap="xs">
|
||||
{children}
|
||||
{!disabled && (
|
||||
<Anchor size="xs" onClick={switchToCron}>
|
||||
{switchToCronLabel}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<NumberInput
|
||||
label={intervalLabel}
|
||||
description={intervalDescription}
|
||||
value={intervalValue}
|
||||
onChange={onIntervalChange}
|
||||
min={min}
|
||||
disabled={disabled}
|
||||
suffix=" hours"
|
||||
/>
|
||||
{!disabled && (
|
||||
<Anchor size="xs" onClick={switchToCron}>
|
||||
{switchToCronLabel}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
background-color: transparent; /* Default background when not active */
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Active state styles */
|
||||
|
|
@ -19,9 +20,9 @@
|
|||
}
|
||||
|
||||
/* Hover effect */
|
||||
.navlink:hover {
|
||||
background-color: #2a2f34; /* Gray hover effect when not active */
|
||||
border: 1px solid #3d3d42;
|
||||
.navlink:hover, .navlink-parent-active {
|
||||
background-color: #2A2F34; /* Gray hover effect when not active */
|
||||
border: 1px solid #3D3D42;
|
||||
}
|
||||
|
||||
/* Hover effect for active state */
|
||||
|
|
@ -38,3 +39,20 @@
|
|||
.navlink:not(.navlink-collapsed) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Left indicator for open nav groups rendered outside buttons */
|
||||
.navgroup-open {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
.navgroup-open::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; /* avoid horizontal overflow; sits at container edge */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #3BA882;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,3 +354,21 @@ export const CONTAINER_EXTENSIONS = [
|
|||
'ts',
|
||||
'mpg',
|
||||
];
|
||||
|
||||
export const SUBSCRIPTION_EVENTS = {
|
||||
channel_start: 'Channel Started',
|
||||
channel_stop: 'Channel Stopped',
|
||||
channel_reconnect: 'Channel Reconnected',
|
||||
channel_error: 'Channel Error',
|
||||
channel_failover: 'Channel Failover',
|
||||
stream_switch: 'Stream Switch',
|
||||
recording_start: 'Recording Started',
|
||||
recording_end: 'Recording Ended',
|
||||
epg_refresh: 'EPG Refreshed',
|
||||
m3u_refresh: 'M3U Refreshed',
|
||||
client_connect: 'Client Connected',
|
||||
client_disconnect: 'Client Disconnected',
|
||||
login_failed: 'Login Failed',
|
||||
epg_blocked: 'EPG Blocked',
|
||||
m3u_blocked: 'M3U Blocked',
|
||||
};
|
||||
|
|
|
|||
203
frontend/src/pages/Connect.jsx
Normal file
203
frontend/src/pages/Connect.jsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Switch,
|
||||
Card,
|
||||
Flex,
|
||||
useMantineTheme,
|
||||
Text,
|
||||
Badge,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import API from '../api';
|
||||
import useConnectStore from '../store/connect';
|
||||
import { SquarePlus, Webhook, FileCode, Logs } from 'lucide-react';
|
||||
import ConnectionForm from '../components/forms/Connection';
|
||||
import { SUBSCRIPTION_EVENTS } from '../constants';
|
||||
|
||||
export default function ConnectPage() {
|
||||
const { integrations, isLoading, fetchIntegrations } = useConnectStore();
|
||||
const theme = useMantineTheme();
|
||||
const [connection, setConnection] = useState(null);
|
||||
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIntegrations();
|
||||
}, [fetchIntegrations]);
|
||||
|
||||
const newConnection = () => {
|
||||
setConnection(null);
|
||||
setIsConnectionModalOpen(true);
|
||||
};
|
||||
|
||||
const editConnection = (connection) => {
|
||||
setConnection(connection);
|
||||
setIsConnectionModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteConnection = async (id) => {
|
||||
console.log('Deleting connection', id);
|
||||
await API.deleteConnectIntegration(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => newConnection()}
|
||||
p={10}
|
||||
color={theme.tailwind.green[5]}
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
New Connection
|
||||
</Button>
|
||||
{isLoading && <div>Loading...</div>}
|
||||
{!isLoading && (
|
||||
<Box
|
||||
style={{
|
||||
gap: '1rem',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))',
|
||||
alignContent: 'start',
|
||||
}}
|
||||
display="grid"
|
||||
py={10}
|
||||
>
|
||||
{integrations.map((i) => (
|
||||
<IntegrationRow
|
||||
key={i.id}
|
||||
integration={i}
|
||||
editConnection={editConnection}
|
||||
deleteConnection={deleteConnection}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ConnectionForm
|
||||
connection={connection}
|
||||
isOpen={isConnectionModalOpen}
|
||||
onClose={() => setIsConnectionModalOpen(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationRow({ integration, editConnection, deleteConnection }) {
|
||||
const type = integration.type || 'webhook';
|
||||
const [enabled, setEnabled] = useState(!!integration.enabled);
|
||||
const webhookUrl = integration?.config?.url || '';
|
||||
const scriptPath = integration?.config?.path || '';
|
||||
|
||||
const toggleIntegration = async () => {
|
||||
try {
|
||||
await API.updateConnectIntegration(integration.id, {
|
||||
...integration,
|
||||
enabled: !enabled,
|
||||
});
|
||||
setEnabled(!enabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to update integration', error);
|
||||
} finally {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={integration.id}
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
color="#fff"
|
||||
w={'100%'}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Group align="flex-start">
|
||||
{integration.type == 'webhook' ? <Webhook /> : <FileCode />}
|
||||
<Text fw={800}>{integration.name}</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
label="Enabled"
|
||||
checked={enabled}
|
||||
onChange={toggleIntegration}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{type === 'webhook' ? (
|
||||
<Group gap={5} align="center">
|
||||
<Text fw={500}>Target:</Text>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip label={webhookUrl} withArrow multiline>
|
||||
<Text
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{webhookUrl}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={5} align="center">
|
||||
<Text fw={500}>Target:</Text>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip label={scriptPath} withArrow multiline>
|
||||
<Text
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{scriptPath}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Text>Triggers</Text>
|
||||
<Group>
|
||||
{integration.subscriptions.map(
|
||||
(sub) =>
|
||||
sub.enabled && (
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{SUBSCRIPTION_EVENTS[sub.event] || sub.event}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button size="xs" onClick={() => editConnection(integration)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={() => deleteConnection(integration.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
299
frontend/src/pages/ConnectLogs.jsx
Normal file
299
frontend/src/pages/ConnectLogs.jsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Title,
|
||||
Badge,
|
||||
Group,
|
||||
Text,
|
||||
Paper,
|
||||
NativeSelect,
|
||||
Pagination,
|
||||
Select,
|
||||
LoadingOverlay,
|
||||
} from '@mantine/core';
|
||||
import API from '../api';
|
||||
import useConnectStore from '../store/connect';
|
||||
import { FileCode, Webhook } from 'lucide-react';
|
||||
import { SUBSCRIPTION_EVENTS } from '../constants';
|
||||
import { CustomTable, useTable } from '../components/tables/CustomTable';
|
||||
import { copyToClipboard } from '../utils';
|
||||
|
||||
export default function ConnectLogsPage() {
|
||||
const { integrations, fetchIntegrations } = useConnectStore();
|
||||
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [count, setCount] = useState(0);
|
||||
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 });
|
||||
const [filters, setFilters] = useState({ type: '', integration: '' });
|
||||
|
||||
const pageCount = useMemo(
|
||||
() => Math.max(1, Math.ceil(count / Math.max(1, pagination.pageSize))),
|
||||
[count, pagination.pageSize]
|
||||
);
|
||||
|
||||
const onPageSizeChange = useCallback((e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
setPagination((prev) => ({ ...prev, pageSize: value, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const onPageIndexChange = useCallback((page) => {
|
||||
setPagination((prev) => ({ ...prev, pageIndex: page - 1 }));
|
||||
}, []);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
};
|
||||
if (filters.type) params.type = filters.type;
|
||||
if (filters.integration) params.integration = filters.integration;
|
||||
|
||||
const data = await API.getConnectLogs(params);
|
||||
const results = Array.isArray(data) ? data : data?.results || [];
|
||||
setLogs(results);
|
||||
setCount(data?.count || results.length || 0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [pagination.pageIndex, pagination.pageSize, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load integrations for filter options if not already available
|
||||
if (!integrations || integrations.length === 0) {
|
||||
fetchIntegrations?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Time',
|
||||
accessorKey: 'created_at',
|
||||
size: 180,
|
||||
cell: ({ getValue }) => (
|
||||
<Text size="sm">{new Date(getValue()).toLocaleString()}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Integration',
|
||||
accessorKey: 'subscription',
|
||||
size: 200,
|
||||
cell: ({ getValue }) => {
|
||||
const subscription = getValue();
|
||||
const integration = integrations.find(
|
||||
(i) => i.id === subscription?.integration
|
||||
);
|
||||
const isWebhook = integration?.type === 'webhook';
|
||||
return (
|
||||
<Group gap={6}>
|
||||
{isWebhook ? <Webhook size={16} /> : <FileCode size={16} />}
|
||||
<Text size="sm">{integration?.name || '-'}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Event',
|
||||
accessorKey: 'subscription',
|
||||
size: 160,
|
||||
cell: ({ getValue }) => (
|
||||
<Text size="sm">{SUBSCRIPTION_EVENTS[getValue()?.event] || '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Response',
|
||||
accessorKey: 'response_payload',
|
||||
grow: true,
|
||||
cell: ({ getValue }) => (
|
||||
<Text
|
||||
size="sm"
|
||||
truncate
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() =>
|
||||
copyToClipboard(getValue() ? JSON.stringify(getValue()) : '')
|
||||
}
|
||||
>
|
||||
{getValue() ? JSON.stringify(getValue()) : '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Error',
|
||||
accessorKey: 'error_message',
|
||||
size: 150,
|
||||
cell: ({ getValue }) => (
|
||||
<Text
|
||||
size="sm"
|
||||
truncate
|
||||
onClick={() => copyToClipboard(getValue() || '')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{getValue() || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
size: 100,
|
||||
cell: ({ getValue }) => (
|
||||
<Badge
|
||||
color={getValue() === 'success' ? 'green' : 'red'}
|
||||
variant="light"
|
||||
>
|
||||
{getValue()}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
[integrations]
|
||||
);
|
||||
|
||||
const data = useMemo(() => logs, [logs]);
|
||||
const allRowIds = useMemo(() => logs.map((l) => l.id), [logs]);
|
||||
|
||||
const renderHeaderCell = (header) => (
|
||||
<Text size="sm" name={header.id}>
|
||||
{header.column.columnDef.header}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const table = useTable({
|
||||
columns,
|
||||
data,
|
||||
allRowIds,
|
||||
enablePagination: false,
|
||||
enableRowSelection: false,
|
||||
enableRowVirtualization: false,
|
||||
renderTopToolbar: false,
|
||||
manualSorting: false,
|
||||
manualFiltering: false,
|
||||
manualPagination: true,
|
||||
headerCellRenderFns: {
|
||||
created_at: renderHeaderCell,
|
||||
subscription: renderHeaderCell,
|
||||
response_payload: renderHeaderCell,
|
||||
error_message: renderHeaderCell,
|
||||
status: renderHeaderCell,
|
||||
},
|
||||
});
|
||||
|
||||
const startIdx = pagination.pageIndex * pagination.pageSize + 1;
|
||||
const endIdx = Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
count
|
||||
);
|
||||
const paginationString = `Showing ${startIdx}-${endIdx} of ${count}`;
|
||||
|
||||
const integrationOptions = useMemo(
|
||||
() => integrations.map((i) => ({ value: String(i.id), label: i.name })),
|
||||
[integrations]
|
||||
);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Title order={3} fw={'bold'}>
|
||||
Connect Logs
|
||||
</Title>
|
||||
<Paper
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 65px)',
|
||||
backgroundColor: '#27272A',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
}}
|
||||
>
|
||||
<Group gap={12} p={12} style={{ borderBottom: '1px solid #3f3f46' }}>
|
||||
<Text size="sm">Type</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
data={[
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'webhook', label: 'Webhooks' },
|
||||
{ value: 'script', label: 'Scripts' },
|
||||
]}
|
||||
value={filters.type}
|
||||
onChange={(value) =>
|
||||
setFilters((prev) => ({ ...prev, type: value }))
|
||||
}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Text size="sm">Integration</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
searchable
|
||||
data={[{ value: '', label: 'All' }, ...integrationOptions]}
|
||||
value={filters.integration}
|
||||
onChange={(value) =>
|
||||
setFilters((prev) => ({ ...prev, integration: value }))
|
||||
}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 100px)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
border: 'solid 1px rgb(68,68,68)',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: '900px', position: 'relative' }}>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
<CustomTable table={table} />
|
||||
</div>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 3,
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap={5}
|
||||
justify="center"
|
||||
style={{ padding: 8, borderTop: '1px solid #666' }}
|
||||
>
|
||||
<Text size="xs">Page Size</Text>
|
||||
<NativeSelect
|
||||
size="xxs"
|
||||
value={pagination.pageSize}
|
||||
data={['25', '50', '100', '250']}
|
||||
onChange={onPageSizeChange}
|
||||
style={{ paddingRight: 20 }}
|
||||
/>
|
||||
<Pagination
|
||||
total={pageCount}
|
||||
value={pagination.pageIndex + 1}
|
||||
onChange={onPageIndexChange}
|
||||
size="xs"
|
||||
withEdges
|
||||
style={{ paddingRight: 20 }}
|
||||
/>
|
||||
<Text size="xs">{paginationString}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
46
frontend/src/store/connect.jsx
Normal file
46
frontend/src/store/connect.jsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
|
||||
const useConnectStore = create((set, get) => ({
|
||||
integrations: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchIntegrations: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const list = await API.getConnectIntegrations();
|
||||
console.log(list);
|
||||
set({
|
||||
integrations: Array.isArray(list) ? list : list?.results || [],
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addIntegration: (integration) =>
|
||||
set((state) => ({ integrations: [...state.integrations, integration] })),
|
||||
|
||||
updateIntegration: (integration) =>
|
||||
set((state) => ({
|
||||
integrations: state.integrations.map((i) =>
|
||||
i.id === integration.id ? integration : i
|
||||
),
|
||||
})),
|
||||
|
||||
removeIntegration: (id) =>
|
||||
set((state) => ({
|
||||
integrations: state.integrations.filter((i) => i.id !== id),
|
||||
})),
|
||||
|
||||
updateIntegrationSubscriptions: (id, events) =>
|
||||
set((state) => ({
|
||||
integrations: state.integrations.map((i) =>
|
||||
i.id === id ? { ...i, subscriptions: events } : i
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useConnectStore;
|
||||
63
frontend/src/utils/cronUtils.js
Normal file
63
frontend/src/utils/cronUtils.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Cron expression validation utility.
|
||||
*
|
||||
* Shared across CronModal, BackupManager, and any other component
|
||||
* that needs to validate 5-part cron expressions.
|
||||
*/
|
||||
|
||||
export function validateCronExpression(expression) {
|
||||
if (!expression || expression.trim() === '') {
|
||||
return { valid: false, error: 'Cron expression is required' };
|
||||
}
|
||||
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'Cron expression must have exactly 5 parts: minute hour day month weekday',
|
||||
};
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
const cronPartRegex =
|
||||
/^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/;
|
||||
|
||||
const fields = [
|
||||
{ value: minute, label: 'minute', min: 0, max: 59 },
|
||||
{ value: hour, label: 'hour', min: 0, max: 23 },
|
||||
{ value: dayOfMonth, label: 'day', min: 1, max: 31 },
|
||||
{ value: month, label: 'month', min: 1, max: 12 },
|
||||
{ value: dayOfWeek, label: 'weekday', min: 0, max: 6 },
|
||||
];
|
||||
|
||||
for (const { value, label, min, max } of fields) {
|
||||
if (!cronPartRegex.test(value)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid ${label} field (${min}-${max}, *, or cron syntax)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Extra numeric-range check for plain numbers
|
||||
if (
|
||||
!(
|
||||
value === '*' ||
|
||||
value.includes('/') ||
|
||||
value.includes('-') ||
|
||||
value.includes(',')
|
||||
)
|
||||
) {
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < min || num > max) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${label.charAt(0).toUpperCase() + label.slice(1)} must be between ${min} and ${max}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue