release: 4.2.2 add full settings autosave

This commit is contained in:
xRangerDE 2026-03-06 02:05:23 +01:00
parent 8d52df23c7
commit 1005b583bd
5 changed files with 441 additions and 34 deletions

4
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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<void> {
const clientId = byId<HTMLInputElement>('clientId').value.trim();
const clientSecret = byId<HTMLInputElement>('clientSecret').value.trim();
const downloadPath = byId<HTMLInputElement>('downloadPath').value;
const downloadMode = byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full';
const partMinutes = parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120;
const performanceMode = byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed';
const smartQueueScheduler = byId<HTMLInputElement>('smartSchedulerToggle').checked;
const duplicatePrevention = byId<HTMLInputElement>('duplicatePreventionToggle').checked;
const metadataCacheMinutes = parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10;
const vodFilenameTemplate = byId<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4';
const partsFilenameTemplate = byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4';
const defaultClipFilenameTemplate = byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4';
function collectCredentialsPayload(): Partial<AppConfig> {
return {
client_id: byId<HTMLInputElement>('clientId').value.trim(),
client_secret: byId<HTMLInputElement>('clientSecret').value.trim()
};
}
if (!validateFilenameTemplates(true)) {
return;
function collectDownloadSettingsPayload(): Partial<AppConfig> {
return {
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
};
}
function collectFilenameTemplatePayload(showAlert = false): Partial<AppConfig> | 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<HTMLInputElement>('vodFilenameTemplate').value.trim() || '{title}.mp4',
filename_template_parts: byId<HTMLInputElement>('partsFilenameTemplate').value.trim() || '{date}_Part{part_padded}.mp4',
filename_template_clip: byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim() || '{date}_{part}.mp4'
};
}
function collectAutoSavePayload(): Partial<AppConfig> {
const payload: Partial<AppConfig> = {
...collectCredentialsPayload(),
...collectDownloadSettingsPayload()
};
const templatePayload = collectFilenameTemplatePayload(false);
if (templatePayload) {
Object.assign(payload, templatePayload);
}
return payload;
}
function getSettingsFingerprint(payload: Partial<AppConfig>): 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<HTMLInputElement>('clientId').value = config.client_id ?? '';
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
@ -328,9 +367,180 @@ async function saveSettings(): Promise<void> {
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
validateFilenameTemplates();
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
}
async function persistSettings(options: {
includeCredentials?: boolean;
includeTemplates?: boolean;
reconnectAfterSave?: boolean;
showTemplateAlert?: boolean;
} = {}): Promise<boolean> {
const payload: Partial<AppConfig> = {
...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();
await refreshRuntimeMetrics();
}
if (canRunSettingsAutoRefresh()) {
await refreshRuntimeMetrics(false);
}
return true;
}
async function flushSettingsAutoSave(reconnectAfterSave = false): Promise<void> {
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<HTMLInputElement | HTMLSelectElement>(id);
element.addEventListener('change', triggerImmediateSave);
element.addEventListener('blur', triggerImmediateSave);
}
for (const id of debouncedSaveIds) {
const element = byId<HTMLInputElement>(id);
element.addEventListener('input', () => {
scheduleSettingsAutoSave();
});
element.addEventListener('blur', () => {
void flushSettingsAutoSave(false);
});
}
for (const id of credentialIds) {
const element = byId<HTMLInputElement>(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<void> {
const saved = await persistSettings({
includeCredentials: true,
includeTemplates: true,
reconnectAfterSave: true,
showTemplateAlert: true
});
if (!saved) {
return;
}
}
async function selectFolder(): Promise<void> {

View File

@ -33,6 +33,7 @@ async function init(): Promise<void> {
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || DEFAULT_VOD_TEMPLATE;
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || DEFAULT_PARTS_TEMPLATE;
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || DEFAULT_CLIP_TEMPLATE;
initSettingsAutoSave();
changeTheme(config.theme ?? 'twitch');
renderStreamers();