feat: live recording health indicator (green/amber dot per item)

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) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 22:04:53 +02:00
parent ddaf4807f4
commit 2c40bbf66e
8 changed files with 117 additions and 8 deletions

View File

@ -4047,6 +4047,20 @@ async function downloadLiveStream(
} }
const recordingStartedAt = Date.now(); 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 // Wrap onProgress so live recordings get a useful meta line. Without
// this the queue meta only shows raw bytes ("4.7 GB heruntergeladen") // 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}" // "{HH:MM:SS} · {size} · {avg Mbps}"
// and clears speed/eta so the renderer doesn't double-up on data. // and clears speed/eta so the renderer doesn't double-up on data.
const wrappedProgress = (p: DownloadProgress): void => { const wrappedProgress = (p: DownloadProgress): void => {
const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000));
const bytes = Number(p.downloadedBytes) || 0; 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 avgBitrateMbps = (bytes * 8) / elapsed / 1_000_000;
const parts: string[] = [formatDuration(elapsed)]; const parts: string[] = [formatDuration(elapsed)];
if (bytes > 0) parts.push(formatBytes(bytes)); if (bytes > 0) parts.push(formatBytes(bytes));
if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`); if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`);
onProgress({ const next = {
...p, ...p,
speed: '', speed: '',
eta: '', 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 // No start/end times for live streams — streamlink records until the
// stream actually ends or we kill it. downloadVODPart already handles // stream actually ends or we kill it. downloadVODPart already handles
// null start/end correctly. // 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) { if (chatSession) {
stopLiveChatCapture(chatSession); stopLiveChatCapture(chatSession);

View File

@ -99,6 +99,7 @@ interface QueueItem {
mergeGroup?: MergeGroup; mergeGroup?: MergeGroup;
outputFiles?: string[]; outputFiles?: string[];
isLive?: boolean; isLive?: boolean;
recordingHealth?: 'ok' | 'stale' | 'unknown';
} }
interface DownloadProgress { interface DownloadProgress {
@ -112,6 +113,7 @@ interface DownloadProgress {
totalParts?: number; totalParts?: number;
downloadedBytes?: number; downloadedBytes?: number;
totalBytes?: number; totalBytes?: number;
recordingHealth?: 'ok' | 'stale' | 'unknown';
} }
interface RuntimeMetricsSnapshot { interface RuntimeMetricsSnapshot {

View File

@ -256,7 +256,12 @@ const UI_TEXT_DE = {
ctxOpenOnTwitch: 'Auf Twitch oeffnen', ctxOpenOnTwitch: 'Auf Twitch oeffnen',
ctxRemove: 'Aus Queue entfernen', ctxRemove: 'Aus Queue entfernen',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.', 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: { streamers: {
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)', recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',

View File

@ -256,7 +256,12 @@ const UI_TEXT_EN = {
ctxOpenOnTwitch: 'Open on Twitch', ctxOpenOnTwitch: 'Open on Twitch',
ctxRemove: 'Remove from queue', ctxRemove: 'Remove from queue',
ctxCopiedUrl: 'URL copied to clipboard.', 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: { streamers: {
recordLiveTitle: 'Record this streamer live (captures until stream ends)', recordLiveTitle: 'Record this streamer live (captures until stream ends)',

View File

@ -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 `<span class="queue-health-dot ${cls}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"></span>`;
}
function renderQueueItemFileActions(item: QueueItem): string { function renderQueueItemFileActions(item: QueueItem): string {
if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) { if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) {
return ''; return '';
@ -523,6 +531,9 @@ function renderQueue(): void {
const liveBadge = item.isLive const liveBadge = item.isLive
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> ` ? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
: ''; : '';
const healthBadge = (item.isLive && item.status === 'downloading')
? renderRecordingHealthBadge(item.recordingHealth)
: '';
const mergeMetaExtra = isMergeGroup const mergeMetaExtra = isMergeGroup
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})` ? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
: ''; : '';
@ -536,7 +547,7 @@ function renderQueue(): void {
<div class="status ${item.status}"></div> <div class="status ${item.status}"></div>
<div class="queue-main"> <div class="queue-main">
<div class="queue-title-row"> <div class="queue-title-row">
<div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${liveBadge}${mergeIcon}${isClip}${safeTitle}</div> <div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div> <div class="queue-status-label">${safeStatusLabel}</div>
</div> </div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div> <div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>

View File

@ -116,6 +116,9 @@ async function init(): Promise<void> {
item.downloadedBytes = progress.downloadedBytes; item.downloadedBytes = progress.downloadedBytes;
item.totalBytes = progress.totalBytes; item.totalBytes = progress.totalBytes;
item.progressStatus = progress.status; item.progressStatus = progress.status;
if (progress.recordingHealth) {
item.recordingHealth = progress.recordingHealth;
}
updateQueueItemProgress(progress); updateQueueItemProgress(progress);
updateStatusBarQueueSummary(); updateStatusBarQueueSummary();
markQueueActivity(); markQueueActivity();

View File

@ -694,6 +694,44 @@ body {
color: #2196f3; 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 { .queue-live-badge {
display: inline-block; display: inline-block;
background: #ff4444; background: #ff4444;

View File

@ -52,6 +52,12 @@ export interface QueueItem {
// filename includes a timestamp so consecutive live recordings of the // filename includes a timestamp so consecutive live recordings of the
// same streamer don't collide. // same streamer don't collide.
isLive?: boolean; 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 { export interface DownloadProgress {
@ -65,6 +71,7 @@ export interface DownloadProgress {
totalParts?: number; totalParts?: number;
downloadedBytes?: number; downloadedBytes?: number;
totalBytes?: number; totalBytes?: number;
recordingHealth?: 'ok' | 'stale' | 'unknown';
} }
export interface DownloadResult { export interface DownloadResult {