Optimize renderer scheduling and batched logging pipeline (v4.1.5)
This commit is contained in:
parent
7412e7aa16
commit
d04779d0ac
4
typescript-version/package-lock.json
generated
4
typescript-version/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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');
|
||||
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<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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 `
|
||||
<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, '"');
|
||||
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> {
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user