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 @@
Lade...
+
+

Discord-Webhook

+

Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.

+
+ + +
+
+ + + +
+
+

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);