feat: auto-vod-download — per-streamer VOD toggle + background poller

Adds the second half of the live-archive flow. AUTO catches a stream
as it happens; VOD catches the recently published archive. Both
together close the gap a Twitch viewer-side archivist cares about.

Streamer list grows a third per-streamer toggle (blue "VOD") next
to AUTO and REC. When enabled, the main-process auto-VOD poller
periodically scans that streamer's VOD list and queues anything
that is (a) within the rolling age window, (b) not already in
downloaded_vod_ids, and (c) not already in the active queue. The
age window keeps freshly-enabled streamers from suddenly dumping
their entire historical backlog into the queue — when a user flips
VOD on, only VODs published in the last N hours (default 24, capped
at 720) get auto-pulled.

Polling cadence is in minutes, not seconds — VOD-listing scans are
heavier than live-status checks and new VODs only appear after a
stream ends, so minute-level lag is fine. Default 15 min, clamped
[5, 360]. Independent timer from the auto-record poller because
their cadences shouldn't be coupled.

UI:
- Streamer item: blue "VOD" pill next to AUTO/REC, identical interaction.
- Settings card "Auto-VOD download": poll interval + max age fields.
- Discord card: optional "Notify when a VOD gets auto-queued" checkbox.

Wires through save-config so toggling triggers restartAutoVodPoller
without a full app restart, and through shutdownCleanup so the
timer is killed on quit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 21:59:05 +02:00
parent 2f1e5f4a9e
commit 1ab6f01e07
9 changed files with 269 additions and 5 deletions

View File

@ -686,6 +686,21 @@
<input type="checkbox" id="discordNotifyVodCompleteToggle">
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
</label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="discordNotifyVodAutoQueuedToggle">
<span id="discordNotifyVodAutoQueuedLabel">Bei automatisch eingereihten VODs benachrichtigen</span>
</label>
</div>
</div>
<div class="settings-card">
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3>
<p id="autoVodCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">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.</p>
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
<span id="autoVodPollMinutesLabel" style="font-size:12px; color:var(--text-secondary);">Poll-Intervall (Minuten)</span>
<input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" style="width:90px;">
<span id="autoVodMaxAgeHoursLabel" style="font-size:12px; color:var(--text-secondary); margin-left:12px;">Max. Alter (Stunden)</span>
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" style="width:90px;">
</div>
</div>

View File

@ -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<void> {
}
}
// ==========================================
// 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<void> {
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<Config>) => {
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<Config>) => {
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,

View File

@ -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;
}

View File

@ -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',

View File

@ -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',

View File

@ -558,6 +558,9 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
discord_notify_vod_auto_queued: byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked,
auto_vod_download_poll_minutes: parseInt(byId<HTMLInputElement>('autoVodPollMinutes').value, 10) || 15,
auto_vod_max_age_hours: parseInt(byId<HTMLInputElement>('autoVodMaxAgeHours').value, 10) || 24,
auto_cleanup_enabled: byId<HTMLInputElement>('autoCleanupEnabledToggle').checked,
auto_cleanup_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
@ -615,6 +618,9 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): 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<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<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true;
byId<HTMLInputElement>('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15);
byId<HTMLInputElement>('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24);
byId<HTMLInputElement>('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
byId<HTMLInputElement>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';

View File

@ -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<void> {
}
}
async function toggleAutoVodDownload(streamer: string): Promise<void> {
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<void> {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
const result = await window.api.startLiveRecording(streamer);

View File

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

View File

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