mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
added event support to plugins
This commit is contained in:
parent
24f812dc4d
commit
e6ffeb19de
7 changed files with 240 additions and 117 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
144
core/utils.py
144
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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ function IntegrationRow({ integration, editConnection, deleteConnection }) {
|
|||
backgroundColor: '#27272A',
|
||||
}}
|
||||
color="#fff"
|
||||
maw={400}
|
||||
w={'100%'}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue