Optimize metadata cache concurrency and memory bounds (v4.1.2)
This commit is contained in:
parent
64fb0f416f
commit
e83b23bd79
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.1",
|
||||
"version": "4.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.1.1",
|
||||
"version": "4.1.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.1.1",
|
||||
"version": "4.1.2",
|
||||
"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.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>
|
||||
</div>
|
||||
|
||||
@ -502,7 +502,7 @@
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Nicht verbunden</span>
|
||||
</div>
|
||||
<span id="versionText">v4.1.1</span>
|
||||
<span id="versionText">v4.1.2</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
|
||||
// ==========================================
|
||||
// CONFIG & CONSTANTS
|
||||
// ==========================================
|
||||
const APP_VERSION = '4.1.1';
|
||||
const APP_VERSION = '4.1.2';
|
||||
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
|
||||
|
||||
// Paths
|
||||
@ -27,6 +27,10 @@ const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
||||
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
||||
const QUEUE_SAVE_DEBOUNCE_MS = 250;
|
||||
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
|
||||
const API_TIMEOUT = 10000;
|
||||
@ -343,6 +347,10 @@ const userIdLoginCache = new Map<string, string>();
|
||||
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
|
||||
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
|
||||
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 = {
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
@ -1029,6 +1037,160 @@ function getMetadataCacheTtlMs(): number {
|
||||
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 {
|
||||
switch (normalizePerformanceMode(config.performance_mode)) {
|
||||
case 'stability':
|
||||
@ -1356,10 +1518,10 @@ async function getPublicUserId(username: string): Promise<string | null> {
|
||||
const login = normalizeLogin(username);
|
||||
if (!login) return null;
|
||||
|
||||
const cached = loginToUserIdCache.get(login);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
const cachedUserId = getCachedValue(loginToUserIdCache, login);
|
||||
if (cachedUserId !== undefined) {
|
||||
runtimeMetrics.cacheHits += 1;
|
||||
return cached.value;
|
||||
return cachedUserId;
|
||||
}
|
||||
|
||||
runtimeMetrics.cacheMisses += 1;
|
||||
@ -1373,7 +1535,7 @@ async function getPublicUserId(username: string): Promise<string | null> {
|
||||
const user = data?.user;
|
||||
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);
|
||||
return user.id;
|
||||
}
|
||||
@ -1429,10 +1591,17 @@ async function getUserId(username: string): Promise<string | null> {
|
||||
const login = normalizeLogin(username);
|
||||
if (!login) return null;
|
||||
|
||||
const cached = loginToUserIdCache.get(login);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
const cachedUserId = getCachedValue(loginToUserIdCache, login);
|
||||
if (cachedUserId !== undefined) {
|
||||
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;
|
||||
@ -1459,7 +1628,7 @@ async function getUserId(username: string): Promise<string | null> {
|
||||
const user = response.data.data[0];
|
||||
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);
|
||||
return user.id;
|
||||
} catch (e) {
|
||||
@ -1469,7 +1638,7 @@ async function getUserId(username: string): Promise<string | null> {
|
||||
const user = retryResponse.data.data[0];
|
||||
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);
|
||||
return user.id;
|
||||
} catch (retryError) {
|
||||
@ -1481,15 +1650,26 @@ async function getUserId(username: string): Promise<string | null> {
|
||||
console.error('Error getting user:', e);
|
||||
return await getUserViaPublicApi();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
|
||||
const cacheKey = `user:${userId}`;
|
||||
if (!forceRefresh) {
|
||||
const cached = vodListCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
const cachedVods = getCachedValue(vodListCache, cacheKey);
|
||||
if (cachedVods !== undefined) {
|
||||
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 [];
|
||||
|
||||
const vods = await getPublicVODsByLogin(login);
|
||||
vodListCache.set(cacheKey, {
|
||||
value: vods,
|
||||
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
||||
});
|
||||
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
||||
return vods;
|
||||
};
|
||||
|
||||
@ -1532,11 +1709,7 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
|
||||
userIdLoginCache.set(userId, normalizeLogin(login));
|
||||
}
|
||||
|
||||
vodListCache.set(cacheKey, {
|
||||
value: vods,
|
||||
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
||||
});
|
||||
|
||||
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
||||
return vods;
|
||||
} catch (e) {
|
||||
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));
|
||||
}
|
||||
|
||||
vodListCache.set(cacheKey, {
|
||||
value: vods,
|
||||
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
||||
});
|
||||
|
||||
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
||||
return vods;
|
||||
} catch (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);
|
||||
return await getVodsViaPublicApi();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getClipInfo(clipId: string): Promise<any | null> {
|
||||
const cached = clipInfoCache.get(clipId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
const cachedClip = getCachedValue(clipInfoCache, clipId);
|
||||
if (cachedClip !== undefined) {
|
||||
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;
|
||||
@ -1591,7 +1768,7 @@ async function getClipInfo(clipId: string): Promise<any | null> {
|
||||
const response = await fetchClip();
|
||||
const clip = response.data.data[0] || null;
|
||||
if (clip) {
|
||||
clipInfoCache.set(clipId, { value: clip, expiresAt: Date.now() + getMetadataCacheTtlMs() });
|
||||
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
||||
}
|
||||
return clip;
|
||||
} catch (e) {
|
||||
@ -1600,7 +1777,7 @@ async function getClipInfo(clipId: string): Promise<any | null> {
|
||||
const retryResponse = await fetchClip();
|
||||
const clip = retryResponse.data.data[0] || null;
|
||||
if (clip) {
|
||||
clipInfoCache.set(clipId, { value: clip, expiresAt: Date.now() + getMetadataCacheTtlMs() });
|
||||
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
||||
}
|
||||
return clip;
|
||||
} catch (retryError) {
|
||||
@ -1612,6 +1789,7 @@ async function getClipInfo(clipId: string): Promise<any | null> {
|
||||
console.error('Error getting clip:', e);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@ -2531,9 +2709,7 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
||||
}
|
||||
|
||||
if (config.metadata_cache_minutes !== previousCacheMinutes) {
|
||||
loginToUserIdCache.clear();
|
||||
vodListCache.clear();
|
||||
clipInfoCache.clear();
|
||||
clearMetadataCaches();
|
||||
}
|
||||
|
||||
saveConfig(config);
|
||||
@ -2863,6 +3039,7 @@ ipcMain.handle('save-video-dialog', async (_, defaultName: string) => {
|
||||
// ==========================================
|
||||
app.whenReady().then(() => {
|
||||
refreshBundledToolPaths();
|
||||
startMetadataCacheCleanup();
|
||||
createWindow();
|
||||
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
||||
|
||||
@ -2874,6 +3051,9 @@ app.whenReady().then(() => {
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
stopMetadataCacheCleanup();
|
||||
cleanupMetadataCaches('shutdown');
|
||||
|
||||
if (currentProcess) {
|
||||
currentProcess.kill();
|
||||
}
|
||||
@ -2885,5 +3065,7 @@ app.on('window-all-closed', () => {
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
stopMetadataCacheCleanup();
|
||||
cleanupMetadataCaches('shutdown');
|
||||
flushQueueSave();
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user