686 lines
26 KiB
TypeScript
686 lines
26 KiB
TypeScript
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) {
|
|
return false;
|
|
}
|
|
|
|
return document.querySelector('.tab-content.active')?.id === 'settingsTab';
|
|
}
|
|
|
|
async function connect(): Promise<void> {
|
|
const hasCredentials = Boolean((config.client_id ?? '').toString().trim() && (config.client_secret ?? '').toString().trim());
|
|
if (!hasCredentials) {
|
|
isConnected = false;
|
|
updateStatus(UI_TEXT.status.noLogin, false);
|
|
return;
|
|
}
|
|
|
|
updateStatus(UI_TEXT.status.connecting, false);
|
|
const success = await window.api.login();
|
|
isConnected = success;
|
|
updateStatus(success ? UI_TEXT.status.connected : UI_TEXT.status.connectFailedPublic, success);
|
|
}
|
|
|
|
function formatBytesForMetrics(bytes: number): string {
|
|
const value = Math.max(0, Number(bytes) || 0);
|
|
if (value < 1024) return `${value.toFixed(0)} B`;
|
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
|
|
function validateFilenameTemplates(showAlert = false): boolean {
|
|
const templates = [
|
|
byId<HTMLInputElement>('vodFilenameTemplate').value.trim(),
|
|
byId<HTMLInputElement>('partsFilenameTemplate').value.trim(),
|
|
byId<HTMLInputElement>('defaultClipFilenameTemplate').value.trim()
|
|
];
|
|
|
|
const unknown = templates.flatMap((template) => collectUnknownTemplatePlaceholders(template));
|
|
const uniqueUnknown = Array.from(new Set(unknown));
|
|
const lintNode = byId('filenameTemplateLint');
|
|
|
|
if (!uniqueUnknown.length) {
|
|
lintNode.style.color = '#8bc34a';
|
|
lintNode.textContent = UI_TEXT.static.templateLintOk;
|
|
return true;
|
|
}
|
|
|
|
lintNode.style.color = '#ff8a80';
|
|
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
|
|
|
|
if (showAlert) {
|
|
alert(`${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function applyTemplatePreset(preset: string): void {
|
|
const presets: Record<string, { vod: string; parts: string; clip: string }> = {
|
|
default: {
|
|
vod: '{title}.mp4',
|
|
parts: '{date}_Part{part_padded}.mp4',
|
|
clip: '{date}_{part}.mp4'
|
|
},
|
|
archive: {
|
|
vod: '{channel}_{date_custom="yyyy-MM-dd"}_{title}.mp4',
|
|
parts: '{channel}_{date_custom="yyyy-MM-dd"}_Part{part_padded}.mp4',
|
|
clip: '{channel}_{date_custom="yyyy-MM-dd"}_{trim_start}_{part}.mp4'
|
|
},
|
|
clipper: {
|
|
vod: '{date_custom="yyyy-MM-dd"}_{title}.mp4',
|
|
parts: '{date_custom="yyyy-MM-dd"}_{part_padded}_{trim_start}.mp4',
|
|
clip: '{title}_{trim_start_custom="HH-mm-ss"}_{part}.mp4'
|
|
}
|
|
};
|
|
|
|
const selected = presets[preset] || presets.default;
|
|
byId<HTMLInputElement>('vodFilenameTemplate').value = selected.vod;
|
|
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
|
|
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
|
|
validateFilenameTemplates();
|
|
}
|
|
|
|
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
|
|
const output = byId('runtimeMetricsOutput');
|
|
if (showLoading) {
|
|
output.textContent = UI_TEXT.static.runtimeMetricsLoading;
|
|
}
|
|
|
|
try {
|
|
const metrics = await window.api.getRuntimeMetrics();
|
|
const lines = [
|
|
`${UI_TEXT.static.runtimeMetricQueue}: ${metrics.queue.total} total (${metrics.queue.pending} pending, ${metrics.queue.downloading} downloading, ${metrics.queue.error} failed)`,
|
|
`${UI_TEXT.static.runtimeMetricMode}: ${metrics.config.performanceMode} | smartScheduler=${metrics.config.smartScheduler} | dedupe=${metrics.config.duplicatePrevention}`,
|
|
`${UI_TEXT.static.runtimeMetricRetries}: ${metrics.retriesScheduled} scheduled, ${metrics.retriesExhausted} exhausted`,
|
|
`${UI_TEXT.static.runtimeMetricIntegrity}: ${metrics.integrityFailures}`,
|
|
`${UI_TEXT.static.runtimeMetricCache}: hits=${metrics.cacheHits}, misses=${metrics.cacheMisses}, vod=${metrics.caches.vodList}, users=${metrics.caches.loginToUserId}, clips=${metrics.caches.clipInfo}`,
|
|
`${UI_TEXT.static.runtimeMetricBandwidth}: current=${formatBytesForMetrics(metrics.lastSpeedBytesPerSec)}/s, avg=${formatBytesForMetrics(metrics.avgSpeedBytesPerSec)}/s`,
|
|
`${UI_TEXT.static.runtimeMetricDownloads}: started=${metrics.downloadsStarted}, done=${metrics.downloadsCompleted}, failed=${metrics.downloadsFailed}, bytes=${formatBytesForMetrics(metrics.downloadedBytesTotal)}`,
|
|
`${UI_TEXT.static.runtimeMetricActive}: ${metrics.activeItemTitle || '-'} (${metrics.activeItemId || '-'})`,
|
|
`${UI_TEXT.static.runtimeMetricLastError}: ${metrics.lastErrorClass || '-'}, retryDelay=${metrics.lastRetryDelaySeconds}s`,
|
|
`${UI_TEXT.static.runtimeMetricUpdated}: ${new Date(metrics.timestamp).toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE')}`
|
|
];
|
|
|
|
const nextOutput = lines.join('\n');
|
|
if (nextOutput !== lastRuntimeMetricsOutput) {
|
|
output.textContent = nextOutput;
|
|
lastRuntimeMetricsOutput = nextOutput;
|
|
}
|
|
} catch {
|
|
if (lastRuntimeMetricsOutput !== UI_TEXT.static.runtimeMetricsError) {
|
|
output.textContent = UI_TEXT.static.runtimeMetricsError;
|
|
lastRuntimeMetricsOutput = UI_TEXT.static.runtimeMetricsError;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function exportRuntimeMetrics(): Promise<void> {
|
|
const result = await window.api.exportRuntimeMetrics();
|
|
|
|
const toast = (window as unknown as { showAppToast?: (message: string, type?: 'info' | 'warn') => void }).showAppToast;
|
|
const notify = (message: string, type: 'info' | 'warn' = 'info') => {
|
|
if (typeof toast === 'function') {
|
|
toast(message, type);
|
|
} else if (type === 'warn') {
|
|
alert(message);
|
|
}
|
|
};
|
|
|
|
if (result.success) {
|
|
notify(UI_TEXT.static.runtimeMetricsExportDone, 'info');
|
|
return;
|
|
}
|
|
|
|
if (result.cancelled) {
|
|
notify(UI_TEXT.static.runtimeMetricsExportCancelled, 'info');
|
|
return;
|
|
}
|
|
|
|
notify(`${UI_TEXT.static.runtimeMetricsExportFailed}${result.error ? `\n${result.error}` : ''}`, 'warn');
|
|
}
|
|
|
|
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
|
|
if (runtimeMetricsAutoRefreshTimer) {
|
|
clearInterval(runtimeMetricsAutoRefreshTimer);
|
|
runtimeMetricsAutoRefreshTimer = null;
|
|
}
|
|
|
|
if (enabled) {
|
|
runtimeMetricsAutoRefreshTimer = window.setInterval(() => {
|
|
if (!canRunSettingsAutoRefresh()) {
|
|
return;
|
|
}
|
|
|
|
void refreshRuntimeMetrics(false);
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
function updateStatus(text: string, connected: boolean): void {
|
|
byId('statusText').textContent = text;
|
|
const dot = byId('statusDot');
|
|
dot.classList.remove('connected', 'error');
|
|
dot.classList.add(connected ? 'connected' : 'error');
|
|
}
|
|
|
|
function changeLanguage(lang: string): void {
|
|
const normalized = setLanguage(lang);
|
|
byId<HTMLSelectElement>('languageSelect').value = normalized;
|
|
updateLanguagePicker(normalized);
|
|
config.language = normalized;
|
|
void window.api.saveConfig({ language: normalized });
|
|
|
|
const currentStatus = byId('statusText').textContent?.trim() || '';
|
|
updateStatus(localizeCurrentStatusText(currentStatus), isConnected);
|
|
|
|
renderQueue();
|
|
renderStreamers();
|
|
// Re-render the VOD grid so the dynamically built button labels
|
|
// (trim / queue) and the filter empty-state pick up the new locale.
|
|
renderVodGridFromCurrentState();
|
|
refreshVodSortSelectLabels();
|
|
|
|
const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
|
|
const activeTab = activeTabId.replace('Tab', '');
|
|
if (activeTab === 'vods' && currentStreamer) {
|
|
byId('pageTitle').textContent = currentStreamer;
|
|
} else {
|
|
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
|
|
}
|
|
|
|
void refreshRuntimeMetrics();
|
|
validateFilenameTemplates();
|
|
}
|
|
|
|
function updateLanguagePicker(lang: string): void {
|
|
const de = byId<HTMLButtonElement>('langOptionDe');
|
|
const en = byId<HTMLButtonElement>('langOptionEn');
|
|
|
|
const isDe = lang === 'de';
|
|
de.classList.toggle('active', isDe);
|
|
en.classList.toggle('active', !isDe);
|
|
de.setAttribute('aria-pressed', String(isDe));
|
|
en.setAttribute('aria-pressed', String(!isDe));
|
|
}
|
|
|
|
function selectLanguageOption(lang: string): void {
|
|
changeLanguage(lang);
|
|
}
|
|
|
|
function renderPreflightResult(result: PreflightResult): void {
|
|
const entries = [
|
|
[UI_TEXT.static.preflightInternet, result.checks.internet],
|
|
[UI_TEXT.static.preflightStreamlink, result.checks.streamlink],
|
|
[UI_TEXT.static.preflightFfmpeg, result.checks.ffmpeg],
|
|
[UI_TEXT.static.preflightFfprobe, result.checks.ffprobe],
|
|
[UI_TEXT.static.preflightPath, result.checks.downloadPathWritable]
|
|
];
|
|
|
|
const lines = entries.map(([name, ok]) => `${ok ? 'OK' : 'FAIL'} ${name}`).join('\n');
|
|
const extra = result.messages.length ? `\n\n${result.messages.join('\n')}` : `\n\n${UI_TEXT.static.preflightReady}`;
|
|
|
|
byId('preflightResult').textContent = `${lines}${extra}`;
|
|
|
|
const badge = byId('healthBadge');
|
|
badge.classList.remove('good', 'warn', 'bad', 'unknown');
|
|
|
|
if (result.ok) {
|
|
badge.classList.add('good');
|
|
badge.textContent = UI_TEXT.static.healthGood;
|
|
return;
|
|
}
|
|
|
|
const failCount = Object.values(result.checks).filter((ok) => !ok).length;
|
|
if (failCount <= 2) {
|
|
badge.classList.add('warn');
|
|
badge.textContent = UI_TEXT.static.healthWarn;
|
|
} else {
|
|
badge.classList.add('bad');
|
|
badge.textContent = UI_TEXT.static.healthBad;
|
|
}
|
|
}
|
|
|
|
async function runPreflight(autoFix = false): Promise<void> {
|
|
const btn = byId<HTMLButtonElement>(autoFix ? 'btnPreflightFix' : 'btnPreflightRun');
|
|
const old = btn.textContent || '';
|
|
btn.disabled = true;
|
|
btn.textContent = autoFix ? UI_TEXT.static.preflightFixing : UI_TEXT.static.preflightChecking;
|
|
|
|
try {
|
|
const result = await window.api.runPreflight(autoFix);
|
|
renderPreflightResult(result);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = old;
|
|
}
|
|
}
|
|
|
|
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> {
|
|
const ok = await window.api.openDebugLogFile();
|
|
if (!ok) {
|
|
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
|
if (toast) toast('Debug log file not yet present.', 'warn');
|
|
}
|
|
}
|
|
|
|
async function refreshDebugLog(): Promise<void> {
|
|
const text = await window.api.getDebugLog(250);
|
|
const panel = byId('debugLogOutput');
|
|
const keepAtBottom = (panel.scrollHeight - panel.scrollTop - panel.clientHeight) < 20;
|
|
|
|
if (text !== lastDebugLogOutput) {
|
|
panel.textContent = text;
|
|
lastDebugLogOutput = text;
|
|
}
|
|
|
|
if (keepAtBottom) {
|
|
panel.scrollTop = panel.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function toggleDebugAutoRefresh(enabled: boolean): void {
|
|
if (debugLogAutoRefreshTimer) {
|
|
clearInterval(debugLogAutoRefreshTimer);
|
|
debugLogAutoRefreshTimer = null;
|
|
}
|
|
|
|
if (enabled) {
|
|
debugLogAutoRefreshTimer = window.setInterval(() => {
|
|
if (!canRunSettingsAutoRefresh()) {
|
|
return;
|
|
}
|
|
|
|
void refreshDebugLog();
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
function collectCredentialsPayload(): Partial<AppConfig> {
|
|
return {
|
|
client_id: byId<HTMLInputElement>('clientId').value.trim(),
|
|
client_secret: byId<HTMLInputElement>('clientSecret').value.trim()
|
|
};
|
|
}
|
|
|
|
function syncPartMinutesFieldState(): void {
|
|
const downloadMode = byId<HTMLSelectElement>('downloadMode').value;
|
|
const partMinutes = byId<HTMLInputElement>('partMinutes');
|
|
const label = byId<HTMLElement>('partMinutesLabel');
|
|
const isSplitMode = downloadMode === 'parts';
|
|
|
|
partMinutes.disabled = !isSplitMode;
|
|
partMinutes.setAttribute('aria-disabled', String(!isSplitMode));
|
|
label.classList.toggle('input-disabled', !isSplitMode);
|
|
}
|
|
|
|
function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
|
return {
|
|
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
|
|
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
|
|
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
|
|
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
|
|
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
|
|
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
|
|
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
|
|
auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked,
|
|
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
|
|
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
|
|
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
|
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
|
};
|
|
}
|
|
|
|
function collectFilenameTemplatePayload(showAlert = false): Partial<AppConfig> | null {
|
|
if (!validateFilenameTemplates(showAlert)) {
|
|
return null;
|
|
}
|
|
|
|
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.parallel_downloads ?? 1,
|
|
effective.performance_mode ?? 'balanced',
|
|
effective.smart_queue_scheduler !== false,
|
|
effective.prevent_duplicate_downloads !== false,
|
|
effective.persist_queue_on_restart !== false,
|
|
effective.auto_resume_queue_on_startup === true,
|
|
effective.notify_on_each_completion === true,
|
|
effective.streamlink_disable_ads !== false,
|
|
effective.streamlink_quality ?? 'best',
|
|
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>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
|
|
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;
|
|
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
|
|
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
|
|
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
|
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
|
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
|
|
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
|
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
|
|
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';
|
|
syncPartMinutesFieldState();
|
|
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();
|
|
}
|
|
|
|
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',
|
|
'parallelDownloads',
|
|
'performanceMode',
|
|
'smartSchedulerToggle',
|
|
'duplicatePreventionToggle',
|
|
'persistQueueToggle',
|
|
'autoResumeQueueToggle',
|
|
'notifyEachCompletionToggle',
|
|
'streamlinkDisableAdsToggle',
|
|
'streamlinkQuality'
|
|
] as const;
|
|
|
|
const debouncedSaveIds = [
|
|
'partMinutes',
|
|
'metadataCacheMinutes',
|
|
'vodFilenameTemplate',
|
|
'partsFilenameTemplate',
|
|
'defaultClipFilenameTemplate'
|
|
] as const;
|
|
|
|
const credentialIds = [
|
|
'clientId',
|
|
'clientSecret'
|
|
] as const;
|
|
|
|
const triggerImmediateSave = () => {
|
|
void flushSettingsAutoSave(false);
|
|
};
|
|
|
|
byId<HTMLSelectElement>('downloadMode').addEventListener('change', syncPartMinutesFieldState);
|
|
|
|
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> {
|
|
const folder = await window.api.selectFolder();
|
|
if (!folder) {
|
|
return;
|
|
}
|
|
|
|
byId<HTMLInputElement>('downloadPath').value = folder;
|
|
config = await window.api.saveConfig({ download_path: folder });
|
|
|
|
// Warn-only validation — the user explicitly chose this folder, so don't
|
|
// refuse to save (they might be picking a path on a USB stick that's
|
|
// currently disconnected). Just surface the writability problem early
|
|
// instead of letting the next download fail with a cryptic error.
|
|
try {
|
|
const writable = await window.api.checkFolderWritable(folder);
|
|
if (!writable) {
|
|
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
|
if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn');
|
|
}
|
|
} catch { /* ignore — preflight will catch it later */ }
|
|
}
|
|
|
|
function openFolder(): void {
|
|
const folder = config.download_path;
|
|
if (!folder || typeof folder !== 'string') {
|
|
return;
|
|
}
|
|
|
|
void window.api.openFolder(folder);
|
|
}
|
|
|
|
function changeTheme(theme: string): void {
|
|
document.body.className = `theme-${theme}`;
|
|
config.theme = theme;
|
|
void window.api.saveConfig({ theme });
|
|
}
|