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; }