From 2c40bbf66eaec72b6b95bba1caa357a335a32813 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 10 May 2026 22:04:53 +0200 Subject: [PATCH] feat: live recording health indicator (green/amber dot per item) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-flight live recordings now show a small coloured dot before the title indicating whether bytes are still flowing. The health state is derived from byte-progress liveness: each time the byte counter advances, we stamp lastBytesAdvancedAt; if more than 30s pass without an advance we flip the badge to amber to tell the user the streamlink subprocess has gone quiet (dropped segments, network blip, or the stream just ended). Until the first segment arrives we report "unknown" so we don't claim health prematurely on a streamlink that's still negotiating playlists. Critical wrinkle: streamlink emits progress events on byte boundaries, so a hung process emits NO events at all. A pure event-driven badge would never update from "ok" to "stale" — it'd stay frozen at the last known good state. To avoid that, downloadLiveStream now runs a 10s health-tick interval that re-emits the most recent progress event with a fresh health computation. The interval is killed in a finally block so process termination doesn't leak it. DownloadProgress + QueueItem in both src/types.ts and the renderer declaration shadow get the new optional recordingHealth field. The renderer queue handler copies it onto the item; the queue render function shows a coloured dot before the title for in-flight live items only (status === 'downloading' && isLive). Three states: green pulsing (ok), amber flashing (stale), grey static (unknown). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 48 +++++++++++++++++++++++++++++++++++---- src/renderer-globals.d.ts | 2 ++ src/renderer-locale-de.ts | 7 +++++- src/renderer-locale-en.ts | 7 +++++- src/renderer-queue.ts | 13 ++++++++++- src/renderer.ts | 3 +++ src/styles.css | 38 +++++++++++++++++++++++++++++++ src/types.ts | 7 ++++++ 8 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/main.ts b/src/main.ts index b1c5fdc..bece7cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4047,6 +4047,20 @@ async function downloadLiveStream( } const recordingStartedAt = Date.now(); + // Health is derived from byte-progress liveness: each time the byte + // counter advances, we stamp lastBytesAdvancedAt; if we go BYTES_FRESH_MS + // without an advance we flip to 'stale'. Until the first byte arrives + // we report 'unknown' so the UI doesn't claim health prematurely on a + // streamlink that hasn't even hit a segment yet. + const BYTES_FRESH_MS = 30_000; + let lastBytesValue = 0; + let lastBytesAdvancedAt = 0; + let lastEmittedProgress: DownloadProgress | null = null; + + const computeHealth = (): 'ok' | 'stale' | 'unknown' => { + if (lastBytesAdvancedAt === 0) return 'unknown'; + return (Date.now() - lastBytesAdvancedAt) <= BYTES_FRESH_MS ? 'ok' : 'stale'; + }; // Wrap onProgress so live recordings get a useful meta line. Without // this the queue meta only shows raw bytes ("4.7 GB heruntergeladen") @@ -4055,24 +4069,48 @@ async function downloadLiveStream( // "{HH:MM:SS} · {size} · {avg Mbps}" // and clears speed/eta so the renderer doesn't double-up on data. const wrappedProgress = (p: DownloadProgress): void => { - const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000)); const bytes = Number(p.downloadedBytes) || 0; + if (bytes > lastBytesValue) { + lastBytesValue = bytes; + lastBytesAdvancedAt = Date.now(); + } + const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000)); const avgBitrateMbps = (bytes * 8) / elapsed / 1_000_000; const parts: string[] = [formatDuration(elapsed)]; if (bytes > 0) parts.push(formatBytes(bytes)); if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`); - onProgress({ + const next = { ...p, speed: '', eta: '', - status: parts.join(' · ') - }); + status: parts.join(' · '), + recordingHealth: computeHealth() + }; + lastEmittedProgress = next; + onProgress(next); }; + // Health-tick: re-emit the most recent progress every 10s so the + // renderer's health badge updates even when streamlink is silent. + // Without this, a streamlink hung on a buffer-stall would keep showing + // 'ok' until the next real byte event — defeats the point of the badge. + const healthTick = setInterval(() => { + if (!lastEmittedProgress) return; + const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() }; + lastEmittedProgress = updated; + onProgress(updated); + }, 10_000); + healthTick.unref?.(); + // 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. - const result = await downloadVODPart(item.url, filename, null, null, wrappedProgress, item.id, 1, 1); + let result: DownloadResult; + try { + result = await downloadVODPart(item.url, filename, null, null, wrappedProgress, item.id, 1, 1); + } finally { + clearInterval(healthTick); + } if (chatSession) { stopLiveChatCapture(chatSession); diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index e1314a0..1ae7065 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -99,6 +99,7 @@ interface QueueItem { mergeGroup?: MergeGroup; outputFiles?: string[]; isLive?: boolean; + recordingHealth?: 'ok' | 'stale' | 'unknown'; } interface DownloadProgress { @@ -112,6 +113,7 @@ interface DownloadProgress { totalParts?: number; downloadedBytes?: number; totalBytes?: number; + recordingHealth?: 'ok' | 'stale' | 'unknown'; } interface RuntimeMetricsSnapshot { diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 3c00ba8..fd1af84 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -256,7 +256,12 @@ const UI_TEXT_DE = { ctxOpenOnTwitch: 'Auf Twitch oeffnen', ctxRemove: 'Aus Queue entfernen', ctxCopiedUrl: 'URL in Zwischenablage kopiert.', - liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet' + liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet', + recordingHealth: { + ok: 'Gesund - Bytes fliessen', + stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)', + unknown: 'Warte auf ersten Segment' + } }, streamers: { recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 9c38d72..5b4ba40 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -256,7 +256,12 @@ const UI_TEXT_EN = { ctxOpenOnTwitch: 'Open on Twitch', ctxRemove: 'Remove from queue', ctxCopiedUrl: 'URL copied to clipboard.', - liveRecordingTitle: 'Live recording — captures until the stream ends' + liveRecordingTitle: 'Live recording — captures until the stream ends', + recordingHealth: { + ok: 'Healthy — bytes flowing', + stale: 'Stalled — no bytes recently (network blip or stream ending)', + unknown: 'Waiting for first segment' + } }, streamers: { recordLiveTitle: 'Record this streamer live (captures until stream ends)', diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts index ecd51ab..2be849f 100644 --- a/src/renderer-queue.ts +++ b/src/renderer-queue.ts @@ -1,3 +1,11 @@ +function renderRecordingHealthBadge(health: 'ok' | 'stale' | 'unknown' | undefined): string { + if (!health) return ''; + const labels = UI_TEXT.queue.recordingHealth || { ok: 'Healthy', stale: 'Stalled', unknown: 'Pending data' }; + const cls = health === 'ok' ? 'health-ok' : (health === 'stale' ? 'health-stale' : 'health-unknown'); + const title = labels[health] || ''; + return ``; +} + function renderQueueItemFileActions(item: QueueItem): string { if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) { return ''; @@ -523,6 +531,9 @@ function renderQueue(): void { const liveBadge = item.isLive ? `REC ` : ''; + const healthBadge = (item.isLive && item.status === 'downloading') + ? renderRecordingHealthBadge(item.recordingHealth) + : ''; const mergeMetaExtra = isMergeGroup ? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})` : ''; @@ -536,7 +547,7 @@ function renderQueue(): void {
-
${liveBadge}${mergeIcon}${isClip}${safeTitle}
+
${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}
${safeStatusLabel}
${safeMeta}${mergeMetaExtra}
diff --git a/src/renderer.ts b/src/renderer.ts index 8343e52..050cecc 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -116,6 +116,9 @@ async function init(): Promise { item.downloadedBytes = progress.downloadedBytes; item.totalBytes = progress.totalBytes; item.progressStatus = progress.status; + if (progress.recordingHealth) { + item.recordingHealth = progress.recordingHealth; + } updateQueueItemProgress(progress); updateStatusBarQueueSummary(); markQueueActivity(); diff --git a/src/styles.css b/src/styles.css index 2e7eeda..4947fcd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -694,6 +694,44 @@ body { color: #2196f3; } +.queue-health-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; + box-shadow: 0 0 4px currentColor; +} + +.queue-health-dot.health-ok { + background: #00c853; + color: #00c853; + animation: queue-health-pulse 2s ease-in-out infinite; +} + +.queue-health-dot.health-stale { + background: #ffab00; + color: #ffab00; + animation: queue-health-flash 1s ease-in-out infinite; +} + +.queue-health-dot.health-unknown { + background: var(--text-secondary); + color: var(--text-secondary); + box-shadow: none; +} + +@keyframes queue-health-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +@keyframes queue-health-flash { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + .queue-live-badge { display: inline-block; background: #ff4444; diff --git a/src/types.ts b/src/types.ts index 1c6ff89..cc2ab8e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,12 @@ export interface QueueItem { // filename includes a timestamp so consecutive live recordings of the // same streamer don't collide. isLive?: boolean; + // Live recording health snapshot. 'ok' means bytes are flowing within + // the freshness window, 'stale' means the streamlink subprocess hasn't + // pushed bytes recently (dropped segments, network blip, or stream just + // ended), 'unknown' until the first progress event arrives. Only set + // for in-flight live recordings; cleared when the recording finishes. + recordingHealth?: 'ok' | 'stale' | 'unknown'; } export interface DownloadProgress { @@ -65,6 +71,7 @@ export interface DownloadProgress { totalParts?: number; downloadedBytes?: number; totalBytes?: number; + recordingHealth?: 'ok' | 'stale' | 'unknown'; } export interface DownloadResult {