release: 4.2.2 add full settings autosave
This commit is contained in:
parent
8d52df23c7
commit
1005b583bd
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
196
scripts/smoke-test-settings-autosave.js
Normal file
196
scripts/smoke-test-settings-autosave.js
Normal 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);
|
||||
});
|
||||
@ -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({});
|
||||
}
|
||||
|
||||
await connect();
|
||||
await refreshRuntimeMetrics();
|
||||
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();
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user