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:
parent
0ab3780ab1
commit
47862e7fbf
@ -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 & 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>
|
||||
|
||||
119
src/main.ts
119
src/main.ts
@ -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.
|
||||
|
||||
4
src/renderer-globals.d.ts
vendored
4
src/renderer-globals.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user