From 1005b583bd86dd15d7a23d5868e9e402ed59c4c1 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Fri, 6 Mar 2026 02:05:23 +0100 Subject: [PATCH] release: 4.2.2 add full settings autosave --- package-lock.json | 4 +- package.json | 2 +- scripts/smoke-test-settings-autosave.js | 196 +++++++++++++++++ src/renderer-settings.ts | 272 +++++++++++++++++++++--- src/renderer.ts | 1 + 5 files changed, 441 insertions(+), 34 deletions(-) create mode 100644 scripts/smoke-test-settings-autosave.js diff --git a/package-lock.json b/package-lock.json index 2648ab1..f15c5e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twitch-vod-manager", - "version": "4.2.1", + "version": "4.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch-vod-manager", - "version": "4.2.1", + "version": "4.2.2", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 368a0d8..9f9e2fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch-vod-manager", - "version": "4.2.1", + "version": "4.2.2", "description": "Twitch VOD Manager - Download Twitch VODs easily", "main": "dist/main.js", "author": "xRangerDE", diff --git a/scripts/smoke-test-settings-autosave.js b/scripts/smoke-test-settings-autosave.js new file mode 100644 index 0000000..d11baa9 --- /dev/null +++ b/scripts/smoke-test-settings-autosave.js @@ -0,0 +1,196 @@ +const { _electron: electron } = require('playwright'); +const path = require('path'); +const fs = require('fs'); + +const APPDATA_DIR = path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Twitch_VOD_Manager'); +const CONFIG_FILE = path.join(APPDATA_DIR, 'config.json'); + +const DEFAULT_CONFIG = { + client_id: '', + client_secret: '', + download_path: path.join(process.env.USERPROFILE || 'C:\\Users\\ploet', 'Desktop', 'Twitch_VODs'), + streamers: [], + theme: 'twitch', + download_mode: 'full', + part_minutes: 120, + language: 'en', + filename_template_vod: '{title}.mp4', + filename_template_parts: '{date}_Part{part_padded}.mp4', + filename_template_clip: '{date}_{part}.mp4', + smart_queue_scheduler: true, + performance_mode: 'balanced', + prevent_duplicate_downloads: true, + metadata_cache_minutes: 10 +}; + +function backupFile(filePath) { + if (!fs.existsSync(filePath)) return null; + return fs.readFileSync(filePath); +} + +function restoreFile(filePath, backup) { + if (backup === null) { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } + return; + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, backup); +} + +function writeConfig(config) { + fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +function readConfig() { + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); +} + +async function launchApp() { + const electronPath = require('electron'); + return electron.launch({ + executablePath: electronPath, + args: ['.'], + cwd: process.cwd() + }); +} + +async function setSettingsAndBlur(win, mode, partMinutes) { + await win.evaluate(async ({ mode, partMinutes }) => { + window.showTab('settings'); + const modeField = document.getElementById('downloadMode'); + const partField = document.getElementById('partMinutes'); + + modeField.value = mode; + modeField.dispatchEvent(new Event('change', { bubbles: true })); + + partField.focus(); + partField.value = String(partMinutes); + partField.dispatchEvent(new Event('input', { bubbles: true })); + partField.blur(); + + await new Promise((resolve) => setTimeout(resolve, 250)); + }, { mode, partMinutes }); +} + +async function setSettingsAndCloseImmediately(win, mode, partMinutes) { + await win.evaluate(({ mode, partMinutes }) => { + window.showTab('settings'); + const modeField = document.getElementById('downloadMode'); + const partField = document.getElementById('partMinutes'); + + modeField.value = mode; + modeField.dispatchEvent(new Event('change', { bubbles: true })); + + partField.focus(); + partField.value = String(partMinutes); + partField.dispatchEvent(new Event('input', { bubbles: true })); + }, { mode, partMinutes }); +} + +async function readSettingsFromUi(win) { + return win.evaluate(() => { + window.showTab('settings'); + return { + downloadMode: document.getElementById('downloadMode')?.value || '', + partMinutes: document.getElementById('partMinutes')?.value || '' + }; + }); +} + +async function run() { + const configBackup = backupFile(CONFIG_FILE); + const baseConfig = configBackup ? { ...DEFAULT_CONFIG, ...JSON.parse(String(configBackup)) } : { ...DEFAULT_CONFIG }; + + let app = null; + try { + writeConfig({ + ...baseConfig, + client_id: '', + client_secret: '', + download_mode: 'full', + part_minutes: 120 + }); + + app = await launchApp(); + let win = await app.firstWindow(); + await win.waitForTimeout(2200); + await setSettingsAndBlur(win, 'parts', 60); + await app.close(); + app = null; + + const afterBlurClose = readConfig(); + + app = await launchApp(); + win = await app.firstWindow(); + await win.waitForTimeout(2200); + const reopenedAfterBlur = await readSettingsFromUi(win); + await app.close(); + app = null; + + writeConfig({ + ...baseConfig, + client_id: '', + client_secret: '', + download_mode: 'full', + part_minutes: 120 + }); + + app = await launchApp(); + win = await app.firstWindow(); + await win.waitForTimeout(2200); + await setSettingsAndCloseImmediately(win, 'parts', 75); + await app.close(); + app = null; + + const afterDirectClose = readConfig(); + + const result = { + afterBlurClose: { + config: { + download_mode: afterBlurClose.download_mode, + part_minutes: afterBlurClose.part_minutes + }, + ui: reopenedAfterBlur + }, + afterDirectClose: { + config: { + download_mode: afterDirectClose.download_mode, + part_minutes: afterDirectClose.part_minutes + } + } + }; + + console.log(JSON.stringify(result, null, 2)); + + const blurCaseOk = + afterBlurClose.download_mode === 'parts' && + afterBlurClose.part_minutes === 60 && + reopenedAfterBlur.downloadMode === 'parts' && + reopenedAfterBlur.partMinutes === '60'; + + const directCloseOk = + afterDirectClose.download_mode === 'parts' && + afterDirectClose.part_minutes === 75; + + process.exit(blurCaseOk && directCloseOk ? 0 : 1); + } finally { + if (app) { + try { + await app.close(); + } catch { + // ignore + } + } + + restoreFile(CONFIG_FILE, configBackup); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index cebc059..f7a2b73 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -1,5 +1,11 @@ let lastRuntimeMetricsOutput = ''; let lastDebugLogOutput = ''; +let settingsAutoSaveBound = false; +let settingsAutoSaveInFlight = false; +let pendingSettingsAutoSave = false; +let settingsAutoSaveTimer: number | null = null; +let pendingCredentialsReconnect = false; +let lastPersistedSettingsFingerprint = ''; function canRunSettingsAutoRefresh(): boolean { if (document.hidden) { @@ -287,39 +293,72 @@ function toggleDebugAutoRefresh(enabled: boolean): void { } } -async function saveSettings(): Promise { - const clientId = byId('clientId').value.trim(); - const clientSecret = byId('clientSecret').value.trim(); - const downloadPath = byId('downloadPath').value; - const downloadMode = byId('downloadMode').value as 'parts' | 'full'; - const partMinutes = parseInt(byId('partMinutes').value, 10) || 120; - const performanceMode = byId('performanceMode').value as 'stability' | 'balanced' | 'speed'; - const smartQueueScheduler = byId('smartSchedulerToggle').checked; - const duplicatePrevention = byId('duplicatePreventionToggle').checked; - const metadataCacheMinutes = parseInt(byId('metadataCacheMinutes').value, 10) || 10; - const vodFilenameTemplate = byId('vodFilenameTemplate').value.trim() || '{title}.mp4'; - const partsFilenameTemplate = byId('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4'; - const defaultClipFilenameTemplate = byId('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4'; +function collectCredentialsPayload(): Partial { + return { + client_id: byId('clientId').value.trim(), + client_secret: byId('clientSecret').value.trim() + }; +} - if (!validateFilenameTemplates(true)) { - return; +function collectDownloadSettingsPayload(): Partial { + return { + download_mode: byId('downloadMode').value as 'parts' | 'full', + part_minutes: parseInt(byId('partMinutes').value, 10) || 120, + performance_mode: byId('performanceMode').value as 'stability' | 'balanced' | 'speed', + smart_queue_scheduler: byId('smartSchedulerToggle').checked, + prevent_duplicate_downloads: byId('duplicatePreventionToggle').checked, + metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10 + }; +} + +function collectFilenameTemplatePayload(showAlert = false): Partial | null { + if (!validateFilenameTemplates(showAlert)) { + return null; } - config = await window.api.saveConfig({ - client_id: clientId, - client_secret: clientSecret, - download_path: downloadPath, - download_mode: downloadMode, - part_minutes: partMinutes, - performance_mode: performanceMode, - smart_queue_scheduler: smartQueueScheduler, - prevent_duplicate_downloads: duplicatePrevention, - metadata_cache_minutes: metadataCacheMinutes, - filename_template_vod: vodFilenameTemplate, - filename_template_parts: partsFilenameTemplate, - filename_template_clip: defaultClipFilenameTemplate - }); + return { + filename_template_vod: byId('vodFilenameTemplate').value.trim() || '{title}.mp4', + filename_template_parts: byId('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4', + filename_template_clip: byId('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4' + }; +} +function collectAutoSavePayload(): Partial { + const payload: Partial = { + ...collectCredentialsPayload(), + ...collectDownloadSettingsPayload() + }; + + const templatePayload = collectFilenameTemplatePayload(false); + if (templatePayload) { + Object.assign(payload, templatePayload); + } + + return payload; +} + +function getSettingsFingerprint(payload: Partial): string { + const effective = { ...config, ...payload }; + return JSON.stringify([ + effective.client_id ?? '', + effective.client_secret ?? '', + effective.download_mode ?? 'full', + effective.part_minutes ?? 120, + effective.performance_mode ?? 'balanced', + effective.smart_queue_scheduler !== false, + effective.prevent_duplicate_downloads !== false, + effective.metadata_cache_minutes ?? 10, + effective.filename_template_vod ?? '{title}.mp4', + effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4', + effective.filename_template_clip ?? '{date}_{part}.mp4' + ]); +} + +function syncSettingsFormFromConfig(): void { + byId('clientId').value = config.client_id ?? ''; + byId('clientSecret').value = config.client_secret ?? ''; + byId('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full'; + byId('partMinutes').value = String((config.part_minutes as number) || 120); byId('performanceMode').value = (config.performance_mode as string) || 'balanced'; byId('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false; byId('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false; @@ -328,9 +367,180 @@ async function saveSettings(): Promise { byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4'; byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4'; validateFilenameTemplates(); + lastPersistedSettingsFingerprint = getSettingsFingerprint({}); +} - await connect(); - await refreshRuntimeMetrics(); +async function persistSettings(options: { + includeCredentials?: boolean; + includeTemplates?: boolean; + reconnectAfterSave?: boolean; + showTemplateAlert?: boolean; +} = {}): Promise { + const payload: Partial = { + ...collectDownloadSettingsPayload() + }; + + if (options.includeCredentials) { + Object.assign(payload, collectCredentialsPayload()); + } + + if (options.includeTemplates !== false) { + const templatePayload = collectFilenameTemplatePayload(options.showTemplateAlert); + if (!templatePayload) { + return false; + } + Object.assign(payload, templatePayload); + } + + config = await window.api.saveConfig(payload); + syncSettingsFormFromConfig(); + pendingCredentialsReconnect = false; + + if (options.reconnectAfterSave) { + await connect(); + } + + if (canRunSettingsAutoRefresh()) { + await refreshRuntimeMetrics(false); + } + + return true; +} + +async function flushSettingsAutoSave(reconnectAfterSave = false): Promise { + if (settingsAutoSaveTimer) { + clearTimeout(settingsAutoSaveTimer); + settingsAutoSaveTimer = null; + } + + const payload = collectAutoSavePayload(); + const fingerprint = getSettingsFingerprint(payload); + + if (fingerprint === lastPersistedSettingsFingerprint) { + if (reconnectAfterSave && pendingCredentialsReconnect) { + pendingCredentialsReconnect = false; + await connect(); + } + return; + } + + if (settingsAutoSaveInFlight) { + pendingSettingsAutoSave = true; + return; + } + + settingsAutoSaveInFlight = true; + try { + config = await window.api.saveConfig(payload); + lastPersistedSettingsFingerprint = getSettingsFingerprint({}); + if (reconnectAfterSave && pendingCredentialsReconnect) { + pendingCredentialsReconnect = false; + await connect(); + } + } finally { + settingsAutoSaveInFlight = false; + if (pendingSettingsAutoSave) { + pendingSettingsAutoSave = false; + void flushSettingsAutoSave(pendingCredentialsReconnect); + } + } +} + +function scheduleSettingsAutoSave(delayMs = 450): void { + if (settingsAutoSaveTimer) { + clearTimeout(settingsAutoSaveTimer); + } + + settingsAutoSaveTimer = window.setTimeout(() => { + settingsAutoSaveTimer = null; + void flushSettingsAutoSave(false); + }, delayMs); +} + +function initSettingsAutoSave(): void { + if (settingsAutoSaveBound) { + return; + } + + settingsAutoSaveBound = true; + syncSettingsFormFromConfig(); + + const immediateSaveIds = [ + 'downloadMode', + 'performanceMode', + 'smartSchedulerToggle', + 'duplicatePreventionToggle' + ] as const; + + const debouncedSaveIds = [ + 'partMinutes', + 'metadataCacheMinutes', + 'vodFilenameTemplate', + 'partsFilenameTemplate', + 'defaultClipFilenameTemplate' + ] as const; + + const credentialIds = [ + 'clientId', + 'clientSecret' + ] as const; + + const triggerImmediateSave = () => { + void flushSettingsAutoSave(false); + }; + + for (const id of immediateSaveIds) { + const element = byId(id); + element.addEventListener('change', triggerImmediateSave); + element.addEventListener('blur', triggerImmediateSave); + } + + for (const id of debouncedSaveIds) { + const element = byId(id); + element.addEventListener('input', () => { + scheduleSettingsAutoSave(); + }); + element.addEventListener('blur', () => { + void flushSettingsAutoSave(false); + }); + } + + for (const id of credentialIds) { + const element = byId(id); + element.addEventListener('input', () => { + pendingCredentialsReconnect = true; + scheduleSettingsAutoSave(); + }); + element.addEventListener('blur', () => { + pendingCredentialsReconnect = true; + void flushSettingsAutoSave(true); + }); + } + + window.addEventListener('blur', () => { + if (settingsAutoSaveTimer || pendingCredentialsReconnect) { + void flushSettingsAutoSave(pendingCredentialsReconnect); + } + }); + + document.addEventListener('visibilitychange', () => { + if (document.hidden && (settingsAutoSaveTimer || pendingCredentialsReconnect)) { + void flushSettingsAutoSave(pendingCredentialsReconnect); + } + }); +} + +async function saveSettings(): Promise { + const saved = await persistSettings({ + includeCredentials: true, + includeTemplates: true, + reconnectAfterSave: true, + showTemplateAlert: true + }); + + if (!saved) { + return; + } } async function selectFolder(): Promise { diff --git a/src/renderer.ts b/src/renderer.ts index 9abd095..0721c2a 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -33,6 +33,7 @@ async function init(): Promise { byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE; byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE; byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE; + initSettingsAutoSave(); changeTheme(config.theme ?? 'twitch'); renderStreamers();