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",
"version": "4.1.4",
"version": "4.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "twitch-vod-manager",
"version": "4.1.4",
"version": "4.1.5",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "twitch-vod-manager",
"version": "4.1.4",
"version": "4.1.5",
"description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js",
"author": "xRangerDE",

View File

@ -457,7 +457,7 @@
<div class="settings-card">
<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>
</div>
@ -502,7 +502,7 @@
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Nicht verbunden</span>
</div>
<span id="versionText">v4.1.4</span>
<span id="versionText">v4.1.5</span>
</div>
</main>
</div>

View File

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

View File

@ -1,3 +1,5 @@
let lastRuntimeMetricsOutput = '';
async function connect(): Promise<void> {
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
if (!hasCredentials) {
@ -73,9 +75,11 @@ function applyTemplatePreset(preset: string): void {
validateFilenameTemplates();
}
async function refreshRuntimeMetrics(): Promise<void> {
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
const output = byId('runtimeMetricsOutput');
if (showLoading) {
output.textContent = UI_TEXT.static.runtimeMetricsLoading;
}
try {
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')}`
];
output.textContent = lines.join('\n');
const nextOutput = lines.join('\n');
if (nextOutput !== lastRuntimeMetricsOutput) {
output.textContent = nextOutput;
lastRuntimeMetricsOutput = nextOutput;
}
} catch {
if (lastRuntimeMetricsOutput !== 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) {
runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
void refreshRuntimeMetrics();
void refreshRuntimeMetrics(false);
}, 2000);
}
}

View File

@ -1,4 +1,31 @@
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 {
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 {
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) {
grid.innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.noResultsTitle}</h3><p>${UI_TEXT.vods.noResultsText}</p></div>`;
return;
}
grid.innerHTML = vods.map((vod: VOD) => {
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);
grid.innerHTML = '';
return `
<div class="vod-card">
<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>'">
<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>
`;
}).join('');
const renderChunk = (startIndex: number): void => {
if (renderTaskId !== vodRenderTaskId) {
return;
}
const chunk = vods.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
if (!chunk.length) {
return;
}
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, streamer)).join(''));
if (startIndex + chunk.length < vods.length) {
scheduleNextChunk(startIndex + chunk.length);
}
};
renderChunk(0);
}
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> {
config = await window.api.getConfig();
const language = setLanguage((config.language as string) || 'en');
@ -34,6 +40,7 @@ async function init(): Promise<void> {
window.api.onQueueUpdated((q: QueueItem[]) => {
queue = mergeQueueState(Array.isArray(q) ? q : []);
renderQueue();
markQueueActivity();
});
window.api.onQueueDuplicateSkipped((payload) => {
@ -57,16 +64,19 @@ async function init(): Promise<void> {
item.totalBytes = progress.totalBytes;
item.progressStatus = progress.status;
renderQueue();
markQueueActivity();
});
window.api.onDownloadStarted(() => {
downloading = true;
updateDownloadButtonState();
markQueueActivity();
});
window.api.onDownloadFinished(() => {
downloading = false;
updateDownloadButtonState();
markQueueActivity();
});
window.api.onCutProgress((percent: number) => {
@ -98,12 +108,71 @@ async function init(): Promise<void> {
validateFilenameTemplates();
void refreshRuntimeMetrics();
setInterval(() => {
void syncQueueAndDownloadState();
}, 2000);
document.addEventListener('visibilitychange', () => {
scheduleQueueSync(document.hidden ? 600 : 150);
});
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
}
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 {
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 {
const btn = byId('btnStart');
const hasPaused = queue.some((item) => item.status === 'paused');
@ -169,8 +250,13 @@ function updateDownloadButtonState(): void {
}
async function syncQueueAndDownloadState(): Promise<void> {
const previousFingerprint = getQueueStateFingerprint(queue);
const latestQueue = await window.api.getQueue();
queue = mergeQueueState(Array.isArray(latestQueue) ? latestQueue : []);
const nextFingerprint = getQueueStateFingerprint(queue);
if (nextFingerprint !== previousFingerprint) {
markQueueActivity();
}
renderQueue();
const backendDownloading = await window.api.isDownloading();