Compare commits
No commits in common. "b21634b5f759e52859d0402ba2797f5d1daa8001" and "805231ae2fa7f73e3cc3087a8722a51a0543eb18" have entirely different histories.
b21634b5f7
...
805231ae2f
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.13",
|
"version": "4.6.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.13",
|
"version": "4.6.12",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.6.13",
|
"version": "4.6.12",
|
||||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"author": "xRangerDE",
|
"author": "xRangerDE",
|
||||||
|
|||||||
@ -562,10 +562,6 @@
|
|||||||
<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,7 +230,6 @@ 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 {
|
||||||
@ -364,8 +363,7 @@ 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;
|
||||||
@ -492,8 +490,7 @@ 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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4030,7 +4027,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 baseFilename = ensureUniqueFilename(
|
const filename = ensureUniqueFilename(
|
||||||
path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`),
|
path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`),
|
||||||
item.id
|
item.id
|
||||||
);
|
);
|
||||||
@ -4039,17 +4036,21 @@ 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. Chat + events span the whole
|
// user reactions) are still captured.
|
||||||
// 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(baseFilename);
|
const chatPath = liveChatPathFor(filename);
|
||||||
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 {
|
||||||
@ -4059,7 +4060,7 @@ async function downloadLiveStream(
|
|||||||
initialGame = info.gameName || '';
|
initialGame = info.gameName || '';
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
eventsTracker = startLiveEventsTracker(item.id, item.streamer, baseFilename, initialTitle, initialGame);
|
eventsTracker = startLiveEventsTracker(item.id, item.streamer, filename, initialTitle, initialGame);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.discord_notify_live_start) {
|
if (config.discord_notify_live_start) {
|
||||||
@ -4069,24 +4070,18 @@ 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(baseFilename), inline: false }
|
{ name: 'Output', value: path.basename(filename), 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;
|
||||||
@ -4096,18 +4091,22 @@ 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 = (totalBytes * 8) / elapsed / 1_000_000;
|
const avgBitrateMbps = (bytes * 8) / elapsed / 1_000_000;
|
||||||
const parts: string[] = [formatDuration(elapsed)];
|
const parts: string[] = [formatDuration(elapsed)];
|
||||||
if (totalBytes > 0) parts.push(formatBytes(totalBytes));
|
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`);
|
||||||
const next = {
|
const next = {
|
||||||
...p,
|
...p,
|
||||||
@ -4123,7 +4122,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.
|
// 'ok' until the next real byte event — defeats the point of the badge.
|
||||||
const healthTick = setInterval(() => {
|
const healthTick = setInterval(() => {
|
||||||
if (!lastEmittedProgress) return;
|
if (!lastEmittedProgress) return;
|
||||||
const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() };
|
const updated: DownloadProgress = { ...lastEmittedProgress, recordingHealth: computeHealth() };
|
||||||
@ -4132,107 +4131,12 @@ async function downloadLiveStream(
|
|||||||
}, 10_000);
|
}, 10_000);
|
||||||
healthTick.unref?.();
|
healthTick.unref?.();
|
||||||
|
|
||||||
const outputs: string[] = [];
|
// No start/end times for live streams — streamlink records until the
|
||||||
let partNumber = 1;
|
// stream actually ends or we kill it. downloadVODPart already handles
|
||||||
let resumeCount = 0;
|
// null start/end correctly.
|
||||||
let lastPartResult: DownloadResult = { success: false, error: tBackend('unknownDownloadError') };
|
let result: DownloadResult;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Resume loop. Each iteration runs streamlink once. On clean exit,
|
result = await downloadVODPart(item.url, filename, null, null, wrappedProgress, item.id, 1, 1);
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
@ -4242,38 +4146,37 @@ async function downloadLiveStream(
|
|||||||
}
|
}
|
||||||
if (eventsTracker) {
|
if (eventsTracker) {
|
||||||
stopLiveEventsTracker(item.id, {
|
stopLiveEventsTracker(item.id, {
|
||||||
success: outputs.length > 0,
|
success: result.success,
|
||||||
durationMs: Date.now() - recordingStartedAt,
|
durationMs: Date.now() - recordingStartedAt,
|
||||||
error: outputs.length === 0 ? lastPartResult.error : undefined
|
error: result.error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = accumulatedBytes;
|
const sizeBytes = fs.existsSync(filename) ? (fs.statSync(filename).size || 0) : 0;
|
||||||
const success = outputs.length > 0;
|
|
||||||
void sendDiscordWebhook({
|
void sendDiscordWebhook({
|
||||||
title: success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`,
|
title: result.success ? `Recording finished: ${item.streamer}` : `Recording failed: ${item.streamer}`,
|
||||||
description: item.title || `${item.streamer}`,
|
description: item.title || `${item.streamer}`,
|
||||||
color: success ? 'success' : 'info',
|
color: result.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(baseFilename), inline: false }
|
{ name: 'Output', value: path.basename(filename), inline: false }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outputs.length === 0) return lastPartResult;
|
if (!result.success) return result;
|
||||||
|
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 { success: true, outputFiles: outputs };
|
return { ...result, outputFiles: outputs };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadVOD(
|
async function downloadVOD(
|
||||||
|
|||||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -38,7 +38,6 @@ 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,7 +89,6 @@ 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)',
|
||||||
@ -264,8 +263,7 @@ 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,7 +89,6 @@ 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.',
|
||||||
@ -264,8 +263,7 @@ 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,7 +556,6 @@ 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,
|
||||||
@ -617,7 +616,6 @@ 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,
|
||||||
@ -653,7 +651,6 @@ 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,7 +197,6 @@ 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,7 +267,6 @@ 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> {
|
||||||
@ -332,7 +331,6 @@ 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'
|
||||||
};
|
};
|
||||||
@ -352,8 +350,6 @@ 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