Optimize metadata cache concurrency and memory bounds (v4.1.2)

This commit is contained in:
xRangerDE 2026-02-16 15:17:46 +01:00
parent 64fb0f416f
commit e83b23bd79
4 changed files with 352 additions and 170 deletions

View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.1.1", "version": "4.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.1.1", "version": "4.1.2",
"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.1", "version": "4.1.2",
"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.1</p> <p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.2</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.1</span> <span id="versionText">v4.1.2</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.1'; const APP_VERSION = '4.1.2';
const UPDATE_CHECK_URL = 'http://24-music.de/version.json'; const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
// Paths // Paths
@ -27,6 +27,10 @@ const DEFAULT_METADATA_CACHE_MINUTES = 10;
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced'; 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 CACHE_CLEANUP_INTERVAL_MS = 60 * 1000;
const MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES = 4096;
const MAX_VOD_LIST_CACHE_ENTRIES = 512;
const MAX_CLIP_INFO_CACHE_ENTRIES = 4096;
// Timeouts // Timeouts
const API_TIMEOUT = 10000; const API_TIMEOUT = 10000;
@ -343,6 +347,10 @@ const userIdLoginCache = new Map<string, string>();
const loginToUserIdCache = new Map<string, CacheEntry<string>>(); const loginToUserIdCache = new Map<string, CacheEntry<string>>();
const vodListCache = new Map<string, CacheEntry<VOD[]>>(); const vodListCache = new Map<string, CacheEntry<VOD[]>>();
const clipInfoCache = new Map<string, CacheEntry<any>>(); const clipInfoCache = new Map<string, CacheEntry<any>>();
const inFlightUserIdRequests = new Map<string, Promise<string | null>>();
const inFlightVodRequests = new Map<string, Promise<VOD[]>>();
const inFlightClipRequests = new Map<string, Promise<any | null>>();
let cacheCleanupTimer: NodeJS.Timeout | null = null;
const runtimeMetrics: RuntimeMetrics = { const runtimeMetrics: RuntimeMetrics = {
cacheHits: 0, cacheHits: 0,
cacheMisses: 0, cacheMisses: 0,
@ -1029,6 +1037,160 @@ function getMetadataCacheTtlMs(): number {
return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000; return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000;
} }
function getCachedValue<T>(cache: Map<string, CacheEntry<T>>, key: string): T | undefined {
const cached = cache.get(key);
if (!cached) {
return undefined;
}
if (cached.expiresAt <= Date.now()) {
cache.delete(key);
return undefined;
}
cache.delete(key);
cache.set(key, cached);
return cached.value;
}
function pruneExpiredCacheEntries<T>(cache: Map<string, CacheEntry<T>>): number {
const now = Date.now();
let removed = 0;
for (const [key, entry] of cache.entries()) {
if (entry.expiresAt <= now) {
cache.delete(key);
removed += 1;
}
}
return removed;
}
function enforceCacheEntryLimit<T>(cache: Map<string, CacheEntry<T>>, maxEntries: number): number {
if (maxEntries <= 0) {
const removed = cache.size;
cache.clear();
return removed;
}
let removed = 0;
while (cache.size > maxEntries) {
const oldest = cache.keys().next().value as string | undefined;
if (!oldest) {
break;
}
cache.delete(oldest);
removed += 1;
}
return removed;
}
function setCachedValue<T>(
cache: Map<string, CacheEntry<T>>,
key: string,
value: T,
maxEntries: number
): void {
cache.set(key, {
value,
expiresAt: Date.now() + getMetadataCacheTtlMs()
});
if (cache.size > maxEntries) {
pruneExpiredCacheEntries(cache);
enforceCacheEntryLimit(cache, maxEntries);
}
}
function cleanupMetadataCaches(reason: 'interval' | 'manual' | 'shutdown'): void {
const before = {
loginToUserId: loginToUserIdCache.size,
vodList: vodListCache.size,
clipInfo: clipInfoCache.size
};
const expired = {
loginToUserId: pruneExpiredCacheEntries(loginToUserIdCache),
vodList: pruneExpiredCacheEntries(vodListCache),
clipInfo: pruneExpiredCacheEntries(clipInfoCache)
};
const evicted = {
loginToUserId: enforceCacheEntryLimit(loginToUserIdCache, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES),
vodList: enforceCacheEntryLimit(vodListCache, MAX_VOD_LIST_CACHE_ENTRIES),
clipInfo: enforceCacheEntryLimit(clipInfoCache, MAX_CLIP_INFO_CACHE_ENTRIES)
};
const removedTotal =
expired.loginToUserId + expired.vodList + expired.clipInfo +
evicted.loginToUserId + evicted.vodList + evicted.clipInfo;
if (removedTotal > 0) {
appendDebugLog('metadata-cache-cleanup', {
reason,
before,
after: {
loginToUserId: loginToUserIdCache.size,
vodList: vodListCache.size,
clipInfo: clipInfoCache.size
},
expired,
evicted,
removedTotal
});
}
}
function clearMetadataCaches(): void {
loginToUserIdCache.clear();
vodListCache.clear();
clipInfoCache.clear();
}
function startMetadataCacheCleanup(): void {
if (cacheCleanupTimer) {
return;
}
cacheCleanupTimer = setInterval(() => {
cleanupMetadataCaches('interval');
}, CACHE_CLEANUP_INTERVAL_MS);
cacheCleanupTimer.unref?.();
}
function stopMetadataCacheCleanup(): void {
if (!cacheCleanupTimer) {
return;
}
clearInterval(cacheCleanupTimer);
cacheCleanupTimer = null;
}
function withInFlightDedup<T>(
store: Map<string, Promise<T>>,
key: string,
factory: () => Promise<T>
): Promise<T> {
const existing = store.get(key);
if (existing) {
return existing;
}
let requestPromise: Promise<T>;
requestPromise = factory().finally(() => {
if (store.get(key) === requestPromise) {
store.delete(key);
}
});
store.set(key, requestPromise);
return requestPromise;
}
function getRetryAttemptLimit(): number { function getRetryAttemptLimit(): number {
switch (normalizePerformanceMode(config.performance_mode)) { switch (normalizePerformanceMode(config.performance_mode)) {
case 'stability': case 'stability':
@ -1356,10 +1518,10 @@ async function getPublicUserId(username: string): Promise<string | null> {
const login = normalizeLogin(username); const login = normalizeLogin(username);
if (!login) return null; if (!login) return null;
const cached = loginToUserIdCache.get(login); const cachedUserId = getCachedValue(loginToUserIdCache, login);
if (cached && cached.expiresAt > Date.now()) { if (cachedUserId !== undefined) {
runtimeMetrics.cacheHits += 1; runtimeMetrics.cacheHits += 1;
return cached.value; return cachedUserId;
} }
runtimeMetrics.cacheMisses += 1; runtimeMetrics.cacheMisses += 1;
@ -1373,7 +1535,7 @@ async function getPublicUserId(username: string): Promise<string | null> {
const user = data?.user; const user = data?.user;
if (!user?.id) return null; if (!user?.id) return null;
loginToUserIdCache.set(login, { value: user.id, expiresAt: Date.now() + getMetadataCacheTtlMs() }); setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
userIdLoginCache.set(user.id, user.login || login); userIdLoginCache.set(user.id, user.login || login);
return user.id; return user.id;
} }
@ -1429,10 +1591,17 @@ async function getUserId(username: string): Promise<string | null> {
const login = normalizeLogin(username); const login = normalizeLogin(username);
if (!login) return null; if (!login) return null;
const cached = loginToUserIdCache.get(login); const cachedUserId = getCachedValue(loginToUserIdCache, login);
if (cached && cached.expiresAt > Date.now()) { if (cachedUserId !== undefined) {
runtimeMetrics.cacheHits += 1; runtimeMetrics.cacheHits += 1;
return cached.value; return cachedUserId;
}
return await withInFlightDedup(inFlightUserIdRequests, login, async () => {
const refreshedCachedUserId = getCachedValue(loginToUserIdCache, login);
if (refreshedCachedUserId !== undefined) {
runtimeMetrics.cacheHits += 1;
return refreshedCachedUserId;
} }
runtimeMetrics.cacheMisses += 1; runtimeMetrics.cacheMisses += 1;
@ -1459,7 +1628,7 @@ async function getUserId(username: string): Promise<string | null> {
const user = response.data.data[0]; const user = response.data.data[0];
if (!user?.id) return await getUserViaPublicApi(); if (!user?.id) return await getUserViaPublicApi();
loginToUserIdCache.set(login, { value: user.id, expiresAt: Date.now() + getMetadataCacheTtlMs() }); setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
userIdLoginCache.set(user.id, user.login || login); userIdLoginCache.set(user.id, user.login || login);
return user.id; return user.id;
} catch (e) { } catch (e) {
@ -1469,7 +1638,7 @@ async function getUserId(username: string): Promise<string | null> {
const user = retryResponse.data.data[0]; const user = retryResponse.data.data[0];
if (!user?.id) return await getUserViaPublicApi(); if (!user?.id) return await getUserViaPublicApi();
loginToUserIdCache.set(login, { value: user.id, expiresAt: Date.now() + getMetadataCacheTtlMs() }); setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
userIdLoginCache.set(user.id, user.login || login); userIdLoginCache.set(user.id, user.login || login);
return user.id; return user.id;
} catch (retryError) { } catch (retryError) {
@ -1481,15 +1650,26 @@ async function getUserId(username: string): Promise<string | null> {
console.error('Error getting user:', e); console.error('Error getting user:', e);
return await getUserViaPublicApi(); return await getUserViaPublicApi();
} }
});
} }
async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> { async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
const cacheKey = `user:${userId}`; const cacheKey = `user:${userId}`;
if (!forceRefresh) { if (!forceRefresh) {
const cached = vodListCache.get(cacheKey); const cachedVods = getCachedValue(vodListCache, cacheKey);
if (cached && cached.expiresAt > Date.now()) { if (cachedVods !== undefined) {
runtimeMetrics.cacheHits += 1; runtimeMetrics.cacheHits += 1;
return cached.value; return cachedVods;
}
}
const requestKey = `${cacheKey}|${forceRefresh ? 'force' : 'default'}`;
return await withInFlightDedup(inFlightVodRequests, requestKey, async () => {
if (!forceRefresh) {
const refreshedCachedVods = getCachedValue(vodListCache, cacheKey);
if (refreshedCachedVods !== undefined) {
runtimeMetrics.cacheHits += 1;
return refreshedCachedVods;
} }
} }
@ -1500,10 +1680,7 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
if (!login) return []; if (!login) return [];
const vods = await getPublicVODsByLogin(login); const vods = await getPublicVODsByLogin(login);
vodListCache.set(cacheKey, { setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
value: vods,
expiresAt: Date.now() + getMetadataCacheTtlMs()
});
return vods; return vods;
}; };
@ -1532,11 +1709,7 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
userIdLoginCache.set(userId, normalizeLogin(login)); userIdLoginCache.set(userId, normalizeLogin(login));
} }
vodListCache.set(cacheKey, { setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
value: vods,
expiresAt: Date.now() + getMetadataCacheTtlMs()
});
return vods; return vods;
} catch (e) { } catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) { if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
@ -1548,11 +1721,7 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
userIdLoginCache.set(userId, normalizeLogin(login)); userIdLoginCache.set(userId, normalizeLogin(login));
} }
vodListCache.set(cacheKey, { setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
value: vods,
expiresAt: Date.now() + getMetadataCacheTtlMs()
});
return vods; return vods;
} catch (retryError) { } catch (retryError) {
console.error('Error getting VODs after relogin:', retryError); console.error('Error getting VODs after relogin:', retryError);
@ -1563,13 +1732,21 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
console.error('Error getting VODs:', e); console.error('Error getting VODs:', e);
return await getVodsViaPublicApi(); return await getVodsViaPublicApi();
} }
});
} }
async function getClipInfo(clipId: string): Promise<any | null> { async function getClipInfo(clipId: string): Promise<any | null> {
const cached = clipInfoCache.get(clipId); const cachedClip = getCachedValue(clipInfoCache, clipId);
if (cached && cached.expiresAt > Date.now()) { if (cachedClip !== undefined) {
runtimeMetrics.cacheHits += 1; runtimeMetrics.cacheHits += 1;
return cached.value; return cachedClip;
}
return await withInFlightDedup(inFlightClipRequests, clipId, async () => {
const refreshedCachedClip = getCachedValue(clipInfoCache, clipId);
if (refreshedCachedClip !== undefined) {
runtimeMetrics.cacheHits += 1;
return refreshedCachedClip;
} }
runtimeMetrics.cacheMisses += 1; runtimeMetrics.cacheMisses += 1;
@ -1591,7 +1768,7 @@ async function getClipInfo(clipId: string): Promise<any | null> {
const response = await fetchClip(); const response = await fetchClip();
const clip = response.data.data[0] || null; const clip = response.data.data[0] || null;
if (clip) { if (clip) {
clipInfoCache.set(clipId, { value: clip, expiresAt: Date.now() + getMetadataCacheTtlMs() }); setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
} }
return clip; return clip;
} catch (e) { } catch (e) {
@ -1600,7 +1777,7 @@ async function getClipInfo(clipId: string): Promise<any | null> {
const retryResponse = await fetchClip(); const retryResponse = await fetchClip();
const clip = retryResponse.data.data[0] || null; const clip = retryResponse.data.data[0] || null;
if (clip) { if (clip) {
clipInfoCache.set(clipId, { value: clip, expiresAt: Date.now() + getMetadataCacheTtlMs() }); setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
} }
return clip; return clip;
} catch (retryError) { } catch (retryError) {
@ -1612,6 +1789,7 @@ async function getClipInfo(clipId: string): Promise<any | null> {
console.error('Error getting clip:', e); console.error('Error getting clip:', e);
return null; return null;
} }
});
} }
// ========================================== // ==========================================
@ -2531,9 +2709,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
} }
if (config.metadata_cache_minutes !== previousCacheMinutes) { if (config.metadata_cache_minutes !== previousCacheMinutes) {
loginToUserIdCache.clear(); clearMetadataCaches();
vodListCache.clear();
clipInfoCache.clear();
} }
saveConfig(config); saveConfig(config);
@ -2863,6 +3039,7 @@ ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
// ========================================== // ==========================================
app.whenReady().then(() => { app.whenReady().then(() => {
refreshBundledToolPaths(); refreshBundledToolPaths();
startMetadataCacheCleanup();
createWindow(); createWindow();
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
@ -2874,6 +3051,9 @@ app.whenReady().then(() => {
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
stopMetadataCacheCleanup();
cleanupMetadataCaches('shutdown');
if (currentProcess) { if (currentProcess) {
currentProcess.kill(); currentProcess.kill();
} }
@ -2885,5 +3065,7 @@ app.on('window-all-closed', () => {
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
stopMetadataCacheCleanup();
cleanupMetadataCaches('shutdown');
flushQueueSave(); flushQueueSave();
}); });