From 28692b2e543ccb92d88979efda8a5189d1e26a04 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 10 May 2026 22:09:59 +0200 Subject: [PATCH] feat: manual scan-now buttons + automation status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with 4.6.10 (auto-VOD) and 4.6.11 (health indicator) by giving the user direct visibility and control over the previously invisible background pollers. Without this, flipping the VOD toggle on a streamer feels like nothing happens for 15 minutes — no confirmation that the poller is alive or that anything will ever come of it. Both run* functions now return the count they handled. Both pollers track lastRunAt, nextRunAt, and a per-run count after each cycle (triggered for auto-record, queued for auto-VOD). Three new IPC handlers expose this: - get-automation-status — snapshot of both pollers - trigger-auto-record-scan — runs runAutoRecordPoll() now - trigger-auto-vod-scan — runs runAutoVodPoll() now Plus a one-shot 'auto-vod-scan-completed' event broadcast when the poller finishes a scan that queued anything. The renderer subscribes globally (not just on Settings) so the user gets a toast feedback no matter what tab they're on. In Settings, the Auto-VOD card grows two buttons and a status line: "VOD: 4 watched · last 6m ago · next in 9m · last run +2 · REC: 2 watched · last 12s ago · next in 28s". Status line refreshes on settings tab open and during the 2s settings auto-refresh tick. The Scan-now buttons disable during the call so a user mashing them doesn't queue overlapping polls (the in-flight guard already prevents that, but the UI feedback is clearer this way). Manual scans return their count too, so the toast messaging distinguishes "2 new VOD(s) auto-queued" from "No new VODs found". Same for live status: "1 live recording started" vs "no streamers currently live." Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.html | 5 +++ src/main.ts | 66 ++++++++++++++++++++++++++++++--- src/preload.ts | 6 +++ src/renderer-globals.d.ts | 7 ++++ src/renderer-locale-de.ts | 8 +++- src/renderer-locale-en.ts | 8 +++- src/renderer-settings.ts | 78 +++++++++++++++++++++++++++++++++++++++ src/renderer-texts.ts | 2 + src/renderer.ts | 7 ++++ 9 files changed, 180 insertions(+), 7 deletions(-) diff --git a/src/index.html b/src/index.html index 2250a5a..6f6d9e0 100644 --- a/src/index.html +++ b/src/index.html @@ -702,6 +702,11 @@ Max. Alter (Stunden) +
+ + + +
diff --git a/src/main.ts b/src/main.ts index bece7cc..7338e83 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2946,6 +2946,9 @@ function downloadVODPart( const autoRecordLastLiveState = new Map(); let autoRecordPollTimer: NodeJS.Timeout | null = null; let autoRecordPollInFlight = false; +let autoRecordLastRunAt = 0; +let autoRecordNextRunAt = 0; +let autoRecordLastTriggerCount = 0; function stopAutoRecordPoller(): void { if (autoRecordPollTimer) { @@ -2965,14 +2968,16 @@ function restartAutoRecordPoller(): void { appendDebugLog('auto-record-poller-start', { streamers: list.length, seconds }); autoRecordPollTimer = setInterval(() => { void runAutoRecordPoll(); }, seconds * 1000); autoRecordPollTimer.unref?.(); + autoRecordNextRunAt = Date.now() + seconds * 1000; // Kick off an immediate first poll so a freshly-enabled streamer that's // already live gets picked up without waiting a full interval. setTimeout(() => { void runAutoRecordPoll(); }, 1500); } -async function runAutoRecordPoll(): Promise { - if (autoRecordPollInFlight) return; +async function runAutoRecordPoll(): Promise { + if (autoRecordPollInFlight) return 0; autoRecordPollInFlight = true; + let triggered = 0; try { const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : []; for (const streamer of list) { @@ -3018,6 +3023,7 @@ async function runAutoRecordPoll(): Promise { downloadQueue.push(liveItem); saveQueue(downloadQueue); emitQueueUpdated(); + triggered++; appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title }); if (!isDownloading) { @@ -3028,7 +3034,12 @@ async function runAutoRecordPoll(): Promise { appendDebugLog('auto-record-poll-failed', String(e)); } finally { autoRecordPollInFlight = false; + autoRecordLastRunAt = Date.now(); + autoRecordLastTriggerCount = triggered; + const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds); + autoRecordNextRunAt = Date.now() + seconds * 1000; } + return triggered; } // ========================================== @@ -3042,6 +3053,9 @@ async function runAutoRecordPoll(): Promise { // minute-level lag is fine. let autoVodPollTimer: NodeJS.Timeout | null = null; let autoVodPollInFlight = false; +let autoVodLastRunAt = 0; +let autoVodNextRunAt = 0; +let autoVodLastQueuedCount = 0; function stopAutoVodPoller(): void { if (autoVodPollTimer) { @@ -3065,15 +3079,17 @@ function restartAutoVodPoller(): void { appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes }); autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000); autoVodPollTimer.unref?.(); + autoVodNextRunAt = Date.now() + minutes * 60 * 1000; setTimeout(() => { void runAutoVodPoll(); }, 5000); } -async function runAutoVodPoll(): Promise { - if (autoVodPollInFlight) return; +async function runAutoVodPoll(): Promise { + if (autoVodPollInFlight) return 0; autoVodPollInFlight = true; + let queuedCount = 0; try { const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : []; - if (list.length === 0) return; + if (list.length === 0) return 0; const maxAgeHours = (() => { const n = Number(config.auto_vod_max_age_hours); @@ -3123,6 +3139,7 @@ async function runAutoVodPoll(): Promise { }; downloadQueue.push(queueItem); queuedUrls.add(vod.url); + queuedCount++; appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title }); if (config.discord_notify_vod_auto_queued) { @@ -3152,7 +3169,19 @@ async function runAutoVodPoll(): Promise { appendDebugLog('auto-vod-poll-failed', String(e)); } finally { autoVodPollInFlight = false; + autoVodLastRunAt = Date.now(); + autoVodLastQueuedCount = queuedCount; + 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))); + })(); + autoVodNextRunAt = Date.now() + minutes * 60 * 1000; + if (queuedCount > 0 && mainWindow) { + mainWindow.webContents.send('auto-vod-scan-completed', { queuedCount }); + } } + return queuedCount; } // ========================================== @@ -5327,6 +5356,33 @@ function setupAutoUpdater() { // ========================================== ipcMain.handle('get-config', () => config); +ipcMain.handle('get-automation-status', () => ({ + autoRecord: { + watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0, + lastRunAt: autoRecordLastRunAt, + nextRunAt: autoRecordNextRunAt, + lastTriggeredCount: autoRecordLastTriggerCount, + inFlight: autoRecordPollInFlight + }, + autoVod: { + watching: Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers.length : 0, + lastRunAt: autoVodLastRunAt, + nextRunAt: autoVodNextRunAt, + lastQueuedCount: autoVodLastQueuedCount, + inFlight: autoVodPollInFlight + } +})); + +ipcMain.handle('trigger-auto-record-scan', async () => { + const triggered = await runAutoRecordPoll(); + return { triggered }; +}); + +ipcMain.handle('trigger-auto-vod-scan', async () => { + const queuedCount = await runAutoVodPoll(); + return { queuedCount }; +}); + ipcMain.handle('save-config', (_, newConfig: Partial) => { const previousClientId = config.client_id; const previousClientSecret = config.client_secret; diff --git a/src/preload.ts b/src/preload.ts index bc43bb7..815dc58 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -92,6 +92,12 @@ contextBridge.exposeInMainWorld('api', { getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), + getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'), + triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'), + triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'), + onAutoVodScanCompleted: (callback: (info: { queuedCount: number }) => void) => { + ipcRenderer.on('auto-vod-scan-completed', (_, info) => callback(info)); + }, // Video Cutter getVideoInfo: (filePath: string): Promise => ipcRenderer.invoke('get-video-info', filePath), diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 1ae7065..2a14c3c 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -264,6 +264,13 @@ interface ApiBridge { getStorageStats(): Promise; runStorageCleanup(options?: { dryRun?: boolean }): Promise; readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number }>; + getAutomationStatus(): Promise<{ + autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean }; + autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean }; + }>; + triggerAutoVodScan(): Promise<{ queuedCount: number }>; + triggerAutoRecordScan(): Promise<{ triggered: number }>; + onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void; getVideoInfo(filePath: string): Promise; extractFrame(filePath: string, timeSeconds: number): Promise; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index fd1af84..522a292 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -93,6 +93,8 @@ const UI_TEXT_DE = { 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)', + autoVodScanNow: 'Jetzt scannen', + autoRecordScanNow: 'Live-Status pruefen', 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.', @@ -274,7 +276,11 @@ const UI_TEXT_DE = { 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.' + autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.', + autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.', + autoVodScanEmpty: 'Keine neuen VODs gefunden.', + autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.', + autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.' }, vods: { noneTitle: 'Keine VODs', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 5b4ba40..dfaf82e 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -94,6 +94,8 @@ const UI_TEXT_EN = { 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)', + autoVodScanNow: 'Scan now', + autoRecordScanNow: 'Check live status', backupCardTitle: 'Backup & Maintenance', backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.', exportConfig: 'Export config', @@ -274,7 +276,11 @@ const UI_TEXT_EN = { 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}.' + autoVodDisabled: 'Auto-VOD disabled for {streamer}.', + autoVodScanQueued: '{count} new VOD(s) auto-queued.', + autoVodScanEmpty: 'No new VODs found.', + autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.', + autoRecordScanEmpty: 'Manual scan: no streamers currently live.' }, vods: { noneTitle: 'No VODs', diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index a500a96..0dd8354 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -162,6 +162,7 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void { } void refreshRuntimeMetrics(false); + void refreshAutomationStatusLine(); }, 2000); } } @@ -199,6 +200,7 @@ function changeLanguage(lang: string): void { } void refreshRuntimeMetrics(); + void refreshAutomationStatusLine(); validateFilenameTemplates(); } @@ -898,3 +900,79 @@ function changeTheme(theme: string): void { config.theme = theme; void window.api.saveConfig({ theme }); } + +function formatRelativeTime(ms: number, future: boolean): string { + if (!Number.isFinite(ms) || ms <= 0) { + return future ? UI_TEXT.streamers.autoVodScanEmpty || '' : '-'; + } + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +async function refreshAutomationStatusLine(): Promise { + const lineEl = document.getElementById('autoVodStatusLine'); + if (!lineEl) return; + try { + const status = await window.api.getAutomationStatus(); + const now = Date.now(); + const parts: string[] = []; + + if (status.autoVod.watching > 0) { + const lastAgo = status.autoVod.lastRunAt > 0 ? formatRelativeTime(now - status.autoVod.lastRunAt, false) : '-'; + const nextIn = status.autoVod.nextRunAt > now ? formatRelativeTime(status.autoVod.nextRunAt - now, true) : '-'; + parts.push(`VOD: ${status.autoVod.watching} watched · last ${lastAgo} ago · next in ${nextIn} · last run +${status.autoVod.lastQueuedCount}`); + } + if (status.autoRecord.watching > 0) { + const lastAgo = status.autoRecord.lastRunAt > 0 ? formatRelativeTime(now - status.autoRecord.lastRunAt, false) : '-'; + const nextIn = status.autoRecord.nextRunAt > now ? formatRelativeTime(status.autoRecord.nextRunAt - now, true) : '-'; + parts.push(`REC: ${status.autoRecord.watching} watched · last ${lastAgo} ago · next in ${nextIn}`); + } + if (parts.length === 0) parts.push('No streamers watched.'); + lineEl.textContent = parts.join(' · '); + } catch (_) { + lineEl.textContent = ''; + } +} + +async function triggerManualAutoVodScan(): Promise { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + const btn = document.getElementById('btnAutoVodScanNow') as HTMLButtonElement | null; + if (btn) btn.disabled = true; + try { + const result = await window.api.triggerAutoVodScan(); + if (toast) { + const tmpl = result.queuedCount > 0 + ? UI_TEXT.streamers.autoVodScanQueued + : UI_TEXT.streamers.autoVodScanEmpty; + toast((tmpl || '').replace('{count}', String(result.queuedCount)), 'info'); + } + } finally { + if (btn) btn.disabled = false; + void refreshAutomationStatusLine(); + } +} + +async function triggerManualAutoRecordScan(): Promise { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + const btn = document.getElementById('btnAutoRecordScanNow') as HTMLButtonElement | null; + if (btn) btn.disabled = true; + try { + const result = await window.api.triggerAutoRecordScan(); + if (toast) { + const tmpl = result.triggered > 0 + ? UI_TEXT.streamers.autoRecordScanTriggered + : UI_TEXT.streamers.autoRecordScanEmpty; + toast((tmpl || '').replace('{count}', String(result.triggered)), 'info'); + } + } finally { + if (btn) btn.disabled = false; + void refreshAutomationStatusLine(); + } +} + +(window as unknown as { triggerManualAutoVodScan: typeof triggerManualAutoVodScan }).triggerManualAutoVodScan = triggerManualAutoVodScan; +(window as unknown as { triggerManualAutoRecordScan: typeof triggerManualAutoRecordScan }).triggerManualAutoRecordScan = triggerManualAutoRecordScan; diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 111070d..1772602 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -202,6 +202,8 @@ function applyLanguageToStaticUI(): void { setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro); setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel); setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel); + setText('btnAutoVodScanNow', UI_TEXT.static.autoVodScanNow); + setText('btnAutoRecordScanNow', UI_TEXT.static.autoRecordScanNow); setText('backupCardTitle', UI_TEXT.static.backupCardTitle); setText('backupCardIntro', UI_TEXT.static.backupCardIntro); setText('btnExportConfig', UI_TEXT.static.exportConfig); diff --git a/src/renderer.ts b/src/renderer.ts index 050cecc..9340045 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -124,6 +124,13 @@ async function init(): Promise { markQueueActivity(); }); + window.api.onAutoVodScanCompleted(({ queuedCount }) => { + if (queuedCount > 0) { + const tmpl = UI_TEXT.streamers.autoVodScanQueued || '{count} new VOD(s) auto-queued.'; + showAppToast(tmpl.replace('{count}', String(queuedCount)), 'info'); + } + }); + window.api.onDownloadStarted(() => { downloading = true; updateDownloadButtonState();