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) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 15:46:21 +02:00
parent 56d4e0904f
commit e2c0e3a2bf
10 changed files with 229 additions and 3 deletions

View File

@ -257,6 +257,10 @@
<option value="duration_asc">Shortest first</option> <option value="duration_asc">Shortest first</option>
</select> </select>
<span id="vodFilterCount" style="color: var(--text-secondary); font-size:12px; min-width:80px;"></span> <span id="vodFilterCount" style="color: var(--text-secondary); font-size:12px; min-width:80px;"></span>
<label id="vodHideDownloadedLabel" style="display:flex; align-items:center; gap:6px; color: var(--text-secondary); font-size:12px; cursor:pointer; user-select:none;" title="">
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()" style="accent-color: var(--accent); cursor:pointer;">
<span id="vodHideDownloadedText">Hide downloaded</span>
</label>
</div> </div>
<div id="vodBulkBar" class="vod-bulk-bar" style="display:none; align-items:center; gap:10px; padding:8px 12px; background: rgba(145, 70, 255, 0.12); border:1px solid rgba(145, 70, 255, 0.4); border-radius:6px; margin-bottom:12px;"> <div id="vodBulkBar" class="vod-bulk-bar" style="display:none; align-items:center; gap:10px; padding:8px 12px; background: rgba(145, 70, 255, 0.12); border:1px solid rgba(145, 70, 255, 0.4); border-radius:6px; margin-bottom:12px;">
<span id="vodBulkCount" style="color: var(--text); font-size:13px; font-weight:600;">0 selected</span> <span id="vodBulkCount" style="color: var(--text); font-size:13px; font-weight:600;">0 selected</span>
@ -558,6 +562,16 @@
<pre id="debugLogOutput" class="log-panel">Lade...</pre> <pre id="debugLogOutput" class="log-panel">Lade...</pre>
</div> </div>
<div class="settings-card">
<h3 id="backupCardTitle">Sicherung &amp; Wartung</h3>
<p id="backupCardIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
<div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;">
<button class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
<button class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
<button class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
</div>
</div>
<div class="settings-card"> <div class="settings-card">
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3> <h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
<div class="form-row" style="margin-bottom: 10px; align-items: center;"> <div class="form-row" style="margin-bottom: 10px; align-items: center;">

View File

@ -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 // Video Cutter IPC
ipcMain.handle('get-video-info', async (_, filePath: string) => { ipcMain.handle('get-video-info', async (_, filePath: string) => {
return await getVideoInfo(filePath); return await getVideoInfo(filePath);

View File

@ -109,6 +109,12 @@ contextBridge.exposeInMainWorld('api', {
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'), getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> => exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
ipcRenderer.invoke('export-runtime-metrics'), 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 // Events
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => { onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {

View File

@ -217,6 +217,9 @@ interface ApiBridge {
getDebugLog(lines: number): Promise<string>; getDebugLog(lines: number): Promise<string>;
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>; getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>; 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; onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
onQueueUpdated(callback: (queue: QueueItem[]) => void): void; onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void; onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;

View File

@ -54,6 +54,17 @@ const UI_TEXT_DE = {
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.', apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps', apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Log-Datei oeffnen', 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', duplicatePreventionLabel: 'Duplikate in Queue verhindern',
persistQueueLabel: 'Queue zwischen App-Starts speichern', persistQueueLabel: 'Queue zwischen App-Starts speichern',
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen', autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
@ -194,7 +205,9 @@ const UI_TEXT_DE = {
bulkClear: 'Loeschen', bulkClear: 'Loeschen',
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.', 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' alreadyDownloaded: 'Bereits heruntergeladen',
hideDownloaded: 'Bereits geladene ausblenden',
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind'
}, },
clips: { clips: {
dialogTitle: 'VOD zuschneiden', dialogTitle: 'VOD zuschneiden',

View File

@ -54,6 +54,17 @@ const UI_TEXT_EN = {
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.', apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
apiHelpLinkText: 'dev.twitch.tv/console/apps', apiHelpLinkText: 'dev.twitch.tv/console/apps',
openDebugLogFile: 'Open log file', 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', duplicatePreventionLabel: 'Prevent duplicate queue entries',
persistQueueLabel: 'Keep queue between app restarts', persistQueueLabel: 'Keep queue between app restarts',
autoResumeQueueLabel: 'Auto-resume the queue on startup', autoResumeQueueLabel: 'Auto-resume the queue on startup',
@ -194,7 +205,9 @@ const UI_TEXT_EN = {
bulkClear: 'Clear', bulkClear: 'Clear',
bulkAddedToQueue: 'Added {count} VODs to the queue.', 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' alreadyDownloaded: 'Already downloaded',
hideDownloaded: 'Hide downloaded',
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded'
}, },
clips: { clips: {
dialogTitle: 'Trim VOD', dialogTitle: 'Trim VOD',

View File

@ -265,6 +265,60 @@ async function runPreflight(autoFix = false): Promise<void> {
} }
} }
async function exportConfigToFile(): Promise<void> {
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<void> {
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<void> {
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<void> { async function openDebugLogFile(): Promise<void> {
const ok = await window.api.openDebugLogFile(); const ok = await window.api.openDebugLogFile();
if (!ok) { if (!ok) {

View File

@ -16,6 +16,32 @@ const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
const selectedVodUrls = new Set<string>(); const selectedVodUrls = new Set<string>();
let vodGridDelegationInitialized = false; 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<HTMLInputElement>('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'; type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc';
const VALID_VOD_SORTS: ReadonlyArray<VodSortKey> = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc']; const VALID_VOD_SORTS: ReadonlyArray<VodSortKey> = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc'];
const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort'; const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort';
@ -503,7 +529,15 @@ function renderVodGridFromCurrentState(): void {
} }
const sorted = sortVods(lastLoadedVods, vodSortKey); 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()) { if (filtered.length === 0 && vodFilterQuery.trim()) {
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText); setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);

View File

@ -152,6 +152,13 @@ function applyLanguageToStaticUI(): void {
setText('debugLogTitle', UI_TEXT.static.debugLogTitle); setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
setText('btnRefreshLog', UI_TEXT.static.refreshLog); setText('btnRefreshLog', UI_TEXT.static.refreshLog);
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile); 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('autoRefreshText', UI_TEXT.static.autoRefresh);
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle); setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh); setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);

View File

@ -60,6 +60,10 @@ async function init(): Promise<void> {
refreshVodSortSelectLabels(); refreshVodSortSelectLabels();
syncVodSortSelect(); syncVodSortSelect();
// Restore "hide downloaded" toggle state.
vodHideDownloaded = loadPersistedHideDownloaded();
syncVodHideDownloadedToggle();
// Restore last active tab from previous session (default 'vods') // Restore last active tab from previous session (default 'vods')
showTab(loadPersistedActiveTab()); showTab(loadPersistedActiveTab());