From 3f04b42b023b922926e9550fc1b422f757bb4daf Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 10 May 2026 15:16:21 +0200 Subject: [PATCH] feat: auto-resume queue toggle + already-downloaded VOD indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real UX wins. 1. Auto-resume queue on startup. New checkbox in Settings -> Download ("Queue beim Start automatisch fortsetzen"). When enabled and the persisted queue has pending items, processQueue() fires ~5 seconds after did-finish-load — long enough for the user to see the queue and pause if they did not actually want this. Default off so the existing behaviour (explicit Start click) is preserved on upgrade. The Settings auto-save fingerprint includes the new flag and syncSettingsFormFromConfig restores it. Tooltip explains the timing on hover. 2. Already-downloaded indicator on VOD cards. Config gains downloaded_vod_ids: string[] (bounded to 4096 latest entries). Every successful queue-item download appends its parsed VOD ID (or every component ID for merge groups). On the VOD grid each card whose vod.id is in the set gets a small green checkmark badge in the top-right plus a slightly dimmed thumbnail, with a localized "Already downloaded" / "Bereits heruntergeladen" tooltip. The lookup builds a Set once per render so it stays O(1) per card. The renderer refreshes its local config copy on every "newly completed" queue update so the badge appears live without waiting for a settings save. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.html | 4 +++ src/main.ts | 66 +++++++++++++++++++++++++++++++++++++-- src/renderer-globals.d.ts | 2 ++ src/renderer-locale-de.ts | 5 ++- src/renderer-locale-en.ts | 5 ++- src/renderer-settings.ts | 6 +++- src/renderer-streamers.ts | 19 +++++++++-- src/renderer-texts.ts | 3 ++ src/renderer.ts | 21 +++++++++++-- src/styles.css | 23 ++++++++++++++ 10 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/index.html b/src/index.html index b017a0e..f1c85b7 100644 --- a/src/index.html +++ b/src/index.html @@ -493,6 +493,10 @@ Queue zwischen App-Starts speichern +
diff --git a/src/main.ts b/src/main.ts index 3a9ad54..15cadd6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -203,6 +203,8 @@ interface Config { persist_queue_on_restart: boolean; metadata_cache_minutes: number; parallel_downloads: number; + auto_resume_queue_on_startup: boolean; + downloaded_vod_ids: string[]; } interface RuntimeMetrics { @@ -314,7 +316,9 @@ const defaultConfig: Config = { prevent_duplicate_downloads: true, persist_queue_on_restart: true, metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES, - parallel_downloads: 1 + parallel_downloads: 1, + auto_resume_queue_on_startup: false, + downloaded_vod_ids: [] }; function normalizeFilenameTemplate(template: string | undefined, fallback: string): string { @@ -340,6 +344,15 @@ function normalizePerformanceMode(mode: unknown): PerformanceMode { } function normalizeConfigTemplates(input: Config): Config { + // downloaded_vod_ids is bounded so a long-running app doesn't accumulate + // an unbounded list across years of downloads. Latest entries kept. + const DOWNLOADED_IDS_MAX = 4096; + const rawIds = Array.isArray(input.downloaded_vod_ids) ? input.downloaded_vod_ids : []; + const cleanIds = rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0); + const trimmedIds = cleanIds.length > DOWNLOADED_IDS_MAX + ? cleanIds.slice(cleanIds.length - DOWNLOADED_IDS_MAX) + : cleanIds; + return { ...input, filename_template_vod: normalizeFilenameTemplate(input.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD), @@ -349,10 +362,27 @@ function normalizeConfigTemplates(input: Config): Config { performance_mode: normalizePerformanceMode(input.performance_mode), prevent_duplicate_downloads: input.prevent_duplicate_downloads !== false, persist_queue_on_restart: input.persist_queue_on_restart !== false, - metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes) + metadata_cache_minutes: normalizeMetadataCacheMinutes(input.metadata_cache_minutes), + auto_resume_queue_on_startup: input.auto_resume_queue_on_startup === true, + downloaded_vod_ids: trimmedIds }; } +function recordDownloadedVodId(vodId: string): void { + if (!vodId) return; + if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = []; + if (config.downloaded_vod_ids.includes(vodId)) return; + config.downloaded_vod_ids.push(vodId); + // Cap to keep config size bounded — drop oldest first. + const DOWNLOADED_IDS_MAX = 4096; + if (config.downloaded_vod_ids.length > DOWNLOADED_IDS_MAX) { + config.downloaded_vod_ids = config.downloaded_vod_ids.slice( + config.downloaded_vod_ids.length - DOWNLOADED_IDS_MAX + ); + } + saveConfig(config); +} + function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -3229,6 +3259,22 @@ async function processOneQueueItem(item: QueueItem): Promise { item.outputFiles = [...finalResult.outputFiles]; } + if (finalResult.success) { + // Record the VOD ID so the renderer can mark this VOD as + // already-downloaded the next time the user browses the + // streamer's archive. Merge groups don't have a single VOD + // ID — record each component instead. + if (item.mergeGroup?.items?.length) { + for (const m of item.mergeGroup.items) { + const id = parseVodId(m.url); + if (id) recordDownloadedVodId(id); + } + } else { + const id = parseVodId(item.url); + if (id) recordDownloadedVodId(id); + } + } + if (finalResult.success) { runtimeMetrics.downloadsCompleted += 1; } else if (!wasPaused) { @@ -3378,6 +3424,22 @@ function createWindow(): void { if (autoUpdateReadyToInstall && downloadedUpdateVersion) { mainWindow?.webContents.send('update-downloaded', buildUpdateInfoPayload(downloadedUpdateVersion)); } + + // Auto-resume: if the user opted in AND the persisted queue has + // pending entries, kick off processing after a short delay so the + // UI has time to render and the user can still pause if they want. + if (config.auto_resume_queue_on_startup && !isDownloading) { + const hasPending = downloadQueue.some((it) => it.status === 'pending'); + if (hasPending) { + appendDebugLog('auto-resume-queue-scheduled', { pending: downloadQueue.filter((it) => it.status === 'pending').length }); + setTimeout(() => { + if (config.auto_resume_queue_on_startup && !isDownloading + && downloadQueue.some((it) => it.status === 'pending')) { + void processQueue(); + } + }, 5000); + } + } }); mainWindow.on('closed', () => { diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index c6aef4c..ef2655b 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -16,6 +16,8 @@ interface AppConfig { persist_queue_on_restart?: boolean; metadata_cache_minutes?: number; parallel_downloads?: number; + auto_resume_queue_on_startup?: boolean; + downloaded_vod_ids?: string[]; [key: string]: unknown; } diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 9df250a..4d4e4fb 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -56,6 +56,8 @@ const UI_TEXT_DE = { openDebugLogFile: 'Log-Datei oeffnen', duplicatePreventionLabel: 'Duplikate in Queue verhindern', persistQueueLabel: 'Queue zwischen App-Starts speichern', + autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen', + autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.', metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)', filenameTemplatesTitle: 'Dateinamen-Templates', vodTemplateLabel: 'VOD-Template', @@ -191,7 +193,8 @@ const UI_TEXT_DE = { bulkAdding: 'Fuege hinzu...', bulkClear: 'Loeschen', bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.', - bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).' + bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).', + alreadyDownloaded: 'Bereits heruntergeladen' }, clips: { dialogTitle: 'VOD zuschneiden', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index d39e0a6..b1d954d 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -56,6 +56,8 @@ const UI_TEXT_EN = { openDebugLogFile: 'Open log file', duplicatePreventionLabel: 'Prevent duplicate queue entries', persistQueueLabel: 'Keep queue between app restarts', + autoResumeQueueLabel: 'Auto-resume the queue on startup', + autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.', metadataCacheMinutesLabel: 'Metadata Cache (Minutes)', filenameTemplatesTitle: 'Filename Templates', vodTemplateLabel: 'VOD Template', @@ -191,7 +193,8 @@ const UI_TEXT_EN = { bulkAdding: 'Adding...', bulkClear: 'Clear', bulkAddedToQueue: 'Added {count} VODs to the queue.', - bulkAddSkipped: 'No VODs were added (already in queue or invalid).' + bulkAddSkipped: 'No VODs were added (already in queue or invalid).', + alreadyDownloaded: 'Already downloaded' }, clips: { dialogTitle: 'Trim VOD', diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 78360d3..5de39b1 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -332,6 +332,7 @@ function collectDownloadSettingsPayload(): Partial { smart_queue_scheduler: byId('smartSchedulerToggle').checked, prevent_duplicate_downloads: byId('duplicatePreventionToggle').checked, persist_queue_on_restart: byId('persistQueueToggle').checked, + auto_resume_queue_on_startup: byId('autoResumeQueueToggle').checked, metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10 }; } @@ -374,6 +375,7 @@ function getSettingsFingerprint(payload: Partial): string { effective.smart_queue_scheduler !== false, effective.prevent_duplicate_downloads !== false, effective.persist_queue_on_restart !== false, + effective.auto_resume_queue_on_startup === true, effective.metadata_cache_minutes ?? 10, effective.filename_template_vod ?? '{title}.mp4', effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4', @@ -391,6 +393,7 @@ function syncSettingsFormFromConfig(): void { byId('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; byId('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; byId('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false; + byId('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true; byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4'; @@ -501,7 +504,8 @@ function initSettingsAutoSave(): void { 'performanceMode', 'smartSchedulerToggle', 'duplicatePreventionToggle', - 'persistQueueToggle' + 'persistQueueToggle', + 'autoResumeQueueToggle' ] as const; const debouncedSaveIds = [ diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index 4112e77..aa37346 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -170,17 +170,22 @@ function focusVodFilter(): void { } } -function buildVodCardHtml(vod: VOD, streamer: string): string { +function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set): 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); const safeUrlAttr = escapeHtml(vod.url); const isChecked = selectedVodUrls.has(vod.url); + const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false; + const downloadedBadge = isAlreadyDownloaded + ? `
` + : ''; return ` -
+
+ ${downloadedBadge}
${safeDisplayTitle}
@@ -509,6 +514,14 @@ function renderVodGridFromCurrentState(): void { grid.replaceChildren(); updateVodFilterCount(filtered.length, total); + // Build the downloaded-ids lookup once per render — Set.has is O(1) vs + // Array.includes which would be O(n*m) across all cards. + const downloadedIds = new Set( + Array.isArray(config.downloaded_vod_ids) + ? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string') + : [] + ); + const scheduleNextChunk = (nextStartIndex: number): void => { const delayMs = document.hidden ? 16 : 0; window.setTimeout(() => { @@ -526,7 +539,7 @@ function renderVodGridFromCurrentState(): void { return; } - grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '')).join('')); + grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '', downloadedIds)).join('')); if (startIndex + chunk.length < filtered.length) { scheduleNextChunk(startIndex + chunk.length); diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index c5b31c4..3958974 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -114,6 +114,9 @@ function applyLanguageToStaticUI(): void { setTitle('smartSchedulerToggle', UI_TEXT.static.smartSchedulerHint); setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel); setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel); + setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel); + setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint); + setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint); setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel); setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle); setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel); diff --git a/src/renderer.ts b/src/renderer.ts index 242a7a6..2853998 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -63,8 +63,25 @@ async function init(): Promise { // Restore last active tab from previous session (default 'vods') showTab(loadPersistedActiveTab()); - window.api.onQueueUpdated((q: QueueItem[]) => { - queue = mergeQueueState(Array.isArray(q) ? q : []); + window.api.onQueueUpdated(async (q: QueueItem[]) => { + const previouslyCompleted = new Set(queue.filter((i) => i.status === 'completed').map((i) => i.id)); + const next = Array.isArray(q) ? q : []; + const newlyCompletedItem = next.some((i) => i.status === 'completed' && !previouslyCompleted.has(i.id)); + queue = mergeQueueState(next); + + // When an item flips to 'completed' the main process appends its + // VOD ID to config.downloaded_vod_ids. Refresh our local config + // copy so the "already downloaded" badge on the VOD grid updates + // live without waiting for a settings save. + if (newlyCompletedItem) { + try { + config = await window.api.getConfig(); + } catch { /* network blip — next sync will refresh */ } + if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } + } + renderQueue(); updateStatusBarQueueSummary(); markQueueActivity(); diff --git a/src/styles.css b/src/styles.css index 8d4b010..b8a4093 100644 --- a/src/styles.css +++ b/src/styles.css @@ -592,6 +592,29 @@ body { box-shadow: 0 0 0 2px #9146FF, 0 8px 25px rgba(145, 70, 255, 0.25); } +.vod-downloaded-badge { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 200, 83, 0.92); + color: white; + border-radius: 50%; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + z-index: 2; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.vod-card.already-downloaded .vod-thumbnail { + opacity: 0.6; +} + .streamer-item.dragging { opacity: 0.4; }