diff --git a/src/index.html b/src/index.html index ba2fcb6..2250a5a 100644 --- a/src/index.html +++ b/src/index.html @@ -686,6 +686,21 @@ Bei abgeschlossenem VOD-Download benachrichtigen + + + + +
+

Auto-VOD-Download

+

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;