feat: auto-resume live recording across streamlink crashes
When a live recording gets cut short by a network blip or a
streamlink subprocess that dies mid-stream, the recording would
end with whatever it had captured up to that point. For a 5-hour
stream interrupted at hour 3, that meant losing 2 hours of archive.
downloadLiveStream now wraps the streamlink call in a resume loop.
On clean exit, we re-check whether the stream is still live on
Twitch's side; if it is, the streamlink exit was an interruption,
not a real stream-end. The recording continues into a new file
("..._part2.mp4", "..._part3.mp4", ...) and both parts get attached
to item.outputFiles so the user sees them as one logical recording.
Guard rails to keep the loop from misbehaving:
- Stream-still-live check before each resume. If the streamer
actually ended their broadcast, we finalize. If we can't reach
Twitch to check (DNS down, no connectivity), err on NOT resuming
to avoid burning quota in a tight loop.
- Skip resume on suspiciously short parts (<30s). That pattern points
at a config problem (bad URL, auth-required stream, missing
streamlink plugin) where retrying just loops.
- Cap at 5 resume attempts per recording. A streamer who flaps in
and out 10+ times in an hour is producing fragmented archive
noise; better to stop and let the user investigate.
- Skip resume on zero-byte parts. Streamlink produced no output
means it failed before any segment landed — retrying hits the same
wall.
- Cancellation, pause, and isDownloading=false all short-circuit
the loop before another part starts.
Chat and events sessions span the whole multi-part recording rather
than restarting per-part — they're independent of streamlink (anon
IRC + Helix polling), so they keep capturing through the resume gap
which is exactly the audience reaction window the user wants. A new
"recording_resume" event type lands in .events.jsonl so the events
viewer shows where each gap happened.
The progress meta line was rewritten to accumulate bytes across
parts. Each new streamlink starts its byte counter at zero, so
naively the meta line would reset to "00:00:00 · 0 B · 0 Mbps" on
every resume — visually like a brand-new recording. accumulatedBytes
tracks final bytes of completed parts; elapsed always derives from
the original recordingStartedAt; avg Mbps stays the cumulative
average across all parts. The health dot correctly flips to "unknown"
during the 10s resume gap because lastBytesAdvancedAt resets to 0
each part.
Settings toggle (default on). When off, behavior is identical to
4.6.12 — single part, no resume.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
805231ae2f
commit
7d82f70ca3
@ -562,6 +562,10 @@
|
|||||||
<input type="checkbox" id="logStreamEventsToggle" checked>
|
<input type="checkbox" id="logStreamEventsToggle" checked>
|
||||||
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span>
|
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
||||||
|
<input type="checkbox" id="autoResumeLiveRecordingToggle" checked>
|
||||||
|
<span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
||||||
|
|||||||
179
src/main.ts
179
src/main.ts
@ -230,6 +230,7 @@ interface Config {
|
|||||||
auto_vod_download_streamers: string[];
|
auto_vod_download_streamers: string[];
|
||||||
auto_vod_download_poll_minutes: number;
|
auto_vod_download_poll_minutes: number;
|
||||||
auto_vod_max_age_hours: number;
|
auto_vod_max_age_hours: number;
|
||||||
|
auto_resume_live_recording: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeMetrics {
|
interface RuntimeMetrics {
|
||||||
@ -363,7 +364,8 @@ const defaultConfig: Config = {
|
|||||||
log_stream_events: true,
|
log_stream_events: true,
|
||||||
auto_vod_download_streamers: [],
|
auto_vod_download_streamers: [],
|
||||||
auto_vod_download_poll_minutes: 15,
|
auto_vod_download_poll_minutes: 15,
|
||||||
auto_vod_max_age_hours: 24
|
auto_vod_max_age_hours: 24,
|
||||||
|
auto_resume_live_recording: true
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
const AUTO_RECORD_POLL_MIN_SECONDS = 30;
|
||||||
@ -490,7 +492,8 @@ function normalizeConfigTemplates(input: Config): Config {
|
|||||||
const n = Number(input.auto_vod_max_age_hours);
|
const n = Number(input.auto_vod_max_age_hours);
|
||||||
if (!Number.isFinite(n)) return 24;
|
if (!Number.isFinite(n)) return 24;
|
||||||
return Math.max(1, Math.min(720, Math.floor(n)));
|
return Math.max(1, Math.min(720, Math.floor(n)));
|
||||||
})()
|
})(),
|
||||||
|
auto_resume_live_recording: input.auto_resume_live_recording !== false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4027,7 +4030,7 @@ async function downloadLiveStream(
|
|||||||
const folder = path.join(config.download_path, safeStreamer, 'live');
|
const folder = path.join(config.download_path, safeStreamer, 'live');
|
||||||
fs.mkdirSync(folder, { recursive: true });
|
fs.mkdirSync(folder, { recursive: true });
|
||||||
|
|
||||||
const filename = ensureUniqueFilename(
|
const baseFilename = ensureUniqueFilename(
|
||||||
path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`),
|
path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`),
|
||||||
item.id
|
item.id
|
||||||
);
|
);
|
||||||
@ -4036,21 +4039,17 @@ async function downloadLiveStream(
|
|||||||
// recording. Sibling .chat.jsonl file. We start it BEFORE streamlink
|
// recording. Sibling .chat.jsonl file. We start it BEFORE streamlink
|
||||||
// so the very first chat lines after JOIN aren't dropped, and stop it
|
// so the very first chat lines after JOIN aren't dropped, and stop it
|
||||||
// AFTER streamlink exits so trailing messages (e.g. "stream offline"
|
// AFTER streamlink exits so trailing messages (e.g. "stream offline"
|
||||||
// user reactions) are still captured.
|
// user reactions) are still captured. Chat + events span the whole
|
||||||
|
// multi-part recording (chat is an independent IRC connection, events
|
||||||
|
// is an independent poller), so they stay alive across resume cycles.
|
||||||
let chatSession: LiveChatSession | null = null;
|
let chatSession: LiveChatSession | null = null;
|
||||||
if (config.capture_live_chat) {
|
if (config.capture_live_chat) {
|
||||||
const chatPath = liveChatPathFor(filename);
|
const chatPath = liveChatPathFor(baseFilename);
|
||||||
chatSession = startLiveChatCapture(item.streamer, chatPath);
|
chatSession = startLiveChatCapture(item.streamer, chatPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream-events tracker — records title/game changes that happen
|
|
||||||
// while we're capturing. Cheap (one Helix/GQL hit per minute) and
|
|
||||||
// useful for long archives where the user later wants to seek.
|
|
||||||
let eventsTracker: LiveEventTracker | null = null;
|
let eventsTracker: LiveEventTracker | null = null;
|
||||||
if (config.log_stream_events) {
|
if (config.log_stream_events) {
|
||||||
// Best-effort initial metadata snapshot. If the call fails the
|
|
||||||
// tracker still starts with empty title/game and the first
|
|
||||||
// successful poll shows them as a change.
|
|
||||||
let initialTitle = '';
|
let initialTitle = '';
|
||||||
let initialGame = '';
|
let initialGame = '';
|
||||||
try {
|
try {
|
||||||
@ -4060,7 +4059,7 @@ async function downloadLiveStream(
|
|||||||
initialGame = info.gameName || '';
|
initialGame = info.gameName || '';
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
eventsTracker = startLiveEventsTracker(item.id, item.streamer, filename, initialTitle, initialGame);
|
eventsTracker = startLiveEventsTracker(item.id, item.streamer, baseFilename, initialTitle, initialGame);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.discord_notify_live_start) {
|
if (config.discord_notify_live_start) {
|
||||||
@ -4070,18 +4069,24 @@ async function downloadLiveStream(
|
|||||||
color: 'live',
|
color: 'live',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'URL', value: item.url, inline: false },
|
{ name: 'URL', value: item.url, inline: false },
|
||||||
{ name: 'Output', value: path.basename(filename), inline: false }
|
{ name: 'Output', value: path.basename(baseFilename), inline: false }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
const BYTES_FRESH_MS = 30_000;
|
||||||
|
const MIN_HEALTHY_PART_MS = 30_000;
|
||||||
|
const RESUME_WAIT_MS = 10_000;
|
||||||
|
const MAX_RESUME_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
// Total-recording byte tracking. Each resumed part starts streamlink
|
||||||
|
// fresh, so its byte counter resets to 0; we keep accumulatedBytes
|
||||||
|
// across parts so the meta line shows the TOTAL recorded size, not
|
||||||
|
// just the current part. Same for elapsed — recordingStartedAt is the
|
||||||
|
// overall start, not per-part.
|
||||||
|
let accumulatedBytes = 0;
|
||||||
|
let currentPartBytes = 0;
|
||||||
let lastBytesValue = 0;
|
let lastBytesValue = 0;
|
||||||
let lastBytesAdvancedAt = 0;
|
let lastBytesAdvancedAt = 0;
|
||||||
let lastEmittedProgress: DownloadProgress | null = null;
|
let lastEmittedProgress: DownloadProgress | null = null;
|
||||||
@ -4091,22 +4096,18 @@ async function downloadLiveStream(
|
|||||||
return (Date.now() - lastBytesAdvancedAt) <= BYTES_FRESH_MS ? 'ok' : 'stale';
|
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")
|
|
||||||
// which doesn't tell the user how long the recording has been running
|
|
||||||
// or whether the bitrate is healthy. Substitutes:
|
|
||||||
// "{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 wrappedProgress = (p: DownloadProgress): void => {
|
||||||
const bytes = Number(p.downloadedBytes) || 0;
|
const bytes = Number(p.downloadedBytes) || 0;
|
||||||
if (bytes > lastBytesValue) {
|
if (bytes > lastBytesValue) {
|
||||||
lastBytesValue = bytes;
|
lastBytesValue = bytes;
|
||||||
lastBytesAdvancedAt = Date.now();
|
lastBytesAdvancedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
currentPartBytes = bytes;
|
||||||
|
const totalBytes = accumulatedBytes + currentPartBytes;
|
||||||
const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
const elapsed = Math.max(1, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
||||||
const avgBitrateMbps = (bytes * 8) / elapsed / 1_000_000;
|
const avgBitrateMbps = (totalBytes * 8) / elapsed / 1_000_000;
|
||||||
const parts: string[] = [formatDuration(elapsed)];
|
const parts: string[] = [formatDuration(elapsed)];
|
||||||
if (bytes > 0) parts.push(formatBytes(bytes));
|
if (totalBytes > 0) parts.push(formatBytes(totalBytes));
|
||||||
if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`);
|
if (avgBitrateMbps > 0) parts.push(`${avgBitrateMbps.toFixed(1)} Mbps`);
|
||||||
const next = {
|
const next = {
|
||||||
...p,
|
...p,
|
||||||
@ -4122,7 +4123,7 @@ async function downloadLiveStream(
|
|||||||
// Health-tick: re-emit the most recent progress every 10s so the
|
// Health-tick: re-emit the most recent progress every 10s so the
|
||||||
// renderer's health badge updates even when streamlink is silent.
|
// renderer's health badge updates even when streamlink is silent.
|
||||||
// Without this, a streamlink hung on a buffer-stall would keep showing
|
// 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.
|
// 'ok' until the next real byte event.
|
||||||
const healthTick = setInterval(() => {
|
const healthTick = setInterval(() => {
|
||||||
if (!lastEmittedProgress) return;
|
if (!lastEmittedProgress) return;
|
||||||
const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() };
|
const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() };
|
||||||
@ -4131,12 +4132,107 @@ async function downloadLiveStream(
|
|||||||
}, 10_000);
|
}, 10_000);
|
||||||
healthTick.unref?.();
|
healthTick.unref?.();
|
||||||
|
|
||||||
// No start/end times for live streams — streamlink records until the
|
const outputs: string[] = [];
|
||||||
// stream actually ends or we kill it. downloadVODPart already handles
|
let partNumber = 1;
|
||||||
// null start/end correctly.
|
let resumeCount = 0;
|
||||||
let result: DownloadResult;
|
let lastPartResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result = await downloadVODPart(item.url, filename, null, null, wrappedProgress, item.id, 1, 1);
|
// Resume loop. Each iteration runs streamlink once. On clean exit,
|
||||||
|
// we re-check whether the stream is still live on Twitch's side;
|
||||||
|
// if yes, the exit was an interruption (network blip, segment
|
||||||
|
// discontinuity, etc.) — start a new part and append. If the
|
||||||
|
// stream really ended, break and finalize.
|
||||||
|
while (true) {
|
||||||
|
const partFilename = partNumber === 1
|
||||||
|
? baseFilename
|
||||||
|
: ensureUniqueFilename(
|
||||||
|
baseFilename.replace(/\.mp4$/i, `_part${partNumber}.mp4`),
|
||||||
|
item.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset per-part counters — streamlink is fresh, byte counter
|
||||||
|
// restarts at zero. lastBytesAdvancedAt stays at zero until
|
||||||
|
// the first segment arrives, which correctly flips the health
|
||||||
|
// dot to 'unknown' during the resume gap.
|
||||||
|
lastBytesValue = 0;
|
||||||
|
lastBytesAdvancedAt = 0;
|
||||||
|
currentPartBytes = 0;
|
||||||
|
|
||||||
|
const partStartedAt = Date.now();
|
||||||
|
appendDebugLog('recording-part-start', { itemId: item.id, partNumber, filename: path.basename(partFilename) });
|
||||||
|
|
||||||
|
lastPartResult = await downloadVODPart(item.url, partFilename, null, null, wrappedProgress, item.id, partNumber, partNumber);
|
||||||
|
|
||||||
|
// Accumulate this part's final bytes into the running total so
|
||||||
|
// the next part's meta line continues from the correct figure.
|
||||||
|
let partFinalBytes = 0;
|
||||||
|
if (fs.existsSync(partFilename)) {
|
||||||
|
try {
|
||||||
|
partFinalBytes = fs.statSync(partFilename).size || 0;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (partFinalBytes > 0) {
|
||||||
|
outputs.push(partFilename);
|
||||||
|
accumulatedBytes += partFinalBytes;
|
||||||
|
} else {
|
||||||
|
// Streamlink produced no bytes — likely permission or auth
|
||||||
|
// failure. Skip resume because retrying will hit the same
|
||||||
|
// wall. The error from lastPartResult will surface upstream.
|
||||||
|
appendDebugLog('recording-part-zero-bytes', { itemId: item.id, partNumber });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume decision tree.
|
||||||
|
if (cancelledItemIds.has(item.id) || !isDownloading || pauseRequested) {
|
||||||
|
appendDebugLog('recording-resume-cancelled', { itemId: item.id, partNumber, reason: pauseRequested ? 'pause' : 'cancel' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!config.auto_resume_live_recording) {
|
||||||
|
appendDebugLog('recording-resume-disabled', { itemId: item.id });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (resumeCount >= MAX_RESUME_ATTEMPTS) {
|
||||||
|
appendDebugLog('recording-resume-max-attempts', { itemId: item.id, max: MAX_RESUME_ATTEMPTS });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Don't resume on suspiciously short parts — that pattern points
|
||||||
|
// at a config issue (bad URL, auth-required stream, streamlink
|
||||||
|
// missing plugin) where retrying will just loop and burn API
|
||||||
|
// quota.
|
||||||
|
const partDurationMs = Date.now() - partStartedAt;
|
||||||
|
if (partDurationMs < MIN_HEALTHY_PART_MS) {
|
||||||
|
appendDebugLog('recording-resume-skip-short', { itemId: item.id, partNumber, durationMs: partDurationMs });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only resume if Twitch still says the stream is live. If the
|
||||||
|
// streamer actually ended their broadcast, we accept the part
|
||||||
|
// we have and call the recording done.
|
||||||
|
let stillLive = false;
|
||||||
|
try {
|
||||||
|
const info = await getLiveStreamInfo(item.streamer);
|
||||||
|
stillLive = info?.isLive === true;
|
||||||
|
} catch {
|
||||||
|
// Unknown liveness — err on the side of NOT resuming to
|
||||||
|
// avoid infinite-loop on network-out conditions where we
|
||||||
|
// can't even reach Twitch to check. The user can always
|
||||||
|
// restart manually.
|
||||||
|
stillLive = false;
|
||||||
|
}
|
||||||
|
if (!stillLive) {
|
||||||
|
appendDebugLog('recording-finished-stream-offline', { itemId: item.id, parts: partNumber });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendDebugLog('recording-resume-attempt', { itemId: item.id, previousPart: partNumber, attempt: resumeCount + 1 });
|
||||||
|
if (eventsTracker) {
|
||||||
|
appendEventLine(eventsTracker, { type: 'recording_resume', part: partNumber + 1 });
|
||||||
|
}
|
||||||
|
resumeCount++;
|
||||||
|
partNumber++;
|
||||||
|
await sleep(RESUME_WAIT_MS);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(healthTick);
|
clearInterval(healthTick);
|
||||||
}
|
}
|
||||||
@ -4146,37 +4242,38 @@ async function downloadLiveStream(
|
|||||||
}
|
}
|
||||||
if (eventsTracker) {
|
if (eventsTracker) {
|
||||||
stopLiveEventsTracker(item.id, {
|
stopLiveEventsTracker(item.id, {
|
||||||
success: result.success,
|
success: outputs.length > 0,
|
||||||
durationMs: Date.now() - recordingStartedAt,
|
durationMs: Date.now() - recordingStartedAt,
|
||||||
error: result.error
|
error: outputs.length === 0 ? lastPartResult.error : undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.discord_notify_live_end) {
|
if (config.discord_notify_live_end) {
|
||||||
const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
|
||||||
const sizeBytes = fs.existsSync(filename) ? (fs.statSync(filename).size || 0) : 0;
|
const sizeBytes = accumulatedBytes;
|
||||||
|
const success = outputs.length > 0;
|
||||||
void sendDiscordWebhook({
|
void sendDiscordWebhook({
|
||||||
title: result.success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`,
|
title: success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`,
|
||||||
description: item.title || `${item.streamer}`,
|
description: item.title || `${item.streamer}`,
|
||||||
color: result.success ? 'success' : 'info',
|
color: success ? 'success' : 'info',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'Duration', value: formatDuration(durationSec), inline: true },
|
{ name: 'Duration', value: formatDuration(durationSec), inline: true },
|
||||||
{ name: 'Size', value: formatBytes(sizeBytes), inline: true },
|
{ name: 'Size', value: formatBytes(sizeBytes), inline: true },
|
||||||
|
{ name: 'Parts', value: String(outputs.length || 1), inline: true },
|
||||||
{ name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true },
|
{ name: 'Chat captured', value: chatSession ? `${chatSession.messageCount} messages` : 'no', inline: true },
|
||||||
{ name: 'Output', value: path.basename(filename), inline: false }
|
{ name: 'Output', value: path.basename(baseFilename), inline: false }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success) return result;
|
if (outputs.length === 0) return lastPartResult;
|
||||||
const outputs = [filename];
|
|
||||||
if (chatSession && fs.existsSync(chatSession.outputPath)) {
|
if (chatSession && fs.existsSync(chatSession.outputPath)) {
|
||||||
outputs.push(chatSession.outputPath);
|
outputs.push(chatSession.outputPath);
|
||||||
}
|
}
|
||||||
if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) {
|
if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) {
|
||||||
outputs.push(eventsTracker.eventsPath);
|
outputs.push(eventsTracker.eventsPath);
|
||||||
}
|
}
|
||||||
return { ...result, outputFiles: outputs };
|
return { success: true, outputFiles: outputs };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadVOD(
|
async function downloadVOD(
|
||||||
|
|||||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -38,6 +38,7 @@ interface AppConfig {
|
|||||||
auto_vod_download_streamers?: string[];
|
auto_vod_download_streamers?: string[];
|
||||||
auto_vod_download_poll_minutes?: number;
|
auto_vod_download_poll_minutes?: number;
|
||||||
auto_vod_max_age_hours?: number;
|
auto_vod_max_age_hours?: number;
|
||||||
|
auto_resume_live_recording?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -89,6 +89,7 @@ const UI_TEXT_DE = {
|
|||||||
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
|
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
|
||||||
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
|
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
|
||||||
discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
|
discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
|
||||||
|
autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)',
|
||||||
autoVodCardTitle: 'Auto-VOD-Download',
|
autoVodCardTitle: 'Auto-VOD-Download',
|
||||||
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.',
|
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)',
|
autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
|
||||||
@ -263,7 +264,8 @@ const UI_TEXT_DE = {
|
|||||||
ok: 'Gesund - Bytes fliessen',
|
ok: 'Gesund - Bytes fliessen',
|
||||||
stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)',
|
stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)',
|
||||||
unknown: 'Warte auf ersten Segment'
|
unknown: 'Warte auf ersten Segment'
|
||||||
}
|
},
|
||||||
|
eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet'
|
||||||
},
|
},
|
||||||
streamers: {
|
streamers: {
|
||||||
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
|
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
|
||||||
|
|||||||
@ -89,6 +89,7 @@ const UI_TEXT_EN = {
|
|||||||
discordNotifyLiveStartLabel: 'Notify on live recording start',
|
discordNotifyLiveStartLabel: 'Notify on live recording start',
|
||||||
discordNotifyLiveEndLabel: 'Notify on live recording end',
|
discordNotifyLiveEndLabel: 'Notify on live recording end',
|
||||||
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
|
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
|
||||||
|
autoResumeLiveRecordingLabel: 'Auto-resume live recording if streamlink crashes (max 5 retries)',
|
||||||
discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued',
|
discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued',
|
||||||
autoVodCardTitle: 'Auto-VOD download',
|
autoVodCardTitle: 'Auto-VOD download',
|
||||||
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.',
|
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.',
|
||||||
@ -263,7 +264,8 @@ const UI_TEXT_EN = {
|
|||||||
ok: 'Healthy — bytes flowing',
|
ok: 'Healthy — bytes flowing',
|
||||||
stale: 'Stalled — no bytes recently (network blip or stream ending)',
|
stale: 'Stalled — no bytes recently (network blip or stream ending)',
|
||||||
unknown: 'Waiting for first segment'
|
unknown: 'Waiting for first segment'
|
||||||
}
|
},
|
||||||
|
eventRecordingResume: 'Recording resumed — starting part {part}'
|
||||||
},
|
},
|
||||||
streamers: {
|
streamers: {
|
||||||
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
|
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
|
||||||
|
|||||||
@ -556,6 +556,7 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
|||||||
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
|
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
|
||||||
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
|
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
|
||||||
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
|
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
|
||||||
|
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
|
||||||
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
|
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
|
||||||
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
||||||
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
||||||
@ -616,6 +617,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
|||||||
effective.download_chat_replay === true,
|
effective.download_chat_replay === true,
|
||||||
effective.capture_live_chat === true,
|
effective.capture_live_chat === true,
|
||||||
effective.log_stream_events !== false,
|
effective.log_stream_events !== false,
|
||||||
|
effective.auto_resume_live_recording !== false,
|
||||||
effective.discord_webhook_url ?? '',
|
effective.discord_webhook_url ?? '',
|
||||||
effective.discord_notify_live_start === true,
|
effective.discord_notify_live_start === true,
|
||||||
effective.discord_notify_live_end === true,
|
effective.discord_notify_live_end === true,
|
||||||
@ -651,6 +653,7 @@ function syncSettingsFormFromConfig(): void {
|
|||||||
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
||||||
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
||||||
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
|
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
|
||||||
|
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
|
||||||
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
|
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
|
||||||
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
||||||
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
||||||
|
|||||||
@ -197,6 +197,7 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
|
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
|
||||||
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
|
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
|
||||||
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
|
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
|
||||||
|
setText('autoResumeLiveRecordingLabel', UI_TEXT.static.autoResumeLiveRecordingLabel);
|
||||||
setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel);
|
setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel);
|
||||||
setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle);
|
setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle);
|
||||||
setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
|
setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
|
||||||
|
|||||||
@ -267,6 +267,7 @@ interface EventLogEntry {
|
|||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
part?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
||||||
@ -331,6 +332,7 @@ function renderEventsList(events: EventLogEntry[]): void {
|
|||||||
const tagColors: Record<string, string> = {
|
const tagColors: Record<string, string> = {
|
||||||
recording_start: '#00c853',
|
recording_start: '#00c853',
|
||||||
recording_end: '#9146ff',
|
recording_end: '#9146ff',
|
||||||
|
recording_resume: '#2196f3',
|
||||||
title_change: '#ffab00',
|
title_change: '#ffab00',
|
||||||
game_change: '#ff4444'
|
game_change: '#ff4444'
|
||||||
};
|
};
|
||||||
@ -350,6 +352,8 @@ function renderEventsList(events: EventLogEntry[]): void {
|
|||||||
: '?';
|
: '?';
|
||||||
const ok = ev.success ? '✓' : '✗';
|
const ok = ev.success ? '✓' : '✗';
|
||||||
detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? ` — ${ev.error}` : ''}`;
|
detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? ` — ${ev.error}` : ''}`;
|
||||||
|
} else if (ev.type === 'recording_resume') {
|
||||||
|
detail.textContent = (UI_TEXT.queue.eventRecordingResume || 'Resume started — part {part}').replace('{part}', String(ev.part || '?'));
|
||||||
} else if (ev.type === 'title_change') {
|
} else if (ev.type === 'title_change') {
|
||||||
detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`;
|
detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`;
|
||||||
} else if (ev.type === 'game_change') {
|
} else if (ev.type === 'game_change') {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user