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 {