added event support to plugins

This commit is contained in:
dekzter 2026-02-11 19:50:11 -05:00
parent 24f812dc4d
commit e6ffeb19de
7 changed files with 240 additions and 117 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => (
<Group key={action.id} justify="space-between">
<div>
@ -39,6 +47,14 @@ const PluginActionList = ({ plugin, enabled, runningActionId, handlePluginRun })
{action.description}
</Text>
)}
<Text size="xs" style={{ paddingTop: 10 }}>
Event Triggers
</Text>
{action.events.map((event) => (
<Badge size="sm" variant="light" color="green">
{SUBSCRIPTION_EVENTS[event] || event}
</Badge>
))}
</div>
<Button
loading={runningActionId === action.id}
@ -226,7 +242,12 @@ const PluginCard = ({
style={{ opacity: !missing && enabled ? 1 : 0.6 }}
>
<Group justify="space-between" mb="xs" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
style={{ minWidth: 0, flex: 1 }}
>
<ActionIcon
variant="subtle"
size="sm"
@ -306,35 +327,51 @@ const PluginCard = ({
</Text>
)}
{expanded && !missing && enabled && plugin.fields && plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
<PluginFieldList
plugin={plugin}
settings={settings}
updateField={updateField}
/>
<Group>
<Button loading={saving} onClick={save} variant="default" size="xs">
Save Settings
</Button>
</Group>
</Stack>
)}
{expanded && !missing && enabled && plugin.actions && plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
<PluginActionList
{expanded &&
!missing &&
enabled &&
plugin.fields &&
plugin.fields.length > 0 && (
<Stack gap="xs" mt="sm">
<PluginFieldList
plugin={plugin}
enabled={enabled}
runningActionId={runningActionId}
handlePluginRun={handlePluginRun}
settings={settings}
updateField={updateField}
/>
<PluginActionStatus running={!!runningActionId} lastResult={lastResult} />
<Group>
<Button
loading={saving}
onClick={save}
variant="default"
size="xs"
>
Save Settings
</Button>
</Group>
</Stack>
</>
)}
)}
{expanded &&
!missing &&
enabled &&
plugin.actions &&
plugin.actions.length > 0 && (
<>
<Divider my="sm" />
<Stack gap="xs">
<PluginActionList
plugin={plugin}
enabled={enabled}
runningActionId={runningActionId}
handlePluginRun={handlePluginRun}
/>
<PluginActionStatus
running={!!runningActionId}
lastResult={lastResult}
/>
</Stack>
</>
)}
</Card>
);
};

View file

@ -129,8 +129,8 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
return (
<Modal opened={isOpen} onClose={handleClose} title="Connection">
<Stack>
<form onSubmit={form.onSubmit(onSubmit)}>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Name"
{...form.getInputProps('name')}
@ -180,8 +180,8 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
Save
</Button>
</Flex>
</form>
</Stack>
</Stack>
</form>
</Modal>
);
};

View file

@ -121,7 +121,6 @@ function IntegrationRow({ integration, editConnection, deleteConnection }) {
backgroundColor: '#27272A',
}}
color="#fff"
maw={400}
w={'100%'}
>
<Stack gap="xs">