initial connect feature

This commit is contained in:
dekzter 2026-02-08 09:29:22 -05:00
parent 78a53e03db
commit 24f812dc4d
28 changed files with 1761 additions and 141 deletions

View file

@ -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"})

View file

@ -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
View file

17
apps/connect/api_urls.py Normal file
View 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
View 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
View 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'

View file

View file

View 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

View 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,
}

View 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}

View 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'),
),
]

View file

36
apps/connect/models.py Normal file
View 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)

View 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
View 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}")

View file

@ -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}")

View file

@ -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",

View file

@ -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 />} />

View file

@ -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);
}
}
}

View file

@ -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

View 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;

View file

@ -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"

View file

@ -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;
}

View file

@ -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',
};

View 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>
);
}

View 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>
);
}

View 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;