Compare commits

..

No commits in common. "97d8cc10ef4227e71fd8fc48ac594b2c52228dd6" and "0ab3780ab17038f1f8953a21a120152ccca2dc84" have entirely different histories.

9 changed files with 6 additions and 182 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "twitch-vod-manager",
"version": "4.6.4",
"version": "4.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.6.4",
"version": "4.6.3",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.6.4",
"version": "4.6.3",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -596,29 +596,6 @@
<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,10 +217,6 @@ 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 {
@ -341,11 +337,7 @@ const defaultConfig: Config = {
auto_record_streamers: [],
auto_record_poll_seconds: 90,
download_chat_replay: false,
capture_live_chat: false,
discord_webhook_url: '',
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false
capture_live_chat: false
};
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
@ -444,14 +436,7 @@ 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,
// 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
capture_live_chat: input.capture_live_chat === true
};
}
@ -3090,59 +3075,6 @@ 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)
// ==========================================
@ -3359,20 +3291,6 @@ 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.
@ -3382,22 +3300,6 @@ 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)) {
@ -3984,23 +3886,6 @@ 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,10 +25,6 @@ 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,12 +54,6 @@ 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,12 +54,6 @@ 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,10 +391,6 @@ 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
};
@ -443,10 +439,6 @@ 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',
@ -470,10 +462,6 @@ 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';
@ -591,9 +579,6 @@ function initSettingsAutoSave(): void {
'streamlinkDisableAdsToggle',
'downloadChatReplayToggle',
'captureLiveChatToggle',
'discordNotifyLiveStartToggle',
'discordNotifyLiveEndToggle',
'discordNotifyVodCompleteToggle',
'streamlinkQuality'
] as const;
@ -602,8 +587,7 @@ function initSettingsAutoSave(): void {
'metadataCacheMinutes',
'vodFilenameTemplate',
'partsFilenameTemplate',
'defaultClipFilenameTemplate',
'discordWebhookUrl'
'defaultClipFilenameTemplate'
] as const;
const credentialIds = [

View File

@ -173,12 +173,6 @@ 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);