Optimize renderer scheduling and batched logging pipeline (v4.1.5)

This commit is contained in:
xRangerDE 2026-02-20 21:36:23 +01:00
parent 7412e7aa16
commit d04779d0ac
7 changed files with 219 additions and 38 deletions

View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.1.4", "version": "4.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.1.4", "version": "4.1.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.1.4", "version": "4.1.5",
"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",

View File

@ -457,7 +457,7 @@
<div class="settings-card"> <div class="settings-card">
<h3 id="updateTitle">Updates</h3> <h3 id="updateTitle">Updates</h3>
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.4</p> <p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.5</p>
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button> <button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div> </div>
@ -502,7 +502,7 @@
<div class="status-dot" id="statusDot"></div> <div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span> <span id="statusText">Nicht verbunden</span>
</div> </div>
<span id="versionText">v4.1.4</span> <span id="versionText">v4.1.5</span>
</div> </div>
</main> </main>
</div> </div>

View File

@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
// ========================================== // ==========================================
// CONFIG & CONSTANTS // CONFIG & CONSTANTS
// ========================================== // ==========================================
const APP_VERSION = '4.1.4'; const APP_VERSION = '4.1.5';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths // Paths
@ -28,6 +28,8 @@ const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
const QUEUE_SAVE_DEBOUNCE_MS = 250; const QUEUE_SAVE_DEBOUNCE_MS = 250;
const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024; const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024;
const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000; const TOOL_PATH_REFRESH_TTL_MS = 10 * 1000;
const DEBUG_LOG_FLUSH_INTERVAL_MS = 1000;
const DEBUG_LOG_BUFFER_FLUSH_LINES = 48;
const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000; const CACHE_CLEANUP_INTERVAL_MS = 60 * 1000;
const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096; const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096;
const MAX_VOD_LIST_CACHE_ENTRIES = 512; const MAX_VOD_LIST_CACHE_ENTRIES = 512;
@ -379,6 +381,8 @@ let ffmpegPathCache: string | null = null;
let ffprobePathCache: string | null = null; let ffprobePathCache: string | null = null;
let bundledToolPathSignature = ''; let bundledToolPathSignature = '';
let bundledToolPathRefreshedAt = 0; let bundledToolPathRefreshedAt = 0;
let debugLogFlushTimer: NodeJS.Timeout | null = null;
let pendingDebugLogLines: string[] = [];
// ========================================== // ==========================================
// TOOL PATHS // TOOL PATHS
@ -502,8 +506,47 @@ async function runPreflight(autoFix = false): Promise<PreflightResult> {
return result; return result;
} }
function flushPendingDebugLogLines(): void {
if (!pendingDebugLogLines.length) {
return;
}
try {
const payload = pendingDebugLogLines.join('');
pendingDebugLogLines = [];
fs.appendFileSync(DEBUG_LOG_FILE, payload);
} catch {
// ignore debug log errors
}
}
function startDebugLogFlushTimer(): void {
if (debugLogFlushTimer) {
return;
}
debugLogFlushTimer = setInterval(() => {
flushPendingDebugLogLines();
}, DEBUG_LOG_FLUSH_INTERVAL_MS);
debugLogFlushTimer.unref?.();
}
function stopDebugLogFlushTimer(flush = true): void {
if (debugLogFlushTimer) {
clearInterval(debugLogFlushTimer);
debugLogFlushTimer = null;
}
if (flush) {
flushPendingDebugLogLines();
}
}
function readDebugLog(lines = 200): string { function readDebugLog(lines = 200): string {
try { try {
flushPendingDebugLogLines();
if (!fs.existsSync(DEBUG_LOG_FILE)) { if (!fs.existsSync(DEBUG_LOG_FILE)) {
return 'Debug-Log ist leer.'; return 'Debug-Log ist leer.';
} }
@ -863,7 +906,14 @@ function appendDebugLog(message: string, details?: unknown): void {
const payload = details === undefined const payload = details === undefined
? '' ? ''
: ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`; : ` | ${typeof details === 'string' ? details : JSON.stringify(details)}`;
fs.appendFileSync(DEBUG_LOG_FILE, `[${ts}] ${message}${payload}\n`);
pendingDebugLogLines.push(`[${ts}] ${message}${payload}\n`);
if (pendingDebugLogLines.length >= DEBUG_LOG_BUFFER_FLUSH_LINES) {
flushPendingDebugLogLines();
} else {
startDebugLogFlushTimer();
}
} catch { } catch {
// ignore debug log errors // ignore debug log errors
} }
@ -3133,6 +3183,7 @@ ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
app.whenReady().then(() => { app.whenReady().then(() => {
refreshBundledToolPaths(true); refreshBundledToolPaths(true);
startMetadataCacheCleanup(); startMetadataCacheCleanup();
startDebugLogFlushTimer();
createWindow(); createWindow();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
@ -3146,6 +3197,7 @@ app.whenReady().then(() => {
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
stopMetadataCacheCleanup(); stopMetadataCacheCleanup();
cleanupMetadataCaches('shutdown'); cleanupMetadataCaches('shutdown');
stopDebugLogFlushTimer(true);
if (currentProcess) { if (currentProcess) {
currentProcess.kill(); currentProcess.kill();
@ -3160,5 +3212,6 @@ app.on('window-all-closed', () => {
app.on('before-quit', () => { app.on('before-quit', () => {
stopMetadataCacheCleanup(); stopMetadataCacheCleanup();
cleanupMetadataCaches('shutdown'); cleanupMetadataCaches('shutdown');
stopDebugLogFlushTimer(true);
flushQueueSave(); flushQueueSave();
}); });

View File

@ -1,3 +1,5 @@
let lastRuntimeMetricsOutput = '';
async function connect(): Promise<void> { async function connect(): Promise<void> {
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim()); const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
if (!hasCredentials) { if (!hasCredentials) {
@ -73,9 +75,11 @@ function applyTemplatePreset(preset: string): void {
validateFilenameTemplates(); validateFilenameTemplates();
} }
async function refreshRuntimeMetrics(): Promise<void> { async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
const output = byId('runtimeMetricsOutput'); const output = byId('runtimeMetricsOutput');
if (showLoading) {
output.textContent = UI_TEXT.static.runtimeMetricsLoading; output.textContent = UI_TEXT.static.runtimeMetricsLoading;
}
try { try {
const metrics = await window.api.getRuntimeMetrics(); const metrics = await window.api.getRuntimeMetrics();
@ -92,9 +96,16 @@ async function refreshRuntimeMetrics(): Promise<void> {
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}` `${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
]; ];
output.textContent = lines.join('\n'); const nextOutput = lines.join('\n');
if (nextOutput !== lastRuntimeMetricsOutput) {
output.textContent = nextOutput;
lastRuntimeMetricsOutput = nextOutput;
}
} catch { } catch {
if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) {
output.textContent = UI_TEXT.static.runtimeMetricsError; output.textContent = UI_TEXT.static.runtimeMetricsError;
lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError;
}
} }
} }
@ -131,7 +142,7 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
if (enabled) { if (enabled) {
runtimeMetricsAutoRefreshTimer = window.setInterval(() => { runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
void refreshRuntimeMetrics(); void refreshRuntimeMetrics(false);
}, 2000); }, 2000);
} }
} }

View File

@ -1,4 +1,31 @@
let selectStreamerRequestId = 0; let selectStreamerRequestId = 0;
let vodRenderTaskId = 0;
const VOD_RENDER_CHUNK_SIZE = 64;
function buildVodCardHtml(vod: VOD, streamer: string): string {
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = formatUiDate(vod.created_at);
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
return `
<div class="vod-card">
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
<div class="vod-info">
<div class="vod-title">${safeDisplayTitle}</div>
<div class="vod-meta">
<span>${date}</span>
<span>${vod.duration}</span>
<span>${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}</span>
</div>
</div>
<div class="vod-actions">
<button class="vod-btn secondary" onclick="openClipDialog('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">Clip</button>
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.addQueue}</button>
</div>
</div>
`;
}
function renderStreamers(): void { function renderStreamers(): void {
const list = byId('streamerList'); const list = byId('streamerList');
@ -92,36 +119,40 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void { function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
const grid = byId('vodGrid'); const grid = byId('vodGrid');
const renderTaskId = ++vodRenderTaskId;
const scheduleNextChunk = (nextStartIndex: number): void => {
const delayMs = document.hidden ? 16 : 0;
window.setTimeout(() => {
renderChunk(nextStartIndex);
}, delayMs);
};
if (!vods || vods.length === 0) { if (!vods || vods.length === 0) {
grid.innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.noResultsTitle}</h3><p>${UI_TEXT.vods.noResultsText}</p></div>`; grid.innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.noResultsTitle}</h3><p>${UI_TEXT.vods.noResultsText}</p></div>`;
return; return;
} }
grid.innerHTML = vods.map((vod: VOD) => { grid.innerHTML = '';
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
const date = formatUiDate(vod.created_at);
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
return ` const renderChunk = (startIndex: number): void => {
<div class="vod-card"> if (renderTaskId !== vodRenderTaskId) {
<img class="vod-thumbnail" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'"> return;
<div class="vod-info"> }
<div class="vod-title">${safeDisplayTitle}</div>
<div class="vod-meta"> const chunk = vods.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
<span>${date}</span> if (!chunk.length) {
<span>${vod.duration}</span> return;
<span>${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}</span> }
</div>
</div> grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, streamer)).join(''));
<div class="vod-actions">
<button class="vod-btn secondary" onclick="openClipDialog('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">Clip</button> if (startIndex + chunk.length < vods.length) {
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.addQueue}</button> scheduleNextChunk(startIndex + chunk.length);
</div> }
</div> };
`;
}).join(''); renderChunk(0);
} }
async function refreshVODs(): Promise<void> { async function refreshVODs(): Promise<void> {

View File

@ -1,3 +1,9 @@
const QUEUE_SYNC_FAST_MS = 900;
const QUEUE_SYNC_DEFAULT_MS = 1800;
const QUEUE_SYNC_IDLE_MS = 4500;
const QUEUE_SYNC_HIDDEN_MS = 9000;
const QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS = 15000;
async function init(): Promise<void> { async function init(): Promise<void> {
config = await window.api.getConfig(); config = await window.api.getConfig();
const language = setLanguage((config.language as string) || 'en'); const language = setLanguage((config.language as string) || 'en');
@ -34,6 +40,7 @@ async function init(): Promise<void> {
window.api.onQueueUpdated((q: QueueItem[]) => { window.api.onQueueUpdated((q: QueueItem[]) => {
queue = mergeQueueState(Array.isArray(q) ? q : []); queue = mergeQueueState(Array.isArray(q) ? q : []);
renderQueue(); renderQueue();
markQueueActivity();
}); });
window.api.onQueueDuplicateSkipped((payload) => { window.api.onQueueDuplicateSkipped((payload) => {
@ -57,16 +64,19 @@ async function init(): Promise<void> {
item.totalBytes = progress.totalBytes; item.totalBytes = progress.totalBytes;
item.progressStatus = progress.status; item.progressStatus = progress.status;
renderQueue(); renderQueue();
markQueueActivity();
}); });
window.api.onDownloadStarted(() => { window.api.onDownloadStarted(() => {
downloading = true; downloading = true;
updateDownloadButtonState(); updateDownloadButtonState();
markQueueActivity();
}); });
window.api.onDownloadFinished(() => { window.api.onDownloadFinished(() => {
downloading = false; downloading = false;
updateDownloadButtonState(); updateDownloadButtonState();
markQueueActivity();
}); });
window.api.onCutProgress((percent: number) => { window.api.onCutProgress((percent: number) => {
@ -98,12 +108,71 @@ async function init(): Promise<void> {
validateFilenameTemplates(); validateFilenameTemplates();
void refreshRuntimeMetrics(); void refreshRuntimeMetrics();
setInterval(() => { document.addEventListener('visibilitychange', () => {
void syncQueueAndDownloadState(); scheduleQueueSync(document.hidden ? 600 : 150);
}, 2000); });
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
} }
let toastHideTimer: number | null = null; let toastHideTimer: number | null = null;
let queueSyncTimer: number | null = null;
let queueSyncInFlight = false;
let lastQueueActivityAt = Date.now();
function markQueueActivity(): void {
lastQueueActivityAt = Date.now();
}
function hasActiveQueueWork(): boolean {
return queue.some((item) => item.status === 'pending' || item.status === 'downloading' || item.status === 'paused');
}
function getNextQueueSyncDelayMs(): number {
if (document.hidden) {
return QUEUE_SYNC_HIDDEN_MS;
}
if (downloading || queue.some((item) => item.status === 'downloading')) {
return QUEUE_SYNC_FAST_MS;
}
if (hasActiveQueueWork()) {
return QUEUE_SYNC_DEFAULT_MS;
}
const idleForMs = Date.now() - lastQueueActivityAt;
return idleForMs > QUEUE_SYNC_RECENT_ACTIVITY_WINDOW_MS ? QUEUE_SYNC_IDLE_MS : QUEUE_SYNC_DEFAULT_MS;
}
function scheduleQueueSync(delayMs = getNextQueueSyncDelayMs()): void {
if (queueSyncTimer) {
clearTimeout(queueSyncTimer);
queueSyncTimer = null;
}
queueSyncTimer = window.setTimeout(() => {
queueSyncTimer = null;
void runQueueSyncCycle();
}, Math.max(300, Math.floor(delayMs)));
}
async function runQueueSyncCycle(): Promise<void> {
if (queueSyncInFlight) {
scheduleQueueSync(400);
return;
}
queueSyncInFlight = true;
try {
await syncQueueAndDownloadState();
} catch {
// ignore transient IPC errors and retry on next cycle
} finally {
queueSyncInFlight = false;
scheduleQueueSync();
}
}
function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void { function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
let toast = document.getElementById('appToast'); let toast = document.getElementById('appToast');
@ -161,6 +230,18 @@ function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
}); });
} }
function getQueueStateFingerprint(items: QueueItem[]): string {
return items.map((item) => [
item.id,
item.status,
Math.round((Number(item.progress) || 0) * 10),
item.currentPart || 0,
item.totalParts || 0,
item.last_error || '',
item.progressStatus || ''
].join(':')).join('|');
}
function updateDownloadButtonState(): void { function updateDownloadButtonState(): void {
const btn = byId('btnStart'); const btn = byId('btnStart');
const hasPaused = queue.some((item) => item.status === 'paused'); const hasPaused = queue.some((item) => item.status === 'paused');
@ -169,8 +250,13 @@ function updateDownloadButtonState(): void {
} }
async function syncQueueAndDownloadState(): Promise<void> { async function syncQueueAndDownloadState(): Promise<void> {
const previousFingerprint = getQueueStateFingerprint(queue);
const latestQueue = await window.api.getQueue(); const latestQueue = await window.api.getQueue();
queue = mergeQueueState(Array.isArray(latestQueue) ? latestQueue : []); queue = mergeQueueState(Array.isArray(latestQueue) ? latestQueue : []);
const nextFingerprint = getQueueStateFingerprint(queue);
if (nextFingerprint !== previousFingerprint) {
markQueueActivity();
}
renderQueue(); renderQueue();
const backendDownloading = await window.api.isDownloading(); const backendDownloading = await window.api.isDownloading();