mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
initial connect feature
This commit is contained in:
parent
78a53e03db
commit
24f812dc4d
28 changed files with 1761 additions and 141 deletions
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ urlpatterns = [
|
|||
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
|
||||
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
|
||||
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')),
|
||||
|
|
|
|||
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
|
||||
133
apps/connect/api_views.py
Normal file
133
apps/connect/api_views.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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 .models import Integration, EventSubscription, DeliveryLog
|
||||
from .serializers import (
|
||||
IntegrationSerializer,
|
||||
EventSubscriptionSerializer,
|
||||
DeliveryLogSerializer,
|
||||
)
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
permission_classes_by_action,
|
||||
)
|
||||
|
||||
|
||||
class IntegrationViewSet(viewsets.ModelViewSet):
|
||||
queryset = Integration.objects.all()
|
||||
serializer_class = IntegrationSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
return [Authenticated()]
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
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
|
||||
28
apps/connect/handlers/script.py
Normal file
28
apps/connect/handlers/script.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# connect/handlers/script.py
|
||||
import os
|
||||
import subprocess
|
||||
from .base import IntegrationHandler
|
||||
|
||||
class ScriptHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
script_path = self.integration.config.get("path")
|
||||
|
||||
# Build environment variables from payload (prefixed for clarity)
|
||||
env = os.environ.copy()
|
||||
for key, value in (self.payload or {}).items():
|
||||
# Convert keys to upper snake case and prefix
|
||||
env_key = f"DISPATCHARR_{str(key).upper()}"
|
||||
env[env_key] = str(value) if value is not None else ""
|
||||
|
||||
result = subprocess.run(
|
||||
[script_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.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
36
apps/connect/models.py
Normal file
36
apps/connect/models.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
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 = [
|
||||
("channel_start", "Channel Started"),
|
||||
("channel_stop", "Channel Stopped"),
|
||||
("movie_added", "Movie Added"),
|
||||
("series_added", "Series Added"),
|
||||
("download_complete", "Download Complete"),
|
||||
]
|
||||
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)
|
||||
46
apps/connect/serializers.py
Normal file
46
apps/connect/serializers.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Integration, EventSubscription, DeliveryLog
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
73
apps/connect/utils.py
Normal file
73
apps/connect/utils.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# connect/utils.py
|
||||
import logging
|
||||
from django.template import Template, Context
|
||||
from .models import EventSubscription, DeliveryLog
|
||||
from .handlers.webhook import WebhookHandler
|
||||
from .handlers.script import ScriptHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HANDLERS = {
|
||||
"webhook": WebhookHandler,
|
||||
"script": ScriptHandler,
|
||||
}
|
||||
|
||||
|
||||
def trigger_event(event_name, payload):
|
||||
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}'")
|
||||
|
||||
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}")
|
||||
|
|
@ -415,6 +415,79 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
|
|||
details=details
|
||||
)
|
||||
|
||||
# Trigger connect integrations for specific events
|
||||
if event_type in ("channel_start", "channel_stop"):
|
||||
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
|
||||
|
||||
# Get max events from settings (default 100)
|
||||
try:
|
||||
from .models import CoreSettings
|
||||
|
|
@ -509,4 +582,3 @@ def send_notification_dismissed(notification_key):
|
|||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification dismissed event: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ INSTALLED_APPS = [
|
|||
"apps.proxy.apps.ProxyConfig",
|
||||
"apps.proxy.ts_proxy",
|
||||
"apps.vod.apps.VODConfig",
|
||||
"apps.connect.apps.ConnectConfig",
|
||||
"core",
|
||||
"daphne",
|
||||
"drf_spectacular",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -171,7 +172,7 @@ export default class API {
|
|||
|
||||
static async logout() {
|
||||
return await request(`${host}/api/accounts/auth/logout/`, {
|
||||
auth: true, // Send JWT token so backend can identify the user
|
||||
auth: true, // Send JWT token so backend can identify the user
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
|
@ -261,15 +262,11 @@ export default class API {
|
|||
API.lastQueryParams = newParams;
|
||||
|
||||
const [response, ids] = await Promise.all([
|
||||
request(
|
||||
`${host}/api/channels/channels/?${newParams.toString()}`
|
||||
),
|
||||
request(`${host}/api/channels/channels/?${newParams.toString()}`),
|
||||
API.getAllChannelIds(newParams),
|
||||
]);
|
||||
|
||||
useChannelsTableStore
|
||||
.getState()
|
||||
.queryChannels(response, newParams);
|
||||
useChannelsTableStore.getState().queryChannels(response, newParams);
|
||||
useChannelsTableStore.getState().setAllQueryIds(ids);
|
||||
|
||||
return response;
|
||||
|
|
@ -390,7 +387,8 @@ export default class API {
|
|||
channelData.channel_number === '' ||
|
||||
channelData.channel_number === null ||
|
||||
channelData.channel_number === undefined ||
|
||||
(typeof channelData.channel_number === 'string' && channelData.channel_number.trim() === '')
|
||||
(typeof channelData.channel_number === 'string' &&
|
||||
channelData.channel_number.trim() === '')
|
||||
) {
|
||||
delete channelData.channel_number;
|
||||
}
|
||||
|
|
@ -719,7 +717,11 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async createChannelsFromStreamsAsync(streamIds, channelProfileIds = null, startingChannelNumber = null) {
|
||||
static async createChannelsFromStreamsAsync(
|
||||
streamIds,
|
||||
channelProfileIds = null,
|
||||
startingChannelNumber = null
|
||||
) {
|
||||
try {
|
||||
const requestBody = {
|
||||
stream_ids: streamIds,
|
||||
|
|
@ -815,15 +817,11 @@ export default class API {
|
|||
|
||||
try {
|
||||
const [response, ids] = await Promise.all([
|
||||
request(
|
||||
`${host}/api/channels/streams/?${params.toString()}`
|
||||
),
|
||||
request(`${host}/api/channels/streams/?${params.toString()}`),
|
||||
API.getAllStreamIds(params),
|
||||
]);
|
||||
|
||||
useStreamsTableStore
|
||||
.getState()
|
||||
.queryStreams(response, params);
|
||||
useStreamsTableStore.getState().queryStreams(response, params);
|
||||
useStreamsTableStore.getState().setAllQueryIds(ids);
|
||||
|
||||
return response;
|
||||
|
|
@ -1179,13 +1177,10 @@ export default class API {
|
|||
|
||||
static async getCurrentPrograms(channelIds = null) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/epg/current-programs/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { channel_ids: channelIds },
|
||||
}
|
||||
);
|
||||
const response = await request(`${host}/api/epg/current-programs/`, {
|
||||
method: 'POST',
|
||||
body: { channel_ids: channelIds },
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -1318,9 +1313,15 @@ export default class API {
|
|||
errorNotification('Failed to retrieve timezones', e);
|
||||
// Return fallback data instead of throwing
|
||||
return {
|
||||
timezones: ['UTC', 'US/Eastern', 'US/Central', 'US/Mountain', 'US/Pacific'],
|
||||
timezones: [
|
||||
'UTC',
|
||||
'US/Eastern',
|
||||
'US/Central',
|
||||
'US/Mountain',
|
||||
'US/Pacific',
|
||||
],
|
||||
grouped: {},
|
||||
count: 5
|
||||
count: 5,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1444,16 +1445,22 @@ export default class API {
|
|||
|
||||
static async refreshAccountInfo(profileId) {
|
||||
try {
|
||||
const response = await request(`${host}/api/m3u/refresh-account-info/${profileId}/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/api/m3u/refresh-account-info/${profileId}/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
// If it's a structured error response, return it instead of throwing
|
||||
if (e.body && typeof e.body === 'object') {
|
||||
return e.body;
|
||||
}
|
||||
errorNotification(`Failed to refresh account info for profile ${profileId}`, e);
|
||||
errorNotification(
|
||||
`Failed to refresh account info for profile ${profileId}`,
|
||||
e
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
@ -1580,7 +1587,11 @@ export default class API {
|
|||
});
|
||||
|
||||
// Wait for the task to complete using token for auth
|
||||
const result = await API.waitForBackupTask(response.task_id, onProgress, response.task_token);
|
||||
const result = await API.waitForBackupTask(
|
||||
response.task_id,
|
||||
onProgress,
|
||||
response.task_token
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to create backup', e);
|
||||
|
|
@ -1593,13 +1604,10 @@ export default class API {
|
|||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await request(
|
||||
`${host}/api/backups/upload/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
const response = await request(`${host}/api/backups/upload/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to upload backup', e);
|
||||
|
|
@ -1622,7 +1630,9 @@ export default class API {
|
|||
static async getDownloadToken(filename) {
|
||||
// Get a download token from the server
|
||||
try {
|
||||
const response = await request(`${host}/api/backups/${encodeURIComponent(filename)}/download-token/`);
|
||||
const response = await request(
|
||||
`${host}/api/backups/${encodeURIComponent(filename)}/download-token/`
|
||||
);
|
||||
return response.token;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
|
|
@ -1666,7 +1676,11 @@ export default class API {
|
|||
|
||||
// Wait for the task to complete using token for auth
|
||||
// Token-based auth allows status polling even after DB restore invalidates user sessions
|
||||
const result = await API.waitForBackupTask(response.task_id, onProgress, response.task_token);
|
||||
const result = await API.waitForBackupTask(
|
||||
response.task_id,
|
||||
onProgress,
|
||||
response.task_token
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to restore backup', e);
|
||||
|
|
@ -1741,17 +1755,27 @@ export default class API {
|
|||
return response;
|
||||
} catch (e) {
|
||||
// Show only the concise error message for plugin import
|
||||
const msg = (e?.body && (e.body.error || e.body.detail)) || e?.message || 'Failed to import plugin';
|
||||
notifications.show({ title: 'Import failed', message: msg, color: 'red' });
|
||||
const msg =
|
||||
(e?.body && (e.body.error || e.body.detail)) ||
|
||||
e?.message ||
|
||||
'Failed to import plugin';
|
||||
notifications.show({
|
||||
title: 'Import failed',
|
||||
message: msg,
|
||||
color: 'red',
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async deletePlugin(key) {
|
||||
try {
|
||||
const response = await request(`${host}/api/plugins/plugins/${key}/delete/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/api/plugins/plugins/${key}/delete/`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to delete plugin', e);
|
||||
|
|
@ -1776,10 +1800,13 @@ export default class API {
|
|||
|
||||
static async runPluginAction(key, action, params = {}) {
|
||||
try {
|
||||
const response = await request(`${host}/api/plugins/plugins/${key}/run/`, {
|
||||
method: 'POST',
|
||||
body: { action, params },
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/api/plugins/plugins/${key}/run/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { action, params },
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to run plugin action', e);
|
||||
|
|
@ -1788,10 +1815,13 @@ export default class API {
|
|||
|
||||
static async setPluginEnabled(key, enabled) {
|
||||
try {
|
||||
const response = await request(`${host}/api/plugins/plugins/${key}/enabled/`, {
|
||||
method: 'POST',
|
||||
body: { enabled },
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/api/plugins/plugins/${key}/enabled/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { enabled },
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update plugin enabled state', e);
|
||||
|
|
@ -1973,7 +2003,7 @@ export default class API {
|
|||
if (!logoIds || logoIds.length === 0) return [];
|
||||
|
||||
const params = new URLSearchParams();
|
||||
logoIds.forEach(id => params.append('ids', id));
|
||||
logoIds.forEach((id) => params.append('ids', id));
|
||||
// Disable pagination for ID-based queries to get all matching logos
|
||||
params.append('no_pagination', 'true');
|
||||
|
||||
|
|
@ -2440,10 +2470,13 @@ export default class API {
|
|||
|
||||
static async updateRecurringRule(ruleId, payload) {
|
||||
try {
|
||||
const response = await request(`${host}/api/channels/recurring-rules/${ruleId}/`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/api/channels/recurring-rules/${ruleId}/`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to update recurring rule ${ruleId}`, e);
|
||||
|
|
@ -2462,9 +2495,13 @@ export default class API {
|
|||
|
||||
static async deleteRecording(id) {
|
||||
try {
|
||||
await request(`${host}/api/channels/recordings/${id}/`, { method: 'DELETE' });
|
||||
await request(`${host}/api/channels/recordings/${id}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
// Optimistically remove locally for instant UI update
|
||||
try { useChannelsStore.getState().removeRecording(id); } catch {}
|
||||
try {
|
||||
useChannelsStore.getState().removeRecording(id);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to delete recording ${id}`, e);
|
||||
}
|
||||
|
|
@ -2472,9 +2509,12 @@ export default class API {
|
|||
|
||||
static async runComskip(recordingId) {
|
||||
try {
|
||||
const resp = await request(`${host}/api/channels/recordings/${recordingId}/comskip/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const resp = await request(
|
||||
`${host}/api/channels/recordings/${recordingId}/comskip/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
// Refresh recordings list to reflect comskip status when done later
|
||||
// This endpoint just queues the task; the websocket/refresh will update eventually
|
||||
return resp;
|
||||
|
|
@ -2512,7 +2552,9 @@ export default class API {
|
|||
static async deleteSeriesRule(tvgId) {
|
||||
try {
|
||||
const encodedTvgId = encodeURIComponent(tvgId);
|
||||
await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, { method: 'DELETE' });
|
||||
await request(`${host}/api/channels/series-rules/${encodedTvgId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
notifications.show({ title: 'Series rule removed' });
|
||||
} catch (e) {
|
||||
errorNotification('Failed to remove series rule', e);
|
||||
|
|
@ -2522,9 +2564,12 @@ export default class API {
|
|||
|
||||
static async deleteAllUpcomingRecordings() {
|
||||
try {
|
||||
const resp = await request(`${host}/api/channels/recordings/bulk-delete-upcoming/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const resp = await request(
|
||||
`${host}/api/channels/recordings/bulk-delete-upcoming/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
notifications.show({ title: `Removed ${resp.removed || 0} upcoming` });
|
||||
useChannelsStore.getState().fetchRecordings();
|
||||
return resp;
|
||||
|
|
@ -2545,12 +2590,19 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async bulkRemoveSeriesRecordings({ tvg_id, title = null, scope = 'title' }) {
|
||||
static async bulkRemoveSeriesRecordings({
|
||||
tvg_id,
|
||||
title = null,
|
||||
scope = 'title',
|
||||
}) {
|
||||
try {
|
||||
const resp = await request(`${host}/api/channels/series-rules/bulk-remove/`, {
|
||||
method: 'POST',
|
||||
body: { tvg_id, title, scope },
|
||||
});
|
||||
const resp = await request(
|
||||
`${host}/api/channels/series-rules/bulk-remove/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { tvg_id, title, scope },
|
||||
}
|
||||
);
|
||||
notifications.show({ title: `Removed ${resp.removed || 0} scheduled` });
|
||||
return resp;
|
||||
} catch (e) {
|
||||
|
|
@ -2712,13 +2764,10 @@ export default class API {
|
|||
try {
|
||||
// Use POST for large ID lists to avoid URL length limitations
|
||||
if (ids.length > 50) {
|
||||
const response = await request(
|
||||
`${host}/api/channels/streams/by-ids/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { ids },
|
||||
}
|
||||
);
|
||||
const response = await request(`${host}/api/channels/streams/by-ids/`, {
|
||||
method: 'POST',
|
||||
body: { ids },
|
||||
});
|
||||
return response;
|
||||
} else {
|
||||
// Use GET for small ID lists for backward compatibility
|
||||
|
|
@ -2744,8 +2793,9 @@ export default class API {
|
|||
return response;
|
||||
} catch (e) {
|
||||
// Don't show error notification for "Invalid page" errors as they're handled gracefully
|
||||
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
|
||||
e.message?.includes('Invalid page');
|
||||
const isInvalidPage =
|
||||
e.body?.detail?.includes('Invalid page') ||
|
||||
e.message?.includes('Invalid page');
|
||||
|
||||
if (!isInvalidPage) {
|
||||
errorNotification('Failed to retrieve movies', e);
|
||||
|
|
@ -2762,8 +2812,9 @@ export default class API {
|
|||
return response;
|
||||
} catch (e) {
|
||||
// Don't show error notification for "Invalid page" errors as they're handled gracefully
|
||||
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
|
||||
e.message?.includes('Invalid page');
|
||||
const isInvalidPage =
|
||||
e.body?.detail?.includes('Invalid page') ||
|
||||
e.message?.includes('Invalid page');
|
||||
|
||||
if (!isInvalidPage) {
|
||||
errorNotification('Failed to retrieve series', e);
|
||||
|
|
@ -2774,7 +2825,10 @@ export default class API {
|
|||
|
||||
static async getAllContent(params = new URLSearchParams()) {
|
||||
try {
|
||||
console.log('Calling getAllContent with URL:', `${host}/api/vod/all/?${params.toString()}`);
|
||||
console.log(
|
||||
'Calling getAllContent with URL:',
|
||||
`${host}/api/vod/all/?${params.toString()}`
|
||||
);
|
||||
const response = await request(
|
||||
`${host}/api/vod/all/?${params.toString()}`
|
||||
);
|
||||
|
|
@ -2787,8 +2841,9 @@ export default class API {
|
|||
console.error('Error message:', e.message);
|
||||
|
||||
// Don't show error notification for "Invalid page" errors as they're handled gracefully
|
||||
const isInvalidPage = e.body?.detail?.includes('Invalid page') ||
|
||||
e.message?.includes('Invalid page');
|
||||
const isInvalidPage =
|
||||
e.body?.detail?.includes('Invalid page') ||
|
||||
e.message?.includes('Invalid page');
|
||||
|
||||
if (!isInvalidPage) {
|
||||
errorNotification('Failed to retrieve content', e);
|
||||
|
|
@ -2911,9 +2966,8 @@ export default class API {
|
|||
);
|
||||
|
||||
// Update the store with fetched notifications
|
||||
const { default: useNotificationsStore } = await import(
|
||||
'./store/notifications'
|
||||
);
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore.getState().setNotifications(response.notifications);
|
||||
|
||||
return response;
|
||||
|
|
@ -2928,9 +2982,8 @@ export default class API {
|
|||
const response = await request(`${host}/api/core/notifications/count/`);
|
||||
|
||||
// Update the store with the count
|
||||
const { default: useNotificationsStore } = await import(
|
||||
'./store/notifications'
|
||||
);
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore.getState().setUnreadCount(response.unread_count);
|
||||
|
||||
return response;
|
||||
|
|
@ -2962,10 +3015,11 @@ export default class API {
|
|||
);
|
||||
|
||||
// Update the store
|
||||
const { default: useNotificationsStore } = await import(
|
||||
'./store/notifications'
|
||||
);
|
||||
useNotificationsStore.getState().dismissNotification(response.notification_key);
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore
|
||||
.getState()
|
||||
.dismissNotification(response.notification_key);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -2984,9 +3038,8 @@ export default class API {
|
|||
);
|
||||
|
||||
// Update the store
|
||||
const { default: useNotificationsStore } = await import(
|
||||
'./store/notifications'
|
||||
);
|
||||
const { default: useNotificationsStore } =
|
||||
await import('./store/notifications');
|
||||
useNotificationsStore.getState().dismissAllNotifications();
|
||||
|
||||
return response;
|
||||
|
|
@ -2994,4 +3047,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
|
||||
|
|
|
|||
189
frontend/src/components/forms/Connection.jsx
Normal file
189
frontend/src/components/forms/Connection.jsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Flex,
|
||||
TextInput,
|
||||
Box,
|
||||
Title,
|
||||
Checkbox,
|
||||
Text,
|
||||
} 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([]);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
console.log(values);
|
||||
try {
|
||||
setSubmitting(true);
|
||||
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 connection', 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} onClose={handleClose} title="Connection">
|
||||
<Stack>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<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}>
|
||||
Triggers
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{EVENT_OPTIONS.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
checked={selectedEvents.includes(opt.value)}
|
||||
onChange={() => toggleEvent(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionForm;
|
||||
|
|
@ -44,7 +44,11 @@ const toIsoIfDate = (value) => {
|
|||
const toTimeString = (value) => {
|
||||
if (!value) return '00:00';
|
||||
if (typeof value === 'string') {
|
||||
const parsed = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], true);
|
||||
const parsed = dayjs(
|
||||
value,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'],
|
||||
true
|
||||
);
|
||||
if (parsed.isValid()) return parsed.format('HH:mm');
|
||||
return value;
|
||||
}
|
||||
|
|
@ -77,7 +81,12 @@ const timeChange = (setter) => (valOrEvent) => {
|
|||
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
|
||||
};
|
||||
|
||||
const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) => {
|
||||
const RecordingModal = ({
|
||||
recording = null,
|
||||
channel = null,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
|
||||
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
|
||||
|
|
@ -93,9 +102,17 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
|
|||
const singleForm = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
channel_id: recording ? `${recording.channel}` : channel ? `${channel.id}` : '',
|
||||
start_time: recording ? asDate(recording.start_time) || defaultStart : defaultStart,
|
||||
end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd,
|
||||
channel_id: recording
|
||||
? `${recording.channel}`
|
||||
: channel
|
||||
? `${channel.id}`
|
||||
: '',
|
||||
start_time: recording
|
||||
? asDate(recording.start_time) || defaultStart
|
||||
: defaultStart,
|
||||
end_time: recording
|
||||
? asDate(recording.end_time) || defaultEnd
|
||||
: defaultEnd,
|
||||
},
|
||||
validate: {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
|
|
@ -126,13 +143,22 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
|
|||
},
|
||||
validate: {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
days_of_week: (value) => (value && value.length ? null : 'Pick at least one day'),
|
||||
days_of_week: (value) =>
|
||||
value && value.length ? null : 'Pick at least one day',
|
||||
start_time: (value) => (value ? null : 'Select a start time'),
|
||||
end_time: (value, values) => {
|
||||
if (!value) return 'Select an end time';
|
||||
const start = dayjs(values.start_time, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
|
||||
const start = dayjs(
|
||||
values.start_time,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A'],
|
||||
true
|
||||
);
|
||||
const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
|
||||
if (start.isValid() && end.isValid() && end.diff(start, 'minute') === 0) {
|
||||
if (
|
||||
start.isValid() &&
|
||||
end.isValid() &&
|
||||
end.diff(start, 'minute') === 0
|
||||
) {
|
||||
return 'End time must differ from start time';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -192,7 +218,10 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
|
|||
if (aNum === bNum) return (a.name || '').localeCompare(b.name || '');
|
||||
return aNum - bNum;
|
||||
});
|
||||
return list.map((item) => ({ value: `${item.id}`, label: item.name || `Channel ${item.id}` }));
|
||||
return list.map((item) => ({
|
||||
value: `${item.id}`,
|
||||
label: item.name || `Channel ${item.id}`,
|
||||
}));
|
||||
}, [channels]);
|
||||
|
||||
const resetForms = () => {
|
||||
|
|
@ -287,7 +316,8 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
|
|||
icon={<CircleAlert />}
|
||||
style={{ paddingBottom: 5, marginBottom: 12 }}
|
||||
>
|
||||
Recordings may fail if active streams or overlapping recordings use up all available tuners.
|
||||
Recordings may fail if active streams or overlapping recordings use up
|
||||
all available tuners.
|
||||
</Alert>
|
||||
|
||||
<Stack gap="md">
|
||||
|
|
@ -330,14 +360,24 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
|
|||
key={singleForm.key('start_time')}
|
||||
label="Start"
|
||||
valueFormat="MMM D, YYYY h:mm A"
|
||||
timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }}
|
||||
timeInputProps={{
|
||||
format: '12',
|
||||
withSeconds: false,
|
||||
amLabel: 'AM',
|
||||
pmLabel: 'PM',
|
||||
}}
|
||||
/>
|
||||
<DateTimePicker
|
||||
{...singleForm.getInputProps('end_time')}
|
||||
key={singleForm.key('end_time')}
|
||||
label="End"
|
||||
valueFormat="MMM D, YYYY h:mm A"
|
||||
timeInputProps={{ format: '12', withSeconds: false, amLabel: 'AM', pmLabel: 'PM' }}
|
||||
timeInputProps={{
|
||||
format: '12',
|
||||
withSeconds: false,
|
||||
amLabel: 'AM',
|
||||
pmLabel: 'PM',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -364,14 +404,19 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
|
|||
label="Start date"
|
||||
value={recurringForm.values.start_date}
|
||||
onChange={(value) =>
|
||||
recurringForm.setFieldValue('start_date', value || new Date())
|
||||
recurringForm.setFieldValue(
|
||||
'start_date',
|
||||
value || new Date()
|
||||
)
|
||||
}
|
||||
valueFormat="MMM D, YYYY"
|
||||
/>
|
||||
<DatePickerInput
|
||||
label="End date"
|
||||
value={recurringForm.values.end_date}
|
||||
onChange={(value) => recurringForm.setFieldValue('end_date', value)}
|
||||
onChange={(value) =>
|
||||
recurringForm.setFieldValue('end_date', value)
|
||||
}
|
||||
valueFormat="MMM D, YYYY"
|
||||
minDate={recurringForm.values.start_date || undefined}
|
||||
/>
|
||||
|
|
@ -382,11 +427,14 @@ const RecordingModal = ({ recording = null, channel = null, isOpen, onClose }) =
|
|||
label="Start time"
|
||||
value={recurringForm.values.start_time}
|
||||
onChange={timeChange((val) =>
|
||||
recurringForm.setFieldValue('start_time', toTimeString(val))
|
||||
recurringForm.setFieldValue(
|
||||
'start_time',
|
||||
toTimeString(val)
|
||||
)
|
||||
)}
|
||||
onBlur={() => recurringForm.validateField('start_time')}
|
||||
withSeconds={false}
|
||||
format="12" // shows 12-hour (so "00:00" renders "12:00 AM")
|
||||
format="12" // shows 12-hour (so "00:00" renders "12:00 AM")
|
||||
inputMode="numeric"
|
||||
amLabel="AM"
|
||||
pmLabel="PM"
|
||||
|
|
|
|||
|
|
@ -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,7 +20,7 @@
|
|||
}
|
||||
|
||||
/* Hover effect */
|
||||
.navlink:hover {
|
||||
.navlink:hover, .navlink-parent-active {
|
||||
background-color: #2A2F34; /* Gray hover effect when not active */
|
||||
border: 1px solid #3D3D42;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -326,22 +326,36 @@ export const REGION_CHOICES = [
|
|||
|
||||
export const VOD_TYPES = {
|
||||
MOVIE: 'movie',
|
||||
EPISODE: 'episode'
|
||||
EPISODE: 'episode',
|
||||
};
|
||||
|
||||
export const VOD_FILTERS = {
|
||||
ALL: 'all',
|
||||
MOVIES: 'movies',
|
||||
SERIES: 'series'
|
||||
SERIES: 'series',
|
||||
};
|
||||
|
||||
export const VOD_SORT_OPTIONS = [
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
{ value: 'created_at', label: 'Date Added' },
|
||||
{ value: 'rating', label: 'Rating' }
|
||||
{ value: 'rating', label: 'Rating' },
|
||||
];
|
||||
|
||||
export const CONTAINER_EXTENSIONS = [
|
||||
'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'mpg'
|
||||
'mp4',
|
||||
'mkv',
|
||||
'avi',
|
||||
'mov',
|
||||
'wmv',
|
||||
'flv',
|
||||
'webm',
|
||||
'm4v',
|
||||
'ts',
|
||||
'mpg',
|
||||
];
|
||||
|
||||
export const SUBSCRIPTION_EVENTS = {
|
||||
channel_start: 'Channel Started',
|
||||
channel_stop: 'Channel Stopped',
|
||||
};
|
||||
|
|
|
|||
204
frontend/src/pages/Connect.jsx
Normal file
204
frontend/src/pages/Connect.jsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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"
|
||||
maw={400}
|
||||
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue