feat: Discord webhook notifications for live + VOD events

For users who run the app on a dedicated archive box and aren't
watching the queue panel directly. Three optional event types post
to a Discord webhook:

- Live recording started: red embed with streamer + URL + output
  filename. Fires inside downloadLiveStream after chat-capture
  init, before streamlink launches, so a hung streamlink doesn't
  silently delay the alert.
- Live recording ended: green (ok) or purple (failed) embed with
  duration, file size, captured-chat-message count, output filename.
  Fires after streamlink exits — picks up cancellation, integrity
  failure, and clean stream-ended exits the same way.
- VOD download complete: green embed with file count + total bytes.
  Skipped for live items (those have their own end-of-recording
  embed; double-firing would be noisy).

Server:
- New isAcceptableDiscordWebhook(url) regex sanity-check —
  refuses URLs that aren't discord.com/api/webhooks/* so a
  pasted-by-mistake other URL doesn't leak data anywhere.
- sendDiscordWebhook(payload) is fire-and-forget: 8s timeout,
  errors logged via appendDebugLog but never surface to the user.
  Should NOT block the recording flow.
- DiscordEmbedColor enum maps live/success/info to known palette
  values (red / green / Twitch purple).
- Embed body slices fields to Discord's documented length limits
  (title 256, description 4096, field name 256, field value 1024,
  max 25 fields per embed) so a runaway long stream title can't
  produce a rejected webhook.

Renderer / settings:
- New Settings card "Discord-Webhook" between Backup and Updates.
  URL input + 3 toggles (live-start / live-end / vod-complete).
  All three default off, URL empty — totally inert until the user
  configures it.
- AppConfig type, autosave fingerprint, syncSettingsForm,
  applyLanguageToStaticUI, debounced-save IDs all updated. Webhook
  URL is debounced like other text inputs so each keystroke
  doesn't trigger a save.
- DE + EN locales for every label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 20:50:58 +02:00
parent 0ab3780ab1
commit 47862e7fbf
7 changed files with 179 additions and 3 deletions

View File

@ -596,6 +596,29 @@
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div>
<div class="settings-card">
<h3 id="discordCardTitle">Discord-Webhook</h3>
<p id="discordCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
<div class="form-group">
<label id="discordWebhookUrlLabel">Webhook-URL</label>
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="discordNotifyLiveStartToggle">
<span id="discordNotifyLiveStartLabel">Bei Live-Aufnahme-Start benachrichtigen</span>
</label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="discordNotifyLiveEndToggle">
<span id="discordNotifyLiveEndLabel">Bei Live-Aufnahme-Ende benachrichtigen</span>
</label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="discordNotifyVodCompleteToggle">
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
</label>
</div>
</div>
<div class="settings-card">
<h3 id="backupCardTitle">Sicherung &amp; Wartung</h3>
<p id="backupCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>

View File

@ -217,6 +217,10 @@ interface Config {
auto_record_poll_seconds: number;
download_chat_replay: boolean;
capture_live_chat: boolean;
discord_webhook_url: string;
discord_notify_live_start: boolean;
discord_notify_live_end: boolean;
discord_notify_vod_complete: boolean;
}
interface RuntimeMetrics {
@ -337,7 +341,11 @@ const defaultConfig: Config = {
auto_record_streamers: [],
auto_record_poll_seconds: 90,
download_chat_replay: false,
capture_live_chat: false
capture_live_chat: false,
discord_webhook_url: '',
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false
};
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
@ -436,7 +444,14 @@ function normalizeConfigTemplates(input: Config): Config {
auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers),
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
download_chat_replay: input.download_chat_replay === true,
capture_live_chat: input.capture_live_chat === true
capture_live_chat: input.capture_live_chat === true,
// Webhook URL is stored but never validated server-side — invalid
// URLs just cause the post to fail (logged, non-fatal). Users with
// accidental whitespace are saved by the .trim().
discord_webhook_url: typeof input.discord_webhook_url === 'string' ? input.discord_webhook_url.trim() : '',
discord_notify_live_start: input.discord_notify_live_start === true,
discord_notify_live_end: input.discord_notify_live_end === true,
discord_notify_vod_complete: input.discord_notify_vod_complete === true
};
}
@ -3075,6 +3090,59 @@ function chatReplayPathFor(vodFilePath: string): string {
return `${base}.chat.json`;
}
// ==========================================
// DISCORD WEBHOOK NOTIFICATIONS
// ==========================================
// Fire-and-forget webhook for "stream went live", "recording finished",
// "VOD download complete". Useful when the user runs the app on a
// dedicated archival machine and isn't checking it directly.
type DiscordEmbedColor = 'live' | 'success' | 'info';
const DISCORD_EMBED_COLORS: Record<DiscordEmbedColor, number> = {
live: 0xE91916, // red — recording started
success: 0x00C853, // green — completed cleanly
info: 0x9146FF // twitch purple — neutral
};
function isAcceptableDiscordWebhook(url: string): boolean {
const trimmed = (url || '').trim();
if (!trimmed) return false;
return /^https:\/\/(?:[a-z]+\.)?discord(?:app)?\.com\/api\/webhooks\//i.test(trimmed);
}
async function sendDiscordWebhook(payload: {
title: string;
description: string;
color: DiscordEmbedColor;
fields?: Array<{ name: string; value: string; inline?: boolean }>;
}): Promise<void> {
const url = (config.discord_webhook_url || '').trim();
if (!isAcceptableDiscordWebhook(url)) return;
const body = {
username: 'Twitch VOD Manager',
embeds: [
{
title: payload.title.slice(0, 256),
description: payload.description.slice(0, 4096),
color: DISCORD_EMBED_COLORS[payload.color],
fields: (payload.fields || []).slice(0, 25).map((f) => ({
name: (f.name || '').slice(0, 256),
value: (f.value || '').slice(0, 1024),
inline: f.inline === true
})),
timestamp: new Date().toISOString()
}
]
};
try {
await axios.post(url, body, { timeout: 8000, headers: { 'Content-Type': 'application/json' } });
appendDebugLog('discord-webhook-ok', { title: payload.title, color: payload.color });
} catch (e) {
appendDebugLog('discord-webhook-failed', { title: payload.title, error: String(e) });
}
}
// ==========================================
// LIVE CHAT CAPTURE (during live recording)
// ==========================================
@ -3291,6 +3359,20 @@ async function downloadLiveStream(
chatSession = startLiveChatCapture(item.streamer, chatPath);
}
if (config.discord_notify_live_start) {
void sendDiscordWebhook({
title: `Recording started: ${item.streamer}`,
description: item.title || `${item.streamer} is live`,
color: 'live',
fields: [
{ name: 'URL', value: item.url, inline: false },
{ name: 'Output', value: path.basename(filename), inline: false }
]
});
}
const recordingStartedAt = Date.now();
// No start/end times for live streams — streamlink records until the
// stream actually ends or we kill it. downloadVODPart already handles
// null start/end correctly.
@ -3300,6 +3382,22 @@ async function downloadLiveStream(
stopLiveChatCapture(chatSession);
}
if (config.discord_notify_live_end) {
const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
const sizeBytes = fs.existsSync(filename) ? (fs.statSync(filename).size || 0) : 0;
void sendDiscordWebhook({
title: result.success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`,
description: item.title || `${item.streamer}`,
color: result.success ? 'success' : 'info',
fields: [
{ name: 'Duration', value: formatDuration(durationSec), inline: true },
{ name: 'Size', value: formatBytes(sizeBytes), inline: true },
{ name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true },
{ name: 'Output', value: path.basename(filename), inline: false }
]
});
}
if (!result.success) return result;
const outputs = [filename];
if (chatSession && fs.existsSync(chatSession.outputPath)) {
@ -3886,6 +3984,23 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
item.outputFiles = [...finalResult.outputFiles];
}
// Discord webhook for non-live VOD completion. Live recordings
// already get their own end-of-recording webhook in downloadLiveStream.
if (finalResult.success && !item.isLive && config.discord_notify_vod_complete) {
const totalBytes = (item.outputFiles || []).reduce((sum, f) => {
try { return sum + (fs.statSync(f).size || 0); } catch { return sum; }
}, 0);
void sendDiscordWebhook({
title: `VOD download complete: ${item.streamer}`,
description: item.title || item.url,
color: 'success',
fields: [
{ name: 'Files', value: String((item.outputFiles || []).length), inline: true },
{ name: 'Size', value: formatBytes(totalBytes), inline: true }
]
});
}
// Per-VOD completion notification (separate from the queue-end
// notification fired at the end of processQueue). Off by default
// because users with long queues would get spammed.

View File

@ -25,6 +25,10 @@ interface AppConfig {
auto_record_poll_seconds?: number;
download_chat_replay?: boolean;
capture_live_chat?: boolean;
discord_webhook_url?: string;
discord_notify_live_start?: boolean;
discord_notify_live_end?: boolean;
discord_notify_vod_complete?: boolean;
[key: string]: unknown;
}

View File

@ -54,6 +54,12 @@ const UI_TEXT_DE = {
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Log-Datei oeffnen',
discordCardTitle: 'Discord-Webhook',
discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.',
discordWebhookUrlLabel: 'Webhook-URL',
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
backupCardTitle: 'Sicherung & Wartung',
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
exportConfig: 'Konfiguration exportieren',

View File

@ -54,6 +54,12 @@ const UI_TEXT_EN = {
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Open log file',
discordCardTitle: 'Discord webhook',
discordCardIntro: 'Send notifications to a Discord channel via webhook — handy for multi-device setups or a dedicated archive machine.',
discordWebhookUrlLabel: 'Webhook URL',
discordNotifyLiveStartLabel: 'Notify on live recording start',
discordNotifyLiveEndLabel: 'Notify on live recording end',
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
backupCardTitle: 'Backup & Maintenance',
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
exportConfig: 'Export config',

View File

@ -391,6 +391,10 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
};
@ -439,6 +443,10 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.streamlink_disable_ads !== false,
effective.download_chat_replay === true,
effective.capture_live_chat === true,
effective.discord_webhook_url ?? '',
effective.discord_notify_live_start === true,
effective.discord_notify_live_end === true,
effective.discord_notify_vod_complete === true,
effective.streamlink_quality ?? 'best',
effective.metadata_cache_minutes ?? 10,
effective.filename_template_vod ?? '{title}.mp4',
@ -462,6 +470,10 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
@ -579,6 +591,9 @@ function initSettingsAutoSave(): void {
'streamlinkDisableAdsToggle',
'downloadChatReplayToggle',
'captureLiveChatToggle',
'discordNotifyLiveStartToggle',
'discordNotifyLiveEndToggle',
'discordNotifyVodCompleteToggle',
'streamlinkQuality'
] as const;
@ -587,7 +602,8 @@ function initSettingsAutoSave(): void {
'metadataCacheMinutes',
'vodFilenameTemplate',
'partsFilenameTemplate',
'defaultClipFilenameTemplate'
'defaultClipFilenameTemplate',
'discordWebhookUrl'
] as const;
const credentialIds = [

View File

@ -173,6 +173,12 @@ function applyLanguageToStaticUI(): void {
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
setText('discordCardTitle', UI_TEXT.static.discordCardTitle);
setText('discordCardIntro', UI_TEXT.static.discordCardIntro);
setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel);
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
setText('btnExportConfig', UI_TEXT.static.exportConfig);