Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.
+
+ Poll-Intervall (Minuten)
+
+ Max. Alter (Stunden)
+
diff --git a/src/main.ts b/src/main.ts
index b5165cf..b1c5fdc 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -221,11 +221,15 @@ interface Config {
discord_notify_live_start: boolean;
discord_notify_live_end: boolean;
discord_notify_vod_complete: boolean;
+ discord_notify_vod_auto_queued: boolean;
auto_cleanup_enabled: boolean;
auto_cleanup_days: number;
auto_cleanup_target: 'live_only' | 'all';
auto_cleanup_action: 'delete' | 'archive';
log_stream_events: boolean;
+ auto_vod_download_streamers: string[];
+ auto_vod_download_poll_minutes: number;
+ auto_vod_max_age_hours: number;
}
interface RuntimeMetrics {
@@ -351,11 +355,15 @@ const defaultConfig: Config = {
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false,
+ discord_notify_vod_auto_queued: false,
auto_cleanup_enabled: false,
auto_cleanup_days: 30,
auto_cleanup_target: 'live_only',
auto_cleanup_action: 'archive',
- log_stream_events: true
+ log_stream_events: true,
+ auto_vod_download_streamers: [],
+ auto_vod_download_poll_minutes: 15,
+ auto_vod_max_age_hours: 24
};
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
@@ -462,6 +470,7 @@ function normalizeConfigTemplates(input: Config): Config {
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,
+ discord_notify_vod_auto_queued: input.discord_notify_vod_auto_queued === true,
auto_cleanup_enabled: input.auto_cleanup_enabled === true,
auto_cleanup_days: (() => {
const n = Number(input.auto_cleanup_days);
@@ -470,7 +479,18 @@ function normalizeConfigTemplates(input: Config): Config {
})(),
auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only',
auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive',
- log_stream_events: input.log_stream_events !== false
+ log_stream_events: input.log_stream_events !== false,
+ auto_vod_download_streamers: normalizeAutoRecordList(input.auto_vod_download_streamers),
+ auto_vod_download_poll_minutes: (() => {
+ const n = Number(input.auto_vod_download_poll_minutes);
+ if (!Number.isFinite(n)) return 15;
+ return Math.max(5, Math.min(360, Math.floor(n)));
+ })(),
+ auto_vod_max_age_hours: (() => {
+ const n = Number(input.auto_vod_max_age_hours);
+ if (!Number.isFinite(n)) return 24;
+ return Math.max(1, Math.min(720, Math.floor(n)));
+ })()
};
}
@@ -3011,6 +3031,130 @@ async function runAutoRecordPoll(): Promise {
}
}
+// ==========================================
+// AUTO-VOD-DOWNLOAD POLLER
+// ==========================================
+// Periodically scans VOD listings of opted-in streamers and auto-queues
+// any VOD that's (a) recent enough to be in scope, (b) not already
+// downloaded, and (c) not already in the active queue. Cadence is
+// minutes, not seconds — a VOD-listing scan is much heavier than a
+// live-status check, and new VODs only appear after a stream ends, so
+// minute-level lag is fine.
+let autoVodPollTimer: NodeJS.Timeout | null = null;
+let autoVodPollInFlight = false;
+
+function stopAutoVodPoller(): void {
+ if (autoVodPollTimer) {
+ clearInterval(autoVodPollTimer);
+ autoVodPollTimer = null;
+ }
+}
+
+function restartAutoVodPoller(): void {
+ stopAutoVodPoller();
+ const list = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
+ if (list.length === 0) {
+ appendDebugLog('auto-vod-poller-idle', { reason: 'no streamers' });
+ return;
+ }
+ const minutes = (() => {
+ const n = Number(config.auto_vod_download_poll_minutes);
+ if (!Number.isFinite(n)) return 15;
+ return Math.max(5, Math.min(360, Math.floor(n)));
+ })();
+ appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes });
+ autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000);
+ autoVodPollTimer.unref?.();
+ setTimeout(() => { void runAutoVodPoll(); }, 5000);
+}
+
+async function runAutoVodPoll(): Promise {
+ if (autoVodPollInFlight) return;
+ autoVodPollInFlight = true;
+ try {
+ const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : [];
+ if (list.length === 0) return;
+
+ const maxAgeHours = (() => {
+ const n = Number(config.auto_vod_max_age_hours);
+ if (!Number.isFinite(n)) return 24;
+ return Math.max(1, Math.min(720, Math.floor(n)));
+ })();
+ const cutoffMs = Date.now() - maxAgeHours * 3600 * 1000;
+
+ const downloadedSet = new Set(Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : []);
+ const queuedUrls = new Set(downloadQueue.map((it) => it.url));
+
+ for (const streamer of list) {
+ if (!config.auto_vod_download_streamers.includes(streamer)) continue;
+
+ const userId = await getUserId(streamer);
+ if (!userId) {
+ appendDebugLog('auto-vod-skip-no-user', { streamer });
+ continue;
+ }
+
+ let vods: VOD[] = [];
+ try {
+ vods = await getVODs(userId, true);
+ } catch (e) {
+ appendDebugLog('auto-vod-list-failed', { streamer, error: String(e) });
+ continue;
+ }
+ if (!Array.isArray(vods) || vods.length === 0) continue;
+
+ for (const vod of vods) {
+ if (!vod || !vod.id || !vod.url) continue;
+ if (downloadedSet.has(vod.id)) continue;
+ if (queuedUrls.has(vod.url)) continue;
+
+ const createdMs = Date.parse(vod.created_at || '');
+ if (!Number.isFinite(createdMs) || createdMs < cutoffMs) continue;
+
+ const queueItem: QueueItem = {
+ id: generateQueueItemId(),
+ title: vod.title || `${streamer} VOD ${vod.id}`,
+ url: vod.url,
+ date: vod.created_at,
+ streamer,
+ duration_str: vod.duration || '',
+ status: 'pending',
+ progress: 0
+ };
+ downloadQueue.push(queueItem);
+ queuedUrls.add(vod.url);
+ appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title });
+
+ if (config.discord_notify_vod_auto_queued) {
+ try {
+ await sendDiscordWebhook({
+ title: 'New VOD auto-queued',
+ description: `\`${streamer}\` published a new VOD — queued for download.`,
+ color: 'info',
+ fields: [
+ { name: 'Title', value: queueItem.title, inline: false },
+ { name: 'VOD ID', value: String(vod.id), inline: true },
+ { name: 'URL', value: vod.url, inline: false }
+ ]
+ });
+ } catch (_) { /* ignore webhook errors */ }
+ }
+ }
+ }
+
+ saveQueue(downloadQueue);
+ emitQueueUpdated();
+
+ if (!isDownloading && downloadQueue.some((it) => it.status === 'pending')) {
+ void processQueue();
+ }
+ } catch (e) {
+ appendDebugLog('auto-vod-poll-failed', String(e));
+ } finally {
+ autoVodPollInFlight = false;
+ }
+}
+
// ==========================================
// CHAT REPLAY DOWNLOAD
// ==========================================
@@ -5153,6 +5297,8 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => {
const previousTheme = config.theme;
const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
+ const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
+ const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
config = normalizeConfigTemplates({ ...config, ...newConfig });
@@ -5195,6 +5341,13 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => {
restartAutoRecordPoller();
}
+ // Same dance for the auto-VOD poller — independent cadence from
+ // auto-record because VOD listings are heavier to fetch.
+ const newAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
+ if (newAutoVodList !== previousAutoVodList || config.auto_vod_download_poll_minutes !== previousAutoVodMinutes) {
+ restartAutoVodPoller();
+ }
+
// Restart cleanup timer when the toggle flips; harmless to call when
// unchanged because restartAutoCleanupTimer just resets the interval.
restartAutoCleanupTimer();
@@ -5958,6 +6111,7 @@ app.whenReady().then(() => {
startMetadataCacheCleanup();
startDebugLogFlushTimer();
restartAutoRecordPoller();
+ restartAutoVodPoller();
restartAutoCleanupTimer();
createWindow();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
@@ -5986,6 +6140,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
cleanupMetadataCaches('shutdown');
stopAutoUpdatePolling();
stopAutoRecordPoller();
+ stopAutoVodPoller();
stopAutoCleanupTimer();
// Kill all active children: queue downloads, standalone clip downloads,
diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts
index 3afb5e6..e1314a0 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -29,11 +29,15 @@ interface AppConfig {
discord_notify_live_start?: boolean;
discord_notify_live_end?: boolean;
discord_notify_vod_complete?: boolean;
+ discord_notify_vod_auto_queued?: boolean;
auto_cleanup_enabled?: boolean;
auto_cleanup_days?: number;
auto_cleanup_target?: 'live_only' | 'all';
auto_cleanup_action?: 'delete' | 'archive';
log_stream_events?: boolean;
+ auto_vod_download_streamers?: string[];
+ auto_vod_download_poll_minutes?: number;
+ auto_vod_max_age_hours?: number;
[key: string]: unknown;
}
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index 64186db..3c00ba8 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -88,6 +88,11 @@ const UI_TEXT_DE = {
discordWebhookUrlLabel: 'Webhook-URL',
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
+ discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
+ autoVodCardTitle: 'Auto-VOD-Download',
+ autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.',
+ autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
+ autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
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.',
@@ -261,7 +266,10 @@ const UI_TEXT_DE = {
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden',
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...',
- autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.'
+ autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.',
+ autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
+ autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.',
+ autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.'
},
vods: {
noneTitle: 'Keine VODs',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index fe29929..9c38d72 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -89,6 +89,11 @@ const UI_TEXT_EN = {
discordNotifyLiveStartLabel: 'Notify on live recording start',
discordNotifyLiveEndLabel: 'Notify on live recording end',
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
+ discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued',
+ autoVodCardTitle: 'Auto-VOD download',
+ autoVodCardIntro: 'Streamers with the VOD toggle on are scanned for new Twitch VODs at the interval set here. New VODs within the age window are added to the download queue automatically.',
+ autoVodPollMinutesLabel: 'Poll interval (minutes)',
+ autoVodMaxAgeHoursLabel: 'Max age (hours)',
backupCardTitle: 'Backup & Maintenance',
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
exportConfig: 'Export config',
@@ -261,7 +266,10 @@ const UI_TEXT_EN = {
liveRecordingFailed: 'Could not start live recording',
autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically',
autoRecordEnabled: 'Auto-record enabled for {streamer}. Polling for live state...',
- autoRecordDisabled: 'Auto-record disabled for {streamer}.'
+ autoRecordDisabled: 'Auto-record disabled for {streamer}.',
+ autoVodTitle: 'Auto-download new VODs (recently published) for this streamer',
+ autoVodEnabled: 'Auto-VOD enabled for {streamer}. Will pick up new VODs.',
+ autoVodDisabled: 'Auto-VOD disabled for {streamer}.'
},
vods: {
noneTitle: 'No VODs',
diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts
index b0cbc4d..a500a96 100644
--- a/src/renderer-settings.ts
+++ b/src/renderer-settings.ts
@@ -558,6 +558,9 @@ function collectDownloadSettingsPayload(): Partial {
discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked,
discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked,
discord_notify_vod_complete: byId('discordNotifyVodCompleteToggle').checked,
+ discord_notify_vod_auto_queued: byId('discordNotifyVodAutoQueuedToggle').checked,
+ auto_vod_download_poll_minutes: parseInt(byId('autoVodPollMinutes').value, 10) || 15,
+ auto_vod_max_age_hours: parseInt(byId('autoVodMaxAgeHours').value, 10) || 24,
auto_cleanup_enabled: byId('autoCleanupEnabledToggle').checked,
auto_cleanup_days: parseInt(byId('autoCleanupDays').value, 10) || 30,
auto_cleanup_target: byId('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
@@ -615,6 +618,9 @@ function getSettingsFingerprint(payload: Partial): string {
effective.discord_notify_live_start === true,
effective.discord_notify_live_end === true,
effective.discord_notify_vod_complete === true,
+ effective.discord_notify_vod_auto_queued === true,
+ effective.auto_vod_download_poll_minutes ?? 15,
+ effective.auto_vod_max_age_hours ?? 24,
effective.auto_cleanup_enabled === true,
effective.auto_cleanup_days ?? 30,
effective.auto_cleanup_target ?? 'live_only',
@@ -647,6 +653,9 @@ function syncSettingsFormFromConfig(): void {
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('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true;
+ byId('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15);
+ byId('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24);
byId('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
byId('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
byId('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts
index d9a8f97..74d8454 100644
--- a/src/renderer-streamers.ts
+++ b/src/renderer-streamers.ts
@@ -421,6 +421,22 @@ function renderStreamers(): void {
void toggleAutoRecord(streamer);
});
+ // VOD-auto-download toggle — when enabled, the main-process auto-VOD
+ // poller scans this streamer's VOD list periodically and queues new
+ // VODs published in the rolling window automatically. Complements
+ // AUTO (live capture): VOD covers downtime + transcoded archive,
+ // AUTO covers a stream as it happens. Useful for both.
+ const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
+ const isVodOn = vodList.includes(streamer);
+ const vodBtn = document.createElement('span');
+ vodBtn.className = 'streamer-vod' + (isVodOn ? ' active' : '');
+ vodBtn.textContent = 'VOD';
+ vodBtn.title = UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs';
+ vodBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ void toggleAutoVodDownload(streamer);
+ });
+
// Live-record button — small red dot, only triggers a live capture
// when the streamer is currently online (server checks via Helix).
const recBtn = document.createElement('span');
@@ -438,7 +454,7 @@ function renderStreamers(): void {
e.stopPropagation();
void removeStreamer(streamer);
});
- item.append(nameSpan, autoBtn, recBtn, removeSpan);
+ item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan);
item.addEventListener('click', () => {
// Skip click if drag was just released — drop fires after dragend
@@ -875,6 +891,25 @@ async function toggleAutoRecord(streamer: string): Promise {
}
}
+async function toggleAutoVodDownload(streamer: string): Promise {
+ const current = ((config.auto_vod_download_streamers as string[]) || []).slice();
+ const idx = current.indexOf(streamer);
+ if (idx >= 0) {
+ current.splice(idx, 1);
+ } else {
+ current.push(streamer);
+ }
+ config = await window.api.saveConfig({ auto_vod_download_streamers: current });
+ renderStreamers();
+
+ const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
+ if (toast) {
+ const wasAdded = idx < 0;
+ const tmpl = wasAdded ? UI_TEXT.streamers.autoVodEnabled : UI_TEXT.streamers.autoVodDisabled;
+ toast(tmpl.replace('{streamer}', streamer), 'info');
+ }
+}
+
async function triggerLiveRecording(streamer: string): Promise {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
const result = await window.api.startLiveRecording(streamer);
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index fc36d3f..111070d 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -197,6 +197,11 @@ function applyLanguageToStaticUI(): void {
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
+ setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel);
+ setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle);
+ setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
+ setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel);
+ setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel);
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
setText('btnExportConfig', UI_TEXT.static.exportConfig);
diff --git a/src/styles.css b/src/styles.css
index 85036b6..2e7eeda 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -669,6 +669,31 @@ body {
color: #00c853;
}
+.streamer-vod {
+ margin-right: 4px;
+ color: var(--text-secondary);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+ padding: 2px 5px;
+ border: 1px solid var(--border-soft);
+ border-radius: 3px;
+ background: transparent;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+
+.streamer-vod.active {
+ color: #2196f3;
+ border-color: rgba(33, 150, 243, 0.45);
+ background: rgba(33, 150, 243, 0.10);
+}
+
+.streamer-vod:hover {
+ background: rgba(33, 150, 243, 0.18);
+ color: #2196f3;
+}
+
.queue-live-badge {
display: inline-block;
background: #ff4444;