diff --git a/src/index.html b/src/index.html
index 3105596..8606646 100644
--- a/src/index.html
+++ b/src/index.html
@@ -596,6 +596,29 @@
+
Sicherung & Wartung
Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.
diff --git a/src/main.ts b/src/main.ts
index 0f7a0b5..2d42c5e 100644
--- a/src/main.ts
+++ b/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
= {
+ 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 {
+ 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 {
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.
diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts
index 2b04286..42b7793 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -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;
}
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index 26d0182..f049f3c 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -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',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index 55d1cd4..8026b93 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -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',
diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts
index 3190f2c..908b92b 100644
--- a/src/renderer-settings.ts
+++ b/src/renderer-settings.ts
@@ -391,6 +391,10 @@ function collectDownloadSettingsPayload(): Partial {
streamlink_disable_ads: byId('streamlinkDisableAdsToggle').checked,
download_chat_replay: byId('downloadChatReplayToggle').checked,
capture_live_chat: byId('captureLiveChatToggle').checked,
+ discord_webhook_url: byId('discordWebhookUrl').value.trim(),
+ discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked,
+ discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked,
+ discord_notify_vod_complete: byId('discordNotifyVodCompleteToggle').checked,
streamlink_quality: byId('streamlinkQuality').value,
metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10
};
@@ -439,6 +443,10 @@ function getSettingsFingerprint(payload: Partial): 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('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
byId('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
byId('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
+ byId('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
+ byId('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
+ byId('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
+ byId('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
byId('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId('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 = [
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index 66d7325..463599d 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -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);