From e2c0e3a2bfdd4c91629556f131b4e50774a3dba4 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 10 May 2026 15:46:21 +0200 Subject: [PATCH] feat: hide-downloaded filter + reset list + config export/import Three companion features around the 4.5.22 already-downloaded badge. 1. "Hide downloaded" toggle in the VOD filter row. Persisted to localStorage so power users who keep it on across sessions don't re-flip it on every launch. Filter applies before the title-search filter so the match counter stays consistent. 2. "Reset downloaded list" button in a new Backup & Maintenance settings card. Confirm-dialog before clearing, IPC returns the removed count for a "cleared N entries" toast. Renderer refreshes its config copy + re-renders the VOD grid so badges disappear immediately. No files are touched. 3. Config export / import via dialog.show*Dialog. Export strips client_secret (should never travel as plain text via cloud sync), tags the file with __exportVersion + __exportedAt. Import runs the JSON through normalizeConfigTemplates so out-of-range fields fall back to defaults; if the imported file lacks client_secret, the existing value is preserved. After import the renderer reloads config + relocalizes if language changed + re-renders streamers / settings form / VOD grid. DE + EN locale strings for every label, button, toast, and confirm dialog. New backupCardTitle / backupCardIntro section header in Settings. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.html | 14 +++++++ src/main.ts | 78 +++++++++++++++++++++++++++++++++++++++ src/preload.ts | 6 +++ src/renderer-globals.d.ts | 3 ++ src/renderer-locale-de.ts | 15 +++++++- src/renderer-locale-en.ts | 15 +++++++- src/renderer-settings.ts | 54 +++++++++++++++++++++++++++ src/renderer-streamers.ts | 36 +++++++++++++++++- src/renderer-texts.ts | 7 ++++ src/renderer.ts | 4 ++ 10 files changed, 229 insertions(+), 3 deletions(-) diff --git a/src/index.html b/src/index.html index f1c85b7..56e0bc7 100644 --- a/src/index.html +++ b/src/index.html @@ -257,6 +257,10 @@ + +
+

Sicherung & Wartung

+

Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.

+
+ + + +
+
+

Runtime Metrics

diff --git a/src/main.ts b/src/main.ts index 15cadd6..4772ab8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4276,6 +4276,84 @@ ipcMain.handle('export-runtime-metrics', async () => { } }); +ipcMain.handle('reset-downloaded-vod-ids', () => { + const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0; + config.downloaded_vod_ids = []; + saveConfig(config); + appendDebugLog('reset-downloaded-vod-ids', { previousCount: count }); + return { success: true, removedCount: count }; +}); + +ipcMain.handle('export-config', async () => { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const defaultName = `twitch-vod-manager-config-${timestamp}.json`; + const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop'); + + const dialogResult = await dialog.showSaveDialog(mainWindow!, { + defaultPath: path.join(preferredDir, defaultName), + filters: [{ name: 'JSON', extensions: ['json'] }] + }); + + if (dialogResult.canceled || !dialogResult.filePath) { + return { success: false, cancelled: true }; + } + + // Strip the secrets from the export — Client Secret should not + // travel as plain text across machines / cloud sync. The user + // re-enters it on the new machine after import. + const exportable = { + ...config, + client_secret: '', + __exportVersion: 1, + __exportedAt: new Date().toISOString() + }; + writeFileAtomicSync(dialogResult.filePath, JSON.stringify(exportable, null, 2)); + return { success: true, filePath: dialogResult.filePath }; + } catch (e) { + appendDebugLog('config-export-failed', String(e)); + return { success: false, error: String(e) }; + } +}); + +ipcMain.handle('import-config', async () => { + try { + const dialogResult = await dialog.showOpenDialog(mainWindow!, { + properties: ['openFile'], + filters: [{ name: 'JSON', extensions: ['json'] }] + }); + if (dialogResult.canceled || !dialogResult.filePaths[0]) { + return { success: false, cancelled: true }; + } + + const importPath = dialogResult.filePaths[0]; + const raw = fs.readFileSync(importPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed)) { + return { success: false, error: 'Imported file is not a JSON object.' }; + } + + // Merge over current config so unknown / missing keys keep their + // existing values. Then run normalizeConfigTemplates so any + // out-of-range field falls back to defaults. + const merged = normalizeConfigTemplates({ ...config, ...parsed } as Config); + + // Preserve the existing client_secret if the import stripped it + // (export does this on purpose) — the user shouldn't lose creds. + if (!merged.client_secret && config.client_secret) { + merged.client_secret = config.client_secret; + } + + config = merged; + saveConfig(config); + appendDebugLog('config-import-applied', { source: importPath }); + return { success: true, filePath: importPath }; + } catch (e) { + appendDebugLog('config-import-failed', String(e)); + return { success: false, error: String(e) }; + } +}); + // Video Cutter IPC ipcMain.handle('get-video-info', async (_, filePath: string) => { return await getVideoInfo(filePath); diff --git a/src/preload.ts b/src/preload.ts index d921e82..95400d4 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -109,6 +109,12 @@ contextBridge.exposeInMainWorld('api', { getRuntimeMetrics: (): Promise => ipcRenderer.invoke('get-runtime-metrics'), exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => ipcRenderer.invoke('export-runtime-metrics'), + resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> => + ipcRenderer.invoke('reset-downloaded-vod-ids'), + exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => + ipcRenderer.invoke('export-config'), + importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => + ipcRenderer.invoke('import-config'), // Events onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index ef2655b..7afe690 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -217,6 +217,9 @@ interface ApiBridge { getDebugLog(lines: number): Promise; getRuntimeMetrics(): Promise; exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; + resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>; + 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; onQueueUpdated(callback: (queue: QueueItem[]) => void): void; onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void; diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 4d4e4fb..bb72e82 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -54,6 +54,17 @@ const UI_TEXT_DE = { apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.', apiHelpLinkText: 'dev.twitch.tv/console/apps', openDebugLogFile: 'Log-Datei oeffnen', + backupCardTitle: 'Sicherung & Wartung', + backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.', + exportConfig: 'Konfiguration exportieren', + importConfig: 'Konfiguration importieren', + resetDownloadedIds: 'Downloaded-VODs zuruecksetzen', + configExported: 'Konfiguration exportiert.', + configExportFailed: 'Export der Konfiguration fehlgeschlagen.', + configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.', + configImportFailed: 'Import der Konfiguration fehlgeschlagen.', + resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zuruecksetzen? Karten verlieren das gruene Haekchen, es werden aber keine Dateien geloescht.', + resetDownloadedDone: '{count} Eintraege aus der Downloaded-Liste entfernt.', duplicatePreventionLabel: 'Duplikate in Queue verhindern', persistQueueLabel: 'Queue zwischen App-Starts speichern', autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen', @@ -194,7 +205,9 @@ const UI_TEXT_DE = { bulkClear: 'Loeschen', bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.', bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).', - alreadyDownloaded: 'Bereits heruntergeladen' + alreadyDownloaded: 'Bereits heruntergeladen', + hideDownloaded: 'Bereits geladene ausblenden', + hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind' }, clips: { dialogTitle: 'VOD zuschneiden', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index b1d954d..4235437 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -54,6 +54,17 @@ const UI_TEXT_EN = { apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.', apiHelpLinkText: 'dev.twitch.tv/console/apps', openDebugLogFile: 'Open log file', + backupCardTitle: 'Backup & Maintenance', + backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.', + exportConfig: 'Export config', + importConfig: 'Import config', + resetDownloadedIds: 'Reset downloaded list', + configExported: 'Configuration exported.', + configExportFailed: 'Configuration export failed.', + configImported: 'Configuration imported. Some changes may need a restart.', + configImportFailed: 'Configuration import failed.', + resetDownloadedConfirm: 'Reset the downloaded-VODs list? Cards will lose the green check mark, but no files are deleted.', + resetDownloadedDone: 'Cleared {count} entries from the downloaded list.', duplicatePreventionLabel: 'Prevent duplicate queue entries', persistQueueLabel: 'Keep queue between app restarts', autoResumeQueueLabel: 'Auto-resume the queue on startup', @@ -194,7 +205,9 @@ const UI_TEXT_EN = { bulkClear: 'Clear', bulkAddedToQueue: 'Added {count} VODs to the queue.', bulkAddSkipped: 'No VODs were added (already in queue or invalid).', - alreadyDownloaded: 'Already downloaded' + alreadyDownloaded: 'Already downloaded', + hideDownloaded: 'Hide downloaded', + hideDownloadedTitle: 'Hide VODs that are marked as already downloaded' }, clips: { dialogTitle: 'Trim VOD', diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 5de39b1..286da45 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -265,6 +265,60 @@ async function runPreflight(autoFix = false): Promise { } } +async function exportConfigToFile(): Promise { + const result = await window.api.exportConfig(); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (result.success) { + if (toast) toast(UI_TEXT.static.configExported, 'info'); + } else if (result.cancelled) { + // User cancelled the dialog — no toast needed. + } else if (toast) { + toast(UI_TEXT.static.configExportFailed + (result.error ? `\n${result.error}` : ''), 'warn'); + } +} + +async function importConfigFromFile(): Promise { + const result = await window.api.importConfig(); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (result.success) { + // Reload local config copy + refresh forms / streamer list / VOD grid + try { + config = await window.api.getConfig(); + if (typeof setLanguage === 'function' && typeof config.language === 'string') { + setLanguage(config.language); + } + if (typeof renderStreamers === 'function') renderStreamers(); + if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig(); + if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } + } catch { /* ignore — next refresh will catch up */ } + if (toast) toast(UI_TEXT.static.configImported, 'info'); + } else if (result.cancelled) { + // User cancelled the dialog — no toast needed. + } else if (toast) { + toast(UI_TEXT.static.configImportFailed + (result.error ? `\n${result.error}` : ''), 'warn'); + } +} + +async function resetDownloadedIds(): Promise { + if (!confirm(UI_TEXT.static.resetDownloadedConfirm)) return; + const result = await window.api.resetDownloadedVodIds(); + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (result.success) { + // Refresh local config so the badges disappear immediately + try { + config = await window.api.getConfig(); + if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } + } catch { /* ignore */ } + if (toast) { + toast(UI_TEXT.static.resetDownloadedDone.replace('{count}', String(result.removedCount)), 'info'); + } + } +} + async function openDebugLogFile(): Promise { const ok = await window.api.openDebugLogFile(); if (!ok) { diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index aa37346..ac0e75f 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -16,6 +16,32 @@ const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter'; const selectedVodUrls = new Set(); let vodGridDelegationInitialized = false; +// Hide-downloaded toggle: when enabled, the VOD grid skips entries whose +// vod.id is in config.downloaded_vod_ids. Persisted to localStorage so a +// power user who keeps it enabled doesn't have to re-flip it every launch. +const VOD_HIDE_DOWNLOADED_STORAGE_KEY = 'twitch-vod-manager:vod-hide-downloaded'; +let vodHideDownloaded = false; + +function loadPersistedHideDownloaded(): boolean { + try { return localStorage.getItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY) === '1'; } catch { return false; } +} + +function persistHideDownloaded(value: boolean): void { + try { localStorage.setItem(VOD_HIDE_DOWNLOADED_STORAGE_KEY, value ? '1' : '0'); } catch { /* ignore */ } +} + +function onVodHideDownloadedChange(): void { + const cb = byId('vodHideDownloadedToggle'); + vodHideDownloaded = cb.checked; + persistHideDownloaded(vodHideDownloaded); + if (lastLoadedStreamer) renderVodGridFromCurrentState(); +} + +function syncVodHideDownloadedToggle(): void { + const cb = document.getElementById('vodHideDownloadedToggle') as HTMLInputElement | null; + if (cb) cb.checked = vodHideDownloaded; +} + type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc'; const VALID_VOD_SORTS: ReadonlyArray = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc']; const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort'; @@ -503,7 +529,15 @@ function renderVodGridFromCurrentState(): void { } const sorted = sortVods(lastLoadedVods, vodSortKey); - const filtered = filterVodsByQuery(sorted, vodFilterQuery); + const downloadedIdsForFilter = new Set( + Array.isArray(config.downloaded_vod_ids) + ? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string') + : [] + ); + const sortedAndHidden = vodHideDownloaded + ? sorted.filter((vod) => !downloadedIdsForFilter.has(vod.id)) + : sorted; + const filtered = filterVodsByQuery(sortedAndHidden, vodFilterQuery); if (filtered.length === 0 && vodFilterQuery.trim()) { setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText); diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 3958974..5d50db8 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -152,6 +152,13 @@ function applyLanguageToStaticUI(): void { setText('debugLogTitle', UI_TEXT.static.debugLogTitle); setText('btnRefreshLog', UI_TEXT.static.refreshLog); setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile); + setText('backupCardTitle', UI_TEXT.static.backupCardTitle); + setText('backupCardIntro', UI_TEXT.static.backupCardIntro); + setText('btnExportConfig', UI_TEXT.static.exportConfig); + setText('btnImportConfig', UI_TEXT.static.importConfig); + setText('btnResetDownloadedIds', UI_TEXT.static.resetDownloadedIds); + setText('vodHideDownloadedText', UI_TEXT.vods.hideDownloaded); + setTitle('vodHideDownloadedLabel', UI_TEXT.vods.hideDownloadedTitle); setText('autoRefreshText', UI_TEXT.static.autoRefresh); setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle); setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh); diff --git a/src/renderer.ts b/src/renderer.ts index 2853998..59cba9a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -60,6 +60,10 @@ async function init(): Promise { refreshVodSortSelectLabels(); syncVodSortSelect(); + // Restore "hide downloaded" toggle state. + vodHideDownloaded = loadPersistedHideDownloaded(); + syncVodHideDownloadedToggle(); + // Restore last active tab from previous session (default 'vods') showTab(loadPersistedActiveTab());