From e6ffeb19deeeb13a0b35af89b6ce3f4ba3104eb3 Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 11 Feb 2026 19:50:11 -0500 Subject: [PATCH] added event support to plugins --- apps/connect/utils.py | 79 ++++++++-- apps/plugins/serializers.py | 3 + core/utils.py | 144 ++++++++++--------- frontend/src/api.js | 29 +++- frontend/src/components/cards/PluginCard.jsx | 93 ++++++++---- frontend/src/components/forms/Connection.jsx | 8 +- frontend/src/pages/Connect.jsx | 1 - 7 files changed, 240 insertions(+), 117 deletions(-) diff --git a/apps/connect/utils.py b/apps/connect/utils.py index dcd44132..a276619b 100644 --- a/apps/connect/utils.py +++ b/apps/connect/utils.py @@ -1,9 +1,10 @@ # connect/utils.py -import logging +import logging, json from django.template import Template, Context from .models import EventSubscription, DeliveryLog from .handlers.webhook import WebhookHandler from .handlers.script import ScriptHandler +from apps.plugins.loader import PluginManager logger = logging.getLogger(__name__) @@ -12,21 +13,44 @@ HANDLERS = { "script": ScriptHandler, } +SUPPORTED_EVENTS = [ + "channel_start", + "channel_stop", + "channel_reconnect", + "channel_error", + "channel_failover", + "stream_switch", + "recording_start", + "recording_end", + "epg_refresh", + "m3u_refresh", + "client_connect", + "client_disconnect", +] + 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") + if event_name not in SUPPORTED_EVENTS: + logger.debug(f"Unsupported event '{event_name}' - skipping") + return + + logger.debug( + f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}" ) + subscriptions = EventSubscription.objects.filter( + event=event_name, enabled=True + ).select_related("integration") count = subscriptions.count() logger.info(f"Found {count} connect subscription(s) for event '{event_name}'") + # First, fetch all subscriptions and trigger for sub in subscriptions: integration = sub.integration if not integration.enabled: - logger.debug(f"Skipping disabled integration id={integration.id} name={integration.name}") + logger.debug( + f"Skipping disabled integration id={integration.id} name={integration.name}" + ) continue # apply optional payload template @@ -37,7 +61,9 @@ def trigger_event(event_name, payload): 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}") + logger.error( + f"Payload template render failed for subscription id={sub.id}: {e}" + ) final_payload = payload handler_cls = HANDLERS.get(integration.type) @@ -48,11 +74,15 @@ def trigger_event(event_name, payload): 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})") + 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}") + logger.debug( + f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}" + ) try: result = handler.execute() @@ -62,7 +92,9 @@ def trigger_event(event_name, payload): request_payload=final_payload, response_payload=result, ) - logger.info(f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'") + logger.info( + f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'" + ) except Exception as e: DeliveryLog.objects.create( subscription=sub, @@ -70,4 +102,29 @@ def trigger_event(event_name, payload): request_payload=final_payload, error_message=str(e), ) - logger.error(f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}") + logger.error( + f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}" + ) + + pm = PluginManager.get() + pm.discover_plugins(sync_db=False, use_cache=True) + plugins = pm.list_plugins() + + logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'") + for plugin in plugins: + if not plugin["enabled"]: + logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}") + continue + + logger.debug(json.dumps(plugin)) + for action in plugin["actions"]: + if "events" in action and event_name in action["events"]: + key = plugin["key"] + params = {"event": event_name, "payload": payload} + action_name = action.get("label") or action.get("id") + action_id = action.get("id") + logger.debug( + f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}" + ) + if action_id: + pm.run_action(key, action_id, params) diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py index 172af265..9f568054 100644 --- a/apps/plugins/serializers.py +++ b/apps/plugins/serializers.py @@ -9,6 +9,9 @@ class PluginActionSerializer(serializers.Serializer): button_label = serializers.CharField(required=False, allow_blank=True) button_variant = serializers.CharField(required=False, allow_blank=True) button_color = serializers.CharField(required=False, allow_blank=True) + events = serializers.ListField( + child=serializers.CharField(), required=False, allow_empty=True + ) class PluginFieldOptionSerializer(serializers.Serializer): diff --git a/core/utils.py b/core/utils.py index cdd9f0bd..98e2ba78 100644 --- a/core/utils.py +++ b/core/utils.py @@ -389,6 +389,78 @@ def validate_flexible_url(value): # If it doesn't match our flexible patterns, raise the original error raise ValidationError("Enter a valid URL.") +def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details): + try: + from apps.connect.utils import trigger_event + from apps.channels.models import Channel, Stream + from core.models import StreamProfile + from core.utils import RedisClient + + payload = {} + + channel_obj = None + if channel_id: + try: + channel_obj = Channel.objects.get(uuid=channel_id) + payload["channel_name"] = channel_obj.name + except Exception: + payload["channel_name"] = channel_name or None + else: + payload["channel_name"] = channel_name or None + + # Resolve current stream info + stream_id = details.get("stream_id") + stream_obj = None + if not stream_id and channel_obj: + try: + redis = RedisClient.get_client() + sid = redis.get(f"channel_stream:{channel_obj.id}") + if sid: + stream_id = int(sid) + except Exception: + stream_id = None + + if stream_id: + try: + stream_obj = Stream.objects.get(id=stream_id) + except Exception: + stream_obj = None + + # Populate stream details + payload["stream_name"] = getattr(stream_obj, "name", None) + payload["stream_url"] = getattr(stream_obj, "url", None) + + # Channel URL: use stream URL as best-effort + payload["channel_url"] = payload.get("stream_url") + + # Provider name from M3U account + provider_name = None + try: + if stream_obj and stream_obj.m3u_account: + provider_name = stream_obj.m3u_account.name + except Exception: + provider_name = None + payload["provider_name"] = provider_name + + # Profile used + profile_used = None + try: + if stream_id: + redis = RedisClient.get_client() + pid = redis.get(f"stream_profile:{stream_id}") + if pid: + profile = StreamProfile.objects.filter(id=int(pid)).first() + profile_used = profile.name if profile else None + except Exception: + profile_used = None + + payload["profile_used"] = profile_used + + trigger_event(event_type, payload) + + except Exception as e: + # Don't fail main path if connect dispatch fails + pass def log_system_event(event_type, channel_id=None, channel_name=None, **details): """ @@ -416,77 +488,7 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **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 + dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details) # Get max events from settings (default 100) try: diff --git a/frontend/src/api.js b/frontend/src/api.js index fe8bf275..ed237027 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -179,9 +179,34 @@ export default class API { static async getChannels() { try { - const response = await request(`${host}/api/channels/channels/`); + // Paginate through channels to avoid heavy single response + const pageSize = 200; + let page = 1; + let allChannels = []; - return response; + while (true) { + const data = await request( + `${host}/api/channels/channels/?page=${page}&page_size=${pageSize}` + ); + + // Backward compatibility: if endpoint returns an array (legacy), just return it + if (Array.isArray(data)) { + allChannels = data; + break; + } + + const results = Array.isArray(data?.results) ? data.results : []; + allChannels = allChannels.concat(results); + + const hasMore = Boolean(data?.next); + if (!hasMore || results.length === 0) { + break; + } + + page += 1; + } + + return allChannels; } catch (e) { errorNotification('Failed to retrieve channels', e); } diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index 68df4728..dfb5a521 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -14,9 +14,11 @@ import { Switch, Text, UnstyledButton, + Badge, } from '@mantine/core'; import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'; import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js'; +import { SUBSCRIPTION_EVENTS } from '../../constants.js'; const PluginFieldList = ({ plugin, settings, updateField }) => { return plugin.fields.map((f) => ( @@ -29,7 +31,13 @@ const PluginFieldList = ({ plugin, settings, updateField }) => { )); }; -const PluginActionList = ({ plugin, enabled, runningActionId, handlePluginRun }) => { +const PluginActionList = ({ + plugin, + enabled, + runningActionId, + handlePluginRun, +}) => { + console.log(plugin); return plugin.actions.map((action) => (
@@ -39,6 +47,14 @@ const PluginActionList = ({ plugin, enabled, runningActionId, handlePluginRun }) {action.description} )} + + Event Triggers + + {action.events.map((event) => ( + + {SUBSCRIPTION_EVENTS[event] || event} + + ))}
-
- - )} - - {expanded && !missing && enabled && plugin.actions && plugin.actions.length > 0 && ( - <> - - - 0 && ( + + - + + + - - )} + )} + + {expanded && + !missing && + enabled && + plugin.actions && + plugin.actions.length > 0 && ( + <> + + + + + + + )} ); }; diff --git a/frontend/src/components/forms/Connection.jsx b/frontend/src/components/forms/Connection.jsx index 32a8f6cd..e92caf13 100644 --- a/frontend/src/components/forms/Connection.jsx +++ b/frontend/src/components/forms/Connection.jsx @@ -129,8 +129,8 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => { return ( - -
+ + { Save - -
+
+
); }; diff --git a/frontend/src/pages/Connect.jsx b/frontend/src/pages/Connect.jsx index 42ad2082..099546af 100644 --- a/frontend/src/pages/Connect.jsx +++ b/frontend/src/pages/Connect.jsx @@ -121,7 +121,6 @@ function IntegrationRow({ integration, editConnection, deleteConnection }) { backgroundColor: '#27272A', }} color="#fff" - maw={400} w={'100%'} >