diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json
index 1230c26..b0bc31d 100644
--- a/typescript-version/package-lock.json
+++ b/typescript-version/package-lock.json
@@ -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",
diff --git a/typescript-version/package.json b/typescript-version/package.json
index e05339c..4c6e5e9 100644
--- a/typescript-version/package.json
+++ b/typescript-version/package.json
@@ -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",
diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html
index cfb7ae9..73f98e6 100644
--- a/typescript-version/src/index.html
+++ b/typescript-version/src/index.html
@@ -457,7 +457,7 @@
Updates
-
Version: v4.1.4
+
Version: v4.1.5
@@ -502,7 +502,7 @@
Nicht verbunden
- v4.1.4
+ v4.1.5
diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts
index b4a5409..fac1038 100644
--- a/typescript-version/src/main.ts
+++ b/typescript-version/src/main.ts
@@ -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 {
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();
});
diff --git a/typescript-version/src/renderer-settings.ts b/typescript-version/src/renderer-settings.ts
index 8421ff1..b4dea2b 100644
--- a/typescript-version/src/renderer-settings.ts
+++ b/typescript-version/src/renderer-settings.ts
@@ -1,3 +1,5 @@
+let lastRuntimeMetricsOutput = '';
+
async function connect(): Promise {
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 {
+async function refreshRuntimeMetrics(showLoading = true): Promise {
const output = byId('runtimeMetricsOutput');
- output.textContent = UI_TEXT.static.runtimeMetricsLoading;
+ if (showLoading) {
+ output.textContent = UI_TEXT.static.runtimeMetricsLoading;
+ }
try {
const metrics = await window.api.getRuntimeMetrics();
@@ -92,9 +96,16 @@ async function refreshRuntimeMetrics(): Promise {
`${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 {
- output.textContent = UI_TEXT.static.runtimeMetricsError;
+ 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);
}
}
diff --git a/typescript-version/src/renderer-streamers.ts b/typescript-version/src/renderer-streamers.ts
index 05203f5..52d75cf 100644
--- a/typescript-version/src/renderer-streamers.ts
+++ b/typescript-version/src/renderer-streamers.ts
@@ -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, '"');
+ const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
+
+ return `
+
+

+
+
${safeDisplayTitle}
+
+ ${date}
+ ${vod.duration}
+ ${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}
+
+
+
+
+
+
+
+ `;
+}
function renderStreamers(): void {
const list = byId('streamerList');
@@ -92,36 +119,40 @@ async function selectStreamer(name: string, forceRefresh = false): Promise
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 = `${UI_TEXT.vods.noResultsTitle}
${UI_TEXT.vods.noResultsText}
`;
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, '"');
- const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
+ grid.innerHTML = '';
- return `
-
-

-
-
${safeDisplayTitle}
-
- ${date}
- ${vod.duration}
- ${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}
-
-
-
-
-
-
-
- `;
- }).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 {
diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts
index a5255f4..11d0b03 100644
--- a/typescript-version/src/renderer.ts
+++ b/typescript-version/src/renderer.ts
@@ -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 {
config = await window.api.getConfig();
const language = setLanguage((config.language as string) || 'en');
@@ -34,6 +40,7 @@ async function init(): Promise {
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 {
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 {
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 {
+ 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 {
+ 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();