Add webhook POST then-action for automation engine

- New 'webhook' then-action: sends HTTP POST with JSON payload to any
  user-configured URL (Gotify, Home Assistant, Slack, n8n, etc.)
- Config: URL, optional custom headers (Key: Value per line with
  variable substitution), optional custom message
- Payload includes all event variables as JSON fields
- 15s timeout, errors on 400+ status codes
- Follows exact same pattern as Discord/Pushbullet/Telegram handlers
- Frontend: config fields, config reader, icon, help docs
- Updated changelogs with webhook, M3U fix, orchestrator hardening
pull/253/head
Broque Thomas 2 months ago
parent 7c85c31e8b
commit bec81cfd8d

@ -836,6 +836,8 @@ class AutomationEngine:
self._send_pushbullet_notification(c, variables)
elif t == 'telegram':
self._send_telegram_notification(c, variables)
elif t == 'webhook':
self._send_webhook(c, variables)
elif t == 'fire_signal':
sig = self._sanitize_signal_name(c.get('signal_name', ''))
if sig:
@ -966,3 +968,36 @@ class AutomationEngine:
data = resp.json() if resp.status_code == 200 else {}
if not data.get('ok'):
raise RuntimeError(f"Telegram returned {resp.status_code}: {resp.text[:200]}")
def _send_webhook(self, config, variables):
"""Send a POST request to a user-configured webhook URL with JSON payload."""
url = config.get('url', '').strip()
if not url:
raise ValueError("No webhook URL configured")
# Build headers — always include Content-Type, plus optional custom headers
headers = {'Content-Type': 'application/json'}
custom_headers = config.get('headers', '').strip()
if custom_headers:
for line in custom_headers.split('\n'):
line = line.strip()
if ':' in line:
key, value = line.split(':', 1)
# Substitute variables in header values
for vk, vv in variables.items():
value = value.replace('{' + vk + '}', vv)
headers[key.strip()] = value.strip()
# Build JSON payload with all variables
payload = dict(variables)
# Add custom message if configured
message = config.get('message', '').strip()
if message:
for key, value in variables.items():
message = message.replace('{' + key + '}', value)
payload['message'] = message
resp = requests.post(url, json=payload, headers=headers, timeout=15)
if resp.status_code >= 400:
raise RuntimeError(f"Webhook returned {resp.status_code}: {resp.text[:200]}")

@ -5631,6 +5631,8 @@ def get_automation_blocks():
"variables": ["time", "name", "run_count", "status"]},
{"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True,
"variables": ["time", "name", "run_count", "status"]},
{"type": "webhook", "label": "Webhook (POST)", "icon": "globe", "description": "Send a POST request to any URL", "available": True,
"variables": ["time", "name", "run_count", "status"]},
# Signal fire action
{"type": "fire_signal", "label": "Fire Signal", "icon": "zap",
"description": "Fire a signal that other automations can listen for", "available": True,
@ -20300,7 +20302,10 @@ def get_version_info():
"• Track source-info and redownload work with Jellyfin string IDs (#237)",
"• Clear Match button to undo wrong manual matches (#236)",
"• Tidal auth no longer crashes when download orchestrator not initialized",
"• Copy Debug Info includes API call rates and Spotify rate limit state"
"• Download orchestrator hardened — one failing client no longer kills all download sources",
"• Webhook THEN action — send HTTP POST to any URL (Gotify, Home Assistant, Slack, n8n) from automations",
"• M3U auto-export now skips albums — only generates for playlists (#241)",
"• Copy Debug Info includes API call rates, Spotify rate limit state, and download client failures"
]
},
{

@ -3403,6 +3403,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.2': [
// Newest features first
{ title: 'Webhook THEN Action', desc: 'Send HTTP POST to any URL when automations complete — integrate with Gotify, Home Assistant, Slack, n8n. Configurable headers and message template', page: 'automations' },
{ title: 'API Rate Monitor', desc: 'Real-time speedometer gauges for all 9 enrichment services on the Dashboard. Click any gauge for 24h history chart. Spotify shows per-endpoint breakdown', page: 'dashboard' },
{ title: 'Configurable Concurrent Downloads', desc: 'Set max simultaneous downloads per batch (1-10) in Settings. Soulseek albums stay at 1 for source reuse. Higher values speed up playlists and wishlists' },
{ title: 'Streaming Search Sources', desc: 'Apple Music and other slow sources now stream results progressively — see artists, albums, tracks as each loads instead of waiting for all 3' },

@ -23157,6 +23157,34 @@ const TOOL_HELP_CONTENT = {
`
},
'auto-webhook': {
title: 'Webhook (POST)',
content: `
<h4>What does this then-action do?</h4>
<p>Sends an HTTP POST request with a JSON payload to any URL when the automation's action completes. Use it to integrate with Gotify, Home Assistant, Slack, n8n, or any service that accepts webhooks.</p>
<h4>Configuration</h4>
<ul>
<li><strong>URL:</strong> The endpoint to POST to (e.g. <code>https://gotify.example.com/message?token=xxx</code>)</li>
<li><strong>Headers:</strong> Optional custom headers, one per line in <code>Key: Value</code> format. Useful for auth tokens.</li>
<li><strong>Custom Message:</strong> Optional message with variable placeholders. Added as a "message" field in the JSON payload.</li>
</ul>
<h4>JSON payload</h4>
<p>The POST body always includes all event variables as JSON fields:</p>
<pre style="background:rgba(255,255,255,0.05);padding:8px;border-radius:6px;font-size:11px;">{"time": "2026-04-02 ...", "name": "My Automation", "status": "success", ...}</pre>
<h4>Available variables</h4>
<p>Use these in your message or header values:</p>
<ul>
<li><code>{time}</code> When the automation ran</li>
<li><code>{name}</code> Automation name</li>
<li><code>{run_count}</code> How many times this automation has run</li>
<li><code>{status}</code> Result status of the action</li>
</ul>
`
},
// ==================== Signal System Help ====================
'auto-signal_received': {
@ -62695,7 +62723,7 @@ const _autoIcons = {
process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F',
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D',
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F',
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', webhook: '\uD83C\uDF10',
signal_received: '\u26A1', fire_signal: '\u26A1',
// Phase 3
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
@ -64758,6 +64786,26 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</div>
${_notifyVarHtml(slotKey)}`;
}
if (blockType === 'webhook') {
const url = _escAttr(config.url || '');
const hdrs = (config.headers || '').replace(/"/g, '&quot;');
return `<div class="config-row">
<label>URL</label>
<input type="text" id="cfg-${slotKey}-url" value="${url}" placeholder="https://your-server.com/hook">
</div>
<div class="config-row">
<label>Headers <span style="opacity:0.4;font-weight:400">(one per line, Key: Value)</span></label>
<textarea id="cfg-${slotKey}-headers" placeholder="Authorization: Bearer token123\nX-Custom: value" style="font-family:monospace;font-size:11px;">${hdrs}</textarea>
</div>
<div class="config-row">
<label>Custom Message <span style="opacity:0.4;font-weight:400">(optional)</span></label>
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || ''}</textarea>
</div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">
Sends a JSON POST with all event variables. Custom message added as "message" field if set.
</div>
${_notifyVarHtml(slotKey)}`;
}
return '';
}
@ -64999,6 +65047,13 @@ function _readPlacedConfig(slotKey) {
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}
if (type === 'webhook') {
return {
url: document.getElementById('cfg-' + slotKey + '-url')?.value?.trim() || '',
headers: document.getElementById('cfg-' + slotKey + '-headers')?.value || '',
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}
return {};
}

Loading…
Cancel
Save