feat: taskbar progress + VOD card delegation + context menu + LRU bound

Four wins from a deep-audit pass.

1. Windows taskbar progress bar. While downloads run, mainWindow.
   setProgressBar(0..1) shows aggregate progress on the taskbar icon
   (visible while minimised). New activeDownloadProgress map tracks
   per-item fractions because main's downloadQueue.progress field
   is not updated mid-download (only renderer streams progress).
   Cleared via clearDownloadProgress in processOneQueueItem.finally
   so the bar resets when the queue idles.

2. VOD card data-* refactor. The previous inline-onclick template
   strings did escapedTitle = title.replace(/'/, "\\'").replace(/"/,
   """) and then interpolated that into onclick="addToQueue('...')".
   Edge cases (titles with backslash, ', etc.) could break the
   JS parser. All identity now lives on data-vod-id / -url / -title /
   -date / -streamer / -duration on .vod-card. A delegated click
   listener on #vodGrid reads the dataset at click time and
   dispatches to openClipDialog / addToQueue / openExternal. Plus:
   clicking the thumbnail / title / meta now opens the VOD on Twitch
   in the OS default browser.

3. Right-click context menu on VOD cards. Items: "Open on Twitch",
   "Copy VOD URL" (uses navigator.clipboard, toast confirmation),
   "Trim VOD", "+ Queue", and toggle "Mark as downloaded" /
   "Unmark downloaded". The mark toggle hits a new
   ipcMain.handle("mark-vod-downloaded", id, mark) so a user can
   add or remove entries in config.downloaded_vod_ids manually
   without re-downloading. Menu auto-closes on outside-click /
   Escape / scroll. Repositioned to stay inside the viewport.

4. userIdLoginCache now bounded (insertion-order eviction at 4096).
   Was Map<string, string> with no cap; setUserIdLogin helper
   centralises insertion + eviction. Long-running sessions with
   thousands of unique streamer lookups no longer accumulate the
   reverse-lookup table forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 15:56:33 +02:00
parent 504007600b
commit 6379723248
6 changed files with 292 additions and 23 deletions

View File

@ -675,7 +675,23 @@ let downloadedBytes = 0;
// Per-item tracking for parallel downloads
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
const cancelledItemIds = new Set<string>();
// userId -> login reverse map. Bounded via Map insertion-order eviction so
// a long-running session doesn't grow it unbounded across thousands of
// streamer lookups. Values are short (~20 char each) but accumulate.
const USER_ID_LOGIN_CACHE_MAX = 4096;
const userIdLoginCache = new Map<string, string>();
function setUserIdLogin(userId: string, login: string): void {
if (!userId || !login) return;
if (userIdLoginCache.has(userId)) {
userIdLoginCache.delete(userId);
}
userIdLoginCache.set(userId, login);
while (userIdLoginCache.size > USER_ID_LOGIN_CACHE_MAX) {
const oldest = userIdLoginCache.keys().next().value as string | undefined;
if (!oldest) break;
userIdLoginCache.delete(oldest);
}
}
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
const clipInfoCache = new Map<string, CacheEntry<any>>();
@ -1484,6 +1500,40 @@ function emitQueueUpdated(force = false): void {
lastQueueBroadcastFingerprint = nextFingerprint;
mainWindow?.webContents.send('queue-updated', downloadQueue);
updateTaskbarProgress();
}
// Per-item taskbar progress is tracked here because main's downloadQueue
// items don't update their .progress field mid-download (only the renderer
// gets a stream of progress events). Map is cleared in processOneQueueItem.finally.
const activeDownloadProgress = new Map<string, number>();
function recordDownloadProgress(progress: DownloadProgress): void {
const p = Number(progress.progress);
const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3;
activeDownloadProgress.set(progress.id, fraction);
updateTaskbarProgress();
}
function clearDownloadProgress(itemId: string): void {
activeDownloadProgress.delete(itemId);
updateTaskbarProgress();
}
// Aggregate progress across all currently-downloading items, mapped to the
// Windows taskbar progress indicator (-1 = no progress, 0..1 = fraction).
// Visible whenever the user has minimised / collapsed the window. Indeterminate
// downloads (no percentage yet) report a 30% bar so the taskbar still shows
// activity instead of going cold.
function updateTaskbarProgress(): void {
if (!mainWindow || mainWindow.isDestroyed()) return;
const entries = Array.from(activeDownloadProgress.values());
if (entries.length === 0) {
try { mainWindow.setProgressBar(-1); } catch { /* unsupported on some platforms */ }
return;
}
const avg = entries.reduce((s, v) => s + v, 0) / entries.length;
try { mainWindow.setProgressBar(Math.max(0, Math.min(1, avg))); } catch { /* ignore */ }
}
function hasQueueItemId(id: string): boolean {
@ -1826,7 +1876,7 @@ async function getPublicUserId(username: string): Promise<string | null> {
if (!user?.id) return null;
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
userIdLoginCache.set(user.id, user.login || login);
setUserIdLogin(user.id, user.login || login);
return user.id;
}
@ -1919,7 +1969,7 @@ async function getUserId(username: string): Promise<string | null> {
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);
setUserIdLogin(user.id, user.login || login);
return user.id;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
@ -1929,7 +1979,7 @@ async function getUserId(username: string): Promise<string | null> {
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);
setUserIdLogin(user.id, user.login || login);
return user.id;
} catch (retryError) {
console.error('Error getting user after relogin:', retryError);
@ -2009,7 +2059,7 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
if (pageCount === 0) {
const login = pageVods[0]?.user_login;
if (login) {
userIdLoginCache.set(userId, normalizeLogin(login));
setUserIdLogin(userId, normalizeLogin(login));
}
}
@ -3190,9 +3240,11 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
const result = item.mergeGroup
? await processDownloadMergeGroup(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
recordDownloadProgress(progress);
})
: await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
recordDownloadProgress(progress);
});
if (result.success) {
@ -3294,6 +3346,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
cancelledItemIds.delete(item.id);
// Release only THIS item's claimed filenames (other parallel downloads keep their claims)
releaseClaimedFilenamesForItem(item.id);
clearDownloadProgress(item.id);
}
}
@ -4276,6 +4329,22 @@ ipcMain.handle('export-runtime-metrics', async () => {
}
});
ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => {
if (typeof vodId !== 'string' || !vodId) return { success: false };
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
const has = config.downloaded_vod_ids.includes(vodId);
if (mark && !has) {
config.downloaded_vod_ids.push(vodId);
} else if (!mark && has) {
config.downloaded_vod_ids = config.downloaded_vod_ids.filter((id) => id !== vodId);
} else {
return { success: true };
}
saveConfig(config);
appendDebugLog('mark-vod-downloaded', { vodId, mark });
return { success: true };
});
ipcMain.handle('reset-downloaded-vod-ids', () => {
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
config.downloaded_vod_ids = [];

View File

@ -111,6 +111,8 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.invoke('export-runtime-metrics'),
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
ipcRenderer.invoke('reset-downloaded-vod-ids'),
markVodDownloaded: (vodId: string, mark: boolean): Promise<{ success: boolean }> =>
ipcRenderer.invoke('mark-vod-downloaded', vodId, mark),
exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
ipcRenderer.invoke('export-config'),
importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>

View File

@ -218,6 +218,7 @@ interface ApiBridge {
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>;
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;

View File

@ -207,7 +207,13 @@ const UI_TEXT_DE = {
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
alreadyDownloaded: 'Bereits heruntergeladen',
hideDownloaded: 'Bereits geladene ausblenden',
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind'
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
openOnTwitch: 'Auf Twitch oeffnen',
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
ctxCopyUrl: 'VOD-URL kopieren',
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
ctxMarkDownloaded: 'Als heruntergeladen markieren',
ctxUnmarkDownloaded: 'Markierung entfernen'
},
clips: {
dialogTitle: 'VOD zuschneiden',

View File

@ -207,7 +207,13 @@ const UI_TEXT_EN = {
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
alreadyDownloaded: 'Already downloaded',
hideDownloaded: 'Hide downloaded',
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded'
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded',
openOnTwitch: 'Open on Twitch',
ctxOpenOnTwitch: 'Open on Twitch',
ctxCopyUrl: 'Copy VOD URL',
ctxCopiedUrl: 'URL copied to clipboard.',
ctxMarkDownloaded: 'Mark as downloaded',
ctxUnmarkDownloaded: 'Unmark downloaded'
},
clips: {
dialogTitle: 'Trim VOD',

View File

@ -199,36 +199,73 @@ function focusVodFilter(): void {
function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<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, '&quot;');
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
const safeUrlAttr = escapeHtml(vod.url);
const safeTitleAttr = escapeHtml(vod.title || '');
const safeStreamerAttr = escapeHtml(streamer);
const safeDateAttr = escapeHtml(vod.created_at);
const safeDurationAttr = escapeHtml(vod.duration);
const safeIdAttr = escapeHtml(vod.id);
const isChecked = selectedVodUrls.has(vod.url);
const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false;
const downloadedBadge = isAlreadyDownloaded
? `<div class="vod-downloaded-badge" title="${escapeHtml(UI_TEXT.vods.alreadyDownloaded)}">&#10003;</div>`
: '';
// All identity attributes go on data-* — a delegated listener on #vodGrid
// reads them at click time. This removes the previous inline-onclick
// template-injection pattern (escapedTitle dance) which was fragile for
// titles containing backslashes / HTML entities like &apos;.
return `
<div class="vod-card${isChecked ? ' selected' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}">
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="Select for bulk action" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
<div class="vod-card${isChecked ? ' selected' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}"
data-vod-id="${safeIdAttr}"
data-vod-url="${safeUrlAttr}"
data-vod-title="${safeTitleAttr}"
data-vod-date="${safeDateAttr}"
data-vod-streamer="${safeStreamerAttr}"
data-vod-duration="${safeDurationAttr}">
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="${escapeHtml(UI_TEXT.vods.bulkSelectedCount.replace('{count}', '0').replace(/[0-9]/g, '').trim() || 'Select')}" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
${downloadedBadge}
<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>'">
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" title="${escapeHtml(UI_TEXT.vods.openOnTwitch)}" 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>
<span>${escapeHtml(vod.duration)}</span>
<span>${formatUiNumber(vod.view_count)} ${escapeHtml(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}')">${UI_TEXT.vods.trimButton}</button>
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.addQueue}</button>
<button class="vod-btn secondary" data-vod-action="trim">${escapeHtml(UI_TEXT.vods.trimButton)}</button>
<button class="vod-btn primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button>
</div>
</div>
`;
}
interface VodCardContext {
id: string;
url: string;
title: string;
date: string;
streamer: string;
duration: string;
}
function readVodCardContext(card: HTMLElement | null): VodCardContext | null {
if (!card) return null;
const url = card.dataset.vodUrl;
if (!url) return null;
return {
id: card.dataset.vodId || '',
url,
title: card.dataset.vodTitle || '',
date: card.dataset.vodDate || '',
streamer: card.dataset.vodStreamer || '',
duration: card.dataset.vodDuration || ''
};
}
let streamerDragInitialized = false;
let draggedStreamerName: string | null = null;
@ -434,19 +471,167 @@ function initVodGridSelectionDelegation(): void {
const grid = document.getElementById('vodGrid');
if (!grid) return;
grid.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (!(target instanceof HTMLInputElement)) return;
if (!target.classList.contains('vod-select-checkbox')) return;
const url = target.dataset.vodUrl || '';
if (!url) return;
if (target.checked) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
// Keep card visual + bar in sync without a full re-render of all cards
// 1) Checkbox toggles (bulk-select)
if (target instanceof HTMLInputElement && target.classList.contains('vod-select-checkbox')) {
const url = target.dataset.vodUrl || '';
if (!url) return;
if (target.checked) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
const card = target.closest('.vod-card') as HTMLElement | null;
if (card) card.classList.toggle('selected', target.checked);
updateVodBulkBar();
return;
}
// 2) Action buttons (trim / queue) — replaces the previous inline
// onclick template that mangled titles with special characters
const btn = target.closest('button[data-vod-action]') as HTMLButtonElement | null;
if (btn) {
const ctx = readVodCardContext(btn.closest('.vod-card') as HTMLElement | null);
if (!ctx) return;
if (btn.dataset.vodAction === 'trim') {
openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
} else if (btn.dataset.vodAction === 'queue') {
void addToQueue(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
}
return;
}
// 3) Click on thumbnail / title / meta -> open VOD on Twitch in the
// OS default browser. Convenient + non-destructive.
const card = target.closest('.vod-card') as HTMLElement | null;
if (card) card.classList.toggle('selected', target.checked);
updateVodBulkBar();
if (!card) return;
if (target.closest('.vod-actions') || target.classList.contains('vod-select-checkbox')) return;
const ctx = readVodCardContext(card);
if (!ctx) return;
void window.api.openExternal(ctx.url);
});
grid.addEventListener('contextmenu', (e) => {
const card = (e.target as HTMLElement).closest('.vod-card') as HTMLElement | null;
if (!card) return;
const ctx = readVodCardContext(card);
if (!ctx) return;
e.preventDefault();
showVodContextMenu(e.clientX, e.clientY, ctx);
});
}
let activeVodContextMenu: HTMLElement | null = null;
function closeVodContextMenu(): void {
if (!activeVodContextMenu) return;
activeVodContextMenu.remove();
activeVodContextMenu = null;
}
function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
closeVodContextMenu();
const menu = document.createElement('div');
menu.className = 'vod-context-menu';
menu.style.position = 'fixed';
menu.style.zIndex = '9999';
menu.style.background = 'var(--bg-card)';
menu.style.border = '1px solid var(--border-soft)';
menu.style.borderRadius = '6px';
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
menu.style.padding = '4px';
menu.style.minWidth = '200px';
const downloadedIds = new Set(
Array.isArray(config.downloaded_vod_ids)
? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string')
: []
);
const isMarkedDownloaded = downloadedIds.has(ctx.id);
const makeItem = (label: string, onClick: () => void): HTMLElement => {
const el = document.createElement('div');
el.textContent = label;
el.style.padding = '8px 12px';
el.style.cursor = 'pointer';
el.style.fontSize = '13px';
el.style.color = 'var(--text)';
el.style.borderRadius = '4px';
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; });
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
el.addEventListener('click', () => {
try { onClick(); } finally { closeVodContextMenu(); }
});
return el;
};
menu.appendChild(makeItem(UI_TEXT.vods.ctxOpenOnTwitch, () => {
void window.api.openExternal(ctx.url);
}));
menu.appendChild(makeItem(UI_TEXT.vods.ctxCopyUrl, () => {
try {
void navigator.clipboard.writeText(ctx.url);
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
if (toast) toast(UI_TEXT.vods.ctxCopiedUrl, 'info');
} catch { /* ignore */ }
}));
menu.appendChild(makeItem(UI_TEXT.vods.trimButton, () => {
openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
}));
menu.appendChild(makeItem(UI_TEXT.vods.addQueue, () => {
void addToQueue(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
}));
menu.appendChild(makeItem(
isMarkedDownloaded ? UI_TEXT.vods.ctxUnmarkDownloaded : UI_TEXT.vods.ctxMarkDownloaded,
() => { void toggleVodDownloadedMark(ctx.id, !isMarkedDownloaded); }
));
document.body.appendChild(menu);
activeVodContextMenu = menu;
// Reposition if it would clip off the viewport
const rect = menu.getBoundingClientRect();
let left = x;
let top = y;
if (left + rect.width > window.innerWidth - 4) left = Math.max(4, window.innerWidth - rect.width - 4);
if (top + rect.height > window.innerHeight - 4) top = Math.max(4, window.innerHeight - rect.height - 4);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
// Close on click anywhere else / Escape / scroll
const dismissOnClick = (ev: MouseEvent) => {
if (!activeVodContextMenu) return;
if (ev.target instanceof Node && activeVodContextMenu.contains(ev.target)) return;
closeVodContextMenu();
document.removeEventListener('mousedown', dismissOnClick, true);
document.removeEventListener('keydown', dismissOnEscape, true);
document.removeEventListener('scroll', dismissOnScroll, true);
};
const dismissOnEscape = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape') return;
closeVodContextMenu();
document.removeEventListener('mousedown', dismissOnClick, true);
document.removeEventListener('keydown', dismissOnEscape, true);
document.removeEventListener('scroll', dismissOnScroll, true);
};
const dismissOnScroll = () => {
closeVodContextMenu();
document.removeEventListener('mousedown', dismissOnClick, true);
document.removeEventListener('keydown', dismissOnEscape, true);
document.removeEventListener('scroll', dismissOnScroll, true);
};
document.addEventListener('mousedown', dismissOnClick, true);
document.addEventListener('keydown', dismissOnEscape, true);
document.addEventListener('scroll', dismissOnScroll, true);
}
async function toggleVodDownloadedMark(vodId: string, mark: boolean): Promise<void> {
const result = await window.api.markVodDownloaded(vodId, mark);
if (!result?.success) return;
try {
config = await window.api.getConfig();
} catch { /* ignore */ }
if (lastLoadedStreamer) renderVodGridFromCurrentState();
}
function updateVodBulkBar(): void {