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",
|
"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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,189 +1591,205 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
runtimeMetrics.cacheMisses += 1;
|
return await withInFlightDedup(inFlightUserIdRequests, login, async () => {
|
||||||
|
const refreshedCachedUserId = getCachedValue(loginToUserIdCache, login);
|
||||||
const getUserViaPublicApi = async () => {
|
if (refreshedCachedUserId !== undefined) {
|
||||||
return await getPublicUserId(login);
|
runtimeMetrics.cacheHits += 1;
|
||||||
};
|
return refreshedCachedUserId;
|
||||||
|
|
||||||
if (!(await ensureTwitchAuth())) return await getUserViaPublicApi();
|
|
||||||
|
|
||||||
const fetchUser = async () => {
|
|
||||||
return await axios.get('https://api.twitch.tv/helix/users', {
|
|
||||||
params: { login },
|
|
||||||
headers: {
|
|
||||||
'Client-ID': config.client_id,
|
|
||||||
'Authorization': `Bearer ${accessToken}`
|
|
||||||
},
|
|
||||||
timeout: API_TIMEOUT
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchUser();
|
|
||||||
const user = response.data.data[0];
|
|
||||||
if (!user?.id) return await getUserViaPublicApi();
|
|
||||||
|
|
||||||
loginToUserIdCache.set(login, { value: user.id, expiresAt: Date.now() + getMetadataCacheTtlMs() });
|
|
||||||
userIdLoginCache.set(user.id, user.login || login);
|
|
||||||
return user.id;
|
|
||||||
} catch (e) {
|
|
||||||
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
|
||||||
try {
|
|
||||||
const retryResponse = await fetchUser();
|
|
||||||
const user = retryResponse.data.data[0];
|
|
||||||
if (!user?.id) return await getUserViaPublicApi();
|
|
||||||
|
|
||||||
loginToUserIdCache.set(login, { value: user.id, expiresAt: Date.now() + getMetadataCacheTtlMs() });
|
|
||||||
userIdLoginCache.set(user.id, user.login || login);
|
|
||||||
return user.id;
|
|
||||||
} catch (retryError) {
|
|
||||||
console.error('Error getting user after relogin:', retryError);
|
|
||||||
return await getUserViaPublicApi();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Error getting user:', e);
|
runtimeMetrics.cacheMisses += 1;
|
||||||
return await getUserViaPublicApi();
|
|
||||||
}
|
const getUserViaPublicApi = async () => {
|
||||||
|
return await getPublicUserId(login);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(await ensureTwitchAuth())) return await getUserViaPublicApi();
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
return await axios.get('https://api.twitch.tv/helix/users', {
|
||||||
|
params: { login },
|
||||||
|
headers: {
|
||||||
|
'Client-ID': config.client_id,
|
||||||
|
'Authorization': `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
timeout: API_TIMEOUT
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchUser();
|
||||||
|
const user = response.data.data[0];
|
||||||
|
if (!user?.id) return await getUserViaPublicApi();
|
||||||
|
|
||||||
|
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
||||||
|
userIdLoginCache.set(user.id, user.login || login);
|
||||||
|
return user.id;
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
||||||
|
try {
|
||||||
|
const retryResponse = await fetchUser();
|
||||||
|
const user = retryResponse.data.data[0];
|
||||||
|
if (!user?.id) return await getUserViaPublicApi();
|
||||||
|
|
||||||
|
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
||||||
|
userIdLoginCache.set(user.id, user.login || login);
|
||||||
|
return user.id;
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error('Error getting user after relogin:', retryError);
|
||||||
|
return await getUserViaPublicApi();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error getting user:', e);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runtimeMetrics.cacheMisses += 1;
|
const requestKey = `${cacheKey}|${forceRefresh ? 'force' : 'default'}`;
|
||||||
|
return await withInFlightDedup(inFlightVodRequests, requestKey, async () => {
|
||||||
const getVodsViaPublicApi = async () => {
|
if (!forceRefresh) {
|
||||||
const login = userIdLoginCache.get(userId);
|
const refreshedCachedVods = getCachedValue(vodListCache, cacheKey);
|
||||||
if (!login) return [];
|
if (refreshedCachedVods !== undefined) {
|
||||||
|
runtimeMetrics.cacheHits += 1;
|
||||||
const vods = await getPublicVODsByLogin(login);
|
return refreshedCachedVods;
|
||||||
vodListCache.set(cacheKey, {
|
|
||||||
value: vods,
|
|
||||||
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
|
||||||
});
|
|
||||||
return vods;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi();
|
|
||||||
|
|
||||||
const fetchVods = async () => {
|
|
||||||
return await axios.get('https://api.twitch.tv/helix/videos', {
|
|
||||||
params: {
|
|
||||||
user_id: userId,
|
|
||||||
type: 'archive',
|
|
||||||
first: 100
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'Client-ID': config.client_id,
|
|
||||||
'Authorization': `Bearer ${accessToken}`
|
|
||||||
},
|
|
||||||
timeout: API_TIMEOUT
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchVods();
|
|
||||||
const vods = response.data.data || [];
|
|
||||||
const login = vods[0]?.user_login;
|
|
||||||
if (login) {
|
|
||||||
userIdLoginCache.set(userId, normalizeLogin(login));
|
|
||||||
}
|
|
||||||
|
|
||||||
vodListCache.set(cacheKey, {
|
|
||||||
value: vods,
|
|
||||||
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
|
||||||
});
|
|
||||||
|
|
||||||
return vods;
|
|
||||||
} catch (e) {
|
|
||||||
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
|
||||||
try {
|
|
||||||
const retryResponse = await fetchVods();
|
|
||||||
const vods = retryResponse.data.data || [];
|
|
||||||
const login = vods[0]?.user_login;
|
|
||||||
if (login) {
|
|
||||||
userIdLoginCache.set(userId, normalizeLogin(login));
|
|
||||||
}
|
|
||||||
|
|
||||||
vodListCache.set(cacheKey, {
|
|
||||||
value: vods,
|
|
||||||
expiresAt: Date.now() + getMetadataCacheTtlMs()
|
|
||||||
});
|
|
||||||
|
|
||||||
return vods;
|
|
||||||
} catch (retryError) {
|
|
||||||
console.error('Error getting VODs after relogin:', retryError);
|
|
||||||
return await getVodsViaPublicApi();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Error getting VODs:', e);
|
runtimeMetrics.cacheMisses += 1;
|
||||||
return await getVodsViaPublicApi();
|
|
||||||
}
|
const getVodsViaPublicApi = async () => {
|
||||||
|
const login = userIdLoginCache.get(userId);
|
||||||
|
if (!login) return [];
|
||||||
|
|
||||||
|
const vods = await getPublicVODsByLogin(login);
|
||||||
|
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
||||||
|
return vods;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(await ensureTwitchAuth())) return await getVodsViaPublicApi();
|
||||||
|
|
||||||
|
const fetchVods = async () => {
|
||||||
|
return await axios.get('https://api.twitch.tv/helix/videos', {
|
||||||
|
params: {
|
||||||
|
user_id: userId,
|
||||||
|
type: 'archive',
|
||||||
|
first: 100
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Client-ID': config.client_id,
|
||||||
|
'Authorization': `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
timeout: API_TIMEOUT
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchVods();
|
||||||
|
const vods = response.data.data || [];
|
||||||
|
const login = vods[0]?.user_login;
|
||||||
|
if (login) {
|
||||||
|
userIdLoginCache.set(userId, normalizeLogin(login));
|
||||||
|
}
|
||||||
|
|
||||||
|
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
||||||
|
return vods;
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
||||||
|
try {
|
||||||
|
const retryResponse = await fetchVods();
|
||||||
|
const vods = retryResponse.data.data || [];
|
||||||
|
const login = vods[0]?.user_login;
|
||||||
|
if (login) {
|
||||||
|
userIdLoginCache.set(userId, normalizeLogin(login));
|
||||||
|
}
|
||||||
|
|
||||||
|
setCachedValue(vodListCache, cacheKey, vods, MAX_VOD_LIST_CACHE_ENTRIES);
|
||||||
|
return vods;
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error('Error getting VODs after relogin:', retryError);
|
||||||
|
return await getVodsViaPublicApi();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error getting VODs:', e);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
runtimeMetrics.cacheMisses += 1;
|
return await withInFlightDedup(inFlightClipRequests, clipId, async () => {
|
||||||
|
const refreshedCachedClip = getCachedValue(clipInfoCache, clipId);
|
||||||
if (!(await ensureTwitchAuth())) return null;
|
if (refreshedCachedClip !== undefined) {
|
||||||
|
runtimeMetrics.cacheHits += 1;
|
||||||
const fetchClip = async () => {
|
return refreshedCachedClip;
|
||||||
return await axios.get('https://api.twitch.tv/helix/clips', {
|
|
||||||
params: { id: clipId },
|
|
||||||
headers: {
|
|
||||||
'Client-ID': config.client_id,
|
|
||||||
'Authorization': `Bearer ${accessToken}`
|
|
||||||
},
|
|
||||||
timeout: API_TIMEOUT
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchClip();
|
|
||||||
const clip = response.data.data[0] || null;
|
|
||||||
if (clip) {
|
|
||||||
clipInfoCache.set(clipId, { value: clip, expiresAt: Date.now() + getMetadataCacheTtlMs() });
|
|
||||||
}
|
}
|
||||||
return clip;
|
|
||||||
} catch (e) {
|
runtimeMetrics.cacheMisses += 1;
|
||||||
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
|
||||||
try {
|
if (!(await ensureTwitchAuth())) return null;
|
||||||
const retryResponse = await fetchClip();
|
|
||||||
const clip = retryResponse.data.data[0] || null;
|
const fetchClip = async () => {
|
||||||
if (clip) {
|
return await axios.get('https://api.twitch.tv/helix/clips', {
|
||||||
clipInfoCache.set(clipId, { value: clip, expiresAt: Date.now() + getMetadataCacheTtlMs() });
|
params: { id: clipId },
|
||||||
}
|
headers: {
|
||||||
return clip;
|
'Client-ID': config.client_id,
|
||||||
} catch (retryError) {
|
'Authorization': `Bearer ${accessToken}`
|
||||||
console.error('Error getting clip after relogin:', retryError);
|
},
|
||||||
return null;
|
timeout: API_TIMEOUT
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchClip();
|
||||||
|
const clip = response.data.data[0] || null;
|
||||||
|
if (clip) {
|
||||||
|
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
||||||
|
}
|
||||||
|
return clip;
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
||||||
|
try {
|
||||||
|
const retryResponse = await fetchClip();
|
||||||
|
const clip = retryResponse.data.data[0] || null;
|
||||||
|
if (clip) {
|
||||||
|
setCachedValue(clipInfoCache, clipId, clip, MAX_CLIP_INFO_CACHE_ENTRIES);
|
||||||
|
}
|
||||||
|
return clip;
|
||||||
|
} catch (retryError) {
|
||||||
|
console.error('Error getting clip after relogin:', retryError);
|
||||||
|
return 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();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user