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:
parent
ddaf4807f4
commit
2c40bbf66e
48
src/main.ts
48
src/main.ts
@ -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);
|
||||||
|
|||||||
2
src/renderer-globals.d.ts
vendored
2
src/renderer-globals.d.ts
vendored
@ -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 {
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user