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 @@
+
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());